diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 606907f..4e4d5fb 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -42,7 +42,7 @@ let credentialRequest = null; let documentDialogs = null; let cancelPageTouch = null; -const workspace = { tabs: [], active: null, sequence: 0, nextID: 0, switching: false, closing: false }; +const workspace = { tabs: [], active: null, sequence: 0, nextID: 0, switching: false, closing: false, objectClipboard: null }; function createResourceManager() { return { kind: "Font", scope: "", views: new Map(), items: [], filteredItems: [], start: -1, end: -1, selected: null, references: [], referenceOffset: 0, targets: [], pick: null, busy: false, sequence: 0, url: null, fileAction: null }; @@ -108,7 +108,7 @@ function createDocumentState() { openActionFrame: 0, outlineAction: "add", styleOriginal: null, - objectClipboard: null, + objectClipboardToken: null, styleClipboard: null, outlineExpanded: new Map(), textFonts: [], @@ -3247,7 +3247,7 @@ async function switchReadingVersion(id) { const doc = await callTabWASM(tab, "ofdgoSelectVersion", id); if (tab.closed || tab.state !== state || state.doc !== originalDocument || openSeq !== state.openSeq && view === tab.view) return; openSeq = state.openSeq = workspace.sequence = Math.max(workspace.sequence, state.openSeq) + 1; - state.objectClipboard = state.styleClipboard = null; + state.objectClipboardToken = state.styleClipboard = null; delete state.textDefaults.fontChoice; if (state.editorInfo) { if (!dirty) state.savedRevision = doc.revision; @@ -3473,7 +3473,7 @@ async function switchDocument(index) { state.packageDirty ||= state.dirty; state.savedRevision = doc.revision; state.editorViews.clear(); - state.objectClipboard = state.styleClipboard = null; + state.objectClipboardToken = state.styleClipboard = null; delete state.textDefaults.fontChoice; state.ofdBytes = null; state.editorInfo = {revision: doc.revision, canUndo: doc.canUndo, canRedo: doc.canRedo, @@ -3590,7 +3590,7 @@ async function toggleEditor() { state.ofdBytes = null; state.selectObjects = true; state.conversionWarnings = []; - state.objectClipboard = state.styleClipboard = null; + state.objectClipboardToken = state.styleClipboard = null; state.pageCache.clear(); tab.refreshPending = true; return; @@ -3602,7 +3602,7 @@ async function toggleEditor() { state.selectObjects = true; state.ofdBytes = null; state.conversionWarnings = []; - state.objectClipboard = null; + state.objectClipboardToken = null; state.styleClipboard = null; canvasEditor.richClipboard = null; canvasEditor.clear(); @@ -4127,7 +4127,7 @@ async function ensureRichInput() { let editing = canvasEditor.input; if (!editing) { const item = canvasEditor.selected; - if (!item || item.items || item.scoped || item.type !== "TextObject") return false; + if (!item || item.items || item.type !== "TextObject") return false; await editCanvasObject(item); editing = canvasEditor.input; } @@ -4157,6 +4157,13 @@ async function changeRangeFormat(key, value) { state.textDefaults[key] = value ?? (key === "background" ? state.textDefaults.background ? "" : textBackground.value : !state.textDefaults[key]); updateObjectControls(null); return; } + const item = canvasEditor.selected; + if (!canvasEditor.input && (item?.items || item?.scoped)) { + const current = currentTextStyle()[key]; + const patch = { [key]: value ?? (key === "background" ? current ? "" : textBackground.value : current !== true) }; + await changeDocument("ofdgoFormatText", { ...item, id: (item.items || [item]).map(member => member.id) }, JSON.stringify(patch)); + 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) }); @@ -4425,7 +4432,6 @@ async function changeTextFont() { setBusy(false, "", 0, "", tab); } } 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))) { const changed = await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, readTextFont(font, tab), 0, null); if (isForegroundDocument(tab) && (changed || openSeq === state.openSeq)) updateTextFonts(canvasEditor.selected, true); @@ -4573,7 +4579,6 @@ 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) { @@ -4713,7 +4718,7 @@ async function changeDocument(name, item, ...args) { const scope = objectParent(item); const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize", ofdgoAlignObject: "align", ofdgoAlignObjects: "align", ofdgoDistributeObjects: "distribute", ofdgoStyleObjects: "style", ofdgoUpdatePathStyle: "paint", ofdgoCompositeTextColor: "textColor", - ofdgoUpdateText: "text", ofdgoStyleText: "textStyle", ofdgoCropImage: "crop", ofdgoLayoutText: "layout", ofdgoFitImage: "fit", ofdgoResetCompositeCrop: "resetCrop", + ofdgoUpdateText: "text", ofdgoStyleText: "textStyle", ofdgoFormatText: "formatText", ofdgoCropImage: "crop", ofdgoLayoutText: "layout", ofdgoFitImage: "fit", ofdgoResetCompositeCrop: "resetCrop", ofdgoReshapeObject: "reshape", ofdgoReshapeLine: "line", ofdgoEraseObjects: "erase", ofdgoEraseObjectsPath: "erasePath", ofdgoPasteStyle: "pasteStyle", ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order", ofdgoGroupObjects: "group" }[name]; const members = item?.items ? [...item.items].sort((a, b) => a.order - b.order) : item ? [item] : []; @@ -4726,7 +4731,7 @@ async function changeDocument(name, item, ...args) { if (scopedRichText) args.push(scope); const textSelection = name === "ofdgoUpdateTextSelection"; if (scoped && !operation && !textSelection && !scopedRichText) throw new Error("内部对象暂不支持此操作"); - const doc = name === "ofdgoStyleText" + const doc = ["ofdgoStyleText", "ofdgoFormatText"].includes(name) ? await callWASM(name, item.index, ids, ...args) : textSelection ? await callWASM(name, item.index, item.selectionIDs, ...args) @@ -4777,7 +4782,7 @@ async function changeDocument(name, item, ...args) { if (state.documentManagement && (name === "ofdgoImportDocuments" || name === "ofdgoChangeDocument" && args[0] === "add")) { state.documentManagement.key = documentEntryKey(nextDocument); } - state.objectClipboard = state.styleClipboard = null; + state.objectClipboardToken = state.styleClipboard = null; canvasEditor.richClipboard = null; state.pageSelection.clear(); state.pageSelectionAnchor = null; @@ -4828,7 +4833,7 @@ async function changeDocument(name, item, ...args) { } const clearSelection = !item && !editingAnnotation || name === "ofdgoChangeAnnotations" && args[0] === "delete" || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); - if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || inserting || name === "ofdgoWriteAnnotation" && !editingAnnotation) { + if (name === "ofdgoCopyObjects" || ["ofdgoPasteObjects", "ofdgoImportObjects"].includes(name) || name === "ofdgoGroupObjects" || inserting || name === "ofdgoWriteAnnotation" && !editingAnnotation) { state.selectObjects = true; if (name !== "ofdgoInsertInk") canvasEditor.setTool(""); pageView.setPan(false); @@ -4842,7 +4847,7 @@ async function changeDocument(name, item, ...args) { } else if (restoring && !doc.changes?.all && doc.changes?.pages?.length === 1 && doc.changes.pages[0] === state.pageIndex && (restoredIndex < 0 || restoredIndex === state.pageIndex)) { if (location) canvasEditor.pendingSelection = {index:state.pageIndex, ids:location.ids}; await refreshEditorPage(doc, state.pageIndex, openSeq, clearSelection); - } else if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) { + } else if (item || ["ofdgoPasteObjects", "ofdgoImportObjects"].includes(name) || inserting || editingAnnotation) { await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection); } else { const samePage = pageIndexByID(doc.pages, previous.id); @@ -10485,7 +10490,7 @@ function copyEditorSelection(event) { return true; } const index = items[0].index, ids = items.map(item => item.id); - const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[index].id, bounds: selectionBounds(items), x: 0, y: 0, cut }; + const clipboard = { token: crypto.randomUUID(), source: `${tab.id}:${state.wasmSeq}:${state.doc.documentIndex || 0}:${state.doc.documentVersion || ""}`, page: state.doc.pages[index].id, bounds: selectionBounds(items), x: 0, y: 0, cut }; if (canvasEditor.nudge) { clipboard.bounds.x += canvasEditor.nudge.x; clipboard.bounds.y += canvasEditor.nudge.y; @@ -10493,17 +10498,19 @@ function copyEditorSelection(event) { event.clipboardData.setData(OBJECT_CLIPBOARD_TYPE, clipboard.token); event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n")); event.preventDefault(); - state.objectClipboard = clipboard; + state.objectClipboardToken = clipboard.token; + workspace.objectClipboard = clipboard; const capture = () => { const openSeq = state.openSeq, revision = state.editorInfo.revision; - return callTabWASM(tab, "ofdgoCaptureObjects", index, ids, clipboard.token).then(async () => { + return callTabWASM(tab, "ofdgoCaptureObjects", index, ids, clipboard.token).then(async result => { + clipboard.data = result.bytes; clipboard.cut = false; - if (cut && isForegroundDocument(tab) && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex && state.objectClipboard === clipboard) { + if (cut && isForegroundDocument(tab) && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex && state.objectClipboardToken === clipboard.token && workspace.objectClipboard === clipboard) { clipboard.cut = Boolean(await changeDocument("ofdgoDeleteObjects", { index, id: ids, items })); } return true; }, err => { - if (isForegroundDocument(tab) && state.objectClipboard === clipboard) showError(err, false); + if (isForegroundDocument(tab) && state.objectClipboardToken === clipboard.token) showError(err, false); return false; }); }; @@ -10534,16 +10541,17 @@ async function pasteEditorContent(event) { if (!isForegroundDocument(tab) || document.body.hasAttribute("data-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag) return; const index = state.pageIndex, page = state.doc.pages[index]; if (token) { - const clipboard = state.objectClipboard; + const clipboard = workspace.objectClipboard; if (!clipboard || token !== clipboard.token) { setStatus("复制内容已失效,请重新复制"); return; } const openSeq = state.openSeq; - if (!await clipboard.ready || !isForegroundDocument(tab) || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return; + if (!await clipboard.ready || !isForegroundDocument(tab) || openSeq !== state.openSeq || index !== state.pageIndex || workspace.objectClipboard !== clipboard) return; if (!pageCan("insert")) return; const visible = visiblePageBounds(index), bounds = clipboard.bounds; - const offset = (clipboard.targetPage || clipboard.page) === page.id ? { x: clipboard.x + (clipboard.cut ? 0 : 3), y: clipboard.y + (clipboard.cut ? 0 : 3) } : { x: 0, y: 0 }; + const target = `${tab.id}:${state.wasmSeq}:${state.doc.documentIndex || 0}:${state.doc.documentVersion || ""}`; + const offset = (clipboard.target || clipboard.source) === target && (clipboard.targetPage || clipboard.page) === page.id ? { x: clipboard.x + (clipboard.cut ? 0 : 3), y: clipboard.y + (clipboard.cut ? 0 : 3) } : { x: 0, y: 0 }; for (const [axis, size] of [["x", "width"], ["y", "height"]]) { const position = bounds[axis] + offset[axis]; if (position < visible[axis] || position + bounds[size] > visible[axis] + visible[size]) { @@ -10551,7 +10559,8 @@ async function pasteEditorContent(event) { offset[axis] = Math.max(visible[axis] + margin, Math.min(position, visible[axis] + visible[size] - margin - bounds[size])) - bounds[axis]; } } - if (await changeDocument("ofdgoPasteObjects", null, index, token, offset.x, offset.y)) { + if (await changeDocument(state.objectClipboardToken === clipboard.token ? "ofdgoPasteObjects" : "ofdgoImportObjects", null, index, state.objectClipboardToken === clipboard.token ? token : clipboard.data, offset.x, offset.y)) { + clipboard.target = target; clipboard.targetPage = page.id; clipboard.x = offset.x; clipboard.y = offset.y; @@ -12327,19 +12336,19 @@ 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 || item.items.some(member => member.richText))) || Boolean(canvasEditor.input?.composing || canvasEditor.input?.saving); + const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection) || 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 && !item.selectionIDs) && !canEditObject(item, "layoutKnown")); 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"))); + const formatsDisabled = textDisabled || Boolean(item && !item.draft && (!selection || !canEditObject(item, "textContent"))); for (const button of textFormatButtons) { button.disabled = formatsDisabled; const key = button.dataset.textFormat; - button.setAttribute("data-pressed", text[key] === undefined && (canvasEditor.input?.rich || item?.richText) ? "mixed" : String(Boolean(text[key]))); + button.setAttribute("data-pressed", text[key] === undefined && (canvasEditor.input?.rich || item?.richText || item?.items) ? "mixed" : String(Boolean(text[key]))); } textBackground.disabled = formatsDisabled; - textBackground.classList.toggle("mixed-color", Boolean((canvasEditor.input?.rich || item?.richText) && text.background === undefined)); + textBackground.classList.toggle("mixed-color", Boolean((canvasEditor.input?.rich || item?.richText || item?.items) && text.background === undefined)); if (text.background) textBackground.value = text.background; document.querySelector("#textHighlight").style.setProperty("--highlight-color", textBackground.value); const richHistory = canvasEditor.input?.rich || canvasEditor.input?.history; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 2bad774..0c99e7b 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -372,9 +372,12 @@ function arrowPath(shape, box) { } export function selectionBounds(items) { - const x = Math.min(...items.map(item => item.x)), y = Math.min(...items.map(item => item.y)); - return { x, y, width: Math.max(...items.map(item => item.x + item.width)) - x, - height: Math.max(...items.map(item => item.y + item.height)) - y }; + let x = Infinity, y = Infinity, right = -Infinity, bottom = -Infinity; + for (const item of items) { + x = Math.min(x, item.x); y = Math.min(y, item.y); + right = Math.max(right, item.x + item.width); bottom = Math.max(bottom, item.y + item.height); + } + return { x, y, width: right - x, height: bottom - y }; } function resizeCorner(node, event) { @@ -523,13 +526,42 @@ export function selectedText(item) { if (!item?.items) return item?.type === "TextObject" ? item : null; if (!item.items.every(member => member.type === "TextObject")) return null; const text = { ...item, type: "TextObject" }; - for (const key of ["font", "fontName", "size", "color"]) { - const value = item.items[0][key]; - text[key] = item.items.every(member => member[key] === value) ? value : undefined; + const formats = item.items.flatMap(member => member.richText ? member.richText.runs.map(run => run.format) : [{ ...member, ...richTextModel(member).runs[0].format }]); + for (const key of ["font", "fontName", "size", "color", "bold", "italic", "underline", "strikeout", "shadow", "background"]) { + const value = formats[0][key]; + text[key] = formats.every(format => format[key] === value) ? value : undefined; } return text; } +export class ObjectIndex { + constructor(items) { + const build = items => { + if (!items.length) return null; + const bounds = selectionBounds(items); + if (items.length <= 16) return { ...bounds, items }; + const axis = bounds.width >= bounds.height ? "x" : "y"; + items.sort((a, b) => a[axis] - b[axis]); + const middle = items.length >> 1; + return { ...bounds, left: build(items.slice(0, middle)), right: build(items.slice(middle)) }; + }; + this.root = build([...items]); + } + + query(point, tolerance = 0) { + const result = []; + const contains = box => point.x >= box.x - tolerance && point.x <= box.x + box.width + tolerance + && point.y >= box.y - tolerance && point.y <= box.y + box.height + tolerance; + const visit = node => { + if (!node || !contains(node)) return; + if (node.items) result.push(...node.items.filter(contains)); + else { visit(node.left); visit(node.right); } + }; + visit(this.root); + return result.sort((a, b) => b.order - a.order); + } +} + export class CanvasEditor { constructor(viewer, options) { this.viewer = viewer; @@ -602,8 +634,7 @@ export class CanvasEditor { const pending = this.pendingSelection?.index === index; const selected = new Set(pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id) : this.items().filter(item => item.index === index).map(item => item.id)); - const selection = []; - this.pages.set(surface, { index, width: page.width, height: page.height }); + const selection = [], items = []; const layer = document.createElement("div"); layer.className = "edit-layer"; const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }])); @@ -645,6 +676,7 @@ export class CanvasEditor { item.contour = { svg, path: contour }; } this.nodes.set(node, item); + items.push(item); node.addEventListener("focus", () => { if (this.input) this.selectAfterText(item); else this.select(item); @@ -655,6 +687,7 @@ export class CanvasEditor { selection.push(item); } } + this.pages.set(surface, { index, width: page.width, height: page.height, hitIndex: new ObjectIndex(items) }); surface.append(layer); if (pending || selected.size) this.setSelection(selection); if (pending) this.pendingSelection = null; @@ -956,7 +989,7 @@ export class CanvasEditor { const rect = surface.getBoundingClientRect(), rotation = this.options.rotation(); const point = pagePoint(event.clientX, event.clientY, rect, page, rotation); const tolerance = 3 * page.width / (rotation % 180 ? rect.height : rect.width); - return [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse().filter(item => !item.parent || this.items().some(selected => selected.id.startsWith(item.parent + "/"))).filter(item => item.type === "TextObject" + return page.hitIndex.query(point, tolerance).filter(item => !item.parent || this.items().some(selected => selected.id.startsWith(item.parent + "/"))).filter(item => item.type === "TextObject" ? point.x >= item.x - tolerance && point.x <= item.x + item.width + tolerance && point.y >= item.y - tolerance && point.y <= item.y + item.height + tolerance : this.contains(item, point, tolerance)); diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 30e07a8..a440d9c 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -3862,6 +3862,7 @@ func (e *businessEngine) editorAppearance(item map[string]any, object ofdgo.Grap var fill, stroke *ofdgo.FillColor switch object.Type { case "TextObject": + item["bold"], item["italic"] = object.TextObject.Weight >= 700, object.TextObject.Italic fill = object.TextObject.FillColor stroke = (*ofdgo.FillColor)(object.TextObject.StrokeColor) alpha = object.TextObject.Alpha @@ -4889,10 +4890,19 @@ func (e *businessEngine) styleText(args []platformValue) (any, error) { if err != nil { return err } - if object.Type != "TextObject" { - return fmt.Errorf("object %q is not text", id) + if object.Type == "TextObject" { + value.WriteString(object.TextObject.Text()) + } else { + path, err := e.compositePath(id) + if err != nil { + return err + } + text, err := e.currentEditor.TargetRichText(page, path) + if err != nil { + return err + } + value.WriteString(text.Text()) } - value.WriteString(object.TextObject.Text()) } if err := e.checkFontGlyphs(data, value.String()); err != nil { return err @@ -4917,6 +4927,33 @@ func (e *businessEngine) styleText(args []platformValue) (any, error) { }) } +// formatText 批量修改选区格式,未指定的属性保持各自原值 +// 入参: args 页面索引、完整路径数组和格式JSON +// 返回: any 文档信息, error 校验或提交错误 +func (e *businessEngine) formatText(args []platformValue) (any, error) { + return e.changeObjects(func() error { + paths, err := e.editorTargetPaths(e.stringsFromJS(args[1])) + if err != nil { + return err + } + var patch ofdgo.TextFormatPatch + if err := json.Unmarshal([]byte(args[2].String()), &patch); err != nil { + return err + } + for _, value := range []*string{patch.Color, patch.Background} { + if value == nil || *value == "" { + continue + } + var color ofdgo.FillColor + if err := e.setEditorColor(&color, *value); err != nil { + return err + } + *value = color.Value + } + return e.currentEditor.FormatTargetsText(args[0].Int(), paths, patch) + }) +} + // editorRichText 将富文本复合图元作为一个文本框提供给编辑器 // 入参: item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称, local 局部编辑框, matrix 页面变换 func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string, local ofdgo.Box, matrix ofdgo.Matrix) { @@ -4966,7 +5003,11 @@ func (e *businessEngine) getRichText(args []platformValue) (any, error) { if e.currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } - value, err := e.currentEditor.RichText(args[0].Int(), args[1].String()) + path, err := e.compositePath(args[1].String()) + if err != nil { + return nil, err + } + value, err := e.currentEditor.TargetRichText(args[0].Int(), path) if err != nil { return nil, err } @@ -5394,7 +5435,7 @@ func (e *businessEngine) copyObjects(args []platformValue) (any, error) { // captureObjects 保存独立选区快照,不修改文档或历史 // 入参: args 页面索引、对象路径数组和剪贴板标识 -// 返回: any 空结果, error 错误信息 +// 返回: any 可跨文档导入的选区包, error 错误信息 func (e *businessEngine) captureObjects(args []platformValue) (any, error) { if e.currentEditor == nil { return nil, fmt.Errorf("no document is being edited") @@ -5404,8 +5445,16 @@ func (e *businessEngine) captureObjects(args []platformValue) (any, error) { return nil, err } clipboard.token = args[2].String() + paths, err := e.editorTargetPaths(e.stringsFromJS(args[1])) + if err != nil { + return nil, err + } + var output bytes.Buffer + if _, err := e.currentEditor.WriteTargetsTo(context.Background(), &output, args[0].Int(), paths, true); err != nil { + return nil, err + } e.copiedObjects = clipboard - return nil, nil + return successResult(map[string]any{"bytes": bytesToJS(output.Bytes())}), nil } // pasteObjects 将当前文档的对象快照粘贴到目标页 @@ -5423,6 +5472,38 @@ func (e *businessEngine) pasteObjects(args []platformValue) (any, error) { return editorSelectionInfo{info, ids}, err } +// importObjects 将独立选区包导入当前文档,来源标签无需保持打开 +// 入参: args 目标页、选区包和横纵位移 +// 返回: any 文档信息及新选区, error 读取或导入错误 +func (e *businessEngine) importObjects(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + data, err := e.bytesFromJS(args[1]) + if err != nil { + return nil, err + } + input, err := ofdgo.NewInput(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, err + } + defer input.Close() + reader, err := ofdgo.NewReader(input) + if err != nil { + return nil, err + } + defer reader.Close() + objects, annotations, err := e.currentEditor.ImportSelection(context.Background(), reader, args[0].Int(), ofdgo.ObjectImportOptions{DX: args[2].Float(), DY: args[3].Float(), AllowDecrypted: true}) + if err != nil { + return nil, err + } + for _, id := range annotations { + objects = append(objects, "annotation:"+id) + } + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + return editorSelectionInfo{info, objects}, err +} + // copyEditorObjects 复制快照并更新预览,返回新对象标识 // 入参: page 目标页, objects 对象快照, dx 横向位移,单位为毫米, dy 纵向位移,单位为毫米 // 返回: any 文档信息及新选区, error 错误信息 @@ -7872,6 +7953,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoPreviewTextSelection": e.previewTextSelection, "ofdgoUpdateTextSelection": e.updateTextSelection, "ofdgoStyleText": e.styleText, + "ofdgoFormatText": e.formatText, "ofdgoGetRichText": e.getRichText, "ofdgoSaveRichText": e.saveRichText, "ofdgoPreviewRichText": e.previewRichText, @@ -7897,6 +7979,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoCopyObjects": e.copyObjects, "ofdgoCaptureObjects": e.captureObjects, "ofdgoPasteObjects": e.pasteObjects, + "ofdgoImportObjects": e.importObjects, "ofdgoOrderObjects": e.orderObjects, "ofdgoDistributeObjects": e.distributeObjects, "ofdgoRotateObjects": e.rotateObjects, diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index 5e38676..35dd55c 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -112,63 +112,107 @@ func (e *Editor) ImportObjects(source *Editor, sourcePage int, ids []string, pag // 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项 // 返回: []string 新对象标识, error 校验、迁移或取消错误 func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourcePage int, ids []string, page int, options ObjectImportOptions) ([]string, error) { + objects, _, err := e.importSelection(ctx, source, sourcePage, ids, nil, page, options, true) + return objects, err +} + +// ImportSelection 导入独立单页选区包,重映射对象和资源标识,一次撤销恢复全部 +// 入参: ctx 取消上下文, source 单页选区阅读器, page 目标页, options 位移及导入配置 +// 返回: []string 正文标识, []string 注解标识, error 校验或迁移错误 +func (e *Editor) ImportSelection(ctx context.Context, source *Reader, page int, options ObjectImportOptions) ([]string, []string, error) { + doc, err := source.DocContext(ctx) + if err != nil { + return nil, nil, err + } + if len(doc.Pages.Page) != 1 { + return nil, nil, fmt.Errorf("selection requires exactly one page") + } + origin, err := source.EditorContext(ctx) + if err != nil { + return nil, nil, err + } + content, err := origin.page(0) + if err != nil { + return nil, nil, err + } + var objects, annotations []string + for _, layer := range content.Content.Layer { + for _, object := range layer.Objects { + objects = append(objects, editorObjectID(object)) + } + } + notes, err := origin.Annotations(0) + if err != nil { + return nil, nil, err + } + for _, note := range notes { + annotations = append(annotations, note.ID) + } + return e.importSelection(ctx, origin, 0, objects, annotations, page, options, true) +} + +// importSelection 迁移正文与注解,共用资源映射并保持来源绘制顺序 +// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, options 导入配置, strictLinks 拒绝跨页链接 +// 返回: []string 正文标识, []string 注解标识, error 迁移或提交错误 +func (e *Editor) importSelection(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, options ObjectImportOptions, strictLinks bool) ([]string, []string, error) { progress := editorProgress(options.OnProgress).withContext(ctx) if err := ctx.Err(); err != nil { - return nil, err + return nil, nil, err } if !finite(options.DX) || !finite(options.DY) { - return nil, fmt.Errorf("import requires finite offsets") + return nil, nil, fmt.Errorf("import requires finite offsets") } if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted { - return nil, ErrEncryptionPolicyRequired + return nil, nil, ErrEncryptionPolicyRequired } objects, err := source.Objects(sourcePage, ids) if err != nil { - return nil, err + return nil, nil, err } target, err := e.page(page) - if err != nil || len(objects) == 0 { - return nil, err + if err != nil || len(objects) == 0 && len(annotations) == 0 { + return nil, nil, err } if source == e { if err := progress.report("commit", 0, 0); err != nil { - return nil, err + return nil, nil, err } - return e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY) + copied, err := e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY) + return copied, nil, err } reader, err := source.readerContext(ctx, progress) if err != nil { - return nil, err + return nil, nil, err } defer reader.Close() doc, err := reader.Doc() if err != nil { - return nil, err + return nil, nil, err } ref := doc.Pages.Page[sourcePage] base, migration, err := e.prepareImport(ctx, reader, doc, map[string]bool{ref.ID: true}, false, progress) if err != nil { - return nil, err + return nil, nil, err } - migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, true + migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, strictLinks pageName := reader.ResPath(ref.BaseLoc) content, err := reader.PageContentByIndex(sourcePage) if err != nil { - return nil, err + return nil, nil, err } for _, value := range content.PageRes { name, err := migration.resolve(pageName, "", value) if err != nil { - return nil, err + return nil, nil, err } if err := migration.resourceIndex(name); err != nil { - return nil, err + return nil, nil, err } } layers := make([][]byte, len(objects)) for i, object := range objects { if err := progress.report("objects", i, len(objects)); err != nil { - return nil, err + return nil, nil, err } var data []byte var attrs ofdAttrs @@ -187,32 +231,48 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc data, err = editorObjectXML(object) } if err != nil { - return nil, err + return nil, nil, err } data, err = editorXMLContainer("Layer", attrs, data) if err != nil { - return nil, err + return nil, nil, err } root, err := parseEditorXMLContext(ctx, data) if err != nil { - return nil, err + return nil, nil, err } layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root) if err != nil { - return nil, err + return nil, nil, err } } + notes := &AnnotationSelection{editor: e, document: e.documentIdentity()} + for _, id := range annotations { + data, err := source.annotationXML(sourcePage, id) + if err != nil { + return nil, nil, err + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return nil, nil, err + } + data, err = migration.encode(editorImportEntry{name: pageName, data: data}, root) + if err != nil { + return nil, nil, err + } + notes.data = append(notes.data, data) + } if err := migration.encodeResources(); err != nil { - return nil, err + return nil, nil, err } for _, resource := range e.resources { delete(migration.files, resource.name) } next, err := migration.merge(base, nil, nil, nil) if err != nil { - return nil, err + return nil, nil, err } - var copied []string + var copied, copiedNotes []string err = e.Transaction(func(editor *Editor) error { if err := progress.report("commit", 0, 0); err != nil { return err @@ -236,13 +296,13 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc return err } object := layer.Objects[0] + object.TextObject.layout = objects[i].TextObject.layout + object.state = objects[i].state + object.CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids) imported[i], err = editor.copiedObjectStyle(object, layer.DrawParam) if err != nil { return err } - imported[i].TextObject.layout = objects[i].TextObject.layout - imported[i].state = objects[i].state - imported[i].CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids) 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) @@ -253,12 +313,17 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc for i, object := range objects { orderedIDs[i] = editorObjectID(object) } - return editor.copyRichTextMetadata(source, sourcePage, orderedIDs, page, copied) + notes.document = editor.documentIdentity() + copiedNotes, err = editor.PasteAnnotations(page, notes, options.DX, options.DY) + if err != nil { + return err + } + return editor.copyRichTextMetadata(ctx, source, sourcePage, orderedIDs, annotations, page, copied, copiedNotes) }) if err != nil { - return nil, err + return nil, nil, err } - return copied, err + return copied, copiedNotes, err } // ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 diff --git a/ofdgo_editor_rich_text.go b/ofdgo_editor_rich_text.go index 6ec4932..08a751c 100644 --- a/ofdgo_editor_rich_text.go +++ b/ofdgo_editor_rich_text.go @@ -25,6 +25,7 @@ import ( "image/color" "io" "reflect" + "slices" "strconv" ) @@ -338,10 +339,17 @@ func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, v return fmt.Errorf("invalid rich text frame") } return e.Transaction(func(e *Editor) error { + member, err := e.CompositeObject(page, path, index) + if err != nil { + return err + } + if member.Object.Type == "TextObject" { + return e.updateCompositePlainRichText(page, path, index, value, offset, width) + } var metadata richTextMetadata entryIndex := -1 changed := false - err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + err = e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { node := nodes[0] current, frame, _, err := e.CompositeRichText(page, members[0]) if err != nil { @@ -388,7 +396,7 @@ func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, v if err != nil || !changed { return err } - member, err := e.CompositeObject(page, path, index) + member, err = e.CompositeObject(page, path, index) if err != nil { return err } @@ -659,18 +667,24 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata, // 入参: 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) + layer, _, err := e.findObject(page, object.TextObject.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(object.TextObject.ID); layer.DrawParam != "" || origin != nil && !editorXMLSupported(origin.node) { + return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")} } - if origin := e.objectOrigin(t.ID); origin != nil && !editorXMLSupported(origin.node) { + return e.promoteTextValue(object.TextObject) +} + +// promoteTextValue 校验普通文字是否可无损提升,保留明确段落或可验证单行布局 +// 入参: t 有效文字样式与局部定位 +// 返回: RichText 单片段, error 布局或格式错误 +func (e *Editor) promoteTextValue(t TextObject) (RichText, error) { + unsupported := func() (RichText, error) { + return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")} + } + if 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() } frame, err := t.TextFrame() @@ -754,29 +768,87 @@ func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) { 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 { +// copyRichTextMetadata 迁移选区及其组合、注解内部的编辑标引,复用标准内容中的字体引用 +// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, copied 新正文标识, copiedNotes 新注解标识 +// 返回: error 元数据损坏、结构不一致或提交错误 +func (e *Editor) copyRichTextMetadata(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, copied, copiedNotes []string) error { + if err := ctx.Err(); err != nil { + return err + } + metadata := source.richTextMetadataIndex() + if metadata.err != nil || len(metadata.entries) == 0 { + return metadata.err + } + seen := make(map[string]bool) + var copyObject func(GraphicObject, GraphicObject) error + copyObject = func(before, after GraphicObject) error { + if err := ctx.Err(); err != nil { + return err + } + if before.Type != "CompositeObject" && before.Type != "CompositeGraphicUnit" { + return nil + } + id := editorObjectID(after) + if id != "" { + if seen[id] { + return nil + } + seen[id] = true + } + entry, err := source.richTextEntry(sourcePage, editorObjectID(before)) + if err == nil { + if _, err := source.richTextObject(sourcePage, before); err != nil { + return err + } + return e.putCompositeRichTextMetadata(page, after, entry.metadata, -1) + } + if !errors.Is(err, ErrNotRichText) { + return err + } + units := []CompositeGraphicUnit{before.CompositeGraphicUnit, after.CompositeGraphicUnit} + for i, owner := range []*Editor{source, e} { + if units[i].ResourceID != "" { + definition, err := owner.compositeDefinition(units[i].ResourceID) + if err != nil { + return err + } + units[i] = definition.object.CompositeGraphicUnit + } + } + original, target := slices.Collect(units[0].objects()), slices.Collect(units[1].objects()) + if len(original) != len(target) { + return fmt.Errorf("imported composite structure differs") + } + for i := range original { + if err := copyObject(original[i], target[i]); err != nil { + return err + } + } + return nil + } for i, id := range ids { - object, err := source.Object(sourcePage, id) + before, 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 - } + after, err := e.Object(page, copied[i]) if err != nil { return err } - if _, err := source.RichText(sourcePage, id); err != nil { + if err := copyObject(before, after); err != nil { return err } - if err := e.putRichTextMetadata(page, copied[i], entry.metadata, -1); err != nil { + } + for i, id := range annotations { + before, err := source.Annotation(sourcePage, id) + if err != nil { + return err + } + after, err := e.Annotation(page, copiedNotes[i]) + if err != nil { + return err + } + if err := copyObject(GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: before.Appearance.Objects}}, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: after.Appearance.Objects}}); err != nil { return err } } @@ -945,3 +1017,102 @@ func richTextMetadataFontSafe(value string) bool { } return true } + +// formatCompositeText 修改内部富文本格式,普通文字安全提升后保留原实例变换 +// 入参: page 页面索引, path 父路径, index 成员序号, patch 格式增量 +// 返回: error 识别、排版或提交错误 +func (e *Editor) formatCompositeText(page int, path ObjectPath, index int, patch TextFormatPatch) error { + value, err := e.TargetRichText(page, ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)}) + if err != nil { + return err + } + if err := value.ApplyFormat(0, value.Len(), patch); err != nil { + return err + } + return e.UpdateCompositeRichText(page, path, index, value, 0, 0) +} + +// updateCompositePlainRichText 将可验证布局的内部文字提升为富文本并保留实例坐标 +// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本内容, offset 局部偏移, width 可选宽度 +// 返回: error 校验或提交错误 +func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) error { + var metadata richTextMetadata + if err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + node, text := nodes[0], members[0].Object.TextObject + if node.object.Type != "TextObject" || !editorXMLSupported(node.span) { + return ErrNotRichText + } + text.CTM = "" + _, err := e.promoteTextValue(text) + if err != nil { + return err + } + if err := value.Validate(); err != nil { + return err + } + frame, err := text.TextFrame() + if err != nil { + return err + } + dx, dy, err := e.richPlainOffset(text) + if err != nil { + return err + } + object := node.object + if !node.boundaryInCTM { + object = compositeBoundary(object, node.parent, true) + } + box, err := creationBox(object.TextObject.Boundary) + if err != nil { + return err + } + matrix := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(dx+offset, dy)) + if width > 0 { + frame.W = width + } + next, meta, err := e.prepareRichText(Box{W: frame.W, H: frame.H}, value) + if err != nil { + return err + } + unit := richTextPlacement(meta, matrix) + unit.ID, unit.ResourceID = object.TextObject.ID, next.CompositeGraphicUnit.ResourceID + next.CompositeGraphicUnit = unit + if !node.boundaryInCTM { + next = compositeBoundary(next, node.parent, false) + } + metadata = meta + return node.update(next) + }); err != nil { + return err + } + member, err := e.CompositeObject(page, path, index) + if err != nil { + return err + } + return e.putCompositeRichTextMetadata(page, member.Object, metadata, -1) +} + +// TargetRichText 读取完整路径文字的富文本模型,普通文字仅在可验证布局时提升 +// 入参: page 页面索引, path 完整对象路径 +// 返回: RichText 独立模型, error 路径或不安全提升错误 +func (e *Editor) TargetRichText(page int, path ObjectPath) (RichText, error) { + if len(path.Children) == 0 { + if path.Annotation != "" { + return RichText{}, ErrNotRichText + } + return e.RichText(page, path.ID) + } + index := path.Children[len(path.Children)-1] + path.Children = path.Children[:len(path.Children)-1] + member, err := e.CompositeObject(page, path, index) + if err != nil { + return RichText{}, err + } + if member.Object.Type == "TextObject" { + text := member.Object.TextObject + text.CTM = "" + return e.promoteTextValue(text) + } + value, _, _, err := e.CompositeRichText(page, member) + return value, err +} diff --git a/ofdgo_editor_selection.go b/ofdgo_editor_selection.go index e7dd661..f24015d 100644 --- a/ofdgo_editor_selection.go +++ b/ofdgo_editor_selection.go @@ -16,9 +16,12 @@ package ofdgo import ( "cmp" + "context" "fmt" + "io" "math" "slices" + "strings" ) // ObjectSelection 保存正文、注解及组合成员的独立快照,共享当前编辑器的不可变资源 @@ -863,12 +866,155 @@ func (e *Editor) ArrangeTargets(page int, paths []ObjectPath, distribute bool, d // 入参: page 页面索引, paths 完整文字路径, style 字体、字号或颜色增量 // 返回: error 路径、文字或样式错误 func (e *Editor) StyleTargetsText(page int, paths []ObjectPath, style TextStyle) error { - return e.editTargets(paths, func(objects, annotations []string) error { + patch := TextFormatPatch{} + if style.Font != "" { + patch.Font = &style.Font + } + if style.Size != 0 { + patch.Size = &style.Size + } + if style.Color != "" { + patch.Color = &style.Color + } + return e.formatTargetsText(page, paths, patch, style.Reflow) +} + +// FormatTargetsText 原子修改普通文字和富文本的指定格式,保留未指定的混排属性 +// 入参: page 页面索引, paths 完整文字路径, patch 格式增量 +// 返回: error 路径、格式或不安全重排错误 +func (e *Editor) FormatTargetsText(page int, paths []ObjectPath, patch TextFormatPatch) error { + return e.formatTargetsText(page, paths, patch, false) +} + +// formatTargetsText 统一文字选区格式事务,普通字体和颜色修改沿用定位保留路径 +// 入参: page 页面索引, paths 完整路径, patch 格式增量, reflow 显式重排 +// 返回: error 校验或编辑错误 +func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextFormatPatch, reflow bool) error { + style := TextStyle{Reflow: reflow} + if patch.Font != nil { + if strings.TrimSpace(*patch.Font) == "" { + return fmt.Errorf("text requires a font") + } + style.Font = *patch.Font + } + if patch.Size != nil { + if !finite(*patch.Size) || *patch.Size <= 0 { + return fmt.Errorf("invalid text size") + } + style.Size = *patch.Size + } + if patch.Color != nil { + style.Color = *patch.Color + if style.Color == "" { + style.Color = "0 0 0" + } + } + decorated := patch.Bold != nil || patch.Italic != nil || patch.Underline != nil || patch.Strikeout != nil || patch.Shadow != nil || patch.Background != nil + return e.editTargets(paths, func(ids, annotations []string) error { if len(annotations) != 0 { return fmt.Errorf("annotation is not a text object") } - return e.StyleText(page, objects, style) + objects, _, err := e.selectedObjects(page, ids) + if err != nil { + return err + } + var plain []string + for _, object := range objects { + id := editorObjectID(object) + if object.Type == "TextObject" && !decorated { + plain = append(plain, id) + continue + } + value, err := e.richTextObject(page, object) + if err != nil { + return err + } + if err := value.ApplyFormat(0, value.Len(), patch); err != nil { + return err + } + if err := e.UpdateRichText(page, id, value); err != nil { + return err + } + } + if len(plain) != 0 { + return e.StyleText(page, plain, style) + } + return nil }, func(path ObjectPath, indexes []int) error { - return e.StyleCompositeText(page, path, indexes, style) + members, err := e.CompositeObjectsAt(page, path, indexes) + if err != nil { + return err + } + var plain []int + for i, member := range members { + if member.Object.Type == "TextObject" && !decorated { + plain = append(plain, indexes[i]) + continue + } + if err := e.formatCompositeText(page, path, indexes[i], patch); err != nil { + return err + } + } + if len(plain) != 0 { + return e.StyleCompositeText(page, path, plain, style) + } + return nil }) } + +// WriteTargetsTo 将选区及其关联资源写成独立单页OFD,来源关闭后仍可导入 +// 不修改来源、历史或组合结构,加密来源须显式允许明文导出 +// 不包含页面模板和签名,指向其他页面的跳转随单页导出移除 +// 入参: ctx 取消上下文, writer 输出流, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出 +// 返回: int64 写入字节数, error 路径、资源或写入错误 +func (e *Editor) WriteTargetsTo(ctx context.Context, writer io.Writer, page int, paths []ObjectPath, allowDecrypted bool) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + if e.encryption != nil && !allowDecrypted { + return 0, ErrEncryptionPolicyRequired + } + if len(paths) == 0 { + return 0, fmt.Errorf("no objects selected") + } + snapshot := e.transactionSnapshot() + snapshot.history, snapshot.historyIndex, snapshot.historyLimit = nil, 0, 0 + snapshot.transactionChanges = nil + for id, origin := range snapshot.origins { + if origin != nil && origin.editor == e { + copy := *origin + copy.editor = &snapshot + snapshot.origins[id] = © + } + } + selection, err := snapshot.CaptureTargets(page, paths) + if err != nil { + return 0, err + } + content, err := snapshot.page(page) + if err != nil { + return 0, err + } + objects, annotations, err := snapshot.PasteSelection(page, selection, 0, 0) + if err != nil { + return 0, err + } + box, err := creationBox(content.Area.PhysicalBox) + if err != nil { + return 0, err + } + packet := NewEditor() + packet.SetRenderBackends(e.backends) + packet.SetResources(e.runtimeResources) + packet.SetFontDirs(e.fontDirs...) + packet.SetFontFS(e.fontFS...) + packet.OnWriteProgress = e.OnWriteProgress + if _, err := packet.AddPage(box.W, box.H); err != nil { + return 0, err + } + packet.pages[0].Area = content.Area + if _, _, err := packet.importSelection(ctx, &snapshot, page, objects, annotations, 0, ObjectImportOptions{AllowDecrypted: true, OnProgress: e.OnWriteProgress}, false); err != nil { + return 0, err + } + return packet.WriteToWithOptions(ctx, writer, WriteOptions{}) +}