diff --git a/assets/webui/assets.go b/assets/webui/assets.go index a801a32..9bf0b70 100644 --- a/assets/webui/assets.go +++ b/assets/webui/assets.go @@ -22,9 +22,14 @@ import ( // FS WebUI嵌入静态文件 // -//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js +//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js var FS embed.FS +// NativeFS WebView嵌入静态文件 +// +//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js +var NativeFS embed.FS + // Checksum 计算WebUI嵌入资源校验值 // 返回: string SHA-256十六进制校验值 func Checksum() string { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index aedc6f4..7d8206d 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,5 +1,6 @@ import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; +import { nativeHost, createEngineWorker, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js"; const MM_TO_PX = 96 / 25.4; const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)"); @@ -595,6 +596,7 @@ const el = { }; const fontManager = new FontManager({ + host: nativeHost, onChange: scheduleFontSync, onPermissionChange: updateFontPermissionHint, }); @@ -1388,8 +1390,8 @@ batchElements.Cancel.addEventListener("click", cancelBatch); batchElements.Form.addEventListener("submit", event => { event.preventDefault(); runBatch(); }); batchElements.Panel.addEventListener("dragover", event => { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = batch.running ? "none" : "copy"; }); batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); }); -batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker; -batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive"; +batchElements.Destination.querySelector('[value="directory"]').disabled = !nativeHost && !window.showDirectoryPicker; +batchElements.Destination.value = nativeHost || window.showDirectoryPicker ? "directory" : "archive"; fontBindingElements.Cancel.addEventListener("click", () => fontBindingElements.Panel.close()); fontBindingElements.Panel.addEventListener("close", () => { fontBinding.font = null; fontBinding.choices = []; fontBinding.sequence++; fontBindingElements.File.value = ""; }); fontBindingElements.Source.addEventListener("change", () => { @@ -1703,6 +1705,7 @@ document.addEventListener("visibilitychange", () => { }); el.fontDirectoryButton.disabled = !("webkitdirectory" in el.fontDirectoryInput); +nativeHost?.installFileInputs(error => showError(error, false)); updateSidebarState(); boot(); @@ -2212,7 +2215,7 @@ async function loadImportFile() { el.importStatus.textContent = ""; setBusy(true); try { - const data = file ? new Uint8Array(await file.arrayBuffer()) : null; + const data = file ? await documentBytes(file) : null; if (openSeq !== state.openSeq) return; const info = data ? await openWithCredentials(null, openSeq, data, true) : await callWASM("ofdgoLoadImport", null); if (openSeq !== state.openSeq || !el.importPanel.open) { @@ -2461,7 +2464,7 @@ async function exportManagedVersion() { setBusy(true, "正在另存版本"); el.versionStatus.textContent = "正在另存版本"; try { - const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName: name, types: [{description: "OFD", accept: {"application/ofd": [".ofd"]}}]}) : null; + const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({suggestedName: name, types: [{description: "OFD", accept: {"application/ofd": [".ofd"]}}]}) : null; if (openSeq !== state.openSeq) return; const result = await callWASM("ofdgoExportVersion", entry.id, file); if (openSeq !== state.openSeq) return; @@ -4126,6 +4129,12 @@ async function boot() { }).catch((err) => showError(err, !state.doc)); }); } + if (nativeHost) await nativeHost.start(files => { + if (document.body.hasAttribute("data-busy")) { setStatus("请等待当前操作完成"); return; } + if (batchElements.Panel.open) addBatchFiles(files); + else if (files.length === 1) openOFD(files[0]); + else if (files.length) setStatus("请选择一个文档"); + }, error => showError(error, !state.doc)); try { const remote = remoteDocumentOptions(window.location.hash); if (remote && openSeq === state.openSeq) await openOFD({ name: remote.name }, remote); @@ -4154,6 +4163,11 @@ async function registerOffline() { } async function prepareOffline() { + if (nativeHost) { + el.appPanel.hidden = false; + el.offlineStatus.textContent = "可离线"; + return true; + } if (!window.isSecureContext || !("serviceWorker" in navigator) || !("caches" in window) || !("locks" in navigator)) { return true; } @@ -4323,7 +4337,7 @@ async function loadWASM() { state.ready = false; state.wasmExited = false; await new Promise((resolve, reject) => { - const worker = new Worker("./ofdgo_wasm.js"); + const worker = createEngineWorker(); wasmWorker = worker; const fail = (err) => { markWASMExited(wasmSeq, err); @@ -4557,7 +4571,7 @@ async function openOFD(file, remote = null) { const source = downloaded ? downloaded.file : file; const format = await callWASM("ofdgoDetectFormat", source); if (openSeq !== state.openSeq) return; - let bytes = new Uint8Array(await source.arrayBuffer()); + let bytes = await documentBytes(source); const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`; let warnings = []; let convertedDoc; @@ -5108,7 +5122,7 @@ async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, import converted.doc = await callWASM("ofdgoOpen", converted.bytes, fonts, state.renderAnnotations, credentials); return converted; } - return importing ? await callWASM("ofdgoLoadImport", data.slice(), credentials) + return importing ? await callWASM("ofdgoLoadImport", data?.nativeFile ? data : data.slice(), credentials) : await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials); } catch (err) { if (openSeq !== state.openSeq || !(pdf ? err.code === "pdfPassword" : ["credentialsRequired", "invalidCredentials"].includes(err.code))) throw err; @@ -7529,7 +7543,7 @@ async function downloadResource(attachment, api, label) { updateControls(); setBusy(true, `正在读取${label}`, null, `正在读取${label}`); try { - const file = window.showSaveFilePicker ? await window.showSaveFilePicker({ suggestedName: attachment.fileName }) : null; + const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({ suggestedName: attachment.fileName }) : null; if (openSeq !== state.openSeq) { return; } @@ -7848,7 +7862,7 @@ async function signDocument(event) { el.signForm.querySelectorAll("input, select, button[type=submit]").forEach(input => { input.disabled = true; }); try { const name = `${baseFileName()}_签章.ofd`; - const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName:name, types:[{description:"OFD",accept:{"application/ofd":[".ofd"]}}]}) : null; + const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({suggestedName:name, types:[{description:"OFD",accept:{"application/ofd":[".ofd"]}}]}) : null; if (openSeq !== state.openSeq || state.signCanceled) return; if (canvasEditor.input || canvasEditor.crop || canvasEditor.nudge || canvasEditor.nudgeCommit) { setBusy(false); @@ -8123,7 +8137,7 @@ function batchCall(name, args, onProgress) { function startBatchWorker() { batch.workerError = null; return new Promise((resolve, reject) => { - const worker = new Worker("./ofdgo_wasm.js"); + const worker = createEngineWorker(); batch.worker = worker; const fail = async error => { batch.workerError = error; @@ -8224,13 +8238,13 @@ async function runBatch() { const entries = []; try { if (archive) { - if (window.showSaveFilePicker) zipFile = await window.showSaveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] }); - if (navigator.storage?.getDirectory) { + zipFile = await saveFilePicker({ suggestedName: "转换结果.zip", types: [{ description: "ZIP", accept: { "application/zip": [".zip"] } }] }); + if (!nativeHost && navigator.storage?.getDirectory) { storage = await navigator.storage.getDirectory(); stagingName = `ofdgo-convert-${crypto.randomUUID()}`; destination = await storage.getDirectoryHandle(stagingName, { create: true }); } - } else destination = await window.showDirectoryPicker({ mode: "readwrite" }); + } else destination = await directoryPicker(); if (batch.canceled) return; if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value) || (item.format || defaultFormat.value) === "ofd" && batchElements.FontMode.value === "1")) { @@ -8357,7 +8371,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) updateControls(); setBusy(true, "正在生成文件", null, status); try { - const file = window.showSaveFilePicker ? await window.showSaveFilePicker({ + const file = nativeHost || window.showSaveFilePicker ? await saveFilePicker({ suggestedName: fileName, types: [{ description: label, accept: { [mime]: [`.${extension}`] } }], }) : null; @@ -11982,11 +11996,11 @@ async function callWASM(name, ...args) { } try { const transfer = []; - if (name === "ofdgoOpen") { + if (name === "ofdgoOpen" && !nativeHost) { args[0] = args[0].slice(); transfer.push(args[0].buffer); } - if (name === "ofdgoLoadImport" && args[0]) transfer.push(args[0].buffer); + if (name === "ofdgoLoadImport" && args[0] && !nativeHost) transfer.push(args[0].buffer); wasmWorker.postMessage({ id, name, args }, transfer); } catch (err) { wasmRequests.delete(id); @@ -12081,6 +12095,7 @@ function formatSize(value) { } function downloadBytes(bytes, mime, name) { + if (nativeHost) return nativeHost.download(bytes, mime, name).catch(error => { if (error.name !== "AbortError") showError(error, false); }); const blob = new Blob([bytes], { type: mime }); const link = document.createElement("a"); const url = URL.createObjectURL(blob); diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index 158b673..9280990 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -159,7 +159,8 @@ export class FontPicker { } export class FontManager { - constructor({ onChange, onPermissionChange }) { + constructor({ onChange, onPermissionChange, host = null }) { + this.host = host; this.userFonts = []; this.localFonts = []; this.catalog = []; @@ -267,7 +268,7 @@ export class FontManager { async queryLocal() { try { - const available = await window.queryLocalFonts(); + const available = this.host ? await this.host.fonts() : await window.queryLocalFonts(); this.catalog = available; this.catalogLoaded = true; this.permission = "granted"; @@ -294,6 +295,7 @@ export class FontManager { } async read(font) { + if (font.nativeFile) return font.nativeFile; if (font.blob) { const blob = await font.blob(); return new Uint8Array(await blob.arrayBuffer()); @@ -407,10 +409,15 @@ export class FontManager { } canReadLocal() { - return typeof window.queryLocalFonts === "function"; + return !!this.host || typeof window.queryLocalFonts === "function"; } async refreshPermission() { + if (this.host) { + this.permission = "granted"; + this.onPermissionChange(); + return; + } if (!this.canReadLocal() || !navigator.permissions?.query) { this.onPermissionChange(); return; diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js new file mode 100644 index 0000000..7a33978 --- /dev/null +++ b/assets/webui/ofdgo_host.js @@ -0,0 +1,214 @@ +export function createEngineWorker() { + return nativeHost ? nativeHost.worker() : new Worker("./ofdgo_wasm.js"); +} + +export async function documentBytes(file) { + return file?.nativeFile ? file : new Uint8Array(await file.arrayBuffer()); +} + +export function saveFilePicker(options) { + return nativeHost ? nativeHost.savePicker(options) : window.showSaveFilePicker ? window.showSaveFilePicker(options) : null; +} + +export function directoryPicker() { + return nativeHost ? nativeHost.directoryPicker() : window.showDirectoryPicker({mode:"readwrite"}); +} + +class NativeFile { + constructor(value) { + Object.assign(this, value); + this.byteLength = this.size; + this.type ||= "application/octet-stream"; + if (value.temporary) nativeHost?.resources?.register(this, value.nativeFile); + } + + async arrayBuffer() { + const response = await fetch(this.url); + if (!response.ok) throw new Error("读取文件失败"); + return response.arrayBuffer(); + } + + async text() { + return new TextDecoder().decode(await this.arrayBuffer()); + } +} + +class NativeHandle { + constructor(host, value) { + this.host = host; + Object.assign(this, value); + } + + async getFile() { + return new NativeFile(await this.host.call("file", {handle:this.nativeHandle})); + } + + async getFileHandle(name, options = {}) { + return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:false})); + } + + async getDirectoryHandle(name, options = {}) { + return new NativeHandle(this.host, await this.host.call("child", {handle:this.nativeHandle, name, create:!!options.create, directory:true})); + } + + removeEntry(name, options = {}) { + return this.host.call("remove", {handle:this.nativeHandle, name, recursive:!!options.recursive}); + } + + async createWritable() { + const {writer} = await this.host.call("write.begin", {handle:this.nativeHandle}); + return { + write: async data => this.host.call("write.data", {writer, data:await this.host.encode(data)}), + close: () => this.host.call("write.commit", {writer}), + abort: () => this.host.call("write.abort", {writer}), + }; + } +} + +class NativeHost { + constructor(bridge) { + this.bridge = bridge; + this.resources = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(file => { + this.call("release", {file}).catch(() => {}); + }) : null; + } + + call(name, value) { + return this.bridge.call(`ofdgo.host.${name}`, value); + } + + async encode(value, key = "") { + if (value?.nativeFile) return {nativeFile:value.nativeFile, size:value.size, name:value.name}; + if (value?.nativeHandle) return {nativeHandle:value.nativeHandle, name:value.name, kind:value.kind}; + if (value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + if (!(value instanceof Blob)) { + const bytes = ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value); + if (bytes.byteLength <= 65536 || ["password", "key", "keyPassword", "certificate"].includes(key)) return {nativeBytes:Array.from(bytes)}; + } + const response = await fetch("/__native/input", {method:"POST", body:value}); + if (!response.ok) throw new Error("传递数据失败"); + return response.json(); + } + if (Array.isArray(value)) return Promise.all(value.map(item => this.encode(item))); + if (value && typeof value === "object") { + const result = {}; + for (const [key, item] of Object.entries(value)) result[key] = await this.encode(item, key); + return result; + } + return value; + } + + async decode(value, name = "", key = "") { + if (value?.nativeFile) { + const file = new NativeFile(value); + if (key === "file" || name === "ofdgoConvertPDF" && key === "bytes") return file; + try { + const bytes = await file.arrayBuffer(); + return key === "blob" ? new Blob([bytes], {type:value.type}) : new Uint8Array(bytes); + } finally { + await this.call("release", {file:value.nativeFile}); + } + } + if (Array.isArray(value)) return Promise.all(value.map(item => this.decode(item, name))); + if (value && typeof value === "object") { + const result = {}; + for (const [field, item] of Object.entries(value)) result[field] = await this.decode(item, name, field); + return result; + } + return value; + } + + worker() { + const host = this; + let closed = false; + let unsubscribe; + let pending = Promise.resolve(); + const canceled = new Set(); + const emit = data => { if (!closed) worker.onmessage?.({data}); }; + const ready = host.call("engine.open", {}).then(({engine}) => { + if (closed) { host.call("engine.close", {engine}).catch(() => {}); return engine; } + unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit); + emit({type:"ready"}); + return engine; + }).catch(error => { if (!closed) worker.onerror?.({message:error.message}); throw error; }); + const worker = { + onmessage:null, + onerror:null, + postMessage(data) { + if (closed) return; + if (data.type === "cancel") { + canceled.add(data.id); + ready.then(engine => host.call("engine.cancel", {engine, id:data.id})).catch(() => {}); + return; + } + if (data.type === "font") return; + pending = pending.then(async () => { + const engine = await ready; + if (closed) return; + if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"}); + const args = await host.encode(data.args); + if (closed) return; + if (canceled.has(data.id)) throw Object.assign(new Error("操作已取消"), {name:"AbortError"}); + const result = await host.call("engine.call", {engine, id:data.id, name:data.name, args}); + emit({id:data.id, ...await host.decode(result, data.name)}); + }).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => canceled.delete(data.id)); + }, + terminate() { + if (closed) return; + closed = true; + unsubscribe?.(); + ready.then(engine => host.call("engine.close", {engine})).catch(() => {}); + }, + }; + ready.catch(() => {}); + return worker; + } + + async savePicker(options) { + return new NativeHandle(this, await this.call("save", options)); + } + + async directoryPicker() { + return new NativeHandle(this, await this.call("directory", {})); + } + + async fonts() { + return (await this.call("fonts", {})).map(font => ({...font, nativeFile:new NativeFile(font.file)})); + } + + async start(onFiles, onError) { + this.bridge.on("ofdgo.files", values => onFiles(values.map(value => new NativeFile(value)))); + this.bridge.on("ofdgo.error", message => onError(new Error(message))); + const title = document.querySelector("title"); + if (title) new MutationObserver(() => this.call("title", {title:document.title}).catch(onError)).observe(title, {childList:true}); + await this.call("ready", {}); + } + + installFileInputs(onError) { + document.addEventListener("click", async event => { + const input = event.target; + if (!(input instanceof HTMLInputElement) || input.type !== "file" || input.disabled) return; + event.preventDefault(); + try { + const files = (await this.call("open", {multiple:input.multiple, directory:input.webkitdirectory, accept:input.accept})).map(value => new NativeFile(value)); + if (!files.length) return; + const transfer = new DataTransfer(); + for (const file of files) transfer.items.add(new File([], file.name, {type:file.type})); + delete input.files; + input.files = transfer.files; + Object.defineProperty(input, "files", {configurable:true, get:() => input.value ? files : []}); + input.dispatchEvent(new Event("input", {bubbles:true})); + input.dispatchEvent(new Event("change", {bubbles:true})); + } catch (error) { if (error.name !== "AbortError") onError(error); } + }, true); + } + + async download(data, mime, name) { + const handle = await this.savePicker({suggestedName:name}); + const writer = await handle.createWritable(); + try { await writer.write(data); await writer.close(); } + catch (error) { await writer.abort().catch(() => {}); throw error; } + } +} + +export const nativeHost = globalThis.guigo ? new NativeHost(globalThis.guigo) : null; diff --git a/assets/webui/ofdgo_work.js b/assets/webui/ofdgo_work.js index 5f39d17..1642c61 100644 --- a/assets/webui/ofdgo_work.js +++ b/assets/webui/ofdgo_work.js @@ -1,6 +1,6 @@ const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`; const META_CACHE = `${CACHE_PREFIX}meta`; -const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"] +const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"] .map((path) => new URL(path, self.registration.scope).href); const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href; diff --git a/cmd/webview/webview.go b/cmd/webview/webview.go new file mode 100644 index 0000000..683d1f8 --- /dev/null +++ b/cmd/webview/webview.go @@ -0,0 +1,40 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && amd64 + +package main + +import ( + "context" + "flag" + "fmt" + "os" + "syscall" + "unsafe" + + "github.com/xiaoqidun/ofdgo/internal/webview" +) + +func main() { + dataDir := flag.String("data-dir", "", "browser data directory") + flag.Parse() + if err := webview.Run(context.Background(), flag.Args(), *dataDir); err != nil { + fmt.Fprintln(os.Stderr, err) + message, _ := syscall.UTF16PtrFromString(err.Error()) + title, _ := syscall.UTF16PtrFromString("OFDGo") + syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(0, uintptr(unsafe.Pointer(message)), uintptr(unsafe.Pointer(title)), 0x10) + os.Exit(1) + } +} diff --git a/cmd/webview/webview_unsupported.go b/cmd/webview/webview_unsupported.go new file mode 100644 index 0000000..57d0940 --- /dev/null +++ b/cmd/webview/webview_unsupported.go @@ -0,0 +1,27 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows || !amd64 + +package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "OFDGo WebView Unsupported") + os.Exit(1) +} diff --git a/go.mod b/go.mod index 05fc51d..960c631 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-text/typesetting v0.3.5 github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918 + github.com/xiaoqidun/guigo v0.0.0-20261017082403-15e0454d4ca3 github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/jbig2 v1.1.8 github.com/xiaoqidun/pdfgo v0.0.0-20261016003351-18348c283ac1 diff --git a/go.sum b/go.sum index 2cd3bfb..57587ea 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8 github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= +github.com/xiaoqidun/guigo v0.0.0-20261017082403-15e0454d4ca3 h1:hyp9meRTDfe22ox/s4n3ZcBWV8CHR+JURoiDAhKSDSg= +github.com/xiaoqidun/guigo v0.0.0-20261017082403-15e0454d4ca3/go.mod h1:QAHK2Sa/KvY6dO+6dtvVUeOXVQO4Zh8R5oSSU+av7kE= github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw= github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= diff --git a/internal/base/font/match.go b/internal/base/font/match.go index 9a9c986..60aadeb 100644 --- a/internal/base/font/match.go +++ b/internal/base/font/match.go @@ -174,7 +174,7 @@ func NormalizeName(name string) string { // 返回: bool 是否为字体文件 func IsFontFileName(name string) bool { switch strings.ToLower(path.Ext(name)) { - case ".ttf", ".otf", ".ttc": + case ".ttf", ".otf", ".ttc", ".otc": return true default: return false diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go deleted file mode 100644 index c2f7a0d..0000000 --- a/internal/webui/wasm.go +++ /dev/null @@ -1,6474 +0,0 @@ -// Copyright 2025-2026 肖其顿 (XIAO QI DUN) -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build js && wasm - -package webui - -import ( - "bufio" - "bytes" - "context" - "crypto" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "image/color" - "io" - "io/fs" - "math" - "reflect" - "slices" - "strconv" - "strings" - "syscall/js" - "time" - - "github.com/xiaoqidun/ofdgo" -) - -// currentSession 当前WebUI文档会话 -var currentSession *Session - -// currentEditor 当前编辑文档 -var currentEditor *ofdgo.Editor - -// pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话 -var pendingImport *ofdgo.Reader - -// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 -var copiedObjects *editorClipboard - -// copiedStyle 当前文档中的独立样式快照 -var copiedStyle *ofdgo.GraphicObject - -// exportWriter 分块传递导出数据 -type exportWriter struct { - write js.Value -} - -// browserReaderAt 分块读取浏览器文件,只缓存最近的一个数据块 -type browserReaderAt struct { - read js.Value - checkpoint js.Value - size int64 - offset int64 - data []byte - checked time.Time -} - -// apiResult 浏览器接口返回结果 -type apiResult struct { - OK bool `json:"ok"` - Code string `json:"code,omitempty"` - Error string `json:"error,omitempty"` - Data any `json:"data,omitempty"` - MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"` - ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"` -} - -// editorClipboard 当前编辑文档中的对象快照与剪贴板标识 -type editorClipboard struct { - token string - selection *ofdgo.ObjectSelection - composite *ofdgo.CompositeSelection -} - -// editorInfo 编辑文档信息与操作状态 -type editorInfo struct { - DocumentInfo - Revision uint64 `json:"revision"` - CanUndo bool `json:"canUndo"` - CanRedo bool `json:"canRedo"` - PageCapabilities []uint `json:"pageCapabilities"` - EditWarnings []string `json:"editWarnings,omitempty"` -} - -// editorPageInfo 页面操作结果与目标页面 -type editorPageInfo struct { - editorInfo - PageIndex int `json:"pageIndex"` -} - -// editorSelectionInfo 对象复制结果及新选区 -type editorSelectionInfo struct { - editorInfo - SelectedIDs []string `json:"selectedIDs"` -} - -// editorOutlineInfo 目录操作后的位置 -type editorOutlineInfo struct { - editorInfo - OutlinePath []int `json:"outlinePath"` -} - -// invoiceRequest 发票操作参数 -type invoiceRequest struct { - Operation string `json:"operation"` - Operations []invoiceRequest `json:"operations"` - Source ofdgo.InvoiceSource `json:"source"` - Index int `json:"index"` - Digest string `json:"digest"` - Invoice ofdgo.Invoice `json:"invoice"` - Changes []ofdgo.InvoiceFieldChange `json:"changes"` - Bindings []ofdgo.InvoiceBinding `json:"bindings"` - XML string `json:"xml"` - Options ofdgo.InvoiceCreateOptions `json:"options"` - Layout ofdgo.InvoiceLayout `json:"layout"` - FontData []byte `json:"fontData"` - Synchronize bool `json:"synchronize"` - DocumentIndex *int `json:"documentIndex"` - Version *string `json:"version"` - Revision *uint64 `json:"revision"` - indexProvided bool -} - -// invoiceEditorInfo 发票操作结果与编辑状态 -type invoiceEditorInfo struct { - editorInfo - InvoiceResult ofdgo.InvoiceUpdateResult `json:"invoiceResult"` -} - -// annotationOptions 保存注解面板的输入,资源字节单独传输 -type annotationOptions struct { - Kind, ID, Text, Old, Creator, Pages, URI, Color string - Base string - X, Y, Width, Height, Size, Angle float64 - Alpha int - Tile bool - Keep bool - Target int - Area *ofdgo.Box -} - -// outputContext 定期让出浏览器线程,将取消请求传入库层长操作 -type outputContext struct { - context.Context - cancel context.CancelFunc - checkpoint func() error - checked time.Time - pending uint8 - interval uint8 -} - -// Write 将数据块交给浏览器保存 -// 入参: data 导出数据 -// 返回: int 写入长度, error 错误信息 -func (w exportWriter) Write(data []byte) (int, error) { - size := len(data) - for len(data) > 0 { - n := min(len(data), 1<<20) - if err := awaitExport(w.write, bytesToJS(data[:n])); err != nil { - return size - len(data), err - } - data = data[n:] - } - return size, nil -} - -// ReadAt 按需读取文件片段并定期交付取消检查点 -// 入参: data 目标缓冲, offset 文件偏移 -// 返回: int 读取字节数, error 读取错误 -func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { - if offset < 0 { - return 0, fmt.Errorf("negative file offset") - } - n := 0 - for len(data) > 0 { - if offset >= r.size { - return n, io.EOF - } - if time.Since(r.checked) >= 50*time.Millisecond { - if err := awaitExport(r.checkpoint); err != nil { - return n, err - } - r.checked = time.Now() - } - if len(r.data) == 0 || offset < r.offset || offset-r.offset >= int64(len(r.data)) { - r.offset = offset / (256 << 10) * (256 << 10) - size := int(min(int64(256<<10), r.size-r.offset)) - if cap(r.data) < size { - r.data = make([]byte, size) - } else { - r.data = r.data[:size] - } - value := r.read.Invoke(float64(r.offset), size) - if js.CopyBytesToGo(r.data, value) != size { - return n, io.ErrUnexpectedEOF - } - } - copied := copy(data, r.data[offset-r.offset:]) - n += copied - offset += int64(copied) - data = data[copied:] - } - return n, nil -} - -// RunWASM 注册浏览器WASM接口并阻塞运行 -func RunWASM() { - registerAsyncCallback("ofdgoDetectFormat", detectDocumentFormat) - registerCallback("ofdgoOpen", openDocument) - registerCallback("ofdgoSelectDocument", selectDocument) - registerCallback("ofdgoDocumentVersions", documentVersions) - registerAsyncCallback("ofdgoExportVersion", exportVersion) - registerCallback("ofdgoAnnotationInfos", annotationInfos) - registerCallback("ofdgoDocumentNavigation", documentNavigation) - registerCallback("ofdgoInvoices", documentInvoices) - registerCallback("ofdgoInvoice", documentInvoice) - registerCallback("ofdgoExportInvoice", exportDocumentInvoice) - registerCallback("ofdgoValidateInvoice", validateDocumentInvoice) - registerCallback("ofdgoChangeInvoice", changeDocumentInvoice) - registerCallback("ofdgoChangeBookmark", changeBookmark) - registerCallback("ofdgoActionList", actionList) - registerCallback("ofdgoChangeActions", changeActions) - registerCallback("ofdgoStyleResource", styleResource) - registerCallback("ofdgoChangeStyleResource", changeStyleResource) - registerCallback("ofdgoApplyStyleResource", applyStyleResource) - registerCallback("ofdgoPageSettings", pageSettings) - registerCallback("ofdgoChangeLayer", changeLayer) - registerCallback("ofdgoChangeTemplate", changeTemplate) - registerCallback("ofdgoSelectVersion", selectDocumentVersion) - registerCallback("ofdgoChangeVersion", changeDocumentVersion) - registerCallback("ofdgoChangeDocument", changePackageDocument) - registerCallback("ofdgoPackageDocuments", packageDocuments) - registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument) - registerAsyncCallback("ofdgoConvertFile", convertFile) - registerAsyncCallback("ofdgoPackFiles", packFiles) - registerCallback("ofdgoOutputFormats", func([]js.Value) (any, error) { return ofdgo.OutputFormats(), nil }) - registerCallback("ofdgoConfigure", configureDocument) - registerCallback("ofdgoDocumentInfo", documentInfo) - registerCallback("ofdgoVerifySignatures", verifySignatures) - registerCallback("ofdgoRenderPage", renderPage) - registerCallback("ofdgoOutlineLinks", outlineLinks) - registerCallback("ofdgoStructureTargets", structureTargets) - registerCallback("ofdgoStructureObjects", structureObjects) - registerCallback("ofdgoChangeStructure", changeStructure) - registerCallback("ofdgoOpenLinks", openLinks) - registerCallback("ofdgoRenderBackends", renderBackends) - registerCallback("ofdgoSVGFontData", svgFontData) - registerCallback("ofdgoSearchPage", searchPage) - registerCallback("ofdgoPageTextString", pageTextString) - registerCallback("ofdgoExportFormats", exportFormats) - registerCallback("ofdgoParsePageRange", parsePageRange) - registerAsyncCallback("ofdgoExportPage", exportPage) - registerAsyncCallback("ofdgoExportDocument", exportDocument) - registerAsyncCallback("ofdgoExportAttachment", exportAttachment) - registerAsyncCallback("ofdgoExportMedia", exportMedia) - registerAsyncCallback("ofdgoResources", documentResources) - registerCallback("ofdgoResourceReferences", documentResourceReferences) - registerCallback("ofdgoResourceTargets", resourceTargets) - registerCallback("ofdgoResourceLocations", resourceLocations) - registerCallback("ofdgoFontPreview", fontPreview) - registerCallback("ofdgoChangeResource", changeResource) - registerAsyncCallback("ofdgoExportResource", exportResource) - registerCallback("ofdgoImages", documentImages) - registerAsyncCallback("ofdgoExportImages", exportImages) - registerCallback("ofdgoMatchFontFiles", matchFontFiles) - registerCallback("ofdgoChangeFontBinding", changeFontBinding) - registerCallback("ofdgoFontFaces", fontFaces) - registerCallback("ofdgoFontFace", fontFace) - registerCallback("ofdgoCreateDocument", createDocument) - registerAsyncCallback("ofdgoImportImages", importImages) - registerCallback("ofdgoEditDocument", editDocument) - registerCallback("ofdgoUpdateInfo", updateInfo) - registerCallback("ofdgoChangeAttachment", changeAttachment) - registerCallback("ofdgoChangePage", changePage) - registerCallback("ofdgoBatchPages", batchPages) - registerCallback("ofdgoChangeOutline", changeOutline) - registerCallback("ofdgoChangeAnnotations", changeAnnotations) - registerCallback("ofdgoChangeSelection", changeSelection) - registerCallback("ofdgoWriteAnnotation", writeAnnotation) - registerCallback("ofdgoReadAnnotation", readAnnotation) - registerCallback("ofdgoInsertInk", insertInk) - registerCallback("ofdgoStyleObjects", styleObjects) - registerCallback("ofdgoPreviewPositionedText", previewPositionedText) - registerCallback("ofdgoMoveOutline", moveOutline) - registerCallback("ofdgoCaptureStyle", captureStyle) - registerCallback("ofdgoGroupObjects", groupObjects) - registerCallback("ofdgoPasteStyle", pasteStyle) - registerCallback("ofdgoResizeObjects", resizeObjects) - registerCallback("ofdgoLoadImport", loadImport) - registerAsyncCallback("ofdgoImportPages", importPages) - registerAsyncCallback("ofdgoImportDocuments", importDocuments) - registerCallback("ofdgoInsertText", insertText) - registerCallback("ofdgoUpdateText", updateText) - registerCallback("ofdgoPreviewText", previewText) - registerCallback("ofdgoTextSelection", textSelection) - registerCallback("ofdgoPreviewTextSelection", previewTextSelection) - registerCallback("ofdgoUpdateTextSelection", updateTextSelection) - registerCallback("ofdgoStyleText", styleText) - registerCallback("ofdgoGetRichText", getRichText) - registerCallback("ofdgoSaveRichText", saveRichText) - registerCallback("ofdgoPreviewRichText", previewRichText) - registerCallback("ofdgoCheckTextFont", checkTextFont) - registerCallback("ofdgoInsertImage", insertImage) - registerCallback("ofdgoInsertShape", insertShape) - registerCallback("ofdgoUpdatePathStyle", updatePathStyle) - registerCallback("ofdgoReplaceImage", replaceImage) - registerCallback("ofdgoEditorFont", editorFont) - registerCallback("ofdgoAlignObject", alignObject) - registerCallback("ofdgoTransformObject", transformObject) - registerCallback("ofdgoReshapeObject", reshapeObject) - registerCallback("ofdgoReshapeLine", reshapeLine) - registerCallback("ofdgoResetImageCrop", resetImageCrop) - registerCallback("ofdgoLayoutText", layoutText) - registerCallback("ofdgoTransformObjects", transformObjects) - registerCallback("ofdgoCompositeObjects", compositeObjects) - registerCallback("ofdgoChangeCompositeObjects", changeCompositeObjects) - registerCallback("ofdgoAlignObjects", alignObjects) - registerCallback("ofdgoDeleteObjects", deleteObjects) - registerCallback("ofdgoEraseObjects", eraseObjects) - registerCallback("ofdgoEraseObjectsPath", eraseObjectsPath) - registerCallback("ofdgoCopyObjects", copyObjects) - registerCallback("ofdgoCaptureObjects", captureObjects) - registerCallback("ofdgoPasteObjects", pasteObjects) - registerCallback("ofdgoOrderObjects", orderObjects) - registerCallback("ofdgoDistributeObjects", distributeObjects) - registerCallback("ofdgoRotateObjects", rotateObjects) - registerCallback("ofdgoFlipObjects", flipObjects) - registerCallback("ofdgoCropImage", cropImage) - registerCallback("ofdgoFitImage", fitImage) - registerCallback("ofdgoPreviewImage", previewImage) - registerCallback("ofdgoDeleteObject", deleteObject) - registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) - registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) - registerAsyncCallback("ofdgoSaveDocument", saveDocument) - registerAsyncCallback("ofdgoSetEncryption", setEncryption) - registerAsyncCallback("ofdgoSaveSigned", saveSigned) - registerCallback("ofdgoPreviewSignatureSeal", previewSignatureSeal) - registerCallback("ofdgoSignaturePlacement", signaturePlacement) - select {} -} - -// awaitExport 等待浏览器处理导出检查点 -// 入参: fn 浏览器回调, args 回调参数 -// 返回: error 写入错误或取消原因 -func awaitExport(fn js.Value, args ...any) error { - done := make(chan error) - callback := js.FuncOf(func(this js.Value, args []js.Value) any { - var err error - if args[1].Bool() { - err = context.Canceled - } else if message := args[0].String(); message != "" { - err = fmt.Errorf("%s", message) - } - done <- err - return nil - }) - defer callback.Release() - fn.Invoke(append(args, callback)...) - return <-done -} - -// registerCallback 注册浏览器回调函数 -// 入参: name 回调名称, fn 回调函数 -func registerCallback(name string, fn func([]js.Value) (any, error)) { - cb := js.FuncOf(func(this js.Value, args []js.Value) any { - return callbackResult(fn, args) - }) - js.Global().Set(name, cb) -} - -// renderBackends 返回库提供的内置后端能力 -// 入参: args 浏览器参数 -// 返回: any 后端能力, error 错误信息 -func renderBackends(args []js.Value) (any, error) { - return ofdgo.RenderBackendInfos(), nil -} - -// registerAsyncCallback 注册异步回调,为浏览器处理数据和取消请求让出事件循环 -// 入参: name 回调名称, fn 回调函数 -func registerAsyncCallback(name string, fn func([]js.Value) (any, error)) { - cb := js.FuncOf(func(this js.Value, args []js.Value) any { - executor := js.FuncOf(func(this js.Value, callbacks []js.Value) any { - resolve := callbacks[0] - go func() { - resolve.Invoke(callbackResult(fn, args)) - }() - return nil - }) - defer executor.Release() - return js.Global().Get("Promise").New(executor) - }) - js.Global().Set(name, cb) -} - -// callbackResult 转换浏览器接口结果 -// 入参: fn 回调函数, args 回调参数 -// 返回: any 浏览器结果 -func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any { - data, err := safeCall(fn, args) - if err != nil { - result := apiResult{Error: err.Error()} - switch { - case errors.Is(err, ofdgo.ErrPDFPassword): - result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误" - case errors.Is(err, ofdgo.ErrCredentialsRequired): - result.Code, result.Error = "credentialsRequired", "文档已加密" - case errors.Is(err, ofdgo.ErrInvalidCredentials): - result.Code, result.Error = "invalidCredentials", "凭据无效或文件损坏" - case errors.Is(err, ofdgo.ErrUnsupportedEncryption): - result.Code, result.Error = "unsupportedEncryption", "不支持此加密方式" - case errors.Is(err, ofdgo.ErrEncryptionPolicyRequired): - result.Code, result.Error = "encryptionPolicyRequired", "请设置加密方式" - } - var failure *ofdgo.EditError - if errors.As(err, &failure) { - result.ReasonCode = failure.Code - } - if errors.As(err, &result.MissingGlyphs) { - result.ReasonCode = ofdgo.EditMissingGlyphs - } - return encodeResult(result) - } - if result, ok := data.(js.Value); ok { - return result - } - return encodeResult(apiResult{OK: true, Data: data}) -} - -// safeCall 调用浏览器回调并转换异常 -// 入参: fn 回调函数, args 回调参数 -// 返回: any 回调结果, error 错误信息 -func safeCall(fn func([]js.Value) (any, error), args []js.Value) (data any, err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("%v", r) - } - }() - return fn(args) -} - -// detectDocumentFormat 通过库按需识别输入,不加载或替换文档会话 -// 入参: args 随机读取回调、文件大小及检查点 -// 返回: any 格式标识, error 读取或识别错误 -func detectDocumentFormat(args []js.Value) (any, error) { - if len(args) != 3 { - return nil, fmt.Errorf("missing detection arguments") - } - source := &browserReaderAt{read: args[0], size: int64(args[1].Float()), checkpoint: args[2]} - return ofdgo.DetectFormat(source, source.size) -} - -// convertPDFDocument 通过库转换PDF,不替换当前文档会话 -// 入参: args PDF数据、外部字体、UTF-8密码、字体目录、进度及字体读取回调 -// 返回: any OFD数据及转换警告, error 错误信息 -func convertPDFDocument(args []js.Value) (any, error) { - if len(args) != 6 { - return nil, fmt.Errorf("missing PDF data") - } - data, err := bytesFromJS(args[0]) - if err != nil { - return nil, err - } - fonts, err := fontsFromJS(args[1]) - if err != nil { - return nil, err - } - var output bytes.Buffer - options := ofdgo.PDFImportOptions{OnProgress: editorOperationProgress(args[4]), PasswordUTF8: true} - if !args[2].IsNull() && !args[2].IsUndefined() { - options.Password, err = bytesFromJS(args[2]) - if err != nil { - return nil, err - } - defer clear(options.Password) - } - fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} - if names := stringsFromJS(args[3]); len(names) > 0 { - fontSources = append(fontSources, ofdgo.NewLazyFontFS(names, func(name string) ([]byte, error) { - return loadConversionFont(args[5], name) - })) - } - options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(fontSources...)} - editor, report, err := ofdgo.ImportPDF(context.Background(), bytes.NewReader(data), int64(len(data)), options) - if err != nil { - return nil, err - } - if len(options.Password) != 0 { - if err := editor.SetEncryption(&ofdgo.EncryptionOptions{Password: options.Password}); err != nil { - return nil, err - } - } - editor.OnWriteProgress = func(stage string, completed, total int) error { - return options.OnProgress("write."+stage, completed, total) - } - if _, err := editor.WriteTo(&output); err != nil { - return nil, err - } - warnings, err := json.Marshal(report.Warnings) - if err != nil { - return nil, err - } - return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "warnings": string(warnings)}), nil -} - -// convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话 -// 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查、字体及按需字体读取 -// 返回: any 转换报告, error 转换错误 -func convertFile(args []js.Value) (any, error) { - if len(args) != 9 { - return nil, fmt.Errorf("missing conversion arguments") - } - settings := args[2] - backends, err := ofdgo.NewRenderBackends(settings.Get("backend").String()) - if err != nil { - return nil, err - } - fonts, err := fontsFromJS(args[7]) - if err != nil { - return nil, err - } - options := ofdgo.ConvertOptions{ - Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(), - SkipUnchanged: true, - Backends: &backends, - RendererOptions: []ofdgo.RendererOption{ofdgo.WithDPI(settings.Get("dpi").Float())}, - OnProgress: func(progress ofdgo.ConvertProgress) error { - return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) - }, - } - if value := settings.Get("fontMode"); value.Type() == js.TypeNumber { - mode := value.Int() - if mode < 0 || mode > int(ofdgo.FontExternal) { - return nil, fmt.Errorf("invalid font mode") - } - options.Fonts = ofdgo.FontMode(mode) - } - if value := settings.Get("compression"); !value.IsNull() && !value.IsUndefined() { - if err := compressionFromJS(value, &options.Compression); err != nil { - return nil, err - } - } - fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} - if names := settings.Get("fontNames"); !names.IsUndefined() && names.Length() > 0 { - fontSources = append(fontSources, ofdgo.NewLazyFontFS(stringsFromJS(names), func(name string) ([]byte, error) { - return loadConversionFont(args[8], name) - })) - } - options.RendererOptions = append(options.RendererOptions, ofdgo.WithFontFS(fontSources...)) - if value := settings.Get("credentials"); !value.IsNull() && !value.IsUndefined() { - credentials, err := credentialsFromJS(value) - if err != nil { - return nil, err - } - defer clear(credentials.Password) - options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) - options.PDF.Password = credentials.Password - options.PDF.PasswordUTF8 = true - } - var format ofdgo.OutputFormat - for _, item := range ofdgo.OutputFormats() { - if item.Value == options.Format { - format = item - } - } - writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) - if format.Paged { - options.PageOutput = func(index int, export func(io.Writer) error) error { - name := fmt.Sprintf("%06d.%s", index+1, format.Extension) - if err := awaitExport(args[5], "open", name); err != nil { - return err - } - if err := export(writer); err != nil { - return err - } - if err := writer.Flush(); err != nil { - return err - } - return awaitExport(args[5], "close", name) - } - } else if err := awaitExport(args[5], "open", "document."+format.Extension); err != nil { - return nil, err - } - source := &browserReaderAt{read: args[0], checkpoint: args[6], size: int64(args[1].Float())} - report, err := ofdgo.Convert(newOutputContext(args[6]), source, source.size, writer, options) - if err != nil { - return nil, err - } - if report.Unchanged && report.Bytes == 0 { - return report, nil - } - if err := writer.Flush(); err != nil { - return nil, err - } - if !format.Paged { - if err := awaitExport(args[5], "close", "document."+format.Extension); err != nil { - return nil, err - } - } - return report, nil -} - -// loadConversionFont 按需读取浏览器已授权字体,不预读系统字体目录 -// 入参: load 浏览器读取函数, name 字体文件名 -// 返回: []byte 字体内容, error 读取错误或取消原因 -func loadConversionFont(load js.Value, name string) ([]byte, error) { - done := make(chan error, 1) - var data []byte - callback := js.FuncOf(func(this js.Value, args []js.Value) any { - var err error - if args[2].Bool() { - err = context.Canceled - } else if message := args[1].String(); message != "" { - err = fmt.Errorf("%s", message) - } else { - data, err = bytesFromJS(args[0]) - } - done <- err - return nil - }) - defer callback.Release() - load.Invoke(name, callback) - err := <-done - return data, err -} - -// packFiles 将已经成功转换的文件逐个打包,不重复转换或读取整个文件 -// 入参: args 文件列表、分块读取、分块写出、进度及取消检查 -// 返回: any 输出格式, error 打包错误 -func packFiles(args []js.Value) (any, error) { - if len(args) < 5 { - return nil, fmt.Errorf("missing archive arguments") - } - writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20) - files := args[0] - entries := make([]ofdgo.ArchiveFile, files.Length()) - for i := 0; i < files.Length(); i++ { - file := files.Index(i) - name := file.Get("name").String() - read := args[1].Invoke(i) - source := &browserReaderAt{read: read, checkpoint: args[4], size: int64(file.Get("size").Float())} - entries[i] = ofdgo.ArchiveFile{Name: name, Source: source, Size: source.size} - } - options := ofdgo.ArchiveOptions{OnProgress: func(completed, total int) error { - if completed > 0 { - entries[completed-1].Source.(*browserReaderAt).data = nil - } - return awaitExport(args[3], completed, total) - }} - if len(args) > 5 && !args[5].IsUndefined() && !args[5].IsNull() { - if err := compressionFromJS(args[5], &options.Compression); err != nil { - return nil, err - } - } - if _, err := ofdgo.WriteArchive(newOutputContext(args[4]), writer, entries, options); err != nil { - return nil, err - } - if err := writer.Flush(); err != nil { - return nil, err - } - return map[string]any{"mime": "application/zip"}, nil -} - -// openDocument 打开OFD文档 -// 入参: args 浏览器参数 -// 返回: any 文档信息, error 错误信息 -func openDocument(args []js.Value) (any, error) { - if len(args) < 3 { - return nil, fmt.Errorf("missing open arguments") - } - data, err := bytesFromJS(args[0]) - if err != nil { - return nil, err - } - fonts, err := fontsFromJS(args[1]) - if err != nil { - return nil, err - } - renderAnnotations := args[2].Bool() - options := OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations} - if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { - credentials, err := credentialsFromJS(args[3]) - if err != nil { - return nil, err - } - defer clear(credentials.Password) - options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) - } - session, err := Open(data, options) - if err != nil { - return nil, err - } - clearImport() - currentEditor = nil - copiedObjects = nil - copiedStyle = nil - if currentSession != nil { - _ = currentSession.Close() - currentSession = nil - } - currentSession = session - return currentSession.Summary(), nil -} - -// configureDocument 更新当前会话的字体和注解设置 -// 入参: args 浏览器参数 -// 返回: any 文档信息, error 错误信息 -func configureDocument(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if !args[0].IsNull() && !args[0].IsUndefined() { - fonts, err := fontsFromJS(args[0]) - if err != nil { - return nil, err - } - if err := currentSession.SetFonts(fonts); err != nil { - return nil, err - } - if currentEditor != nil { - currentEditor.SetFontFS() - if currentSession.fontFS != nil { - currentEditor.SetFontFS(currentSession.fontFS) - } - } - } - if currentSession.Renderer.RenderAnnotations != args[1].Bool() { - currentSession.Renderer.RenderAnnotations = args[1].Bool() - clear(currentSession.textCache) - currentSession.textOrder = nil - } - return currentSession.Summary(), nil -} - -// documentInfo 获取字体统计和验签结果,首个参数为true时分批扫描 -// 入参: args 浏览器参数 -// 返回: any 文档信息, error 错误信息 -func documentInfo(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if len(args) > 0 && args[0].Bool() { - if !currentSession.scanFontInfoBatch() { - return map[string]bool{"detailsPending": true}, nil - } - return map[string]any{"fonts": currentSession.fontInfos, "fontCount": len(currentSession.fontInfos), "detailsPending": false}, nil - } - return currentSession.Details(), nil -} - -// documentImages 读取当前文档的原始图片目录 -// 入参: args 浏览器参数 -// 返回: any 图片目录, error 读取错误 -func documentImages(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if len(args) != 0 { - return currentSession.Reader.Image(context.Background(), args[0].String()) - } - return currentSession.Reader.Images(context.Background()) -} - -// exportImages 分块保存单幅原始图片或图片归档 -// 入参: args 图片标识和写出回调,空标识保存全部 -// 返回: any 文件类型, error 读写错误 -func exportImages(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) - mime := "application/octet-stream" - if id := args[0].String(); id != "" { - input, err := currentSession.Reader.OpenImage(context.Background(), id) - if err != nil { - return nil, err - } - defer input.Close() - if _, err := io.Copy(writer, input); err != nil { - return nil, err - } - } else { - mime = "application/zip" - if err := currentSession.Reader.WriteImages(context.Background(), writer); err != nil { - return nil, err - } - } - if err := writer.Flush(); err != nil { - return nil, err - } - return successResult(map[string]any{"mime": mime}), nil -} - -// exportAttachment 分块导出附件 -// 入参: args 浏览器参数 -// 返回: any 附件数据, error 错误信息 -func exportAttachment(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - stream, err := currentSession.Reader.OpenAttachment(args[0].String()) - if err != nil { - return nil, err - } - defer stream.Close() - writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) - if _, err := io.Copy(writer, stream); err != nil { - return nil, err - } - if err := writer.Flush(); err != nil { - return nil, err - } - return successResult(map[string]any{ - "mime": "application/octet-stream", - }), nil -} - -// exportMedia 分块读取文档音视频,播放由浏览器负责 -// 入参: args 资源标识和分块接收函数 -// 返回: any 媒体格式, error 资源或读取错误 -func exportMedia(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - media, err := currentSession.Reader.Media(args[0].String()) - if err != nil { - return nil, err - } - stream, err := currentSession.Reader.OpenMedia(media.ID) - if err != nil { - return nil, err - } - defer stream.Close() - writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) - if _, err := io.Copy(writer, stream); err != nil { - return nil, err - } - if err := writer.Flush(); err != nil { - return nil, err - } - return successResult(map[string]any{"format": media.Format, "kind": media.Type}), nil -} - -// documentResources 读取当前文档和版本的资源摘要及引用总数,不执行媒体动作 -// 入参: args 可选的取消检查回调 -// 返回: any 资源摘要, error 读取错误 -func documentResources(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - ctx := context.Background() - if len(args) == 1 { - operation := newOutputContext(args[0]) - operation.interval = 255 - ctx = operation - } - return currentSession.Reader.ResourceSummaries(ctx) -} - -// documentResourceReferences 分页读取选中资源的引用位置 -// 入参: args 资源类型、标识、零基起始位置和最大数量 -// 返回: any 引用列表, error 参数或读取错误 -func documentResourceReferences(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 4 { - return nil, fmt.Errorf("invalid resource reference request") - } - return currentSession.Reader.ResourceReferences(context.Background(), args[0].String(), args[1].String(), args[2].Int(), args[3].Int()) -} - -// resourceTargets 列出当前页可绑定指定资源的图元及已有绑定状态 -// 入参: args 零基页码、资源类型和标识 -// 返回: any 图元列表, error 页面读取错误 -func resourceTargets(args []js.Value) (any, error) { - if currentEditor == nil || len(args) != 3 { - return nil, fmt.Errorf("no document is being edited") - } - return currentEditor.ResourceTargets(context.Background(), args[1].String(), args[2].String(), args[0].Int()) -} - -// documentInvoices 获取发票列表与格式能力 -// 入参: args 空参数 -// 返回: any 发票提取结果与格式列表, error 读取错误 -func documentInvoices(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 0 { - return nil, fmt.Errorf("ofd document is not opened") - } - r, close, err := currentInvoiceReader() - if err != nil { - return nil, err - } - defer close() - report, err := r.Invoices() - if err != nil { - return nil, err - } - return map[string]any{"report": report, "formats": ofdgo.InvoiceFormats()}, nil -} - -// currentInvoiceReader 获取当前发票读取器 -// 返回: *ofdgo.Reader 当前读取器, func() 快照释放函数, error 快照错误 -func currentInvoiceReader() (*ofdgo.Reader, func(), error) { - if currentEditor != nil { - r, err := currentEditor.Reader() - if err != nil { - return nil, nil, err - } - return r, func() { _ = r.Close() }, nil - } - if currentSession == nil { - return nil, nil, fmt.Errorf("ofd document is not opened") - } - return currentSession.Reader, func() {}, nil -} - -// decodeInvoiceRequest 解析发票参数并校验文档状态 -// 入参: args 单个JSON操作对象 -// 返回: invoiceRequest 操作参数, error 参数或过期状态错误 -func decodeInvoiceRequest(args []js.Value) (invoiceRequest, error) { - var request invoiceRequest - if currentSession == nil || len(args) != 1 || args[0].Type() != js.TypeString { - return request, fmt.Errorf("invalid invoice arguments") - } - data := args[0].String() - if len(data) > 64<<20 { - return request, fmt.Errorf("invoice request exceeds size limit") - } - if err := json.Unmarshal([]byte(data), &request); err != nil { - return request, err - } - var presence struct { - Operations []struct { - Index *int `json:"index"` - } `json:"operations"` - } - if err := json.Unmarshal([]byte(data), &presence); err != nil { - return request, err - } - for i := range presence.Operations { - request.Operations[i].indexProvided = presence.Operations[i].Index != nil - } - index := currentSession.Reader.DocumentIndex() - if currentEditor != nil { - index = currentEditor.DocumentIndex() - } - if request.DocumentIndex != nil && *request.DocumentIndex != index { - return request, fmt.Errorf("invoice document has changed") - } - if request.Version != nil { - version, err := currentSession.Reader.DocumentVersionID() - if currentEditor != nil { - version, err = currentEditor.DocumentVersionID() - } - if err != nil { - return request, err - } - if *request.Version != version { - return request, fmt.Errorf("invoice document version has changed") - } - } - if request.Revision != nil && (currentEditor == nil || *request.Revision != currentEditor.Revision()) { - return request, fmt.Errorf("invoice editing revision has changed") - } - return request, nil -} - -// documentInvoice 获取发票详情与票面关联 -// 入参: args 来源及来源内发票索引,新建时提供发票模型 -// 返回: any 发票详情, error 来源、索引或读取错误 -func documentInvoice(args []js.Value) (any, error) { - request, err := decodeInvoiceRequest(args) - if err != nil { - return nil, err - } - ctx := context.Background() - if request.Source.Kind == "" { - return map[string]any{"invoice": request.Invoice, "fields": ofdgo.InvoiceFields(request.Invoice)}, nil - } - r, close, err := currentInvoiceReader() - if err != nil { - return nil, err - } - defer close() - data, err := r.ExportInvoice(ctx, request.Source) - if err != nil { - return nil, err - } - report, err := ofdgo.DecodeInvoices(ctx, data) - if request.Source.Kind == "tag" && len(report.Invoices) == 0 { - report, err = r.ReadInvoice(ctx, request.Source) - } - if err != nil { - return nil, err - } - if request.Index < 0 || request.Index >= len(report.Invoices) { - return nil, fmt.Errorf("invoice index out of range") - } - value := report.Invoices[request.Index] - standardLabel := value.Standard - switch value.Standard { - case ofdgo.InvoiceStandardEDRM2019: - standardLabel = "电子发票/2019" - case ofdgo.InvoiceStandardObjectReference: - standardLabel = "发票标引" - } - capabilities, err := r.InvoiceCapabilities(ctx, request.Source, request.Index) - if err != nil { - return nil, err - } - bindings, bindingErr := r.InvoiceBindings(ctx, request.Source, request.Index) - if bindingErr != nil { - report.Issues = append(report.Issues, ofdgo.InvoiceIssue{Code: "binding-error", Message: bindingErr.Error(), Source: request.Source}) - } - return map[string]any{"invoice": value, "standardLabel": standardLabel, "fields": ofdgo.InvoiceFields(value), "capabilities": capabilities, "bindings": bindings, "digest": ofdgo.InvoiceDigest(data), "xml": string(data), "issues": report.Issues}, nil -} - -// exportDocumentInvoice 导出发票原文 -// 入参: args 发票来源 -// 返回: any 原始字节, error 来源或读取错误 -func exportDocumentInvoice(args []js.Value) (any, error) { - request, err := decodeInvoiceRequest(args) - if err != nil { - return nil, err - } - r, close, err := currentInvoiceReader() - if err != nil { - return nil, err - } - defer close() - data, err := r.ExportInvoice(context.Background(), request.Source) - if err != nil { - return nil, err - } - return successResult(map[string]any{"bytes": bytesToJS(data)}), nil -} - -// validateDocumentInvoice 校验发票草稿 -// 入参: args 发票模型或来源、草稿操作与原始摘要 -// 返回: any 字段与票面诊断, error 校验或读取错误 -func validateDocumentInvoice(args []js.Value) (any, error) { - request, err := decodeInvoiceRequest(args) - if err != nil { - return nil, err - } - ctx := context.Background() - if request.Operation == "" { - switch { - case len(request.Operations) != 0: - request.Operation = "batch" - case len(request.Changes) != 0: - request.Operation = "patch" - case request.XML != "": - request.Operation = "replace" - if request.Source.Kind == "" { - request.Operation = "import" - } - } - } - if request.Operation != "" { - return validateInvoiceDraft(ctx, request) - } - issues, err := ofdgo.ValidateInvoice(ctx, request.Invoice) - if err != nil { - return nil, err - } - var appearance []ofdgo.InvoiceIssue - if request.Source.Kind != "" { - r, close, err := currentInvoiceReader() - if err != nil { - return nil, err - } - defer close() - appearance, err = r.CheckInvoiceAppearance(ctx, request.Source, request.Index) - if err != nil { - appearance = append(appearance, ofdgo.InvoiceIssue{Code: "appearance-error", Message: err.Error(), Source: request.Source}) - } - } - return map[string]any{"issues": issues, "appearance": appearance}, nil -} - -// validateInvoiceDraft 在独立快照中校验发票修改 -// 入参: ctx 取消上下文, request 已核对当前文档状态的操作 -// 返回: any 草稿发票及诊断, error 演练或读取错误 -func validateInvoiceDraft(ctx context.Context, request invoiceRequest) (any, error) { - r, close, err := currentInvoiceReader() - if err != nil { - return nil, err - } - defer close() - editor, err := r.EditorContext(ctx) - if err != nil { - return nil, err - } - request.Operations = append([]invoiceRequest(nil), request.Operations...) - for i := range request.Operations { - operation := &request.Operations[i] - if operation.Revision != nil && (currentEditor == nil || *operation.Revision != currentEditor.Revision()) { - return nil, fmt.Errorf("invoice editing revision has changed") - } - operation.Revision = nil - } - var result ofdgo.InvoiceUpdateResult - if err := editor.Transaction(func(next *ofdgo.Editor) error { - var err error - result, err = applyInvoiceRequest(ctx, next, request) - return err - }); err != nil { - return nil, err - } - issues := append([]ofdgo.InvoiceIssue(nil), result.Issues...) - var appearance []ofdgo.InvoiceIssue - if request.Operation == "delete" { - return map[string]any{"issues": issues, "appearance": appearance}, nil - } - source := result.Source - if source.Kind == "" { - source = request.Source - } - view, err := editor.ReaderContext(ctx) - if err != nil { - return nil, err - } - defer view.Close() - data, err := view.ExportInvoice(ctx, source) - if err != nil { - return nil, err - } - report, err := ofdgo.DecodeInvoices(ctx, data) - if source.Kind == "tag" && len(report.Invoices) == 0 { - report, err = view.ReadInvoice(ctx, source) - } - if err != nil { - return nil, err - } - issues = append(issues, report.Issues...) - for index, invoice := range report.Invoices { - validation, err := ofdgo.ValidateInvoice(ctx, invoice) - if err != nil { - return nil, err - } - for i := range validation { - validation[i].Source = source - } - issues = append(issues, validation...) - checks, err := view.CheckInvoiceAppearance(ctx, source, index) - if err != nil { - appearance = append(appearance, ofdgo.InvoiceIssue{Code: "appearance-error", Message: err.Error(), Source: source}) - } else { - appearance = append(appearance, checks...) - } - } - return map[string]any{"issues": issues, "appearance": appearance, "invoices": report.Invoices}, nil -} - -// changeDocumentInvoice 提交发票修改 -// 入参: args 操作及业务参数 -// 返回: any 编辑状态与签名诊断, error 校验或提交错误 -func changeDocumentInvoice(args []js.Value) (any, error) { - request, err := decodeInvoiceRequest(args) - if err != nil { - return nil, err - } - ctx := context.Background() - var result ofdgo.InvoiceUpdateResult - info, err := changeAtomicObjects(func() error { - return currentEditor.Transaction(func(editor *ofdgo.Editor) error { - var err error - result, err = applyInvoiceRequest(ctx, editor, request) - return err - }) - }) - if err != nil { - return nil, err - } - return invoiceEditorInfo{editorInfo: info.(editorInfo), InvoiceResult: result}, nil -} - -// prepareInvoiceLayout 在发票创建事务内导入票面字体,不修改调用方布局 -// 入参: ctx 取消上下文, editor 事务编辑器, request 发票请求 -// 返回: error 字体处理错误 -func prepareInvoiceLayout(ctx context.Context, editor *ofdgo.Editor, request *invoiceRequest) error { - if request.Options.Layout == nil || len(request.FontData) == 0 { - return nil - } - layout := *request.Options.Layout - request.Options.Layout = &layout - file := ofdgo.FontFile{Data: request.FontData} - if layout.Font != "" { - return editor.EmbedFont(ctx, layout.Font, file, 0) - } - id, err := editor.AddFont(file, 0) - if err != nil { - return err - } - layout.Font = id - return nil -} - -// applyInvoiceRequest 校验来源摘要并执行发票操作 -// 入参: ctx 取消上下文, editor 事务编辑器, request 操作参数 -// 返回: ofdgo.InvoiceUpdateResult 提交结果, error 校验或写入错误 -func applyInvoiceRequest(ctx context.Context, editor *ofdgo.Editor, request invoiceRequest) (ofdgo.InvoiceUpdateResult, error) { - var result ofdgo.InvoiceUpdateResult - if request.Operation == "batch" { - return applyInvoiceBatch(ctx, editor, request) - } - if len(request.Operations) != 0 { - return result, fmt.Errorf("invoice operations require batch action") - } - err := func() error { - if request.Operation != "create" && request.Operation != "import" { - data, err := editor.ExportInvoice(ctx, request.Source) - if err != nil { - return err - } - if request.Digest == "" || ofdgo.InvoiceDigest(data) != request.Digest { - return fmt.Errorf("invoice source has changed") - } - } - switch request.Operation { - case "create", "import": - var source ofdgo.InvoiceSource - var err error - if err = prepareInvoiceLayout(ctx, editor, &request); err != nil { - return err - } - if request.Operation == "create" { - source, err = editor.CreateInvoice(ctx, request.Invoice, request.Options) - } else { - source, err = editor.ImportInvoice(ctx, []byte(request.XML), request.Options) - } - result.Source = source - return err - case "set": - var err error - result, err = editor.SetInvoice(ctx, request.Source, request.Digest, request.Index, request.Invoice, request.Synchronize) - return err - case "patch": - for i := range request.Changes { - request.Changes[i].Source.Kind = request.Source.Kind - request.Changes[i].Source.ID = request.Source.ID - request.Changes[i].Source.FileLoc = request.Source.FileLoc - if request.Source.NodePath != "" { - request.Changes[i].Source.NodePath = request.Source.NodePath + request.Changes[i].Source.NodePath - } - } - var err error - result, err = editor.EditInvoice(ctx, request.Source, request.Digest, request.Changes, request.Synchronize) - return err - case "replace": - var err error - result, err = editor.UpdateInvoice(ctx, request.Source, request.Digest, []byte(request.XML), request.Synchronize) - return err - case "delete": - return editor.DeleteInvoice(ctx, request.Source, request.Digest) - case "bindings": - return editor.SetInvoiceBindings(ctx, request.Source, request.Index, request.Bindings) - case "generate": - if len(request.FontData) != 0 { - file := ofdgo.FontFile{Data: request.FontData} - if request.Layout.Font == "" { - id, err := editor.AddFont(file, 0) - if err != nil { - return err - } - request.Layout.Font = id - } else if err := editor.EmbedFont(ctx, request.Layout.Font, file, 0); err != nil { - return err - } - } - return editor.GenerateInvoicePages(ctx, request.Source, request.Index, request.Layout) - case "sync": - data, err := editor.ExportInvoice(ctx, request.Source) - if err != nil { - return err - } - result, err = editor.UpdateInvoice(ctx, request.Source, request.Digest, data, true) - return err - default: - return fmt.Errorf("unsupported invoice action") - } - }() - if err != nil { - return ofdgo.InvoiceUpdateResult{}, err - } - return result, nil -} - -// applyInvoiceBatch 合并发票修改并同步票面 -// 入参: ctx 取消上下文, editor 事务编辑器, request 批次及原始来源 -// 返回: ofdgo.InvoiceUpdateResult 提交结果, error 冲突或写入错误 -func applyInvoiceBatch(ctx context.Context, editor *ofdgo.Editor, request invoiceRequest) (ofdgo.InvoiceUpdateResult, error) { - var result ofdgo.InvoiceUpdateResult - data, err := editor.ExportInvoice(ctx, request.Source) - if err != nil { - return result, err - } - if request.Digest == "" || ofdgo.InvoiceDigest(data) != request.Digest { - return result, fmt.Errorf("invoice source has changed") - } - if len(request.Operations) == 0 || len(request.Operations) > 256 { - return result, fmt.Errorf("invalid invoice batch size") - } - report, err := ofdgo.DecodeInvoices(ctx, data) - if request.Source.Kind == "tag" && len(report.Invoices) == 0 { - view, viewErr := editor.ReaderContext(ctx) - if viewErr != nil { - return result, viewErr - } - defer view.Close() - report, err = view.ReadInvoice(ctx, request.Source) - } - if err != nil { - return result, err - } - if request.Index < 0 || request.Index >= len(report.Invoices) { - return result, fmt.Errorf("invoice index out of range") - } - paths := make(map[string]bool) - for _, fact := range report.Invoices[request.Index].Facts { - paths[fact.Source.NodePath] = true - } - for _, item := range report.Invoices[request.Index].Items { - for _, fact := range item.Fields { - paths[fact.Source.NodePath] = true - } - } - request.Operations = append([]invoiceRequest(nil), request.Operations...) - for i := range request.Operations { - operation := &request.Operations[i] - if (operation.indexProvided || operation.Index != 0) && operation.Index != request.Index { - return result, fmt.Errorf("invoice batch cannot span candidates") - } - operation.Index = request.Index - } - document := editor.DocumentIndex() - version, err := editor.DocumentVersionID() - if err != nil { - return result, err - } - revision := editor.Revision() - models, bindings := make(map[int]bool), make(map[int]bool) - patched := make(map[string]bool) - hasSet, hasPatch := false, false - for _, operation := range request.Operations { - if err := ctx.Err(); err != nil { - return result, err - } - if len(operation.Operations) != 0 || operation.Operation == "batch" || operation.Synchronize { - return result, fmt.Errorf("nested invoice batch or synchronization is not supported") - } - if operation.Source != (ofdgo.InvoiceSource{}) && operation.Source != request.Source { - return result, fmt.Errorf("invoice batch cannot span sources") - } - if operation.Digest != "" && operation.Digest != request.Digest { - return result, fmt.Errorf("invoice batch requires one original digest") - } - if operation.DocumentIndex != nil && *operation.DocumentIndex != document || operation.Version != nil && *operation.Version != version || operation.Revision != nil && *operation.Revision != revision { - return result, fmt.Errorf("invoice batch scope has changed") - } - switch operation.Operation { - case "set": - if models[operation.Index] { - return result, fmt.Errorf("duplicate invoice model update") - } - models[operation.Index], hasSet = true, true - case "patch": - if len(operation.Changes) == 0 { - return result, fmt.Errorf("empty invoice field update") - } - hasPatch = true - for _, change := range operation.Changes { - if change.Source.Kind != "" && change.Source.Kind != request.Source.Kind || change.Source.ID != "" && change.Source.ID != request.Source.ID || change.Source.FileLoc != "" && change.Source.FileLoc != request.Source.FileLoc { - return result, fmt.Errorf("invoice patch cannot span sources") - } - if change.Source.NodePath == "" || patched[change.Source.NodePath] { - return result, fmt.Errorf("duplicate or missing invoice field path") - } - if !paths[change.Source.NodePath] { - return result, fmt.Errorf("invoice patch cannot span candidates") - } - if change.Revision != "" && change.Revision != request.Digest { - return result, fmt.Errorf("invoice field revision has changed") - } - patched[change.Source.NodePath] = true - } - case "bindings": - if bindings[operation.Index] { - return result, fmt.Errorf("duplicate invoice binding update") - } - bindings[operation.Index] = true - default: - return result, fmt.Errorf("unsupported invoice batch action") - } - } - if hasSet && hasPatch { - return result, fmt.Errorf("invoice batch cannot mix model and raw field updates") - } - var changes []ofdgo.InvoiceFieldChange - for _, operation := range request.Operations { - if err := ctx.Err(); err != nil { - return result, err - } - switch operation.Operation { - case "set": - data, err = ofdgo.UpdateInvoiceXML(ctx, data, operation.Index, operation.Invoice) - case "patch": - changes = append(changes, operation.Changes...) - } - if err != nil { - return result, err - } - } - if len(changes) != 0 { - for i := range changes { - changes[i].Revision = request.Digest - changes[i].Source.Kind = "" - changes[i].Source.ID = "" - changes[i].Source.FileLoc = "" - } - data, err = ofdgo.PatchInvoice(ctx, data, changes) - if err != nil { - return result, err - } - } - for _, operation := range request.Operations { - if operation.Operation == "bindings" { - if err := editor.SetInvoiceBindings(ctx, request.Source, operation.Index, operation.Bindings); err != nil { - return result, err - } - } - } - return editor.UpdateInvoice(ctx, request.Source, request.Digest, data, request.Synchronize) -} - -// resourceLocations 定位当前文档与版本中的资源引用实例 -// 入参: args 资源类型、标识和引用索引 -// 返回: any 页面图元范围, error 引用或度量错误 -func resourceLocations(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 3 { - return nil, fmt.Errorf("ofd document is not opened") - } - return currentSession.Renderer.ResourceLocations(context.Background(), args[0].String(), args[1].String(), args[2].Int()) -} - -// fontPreview 使用当前文档和版本的字形轮廓生成独立预览 -// 入参: args 字体资源标识及可选字形映射标记 -// 返回: any SVG数据, error 字体或绘制错误 -func fontPreview(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 1 { - return nil, fmt.Errorf("ofd document is not opened") - } - var output bytes.Buffer - if err := currentSession.Renderer.RenderFontPreview(context.Background(), args[0].String(), &output); err != nil { - return nil, err - } - return successResult(map[string]any{"bytes": bytesToJS(output.Bytes())}), nil -} - -// changeResource 调用库层资源编辑,浏览器不改写资源声明和包路径 -// 入参: args 操作、类型、标识、格式、数据、页码和图元标识 -// 返回: any 编辑状态, error 资源操作错误 -func changeResource(args []js.Value) (any, error) { - if len(args) != 7 { - return nil, fmt.Errorf("invalid resource arguments") - } - return changeAtomicObjects(func() error { - ctx := context.Background() - action, kind, id := args[0].String(), args[1].String(), args[2].String() - switch action { - case "delete": - return currentEditor.DeleteResource(ctx, kind, id) - case "bind", "unbind": - if kind == "Attachment" { - return currentEditor.BindAttachment(ctx, id, args[5].Int(), args[6].String(), action == "bind") - } - if kind != "Audio" && kind != "Video" { - return fmt.Errorf("resource does not support actions") - } - return currentEditor.BindMedia(ctx, id, args[5].Int(), args[6].String(), action == "bind") - case "add", "replace": - data, err := bytesFromJS(args[4]) - if err != nil { - return err - } - if action == "add" { - _, err = currentEditor.ImportMedia(ctx, kind, args[3].String(), data) - return err - } - return currentEditor.ReplaceResource(ctx, kind, id, args[3].String(), data) - default: - return fmt.Errorf("unsupported resource action") - } - }) -} - -// exportResource 分块导出当前资源的原始文件,不接收任意包路径 -// 入参: args 类型及标识的JSON数组、分块接收函数 -// 返回: any 输出格式, error 读取或写入错误 -func exportResource(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 2 { - return nil, fmt.Errorf("ofd document is not opened") - } - var key [2]string - if err := json.Unmarshal([]byte(args[0].String()), &key); err != nil { - return nil, err - } - stream, err := currentSession.Reader.OpenResource(context.Background(), key[0], key[1]) - if err != nil { - return nil, err - } - defer stream.Close() - writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) - if _, err := io.Copy(writer, stream); err != nil { - return nil, err - } - if err := writer.Flush(); err != nil { - return nil, err - } - return successResult(map[string]any{"mime": "application/octet-stream"}), nil -} - -// svgFontData 读取SVG字体资源 -// 入参: args 浏览器参数 -// 返回: any 字体数据, error 错误信息 -func svgFontData(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - data, err := currentSession.SVGFontData(args[0].String()) - if err != nil { - return nil, err - } - return successResult(map[string]any{"bytes": bytesToJS(data)}), nil -} - -// renderPage 渲染OFD页面 -// 入参: args 页面索引、后端组合、DPI和光栅显示开关 -// 返回: any 页面SVG结果, error 错误信息 -func renderPage(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if len(args) == 0 { - return nil, fmt.Errorf("missing page index") - } - backend, dpi := "canvas", 150.0 - if len(args) > 1 { - backend = args[1].String() - } - if len(args) > 2 { - dpi = args[2].Float() - } - raster := len(args) > 3 && args[3].Bool() - if currentEditor != nil { - page, err := currentEditor.Page(args[0].Int()) - if err != nil { - return nil, err - } - if err := syncEditorPageIDs(args[0].Int(), page); err != nil { - return nil, err - } - } - page, err := currentSession.RenderPage(args[0].Int(), backend, dpi, raster) - if err != nil { - return nil, err - } - if currentEditor != nil && !reflect.DeepEqual(currentEditor.Backends(), currentSession.Renderer.Backends()) { - currentEditor.SetRenderBackends(currentSession.Renderer.Backends()) - } - text, err := currentSession.PageText(args[0].Int()) - if err != nil { - return nil, err - } - textData, err := json.Marshal(text) - if err != nil { - return nil, err - } - objects, err := editorObjects(args[0].Int(), text) - if err != nil { - return nil, err - } - links, err := linkResults(page.Links) - if err != nil { - return nil, err - } - fonts := make([]any, len(page.Fonts)) - for i, font := range page.Fonts { - fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style} - } - annotations, err := json.Marshal(page.Annotations) - if err != nil { - return nil, err - } - return successResult(map[string]any{ - "annotations": string(annotations), - "index": page.Index, - "number": page.Number, - "id": page.ID, - "width": page.Width, - "height": page.Height, - "svg": page.SVG, - "links": links, - "fonts": fonts, - "images": svgImagesToJS(page.Images), - "text": string(textData), - "objects": objects, - }), nil -} - -// openLinks 获取文档或页面打开动作,不执行动作 -// 入参: args 可选页面索引,省略时读取文档打开动作 -// 返回: any 动作目标, error 文档或页码错误 -func openLinks(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - var links []ofdgo.PageLink - var err error - if len(args) == 0 { - links, err = currentSession.Renderer.DocumentOpenLinks() - } else if len(args) == 1 { - page, readErr := currentSession.Reader.PageContentByIndex(args[0].Int()) - if readErr != nil { - return nil, readErr - } - links, err = currentSession.Renderer.PageOpenLinks(page) - } else { - return nil, fmt.Errorf("unexpected open action arguments") - } - if err != nil { - return nil, err - } - return linkResults(links) -} - -// outlineLinks 获取目录节点的有序动作目标 -// 入参: args 各级目录索引 -// 返回: any 动作目标, error 目录或文档错误 -func outlineLinks(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if len(args) != 1 { - return nil, fmt.Errorf("missing outline path") - } - links, err := currentSession.Renderer.OutlineLinks(indexesFromJS(args[0])) - if err != nil { - return nil, err - } - return linkResults(links) -} - -// changeStructure 提交标引操作,XML、目标校验及历史维护均由库完成 -// 入参: args JSON操作参数 -// 返回: any 编辑状态, error 参数、校验或提交错误 -func changeStructure(args []js.Value) (any, error) { - if len(args) != 1 || args[0].Type() != js.TypeString { - return nil, fmt.Errorf("invalid structure arguments") - } - var request struct { - Action string `json:"action"` - Index int `json:"index"` - Path []int `json:"path"` - TypeID string `json:"typeId"` - Node ofdgo.StructureNode `json:"node"` - References []ofdgo.StructureReference `json:"references"` - } - if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil { - return nil, err - } - return changeObjects(func() error { - if request.Action != "create" { - structures, err := currentEditor.Structure() - if err != nil { - return err - } - if request.Index >= 0 && request.Index < len(structures) && structures[request.Index].Internal { - return fmt.Errorf("internal text metadata must be edited through the text editor") - } - } - switch request.Action { - case "create": - _, err := currentEditor.AddStructure(ofdgo.CustomTag{TypeID: request.TypeID, NameSpace: request.Node.Namespace}, request.Node) - return err - case "add": - _, err := currentEditor.AddStructureNode(request.Index, request.Path, request.Node) - return err - case "update": - return currentEditor.UpdateStructureNode(request.Index, request.Path, request.Node) - case "delete": - if len(request.Path) == 0 { - return currentEditor.DeleteStructure(request.Index) - } - return currentEditor.DeleteStructureNode(request.Index, request.Path) - case "bind": - return currentEditor.SetStructureReferences(request.Index, request.Path, request.References) - default: - return fmt.Errorf("unsupported structure action %q", request.Action) - } - }) -} - -// structureObjects 获取当前编辑文档的绑定候选,不允许阅读模式调用编辑入口 -// 入参: args 从0开始的页面索引 -// 返回: any 图元标识和原文, error 参数或读取错误 -func structureObjects(args []js.Value) (any, error) { - if currentEditor == nil || currentSession == nil || len(args) != 1 { - return nil, fmt.Errorf("no document is being edited") - } - return currentSession.Renderer.StructureObjects(args[0].Int()) -} - -// structureTargets 查询当前文档标引节点的原文与定位范围 -// 入参: args 标引清单索引和节点路径 -// 返回: any 标引目标, error 参数或解析错误 -func structureTargets(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if len(args) != 2 { - return nil, fmt.Errorf("missing structure path") - } - return currentSession.Renderer.StructureTargets(args[0].Int(), indexesFromJS(args[1])) -} - -// linkResults 将库层动作目标转换为浏览器数据 -// 入参: source 动作目标 -// 返回: []any 浏览器动作数据, error 附件读取错误 -func linkResults(source []ofdgo.PageLink) ([]any, error) { - links := make([]any, len(source)) - attachmentNames := make(map[string]string) - for _, link := range source { - if link.Attachment == "" { - continue - } - attachments, err := currentSession.Reader.Attachments() - if err != nil { - return nil, err - } - for _, attachment := range attachments { - attachmentNames[attachment.ID] = attachment.FileName() - } - break - } - for i, link := range source { - item := map[string]any{ - "uri": link.URI, - "target": link.Target, - "path": link.Path, - "group": link.Group, - "x": link.Box.X, - "y": link.Box.Y, - "width": link.Box.W, - "height": link.Box.H, - } - if link.Attachment != "" { - item["attachment"] = link.Attachment - item["fileName"] = attachmentNames[link.Attachment] - } - if sound := link.Sound; sound != nil { - volume := 100 - if sound.Volume != nil { - volume = *sound.Volume - } - item["sound"] = map[string]any{"resourceID": sound.ResourceID, "volume": volume, "repeat": sound.Repeat, "synchronous": sound.Synchronous} - } - if movie := link.Movie; movie != nil { - item["movie"] = map[string]any{"resourceID": movie.ResourceID, "operator": movie.Operator} - if box := link.PlaybackBox; box != nil { - item["playbackBox"] = map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H} - } - } - if dest := link.Dest; dest != nil { - item["dest"] = map[string]any{"type": dest.Type, "pageID": resolvedPageID(dest.PageID), "left": dest.Left, "top": dest.Top, - "right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom, - "omitLeft": dest.OmitLeft, "omitTop": dest.OmitTop, "omitZoom": dest.OmitZoom} - } - links[i] = item - } - return links, nil -} - -// resolvedPageID 将跳转目标转换为界面页面列表中的标识,未找到时保留原值 -// 入参: id 目标页面标识 -// 返回: string 页面列表标识或原值 -func resolvedPageID(id string) string { - if currentSession != nil { - if index := currentSession.doc.PageIndex(id); index >= 0 { - return currentSession.doc.Pages.Page[index].ID - } - } - return id -} - -// pageTextString 提取OFD页面原文 -// 入参: args 页面索引 -// 返回: any 页面原文, error 错误信息 -func pageTextString(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - return currentSession.PageTextString(args[0].Int()) -} - -// searchPage 搜索OFD页面文字 -// 入参: args 浏览器参数 -// 返回: any 搜索结果, error 错误信息 -func searchPage(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - return currentSession.SearchPage(args[0].Int(), args[1].String()) -} - -// exportFormats 获取导出格式 -// 入参: args 浏览器参数 -// 返回: any 导出格式, error 错误信息 -func exportFormats(args []js.Value) (any, error) { - return ExportFormats(), nil -} - -// parsePageRange 解析当前文档的导出页码 -// 入参: args 浏览器参数 -// 返回: any 页面索引, error 错误信息 -func parsePageRange(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - count, err := currentSession.Reader.PageCount() - if err != nil { - return nil, err - } - return ofdgo.ParsePageRange(args[0].String(), count) -} - -// exportPage 导出OFD单页 -// 入参: args 浏览器参数 -// 返回: any 导出结果, error 错误信息 -func exportPage(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if len(args) < 4 { - return nil, fmt.Errorf("missing export page arguments") - } - session, writerIndex, err := exportSession(args) - if err != nil { - return nil, err - } - writer := bufio.NewWriterSize(exportWriter{write: args[writerIndex]}, 1<<20) - format, err := session.ExportPage(args[0].Int(), args[1].String(), args[2].Float(), writer) - if err != nil { - return nil, err - } - if err := writer.Flush(); err != nil { - return nil, err - } - return successResult(map[string]any{ - "label": format.Label, - "mime": format.MIME, - }), nil -} - -// exportDocument 导出OFD文档 -// 入参: args 浏览器参数 -// 返回: any 导出结果, error 错误信息 -func exportDocument(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - session, writerIndex, err := exportSession(args) - if err != nil { - return nil, err - } - renderer := session.Renderer - renderer.OnExportProgress = func(completed, total int) error { - return awaitExport(args[writerIndex+1], completed, total) - } - defer func() { renderer.OnExportProgress = nil }() - var indices []int - if !args[2].IsNull() { - indices = make([]int, args[2].Length()) - for i := range indices { - indices[i] = args[2].Index(i).Int() - } - } - writer := bufio.NewWriterSize(exportWriter{write: args[writerIndex]}, 1<<20) - format, err := session.ExportDocument(args[0].String(), args[1].Float(), writer, indices...) - if err != nil { - return nil, err - } - if err := writer.Flush(); err != nil { - return nil, err - } - return successResult(map[string]any{ - "label": format.Label, - "mime": format.MIME, - }), nil -} - -// exportSession 创建独立导出配置,写入回调前可指定光栅后端 -// 入参: args 浏览器参数 -// 返回: *Session 导出会话, int 写入回调位置, error 错误信息 -func exportSession(args []js.Value) (*Session, int, error) { - session := *currentSession - renderer := *session.Renderer - session.Renderer = &renderer - writerIndex := 3 - if args[3].Type() == js.TypeString { - if err := session.SetRenderBackend(args[3].String()); err != nil { - return nil, 0, err - } - writerIndex++ - } - if args[writerIndex].Type() == js.TypeObject { - if err := compressionFromJS(args[writerIndex], &renderer.Compression); err != nil { - return nil, 0, err - } - writerIndex++ - if len(args) > writerIndex+1 { - renderer.OutputContext = newOutputContext(args[len(args)-1]) - } - } - return &session, writerIndex, nil -} - -// successResult 创建成功接口结果 -// 入参: data 返回数据 -// 返回: js.Value 接口结果 -func successResult(data any) js.Value { - result := js.Global().Get("Object").New() - result.Set("ok", true) - result.Set("data", data) - return result -} - -// bytesToJS 将字节数据转换为Uint8Array -// 入参: data 字节数据 -// 返回: js.Value Uint8Array对象 -func bytesToJS(data []byte) js.Value { - value := js.Global().Get("Uint8Array").New(len(data)) - js.CopyBytesToJS(value, data) - return value -} - -// matchFontFiles 匹配文档所需的字体文件 -// 入参: args 浏览器参数 -// 返回: any 匹配的字体文件名称, error 错误信息 -func matchFontFiles(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - ids := make([]string, 0, len(currentSession.fontInfos)) - for _, info := range currentSession.fontInfos { - if info.Used > 0 { - ids = append(ids, info.ID) - } - } - return currentSession.Reader.MatchFontFiles(stringsFromJS(args[0]), ids...) -} - -// fontFaces 读取上传字体的名称和集合索引 -// 入参: args 字体数据 -// 返回: any 字体列表, error 错误信息 -func fontFaces(args []js.Value) (any, error) { - data, err := bytesFromJS(args[0]) - if err != nil { - return nil, err - } - return (ofdgo.FontFile{Data: data}).Faces() -} - -// fontFace 提取编辑时选定的字体,供输入预览和保存共用 -// 入参: args 字体数据和零起始索引 -// 返回: any 独立字体数据, error 错误信息 -func fontFace(args []js.Value) (any, error) { - data, err := bytesFromJS(args[0]) - if err != nil { - return nil, err - } - data, err = (ofdgo.FontFile{Data: data}).Face(args[1].Int()) - if err != nil { - return nil, err - } - return successResult(map[string]any{"bytes": bytesToJS(data)}), nil -} - -// bytesFromJS 从浏览器值读取二进制数据 -// 入参: value 浏览器值 -// 返回: []byte 二进制数据, error 错误信息 -func bytesFromJS(value js.Value) ([]byte, error) { - if value.IsUndefined() || value.IsNull() { - return nil, fmt.Errorf("missing binary data") - } - uint8Array := js.Global().Get("Uint8Array") - arrayBuffer := js.Global().Get("ArrayBuffer") - if value.InstanceOf(arrayBuffer) { - value = uint8Array.New(value) - } - if !value.InstanceOf(uint8Array) { - return nil, fmt.Errorf("binary data must be Uint8Array or ArrayBuffer") - } - data := make([]byte, value.Get("byteLength").Int()) - if n := js.CopyBytesToGo(data, value); n != len(data) { - return nil, fmt.Errorf("copied %d of %d bytes", n, len(data)) - } - return data, nil -} - -// fontsFromJS 从浏览器值读取字体文件 -// 入参: value 浏览器值 -// 返回: []FontFile 字体文件, error 错误信息 -func fontsFromJS(value js.Value) ([]FontFile, error) { - if value.IsUndefined() || value.IsNull() { - return nil, nil - } - length := value.Get("length").Int() - fonts := make([]FontFile, 0, length) - for i := 0; i < length; i++ { - item := value.Index(i) - data, err := bytesFromJS(item.Get("data")) - if err != nil { - return nil, err - } - fonts = append(fonts, FontFile{Name: item.Get("name").String(), Data: data}) - } - return fonts, nil -} - -// stringsFromJS 从浏览器值读取字符串列表 -// 入参: value 浏览器值 -// 返回: []string 字符串列表 -func stringsFromJS(value js.Value) []string { - items := make([]string, value.Length()) - for i := range items { - items[i] = value.Index(i).String() - } - return items -} - -// encodeResult 编码浏览器接口返回结果 -// 入参: result 返回结果 -// 返回: string JSON字符串 -func encodeResult(result apiResult) string { - data, err := json.Marshal(result) - if err != nil { - fallback, _ := json.Marshal(apiResult{OK: false, Error: err.Error()}) - return string(fallback) - } - return string(data) -} - -// editorSummary 获取当前编辑文档状态 -// 返回: editorInfo 文档信息 -func editorSummary() editorInfo { - info := editorInfo{DocumentInfo: currentSession.Summary(), Revision: currentEditor.Revision(), CanUndo: currentEditor.CanUndo(), CanRedo: currentEditor.CanRedo()} - if !currentSession.doc.Permissions.Edit { - info.EditWarnings = append(info.EditWarnings, "原文件声明不允许编辑") - } - if slices.ContainsFunc(currentSession.Reader.OFD.DocBody, func(body ofdgo.DocBody) bool { return body.Signatures != "" }) { - info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效") - } - info.PageCapabilities = make([]uint, currentEditor.PageCount()) - for i := range info.PageCapabilities { - capability, _ := currentEditor.PageCapabilities(i) - info.PageCapabilities[i] = pageCapabilityMask(capability) - } - return info -} - -// pageCapabilityMask 按插入、复制、删除、移动和尺寸的固定顺序编码页面能力,避免重复传输字段名 -// 入参: capability 页面能力 -// 返回: uint 能力位标记 -func pageCapabilityMask(capability ofdgo.PageCapabilities) uint { - var mask uint - for i, enabled := range [...]bool{capability.Insert, capability.Copy, capability.Delete, capability.Move, capability.Resize} { - if enabled { - mask |= 1 << i - } - } - return mask -} - -// restoreEditor 撤销或重做并更新预览 -// 入参: redo 是否重做 -// 返回: any 文档信息, error 错误信息 -func restoreEditor(redo bool) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - apply := currentEditor.Undo - if redo { - apply = currentEditor.Redo - } - if !apply() { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// editorObjects 提供编辑画布的对象范围、文字排版及原始图片边界 -// 入参: index 页面索引, text 页面文字 -// 返回: []any 对象区域, error 错误信息 -func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { - objects := []any{} - if currentEditor == nil { - return objects, nil - } - page, err := currentEditor.Page(index) - if err != nil { - return nil, err - } - info, err := currentEditor.PageObjectInfo(index) - if err != nil { - return nil, err - } - textBoxes := make(map[string]ofdgo.Box) - for _, run := range text.Runs { - for _, box := range run.Boxes { - if box.W <= 0 || box.H <= 0 { - continue - } - prev, exists := textBoxes[run.ID] - if !exists { - textBoxes[run.ID] = box - continue - } - x, y := math.Min(prev.X, box.X), math.Min(prev.Y, box.Y) - textBoxes[run.ID] = ofdgo.Box{X: x, Y: y, W: math.Max(prev.X+prev.W, box.X+box.W) - x, H: math.Max(prev.Y+prev.H, box.Y+box.H) - y} - } - } - fonts, err := currentSession.Reader.Fonts() - if err != nil { - return nil, err - } - fontNames := make(map[string]string, len(fonts)) - for _, font := range fonts { - fontNames[font.ID] = font.FontName - } - for _, layer := range page.Content.Layer { - for order, object := range layer.Objects { - var id string - switch object.Type { - case "TextObject": - id = object.TextObject.ID - case "ImageObject": - id = object.ImageObject.ID - case "PathObject": - id = object.PathObject.ID - case "CompositeObject", "CompositeGraphicUnit": - id = object.CompositeGraphicUnit.ID - default: - continue - } - capability := info[id].Capabilities - var box ofdgo.Box - var contours []ofdgo.ObjectContour - switch object.Type { - case "TextObject": - box = textBoxes[id] - default: - box, contours, err = currentSession.Renderer.ObjectGeometry(object, layer.DrawParam) - } - if err != nil { - continue - } - if box.W > 0 && box.H > 0 { - position := info[id].Position - item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, - "capabilities": editorCapabilities(capability, object.Type)} - editorAppearance(item, object, capability.Paint, editorPathScale(object.PathObject)) - if object.Type == "PathObject" { - path := object.PathObject - kind, geometry := path.Shape() - if kind != "" && capability.Update { - item["shape"] = string(kind) - item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H} - } else if outline, err := currentSession.Renderer.PathOutline(path); err == nil { - item["outline"] = outline - } - if kind == "" && capability.Update { - if kind, box, matrix := path.ShapeFrame(); kind != "" { - item["oriented"] = map[string]any{"kind": string(kind), "box": editorBox(box), "matrix": editorMatrix(matrix)} - } - } - } - if object.Type == "PathObject" || object.Type == "ImageObject" || object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { - paths := make([]any, len(contours)) - for i, contour := range contours { - paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} - } - item["contours"] = paths - } - if object.Type == "ImageObject" && capability.Update { - full, err := object.ImageObject.ImageBounds() - if err != nil { - return nil, err - } - item["imageBounds"] = editorBox(full) - } - if object.Type == "ImageObject" { - item["imageResource"] = object.ImageObject.ResourceID - } - if object.Type == "TextObject" { - item["bounds"] = editorBox(box) - value, layout := object.TextObject.TextLayout() - item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size - item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight - item["letterSpacing"] = layout.LetterSpacing - item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent - if capability.Reflow { - frame, err := object.TextObject.TextFrame() - if err != nil { - return nil, err - } - boundary, _ := ofdgo.ParseBox(object.TextObject.Boundary) - matrix := ofdgo.TranslationMatrix(boundary.X, boundary.Y).Multiply(ofdgo.NewMatrix(object.TextObject.CTM)) - inverse, _ := matrix.Invert() - local := inverse.TransformBox(box) - width := math.Max(object.TextObject.Size, local.X+local.W) - if layout.Wrap { - item["paragraphWidth"] = frame.W - } - frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)} - item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(matrix)} - if layout.Wrap { - bounds := matrix.TransformBox(frame) - item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H - } - } - if codes := object.TextObject.TextCode; len(codes) > 1 { - first, _ := strconv.ParseFloat(codes[0].Y, 64) - second, _ := strconv.ParseFloat(codes[1].Y, 64) - item["lineHeight"] = second - first - } - } - if object.Type == "CompositeObject" { - if value, richErr := currentEditor.RichText(index, id); richErr == nil { - editorRichText(index, item, object, value, fontNames) - } - } - objects = append(objects, item) - } - } - } - for _, annotation := range currentSession.Reader.Annots[page.ID] { - if annotation.Visible != nil && !*annotation.Visible { - continue - } - box, contours, err := currentSession.Renderer.AnnotationGeometry(annotation) - capabilities := map[string]any{"update": true, "transform": true, "arrange": true, "copy": true, "delete": true, "enter": len(annotation.Appearance.Objects) != 0} - if err != nil { - capabilities["transform"], capabilities["arrange"], capabilities["enter"] = false, false, false - capabilities["reason"], capabilities["reasonCode"] = err.Error(), "invalidObject" - } - if box.W <= 0 || box.H <= 0 { - box, _ = ofdgo.ParseBox(annotation.Appearance.Boundary) - } - paths := make([]any, len(contours)) - for i, contour := range contours { - paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} - } - alpha, mixed := 255, false - for i, object := range annotation.Appearance.Objects { - var value *int - switch object.Type { - case "TextObject": - value = object.TextObject.Alpha - case "PathObject": - value = object.PathObject.Alpha - case "ImageObject": - value = object.ImageObject.Alpha - case "CompositeObject", "CompositeGraphicUnit": - value = object.CompositeGraphicUnit.Alpha - } - current := 255 - if value != nil { - current = *value - } - if i == 0 { - alpha = current - } else if current != alpha { - mixed = true - } - } - item := map[string]any{"id": "annotation:" + annotation.ID, "type": "Annotation", "annotationType": annotation.Type, - "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "remark": annotation.Remark, "creator": annotation.Creator, - "alpha": alpha, "alphaMixed": mixed, "capabilities": capabilities} - if len(annotation.Appearance.Objects) == 0 && strings.TrimSpace(annotation.Remark) != "" { - item["note"] = true - } else { - item["contours"] = paths - } - objects = append(objects, item) - } - return objects, nil -} - -// changeAnnotations 修改注解说明、平移或删除同页注解,复用会话历史与预览 -// 入参: args 页面索引、注解标识数组、操作及参数 -// 返回: any 编辑状态, error 错误信息 -func changeAnnotations(args []js.Value) (any, error) { - return changeObjects(func() error { - ids := stringsFromJS(args[1]) - for i := range ids { - ids[i] = strings.TrimPrefix(ids[i], "annotation:") - } - switch args[2].String() { - case "update": - if len(ids) != 1 { - return fmt.Errorf("select one annotation to update") - } - return currentEditor.UpdateAnnotation(args[0].Int(), ids[0], args[3].String(), args[4].String()) - case "move": - return currentEditor.MoveAnnotations(args[0].Int(), ids, args[3].Float(), args[4].Float()) - case "delete": - return currentEditor.DeleteAnnotations(args[0].Int(), ids) - default: - return fmt.Errorf("unsupported annotation action %q", args[2].String()) - } - }) -} - -// readAnnotation 获取编辑面板所需的注解内容,不修改文件 -// 入参: args 页面索引与注解标识 -// 返回: any 面板数据, error 错误信息 -func readAnnotation(args []js.Value) (any, error) { - annotation, err := currentEditor.Annotation(args[0].Int(), strings.TrimPrefix(args[1].String(), "annotation:")) - if err != nil { - return nil, err - } - texts, err := currentEditor.AnnotationTexts(args[0].Int(), annotation.ID) - if err != nil { - return nil, err - } - info := map[string]any{"texts": texts, "remark": annotation.Remark, "creator": annotation.Creator} - if annotation.Type == "Link" { - info["linkKind"] = "keep" - link, err := currentEditor.AnnotationLink(args[0].Int(), annotation.ID) - if err == nil && link.URI != nil { - info["linkKind"], info["linkURI"], info["linkBase"] = "uri", link.URI.URI, link.URI.Base - info["linkTarget"] = link.URI.Target - } else if err == nil && link.Goto != nil && link.Goto.Dest != nil { - if index := currentSession.doc.PageIndex(link.Goto.Dest.PageID); index >= 0 { - info["linkKind"], info["linkPage"] = "page", index+1 - } - } - } - return info, nil -} - -// writeAnnotation 原子创建多页注解或修改现有链接、重复文字 -// 入参: args 当前页面、JSON选项、字体字节、图片字节 -// 返回: any 文档信息及新选区, error 错误信息 -func writeAnnotation(args []js.Value) (any, error) { - var options annotationOptions - if err := json.Unmarshal([]byte(args[1].String()), &options); err != nil { - return nil, err - } - if options.Kind == "link" && options.ID != "" && options.Keep { - return editorSummary(), nil - } - page := args[0].Int() - var ids []string - result, err := changeObjects(func() error { - font, resource := "", "" - if !args[2].IsNull() { - data, err := bytesFromJS(args[2]) - if err != nil { - return err - } - font, err = currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return err - } - } - if !args[3].IsNull() { - data, err := bytesFromJS(args[3]) - if err != nil { - return err - } - resource, err = currentEditor.AddImage(data) - if err != nil { - return err - } - size, err := currentEditor.ImageSize(resource) - if err != nil { - return err - } - options.Height = options.Width * float64(size.Y) / float64(size.X) - } - if options.Kind == "replace" { - style := ofdgo.TextStyle{Font: font} - _, err := currentEditor.ReplaceAnnotationText(page, options.ID, options.Old, options.Text, style) - return err - } - target := ofdgo.AnnotationLink{URI: &ofdgo.URI{URI: options.URI, Base: options.Base}} - if options.Target > 0 { - if options.Target > currentEditor.PageCount() { - return fmt.Errorf("destination page is out of range") - } - page, err := currentEditor.Page(options.Target - 1) - if err != nil { - return err - } - target = ofdgo.AnnotationLink{Dest: &ofdgo.Dest{Type: "Fit", PageID: page.ID}} - } - if options.Kind == "link" && options.ID != "" { - if target.URI != nil { - previous, err := currentEditor.AnnotationLink(page, options.ID) - if err != nil { - return err - } - if previous.URI != nil { - target.URI.Target = previous.URI.Target - } - } - return currentEditor.UpdateAnnotationLink(page, options.ID, target) - } - if options.Width <= 0 || options.Height <= 0 || options.Alpha < 0 || options.Alpha > 255 { - return fmt.Errorf("invalid annotation dimensions or opacity") - } - pages, err := ofdgo.ParsePageRange(options.Pages, currentEditor.PageCount()) - if err != nil { - return err - } - if options.Kind == "watermark" { - boundary := fmt.Sprintf("0 0 %g %g", options.Width, options.Height) - var object ofdgo.GraphicObject - if resource != "" { - object = ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: boundary, Alpha: &options.Alpha}} - } else { - text := ofdgo.TextObject{Font: font, Size: options.Size, Boundary: boundary, Alpha: &options.Alpha} - if err := currentEditor.LayoutText(&text, options.Text, ofdgo.TextLayout{Align: "center"}); err != nil { - return err - } - if err := setTextColor(&text, options.Color); err != nil { - return err - } - object = ofdgo.GraphicObject{Type: "TextObject", TextObject: text} - } - added, err := currentEditor.AddWatermarks(context.Background(), pages, object, ofdgo.WatermarkOptions{Creator: options.Creator, Angle: options.Angle, Tile: options.Tile, Area: options.Area}) - if err != nil { - return err - } - for i, index := range pages { - if index == page { - ids = append(ids, "annotation:"+added[i]) - } - } - return nil - } - for _, index := range pages { - box := ofdgo.Box{X: options.X, Y: options.Y, W: options.Width, H: options.Height} - var id string - if options.Kind == "link" { - id, err = currentEditor.AddLinkAnnotation(index, box, target, options.Creator) - } else { - annotation := ofdgo.Annotation{Creator: options.Creator, Appearance: ofdgo.Appearance{Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)}} - switch options.Kind { - case "note": - annotation.Type, annotation.Remark = "Path", options.Text - case "stamp": - annotation.Type = "Stamp" - if resource == "" { - return fmt.Errorf("stamp requires an image") - } - object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), CTM: fmt.Sprintf("%g 0 0 %g 0 0", box.W, box.H), Alpha: &options.Alpha}} - angle := options.Angle * math.Pi / 180 - matrix := ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle))) - matrix = ofdgo.TranslationMatrix(box.W/2, box.H/2).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-box.W/2, -box.H/2)) - object.ImageObject.CTM = matrix.Multiply(ofdgo.NewMatrix(object.ImageObject.CTM)).String() - annotation.Appearance.Objects = []ofdgo.GraphicObject{object} - default: - return fmt.Errorf("unsupported annotation kind %q", options.Kind) - } - id, err = currentEditor.AddAnnotation(index, annotation) - } - if err != nil { - return err - } - if index == page { - ids = append(ids, "annotation:"+id) - } - } - return nil - }) - if err != nil { - return nil, err - } - return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: ids}, nil -} - -// insertInk 将一笔采样写为一个标准矢量对象,复用内部插入与撤销逻辑 -// 入参: args 页面、JSON采样、线宽、压感开关、颜色及可选父路径 -// 返回: any 文档和选区, error 错误信息 -func insertInk(args []js.Value) (any, error) { - var points []ofdgo.InkPoint - if err := json.Unmarshal([]byte(args[1].String()), &points); err != nil { - return nil, err - } - path, err := ofdgo.NewInk(points, args[2].Float(), args[3].Bool()) - if err != nil { - return nil, err - } - if path.Fill != nil && *path.Fill { - err = setEditorColor(path.FillColor, args[4].String()) - } else { - err = setEditorColor((*ofdgo.FillColor)(path.StrokeColor), args[4].String()) - } - if err != nil { - return nil, err - } - return insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[5:]) -} - -// splitEditorSelection 区分页面对象与注解标识,不以外观大小或类型猜测 -// 入参: ids 画布选区 -// 返回: []string 正文标识, []string 注解标识 -func splitEditorSelection(ids []string) ([]string, []string) { - var objects, annotations []string - for _, id := range ids { - if value, ok := strings.CutPrefix(id, "annotation:"); ok { - annotations = append(annotations, value) - } else { - objects = append(objects, id) - } - } - return objects, annotations -} - -// changeSelection 原子处理正文和注解混选,保持绘制顺序及统一历史 -// 入参: args 页面索引、标识列表、操作与参数 -// 返回: any 文档与新选区, error 错误信息 -func changeSelection(args []js.Value) (any, error) { - var selected []string - result, err := changeAtomicObjects(func() error { - page, operation := args[0].Int(), args[2].String() - objects, annotations := splitEditorSelection(stringsFromJS(args[1])) - switch operation { - case "align", "distribute": - return arrangeEditorSelection(page, stringsFromJS(args[1]), operation, args[3].String()) - case "delete": - return currentEditor.DeleteSelection(page, objects, annotations) - case "copy": - clipboard, err := captureEditorSelection(page, stringsFromJS(args[1])) - if err != nil { - return err - } - selected, err = pasteEditorSelection(page, clipboard, args[3].Float(), args[4].Float()) - return err - case "style": - return currentEditor.StyleSelection(page, objects, annotations, objectStyle(args[3])) - case "erase": - return currentEditor.EraseSelection(page, objects, annotations, ofdgo.Box{ - X: args[3].Float(), Y: args[4].Float(), W: args[5].Float(), H: args[6].Float(), - }) - case "erasePath": - return currentEditor.EraseSelectionPath(page, objects, annotations, pointsFromJS(args[3])) - case "rotate": - return currentEditor.RotateSelection(page, objects, annotations, args[3].Int()) - case "flip": - return currentEditor.FlipSelection(page, objects, annotations, args[3].String()) - case "resize": - return currentEditor.ResizeSelection(page, objects, annotations, ofdgo.Box{ - X: args[3].Float(), Y: args[4].Float(), W: args[5].Float(), H: args[6].Float(), - }) - case "transform": - dx, dy, scale := args[3].Float(), args[4].Float(), args[5].Float() - if scale <= 0 { - return fmt.Errorf("scale must be positive") - } - matrix := ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", scale, scale, dx, dy)) - return currentEditor.TransformSelection(page, objects, annotations, matrix) - default: - return fmt.Errorf("unsupported mixed selection operation %q", operation) - } - }) - if err != nil || selected == nil { - return result, err - } - return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil -} - -// arrangeEditorSelection 对正文和注解统一对齐或分布,不改变各自绘制层级 -// 入参: page 页面, ids 标识, operation 操作, direction 方向 -// 返回: error 错误信息 -func arrangeEditorSelection(page int, ids []string, operation, direction string) error { - objects, annotations := splitEditorSelection(ids) - switch operation { - case "align": - return currentEditor.AlignSelection(page, objects, annotations, direction) - case "distribute": - return currentEditor.DistributeSelection(page, objects, annotations, direction) - default: - return fmt.Errorf("invalid arrangement operation %q", operation) - } -} - -// captureEditorSelection 捕获正文与注解的独立副本 -// 入参: page 页面索引, ids 画布标识 -// 返回: *editorClipboard 快照, error 错误信息 -func captureEditorSelection(page int, ids []string) (*editorClipboard, error) { - objects, annotations := splitEditorSelection(ids) - result := &editorClipboard{} - var err error - result.selection, err = currentEditor.CaptureSelection(page, objects, annotations) - return result, err -} - -// pasteEditorSelection 原子粘贴正文和注解,共享字体与图片数据 -// 入参: page 目标页, clipboard 快照, dx 横向位移, dy 纵向位移 -// 返回: []string 新画布标识, error 错误信息 -func pasteEditorSelection(page int, clipboard *editorClipboard, dx, dy float64) ([]string, error) { - ids, annotations, err := currentEditor.PasteSelection(page, clipboard.selection, dx, dy) - for _, id := range annotations { - ids = append(ids, "annotation:"+id) - } - return ids, err -} - -// editorCapabilities 统一顶层与内部对象的操作能力和受限原因 -// 入参: capability 库层能力, kind 对象类型 -// 返回: map[string]any 前端能力 -func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any { - result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode), - "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "stretch": capability.Stretch, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} - result["textContent"] = capability.TextContent - result["rewriteText"] = capability.RewriteText - if missing := capability.MissingGlyphs; missing != nil { - result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} - } - return result -} - -// editorAppearance 提供对象自身透明度及有效绘制外观,不混入父对象透明度 -// 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率 -func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) { - links := 0 - for _, action := range object.Actions() { - if action.Event == "CLICK" && (action.URI != nil || action.Goto != nil) { - links++ - if action.Goto != nil && action.Goto.Dest != nil { - gotoAction, dest := *action.Goto, *action.Goto.Dest - dest.PageID = resolvedPageID(dest.PageID) - gotoAction.Dest, action.Goto = &dest, &gotoAction - } - if data, err := json.Marshal(action); err == nil { - item["link"] = string(data) - } - } - } - item["linkCount"] = links - var alpha *int - var fill, stroke *ofdgo.FillColor - switch object.Type { - case "TextObject": - fill = object.TextObject.FillColor - stroke = (*ofdgo.FillColor)(object.TextObject.StrokeColor) - alpha = object.TextObject.Alpha - if paint { - item["color"] = editorColorHex(object.TextObject.FillColor) - } - case "ImageObject": - alpha = object.ImageObject.Alpha - item["imageBorder"] = object.ImageObject.Border != nil - item["borderColor"] = "#000000" - if border := object.ImageObject.Border; border != nil { - item["borderColor"] = editorColorHex((*ofdgo.FillColor)(border.BorderColor)) - } - if data, err := json.Marshal(object.ImageObject.Border); err == nil { - item["borderStyle"] = string(data) - } - case "PathObject": - path := object.PathObject - fill = path.FillColor - stroke = (*ofdgo.FillColor)(path.StrokeColor) - if box, err := ofdgo.ParseBox(path.Boundary); err == nil { - item["paintSize"] = []any{box.W, box.H} - } - alpha = path.Alpha - item["dashPattern"], item["cap"], item["join"], item["dashOffset"] = path.DashPattern, path.Cap, path.Join, 0.0 - if path.DashOffset != nil { - item["dashOffset"] = *path.DashOffset - } - if item["scoped"] == true { - item["dashPattern"] = editorDashPattern(path.DashPattern, scale) - if path.DashOffset != nil { - item["dashOffset"] = *path.DashOffset * scale - } - } - if paint { - item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke - item["fillColor"], item["strokeColor"] = editorColorHex(path.FillColor), editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) - item["lineWidth"] = path.LineWidth * scale - } - case "CompositeObject", "CompositeGraphicUnit": - alpha = object.CompositeGraphicUnit.Alpha - } - item["alpha"] = 255 - if alpha != nil { - item["alpha"] = *alpha - } - for key, paint := range map[string]*ofdgo.FillColor{"fill": fill, "stroke": stroke} { - if paint == nil { - continue - } - if p := paint.Pattern; p != nil { - item[key+"Pattern"] = map[string]any{"width": p.Width, "height": p.Height, "xStep": p.XStep, "yStep": p.YStep, "ctm": p.CTM} - } - var segments []ofdgo.ShdSegment - if paint.AxialShd != nil { - segments = paint.AxialShd.Segment - } else if paint.RadialShd != nil { - segments = paint.RadialShd.Segment - } - if len(segments) == 0 { - continue - } - stops, err := currentEditor.GradientStops(segments, nil) - if err != nil { - continue - } - values := make([]any, len(stops)) - for i, stop := range stops { - c := segments[i].Color - values[i] = map[string]any{"position": stop.Offset * 100, "color": editorColorHex(&ofdgo.FillColor{Value: c.Value, Index: c.Index, ColorSpace: c.ColorSpace}), "transparency": (1 - float64(stop.Color.A)/255) * 100} - } - if data, err := json.Marshal(paint); err == nil { - item[key+"Gradient"] = map[string]any{"paint": string(data), "stops": values} - } - } -} - -// compositePath 解析画布复合对象路径 -// 入参: value 顶层标识与逐层序号 -// 返回: ofdgo.ObjectPath 库层路径, error 错误信息 -func compositePath(value string) (ofdgo.ObjectPath, error) { - parts := strings.Split(value, "/") - path := ofdgo.ObjectPath{ID: parts[0]} - if id, ok := strings.CutPrefix(path.ID, "annotation:"); ok { - path.ID, path.Annotation = "", id - } - for _, part := range parts[1:] { - index, err := strconv.Atoi(part) - if err != nil || index < 0 { - return path, fmt.Errorf("invalid composite path") - } - path.Children = append(path.Children, index) - } - return path, nil -} - -// compositeObjects 获取内部编辑范围,不重新渲染页面或传输图片 -// 入参: args 页面索引与复合路径 -// 返回: any 画布对象列表, error 错误信息 -func compositeObjects(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - key := args[1].String() - path, err := compositePath(key) - if err != nil { - return nil, err - } - members, err := currentEditor.CompositeObjects(args[0].Int(), path) - if err != nil { - return nil, err - } - fonts, err := currentSession.Reader.Fonts() - if err != nil { - return nil, err - } - fontNames := make(map[string]string, len(fonts)) - for _, font := range fonts { - fontNames[font.ID] = font.FontName - } - objects := make([]any, 0, len(members)) - for i, member := range members { - box := member.Bounds - if box.W <= 0 || box.H <= 0 { - continue - } - item := map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": member.Position.Index, "count": member.Position.Count, "container": key + ":" + member.Position.Container, "scoped": true, "contours": member.Contours, - "capabilities": editorCapabilities(member.Capabilities, member.Object.Type)} - editorAppearance(item, member.Object, member.Capabilities.Paint, member.StrokeScale) - if member.Object.Type == "ImageObject" { - item["imageResource"] = member.Object.ImageObject.ResourceID - } - if kind, geometry := member.Shape(); kind != "" { - item["shape"], item["geometry"] = string(kind), editorBox(geometry) - } else if kind, box, matrix := member.ShapeFrame(); kind != "" { - item["oriented"] = map[string]any{"kind": string(kind), "box": editorBox(box), "matrix": editorMatrix(matrix)} - } - if member.Object.Type == "TextObject" { - object := member.Object.TextObject - value, layout := object.TextLayout() - item["text"] = value - item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight - item["letterSpacing"] = layout.LetterSpacing - item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent - item["font"], item["fontName"], item["size"] = object.Font, fontNames[object.Font], object.Size - item["bounds"] = editorBox(box) - if member.Capabilities.Reflow { - inverse, _ := member.Matrix.Invert() - local := inverse.TransformBox(box) - width := math.Max(object.Size, local.X+local.W) - if layout.Wrap { - frame, err := object.TextFrame() - if err != nil { - return nil, err - } - item["paragraphWidth"] = frame.W - } - frame := ofdgo.Box{W: width, H: math.Max(object.Size, local.Y+local.H)} - item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(member.Matrix)} - if layout.Wrap { - bounds := member.Matrix.TransformBox(frame) - item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H - } - } - } - if member.Capabilities.CropImage { - item["imageBounds"] = editorBox(box) - item["cropped"] = member.Cropped - } - objects = append(objects, item) - } - return objects, nil -} - -// changeCompositeObjects 提交内部选区操作,结构变化后返回新的选区路径 -// 入参: args 页面索引、父路径、成员路径列表、操作及参数 -// 返回: any 文档信息, error 错误信息 -func changeCompositeObjects(args []js.Value) (any, error) { - var selected []int - result, err := changeObjects(func() error { - key := args[1].String() - path, err := compositePath(key) - if err != nil { - return err - } - indexes, err := compositeIndexes(key, stringsFromJS(args[2])) - if err != nil { - return err - } - page := args[0].Int() - operation := args[3].String() - switch operation { - case "group": - if args[4].Bool() { - if len(indexes) != 1 { - return fmt.Errorf("ungrouping requires one member") - } - selected, err = currentEditor.UngroupCompositeObject(page, path, indexes[0]) - } else { - var index int - index, err = currentEditor.GroupCompositeObjects(page, path, indexes) - selected = []int{index} - } - return err - case "pasteStyle": - if copiedStyle == nil { - return fmt.Errorf("style clipboard is empty") - } - return currentEditor.CopyCompositeStyle(page, path, indexes, *copiedStyle) - case "reshape", "line": - if len(indexes) != 1 { - return fmt.Errorf("shape editing requires one member") - } - box := ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()} - if operation == "line" { - return currentEditor.ReshapeCompositeLine(page, path, indexes[0], ofdgo.ShapeKind(args[8].String()), box) - } - if len(args) > 8 && args[8].Bool() { - return currentEditor.ReshapeCompositeFrame(page, path, indexes[0], box) - } - return currentEditor.ReshapeCompositeObject(page, path, indexes[0], box) - case "erase": - return currentEditor.EraseCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) - case "erasePath": - return currentEditor.EraseCompositeObjectsPath(page, path, indexes, pointsFromJS(args[4])) - case "delete": - return currentEditor.DeleteCompositeObjects(page, path, indexes) - case "copy": - selected, err = currentEditor.CopyCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float()) - return err - case "order": - selected, err = currentEditor.OrderCompositeObjects(page, path, indexes, args[4].String()) - return err - case "text", "textStyle": - return changeCompositeText(page, path, indexes, operation, args[4:]) - case "layout": - if len(indexes) != 1 { - return fmt.Errorf("text layout requires one member") - } - layout := ofdgo.TextLayout{AutoBidi: true, Wrap: args[6].Bool(), Align: args[7].String(), LineHeight: args[8].Float(), LetterSpacing: args[9].Float(), LeftIndent: args[10].Float(), RightIndent: args[11].Float(), FirstLineIndent: args[12].Float()} - if !args[4].IsNull() { - return currentEditor.ResizeCompositeTextFrame(page, path, indexes[0], args[4].Float(), args[5].Float(), layout) - } - member, err := currentEditor.CompositeObject(page, path, indexes[0]) - if err != nil { - return err - } - value, _ := member.Object.TextObject.TextLayout() - return currentEditor.LayoutCompositeText(page, path, indexes[0], value, layout) - case "fit", "resetCrop": - if len(indexes) != 1 { - return fmt.Errorf("image operation requires one member") - } - if operation == "fit" { - return currentEditor.FitCompositeImage(page, path, indexes[0], args[4].String()) - } - return currentEditor.ResetCompositeImageCrop(page, path, indexes[0]) - case "crop": - if len(indexes) != 1 { - return fmt.Errorf("image crop requires one member") - } - return currentEditor.CropCompositeImage(page, path, indexes[0], ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) - case "image": - if len(indexes) != 1 { - return fmt.Errorf("image replacement requires one member") - } - data, err := bytesFromJS(args[4]) - if err != nil { - return err - } - return currentEditor.ReplaceCompositeImage(page, path, indexes[0], data) - case "transform": - return currentEditor.TransformCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float(), args[6].Float()) - case "rotate": - return currentEditor.RotateCompositeObjects(page, path, indexes, args[4].Int()) - case "flip": - return currentEditor.FlipCompositeObjects(page, path, indexes, args[4].String()) - case "resize": - return currentEditor.ResizeCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) - case "align": - return currentEditor.AlignCompositeObjects(page, path, indexes, args[4].String()) - case "distribute": - return currentEditor.DistributeCompositeObjects(page, path, indexes, args[4].String()) - case "style": - return currentEditor.StyleCompositeObjects(page, path, indexes, objectStyle(args[4])) - case "paint", "textColor": - style := ofdgo.ObjectStyle{} - if operation == "textColor" { - style.FillColor = &ofdgo.FillColor{} - if err := setEditorColor(style.FillColor, args[4].String()); err != nil { - return err - } - } else { - if !args[4].IsNull() { - value := args[4].Bool() - style.Fill = &value - } - if !args[6].IsNull() { - value := args[6].Bool() - style.Stroke = &value - } - var colors [2]*ofdgo.FillColor - for i := range colors { - if !args[5+i*2].IsNull() { - colors[i] = &ofdgo.FillColor{} - if err := setEditorColor(colors[i], args[5+i*2].String()); err != nil { - return err - } - } - } - style.FillColor, style.StrokeColor = colors[0], (*ofdgo.StrokeColor)(colors[1]) - if !args[8].IsNull() { - value := args[8].Float() - style.LineWidth = &value - } - } - return currentEditor.StyleCompositeObjects(page, path, indexes, style) - default: - return fmt.Errorf("unsupported composite operation") - } - }) - if err != nil { - return nil, err - } - if selected == nil { - return result, nil - } - doc := editorSelectionInfo{editorInfo: result.(editorInfo)} - for _, index := range selected { - doc.SelectedIDs = append(doc.SelectedIDs, fmt.Sprintf("%s/%d", args[1].String(), index)) - } - return doc, nil -} - -// compositeIndexes 将画布成员路径转换为同一范围的序号 -// 入参: key 父路径, ids 成员路径 -// 返回: []int 成员序号, error 错误信息 -func compositeIndexes(key string, ids []string) ([]int, error) { - indexes := make([]int, 0, len(ids)) - for _, id := range ids { - part, found := strings.CutPrefix(id, key+"/") - if !found { - return nil, fmt.Errorf("selection is outside composite") - } - i, err := strconv.Atoi(part) - if err != nil || i < 0 { - return nil, fmt.Errorf("invalid composite member") - } - indexes = append(indexes, i) - } - return indexes, nil -} - -// changeCompositeText 修改内部文字,嵌入字体前校验选区用字 -// 入参: page 页面索引, path 父路径, indexes 成员序号, operation 操作, args 内容及样式 -// 返回: error 错误信息 -func changeCompositeText(page int, path ofdgo.ObjectPath, indexes []int, operation string, args []js.Value) error { - var value *string - if operation == "text" { - if len(indexes) != 1 { - return fmt.Errorf("text editing requires one member") - } - if !args[0].IsNull() { - text := args[0].String() - value = &text - } - args = args[1:] - } - style := ofdgo.TextStyle{Size: args[1].Float()} - if len(args) > 3 { - style.Reflow = args[3].Bool() - } - if !args[0].IsNull() { - data, err := bytesFromJS(args[0]) - if err != nil { - return err - } - members, err := currentEditor.CompositeObjectsAt(page, path, indexes) - if err != nil { - return err - } - var text strings.Builder - for _, member := range members { - if !member.Capabilities.ReplaceFont { - return fmt.Errorf("composite font replacement is not supported") - } - if value != nil { - text.WriteString(*value) - } else { - text.WriteString(member.Object.TextObject.Text()) - } - } - if err := checkFontGlyphs(data, text.String()); err != nil { - return err - } - style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return err - } - } - if !args[2].IsNull() { - var fill ofdgo.FillColor - if err := setEditorColor(&fill, args[2].String()); err != nil { - return err - } - style.Color = fill.Value - } - if value != nil { - return currentEditor.UpdateCompositeText(page, path, indexes[0], *value, style) - } - return currentEditor.StyleCompositeText(page, path, indexes, style) -} - -// editorFont 读取对象编辑使用的字体,匹配的外部字体不嵌入文档 -// 入参: args 字体资源标识 -// 返回: any 字体数据, error 错误信息 -func editorFont(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - data, err := currentEditor.FontData(args[0].String()) - if err != nil { - return nil, err - } - if len(args) > 1 && args[1].Bool() { - _, data, _, _, err = ofdgo.FixFontDataAggressive(data, true, false) - if err != nil { - return nil, err - } - } - return successResult(map[string]any{"bytes": bytesToJS(data)}), nil -} - -// editorObjectAt 读取页面对象或复合对象成员的有效样式快照 -// 入参: page 页面索引, key 界面对象路径 -// 返回: ofdgo.GraphicObject 对象快照, error 错误信息 -func editorObjectAt(page int, key string) (ofdgo.GraphicObject, error) { - if currentEditor == nil { - return ofdgo.GraphicObject{}, fmt.Errorf("no document is being edited") - } - if separator := strings.LastIndex(key, "/"); separator >= 0 { - path, err := compositePath(key[:separator]) - if err != nil { - return ofdgo.GraphicObject{}, err - } - member, err := strconv.Atoi(key[separator+1:]) - if err != nil { - return ofdgo.GraphicObject{}, err - } - members, err := currentEditor.CompositeObjectsAt(page, path, []int{member}) - if err != nil { - return ofdgo.GraphicObject{}, err - } - return members[0].Object, nil - } - return currentEditor.Object(page, key) -} - -// previewText 生成保留原定位的输入预览,不修改文档或历史 -// 入参: args 页码、对象路径、新文字、可选字体数据、字号及重排标记 -// 返回: any 定位段及字体样式, error 错误信息 -func previewText(args []js.Value) (any, error) { - key := args[1].String() - object, err := editorObjectAt(args[0].Int(), key) - if err != nil { - return nil, err - } - text := object.TextObject - if object.Type != "TextObject" { - return nil, fmt.Errorf("object is not text") - } - editor := currentEditor - fontID := "" - if len(args) > 3 && !args[3].IsNull() { - data, err := bytesFromJS(args[3]) - if err != nil { - return nil, err - } - editor = ofdgo.NewEditor() - fontID, err = editor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return nil, err - } - text.Font = "" - } - if len(args) > 4 && args[4].Float() != text.Size || len(args) > 5 && args[5].Bool() { - if fontID != "" { - text.Font = fontID - } - var x, y float64 - if strings.Contains(key, "/") { - x, _ = strconv.ParseFloat(text.TextCode[0].X, 64) - y, _ = strconv.ParseFloat(text.TextCode[0].Y, 64) - } - text.Size = args[4].Float() - _, layout := text.TextLayout() - if err := editor.LayoutText(&text, args[2].String(), layout); err != nil { - return nil, err - } - if strings.Contains(key, "/") { - baseline, _ := strconv.ParseFloat(text.TextCode[0].Y, 64) - for i := range text.TextCode { - code := &text.TextCode[i] - cx, _ := strconv.ParseFloat(code.X, 64) - cy, _ := strconv.ParseFloat(code.Y, 64) - code.X, code.Y = fmt.Sprint(cx+x), fmt.Sprint(cy+y-baseline) - } - } - } else if err := editor.RewriteTextWithFont(&text, args[2].String(), fontID); err != nil { - return nil, err - } - var runs []any - var x, y float64 - value, offset := []rune(text.Text()), 0 - for _, code := range text.TextCode { - if code.X != "" { - x, _ = strconv.ParseFloat(code.X, 64) - } - if code.Y != "" { - y, _ = strconv.ParseFloat(code.Y, 64) - } - line := offset < len(value) && value[offset] == '\n' - if line { - offset++ - } - content := (ofdgo.TextObject{TextCode: []ofdgo.TextCode{code}}).Text() - code.X, code.Y = fmt.Sprint(x), fmt.Sprint(y) - positioner := ofdgo.NewTextPositioner(code) - positions := make([][2]float64, len([]rune(content))) - for i := range positions { - point := positioner.Next() - positions[i] = [2]float64{point.X, point.Y} - } - runs = append(runs, map[string]any{"text": content, "x": x, "y": y, "positions": positions, "line": line}) - offset += len(positions) - } - scale := text.HScale - if scale == 0 { - scale = 1 - } - glyphs := make(map[int]string) - if fontID == "" { - for _, transform := range text.CGTransform { - if transform.CodeCount != transform.GlyphCount { - continue - } - for i, value := range strings.Fields(transform.Glyphs) { - glyph, err := strconv.ParseUint(value, 10, 16) - if err != nil { - return nil, err - } - glyphs[transform.CodePosition+i] = string(rune(0xF0000 + glyph)) - } - } - } - return map[string]any{"runs": runs, "glyphs": glyphs, "scale": scale, "vscale": text.VScale, "rotation": text.CharDirection, "weight": text.Weight, "italic": text.Italic}, nil -} - -// readTextSelection 将画布路径转换为同一容器的库层文字选区 -// 入参: ids 图元路径列表 -// 返回: ofdgo.TextSelection 文字选区, error 路径错误 -func readTextSelection(ids []string) (ofdgo.TextSelection, error) { - selection := ofdgo.TextSelection{IDs: ids} - if currentEditor == nil { - return selection, fmt.Errorf("no document is being edited") - } - if len(ids) == 0 { - return selection, fmt.Errorf("text selection is empty") - } - if index := strings.LastIndex(ids[0], "/"); index >= 0 { - key := ids[0][:index] - path, err := compositePath(key) - if err != nil { - return selection, err - } - indexes, err := compositeIndexes(key, ids) - return ofdgo.TextSelection{Root: &path, Indexes: indexes}, err - } - for _, id := range ids { - if strings.Contains(id, "/") { - return selection, fmt.Errorf("text selection crosses containers") - } - } - return selection, nil -} - -// textSelection 分析联合文字并加载预览所需字体,不改变文档资源 -// 入参: args 页码和图元路径列表 -// 返回: any 文字选区、定位预览和字体, error 分析错误 -func textSelection(args []js.Value) (any, error) { - selection, err := readTextSelection(stringsFromJS(args[1])) - if err != nil { - return nil, err - } - analysis, err := currentEditor.AnalyzeTextSelection(args[0].Int(), selection) - if err != nil { - return nil, err - } - source, err := textSelectionSource(analysis.Parts, analysis.Bounds, analysis.Text, true) - if err != nil { - return nil, err - } - first := analysis.Parts[0].TextObject - _, origin := analysis.Parts[0].Matrix.Transform(0, 0) - _, unit := analysis.Parts[0].Matrix.Transform(0, 1) - item := editorBox(analysis.Bounds) - item["text"], item["font"], item["size"] = analysis.Text, first.Font, first.Size*(unit-origin) - item["color"] = richTextColorHex(analysis.Parts[0].Color) - item["alpha"] = 255 - if first.Alpha != nil { - item["alpha"] = *first.Alpha - } - item["capabilities"] = map[string]any{"textContent": true, "rewriteText": analysis.Replace, "replaceFont": true, "paint": true, "reflow": analysis.Reflow, "layoutKnown": false} - item["textFrame"] = map[string]any{"width": analysis.Bounds.W, "height": analysis.Bounds.H, "matrix": editorMatrix(ofdgo.TranslationMatrix(analysis.Bounds.X, analysis.Bounds.Y))} - available, err := currentSession.Reader.Fonts() - if err != nil { - return nil, err - } - for _, font := range available { - if font.ID == first.Font { - item["fontName"] = font.FontName - break - } - } - data, err := json.Marshal(map[string]any{"item": item, "source": source}) - if err != nil { - return nil, err - } - result := js.Global().Get("JSON").Call("parse", string(data)) - fonts := js.Global().Get("Array").New() - seen := make(map[string]bool) - for _, part := range analysis.Parts { - id := part.TextObject.Font - if seen[id] { - continue - } - seen[id] = true - data, err := currentEditor.FontData(id) - if err != nil { - return nil, err - } - _, data, _, _, err = ofdgo.FixFontDataAggressive(data, true, false) - if err != nil { - return nil, err - } - font := js.Global().Get("Object").New() - font.Set("id", id) - font.Set("bytes", bytesToJS(data)) - fonts.Call("push", font) - } - result.Set("fonts", fonts) - return successResult(result), nil -} - -// textSelectionSource 将库层逐字定位转换为选区局部坐标及分段字体样式 -// 入参: parts 预览文字图元, box 原始选区边界, value 完整原文, mapped 是否使用原字形映射 -// 返回: map[string]any 定位预览, error 字形映射错误 -func textSelectionSource(parts []ofdgo.TextSelectionPart, box ofdgo.Box, value string, mapped bool) (map[string]any, error) { - runs := make([]any, 0, len(parts)) - glyphs := make(map[int]string) - offset := 0 - logical, cursor := []rune(value), 0 - for _, part := range parts { - if part.Deleted { - continue - } - text := part.TextObject - originX, originY := part.Matrix.Transform(0, 0) - xAxis, _ := part.Matrix.Transform(1, 0) - _, yAxis := part.Matrix.Transform(0, 1) - sx, sy := xAxis-originX, yAxis-originY - scale := text.HScale - if scale == 0 { - scale = 1 - } - for _, transform := range text.CGTransform { - if !mapped { - break - } - if transform.CodeCount != transform.GlyphCount { - return nil, fmt.Errorf("text selection contains ambiguous glyph mapping") - } - for i, value := range strings.Fields(transform.Glyphs) { - glyph, err := strconv.ParseUint(value, 10, 16) - if err != nil { - return nil, err - } - glyphs[offset+transform.CodePosition+i] = string(rune(0xF0000 + glyph)) - } - } - alpha := 255 - if text.Alpha != nil { - alpha = int(*text.Alpha) - } - chars := []rune(strings.ReplaceAll(text.Text(), "\n", "")) - if len(chars) != len(part.Points) { - return nil, fmt.Errorf("text selection positions do not match characters") - } - for i, char := range chars { - x, y := part.Matrix.Transform(part.Points[i].X, part.Points[i].Y) - line := false - for cursor < len(logical) && logical[cursor] == '\n' { - if line || len(runs) == 0 { - runs = append(runs, map[string]any{"text": "", "x": x - box.X, "y": y - box.Y - text.Size*sy, - "positions": [][2]float64{}, "line": line, "font": text.Font, "size": text.Size * sy}) - } - line = true - cursor++ - } - if cursor >= len(logical) || logical[cursor] != char { - return nil, fmt.Errorf("text selection preview does not match content") - } - cursor++ - runs = append(runs, map[string]any{"text": string(char), "x": x - box.X, "y": y - box.Y, - "positions": [][2]float64{{x - box.X, y - box.Y}}, "line": line, "font": text.Font, - "size": text.Size * sy, "scale": scale * sx / sy, "vscale": 1, "rotation": 0, - "weight": text.Weight, "italic": text.Italic, "alpha": alpha, "color": richTextColorHex(part.Color)}) - } - offset += len(chars) - } - for cursor < len(logical) && logical[cursor] == '\n' && len(runs) > 0 { - last := runs[len(runs)-1].(map[string]any) - runs = append(runs, map[string]any{"text": "", "x": float64(0), "y": last["y"].(float64) + last["size"].(float64), - "positions": [][2]float64{}, "line": true, "font": last["font"], "size": last["size"]}) - cursor++ - } - if cursor != len(logical) { - return nil, fmt.Errorf("text selection preview is incomplete") - } - return map[string]any{"runs": runs, "glyphs": glyphs}, nil -} - -// readTextSelectionEdit 将完整草稿转为码点范围修改或显式富文本重排 -// 入参: args 编辑参数, analysis 原始选区 -// 返回: ofdgo.TextSelectionEdit 修改内容, error 参数错误 -func readTextSelectionEdit(args []js.Value, analysis ofdgo.TextSelectionAnalysis) (ofdgo.TextSelectionEdit, error) { - old, next := []rune(analysis.Text), []rune(args[2].String()) - start, end, tail := 0, len(old), len(next) - for start < end && start < tail && old[start] == next[start] { - start++ - } - for end > start && tail > start && old[end-1] == next[tail-1] { - end-- - tail-- - } - edit := ofdgo.TextSelectionEdit{Start: start, End: end, Text: string(next[start:tail])} - if !args[3].IsNull() { - var err error - edit.FontData, err = bytesFromJS(args[3]) - if err != nil { - return edit, err - } - } - if !args[4].IsNull() { - edit.Size = args[4].Float() - } - if !args[5].IsNull() { - value := args[5].String() - if len(value) != 7 || value[0] != '#' { - return edit, fmt.Errorf("invalid RGB color %q", value) - } - rgb, err := hex.DecodeString(value[1:]) - if err != nil { - return edit, err - } - edit.Color = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) - } - if args[6].Bool() && len(next) > 0 { - value := analysis.RichText - var replacement []ofdgo.RichTextRun - if tail > start { - part, err := value.Slice(start, start) - if tail-start == end-start { - part, err = value.Slice(start, end) - } - if err != nil { - return edit, err - } - if tail-start == end-start { - offset := start - for _, run := range part.Runs { - count := len([]rune(run.Text)) - replacement = append(replacement, ofdgo.RichTextRun{Text: string(next[offset : offset+count]), Format: run.Format}) - offset += count - } - } else { - replacement = []ofdgo.RichTextRun{{Text: edit.Text, Format: part.Runs[0].Format}} - } - } - if err := value.Replace(start, end, replacement); err != nil { - return edit, err - } - value.Layout.AutoBidi, value.Layout.Wrap = true, true - edit.Reflow = &value - } - return edit, nil -} - -// previewTextSelection 预览联合修改,不改变文档资源及撤销记录 -// 入参: args 页码、图元路径、内容、字体数据、字号、颜色和重排标记 -// 返回: any 定位预览, error 编辑错误 -func previewTextSelection(args []js.Value) (any, error) { - selection, err := readTextSelection(stringsFromJS(args[1])) - if err != nil { - return nil, err - } - analysis, err := currentEditor.AnalyzeTextSelection(args[0].Int(), selection) - if err != nil { - return nil, err - } - edit, err := readTextSelectionEdit(args, analysis) - if err != nil { - return nil, err - } - preview, err := currentEditor.PreviewTextSelection(args[0].Int(), analysis.Selection, edit) - if err != nil { - return nil, err - } - return textSelectionSource(preview.Parts, analysis.Bounds, preview.Text, len(edit.FontData) == 0) -} - -// updateTextSelection 原子提交联合修改并恢复删除或重排后的选区 -// 入参: args 页码、图元路径、内容、字体数据、字号、颜色和重排标记 -// 返回: any 文档及选区信息, error 编辑错误 -func updateTextSelection(args []js.Value) (any, error) { - ids := stringsFromJS(args[1]) - selection, err := readTextSelection(ids) - if err != nil { - return nil, err - } - page := args[0].Int() - analysis, err := currentEditor.AnalyzeTextSelection(page, selection) - if err != nil { - return nil, err - } - edit, err := readTextSelectionEdit(args, analysis) - if err != nil { - return nil, err - } - preview, err := currentEditor.PreviewTextSelection(page, analysis.Selection, edit) - if err != nil { - return nil, err - } - result, err := changeObjects(func() error { return currentEditor.UpdateTextSelection(page, analysis.Selection, edit) }) - if err != nil { - return nil, err - } - selected := append([]string{}, preview.Selection.IDs...) - for _, index := range preview.Selection.Indexes { - key := ids[0][:strings.LastIndex(ids[0], "/")] - selected = append(selected, fmt.Sprintf("%s/%d", key, index)) - } - return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil -} - -// previewPositionedText 预览保留定位的文字替换,不修改编辑器或撤销记录 -// 入参: args 页面、对象路径和内容 -// 返回: any SVG预览, error 错误信息 -func previewPositionedText(args []js.Value) (any, error) { - object, err := editorObjectAt(args[0].Int(), args[1].String()) - if err != nil { - return nil, err - } - if object.Type != "TextObject" { - return nil, fmt.Errorf("object is not text") - } - if err := currentEditor.RewriteText(&object.TextObject, args[2].String()); err != nil { - return nil, err - } - measured, err := currentSession.Renderer.MeasureObject(object, ofdgo.MeasureOptions{}) - if err != nil { - return nil, err - } - box := measured.Bounds - margin := object.TextObject.Size / 4 - box.X, box.Y, box.W, box.H = box.X-margin, box.Y-margin, box.W+2*margin, box.H+2*margin - boundary, err := ofdgo.ParseBox(object.TextObject.Boundary) - if err != nil { - return nil, err - } - object.TextObject.Boundary = fmt.Sprintf("%g %g %g %g", boundary.X-box.X, boundary.Y-box.Y, boundary.W, boundary.H) - page := &ofdgo.PageContent{Area: ofdgo.PageArea{PhysicalBox: fmt.Sprintf("0 0 %g %g", box.W, box.H)}, Content: ofdgo.Content{Layer: []ofdgo.Layer{{Objects: []ofdgo.GraphicObject{object}}}}} - var output bytes.Buffer - if err := currentSession.Renderer.RenderToSVG(page, &output); err != nil { - return nil, err - } - return output.String(), nil -} - -// updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性 -// 入参: args 页码、对象标识、内容、字体数据、字号、颜色及可选重排标记 -// 返回: any 文档信息, error 错误信息 -func updateText(args []js.Value) (any, error) { - return changeObjects(func() error { return applyTextUpdate(args) }) -} - -// applyTextUpdate 在当前事务中修改文字及字体,不刷新会话 -// 入参: args 页码、对象标识、内容、字体数据、字号、颜色及可选重排标记 -// 返回: error 字体、排版或提交错误 -func applyTextUpdate(args []js.Value) error { - page, id := args[0].Int(), args[1].String() - object, err := currentEditor.Object(page, id) - if err != nil { - return err - } - if object.Type != "TextObject" { - return fmt.Errorf("object %q is not text", id) - } - if !args[3].IsNull() { - data, err := bytesFromJS(args[3]) - if err != nil { - return err - } - value := object.TextObject.Text() - if !args[2].IsNull() { - value = args[2].String() - } - if err := checkFontGlyphs(data, value); err != nil { - return err - } - object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return err - } - } - if !args[2].IsNull() { - if args[4].Float() == object.TextObject.Size || len(args) > 6 && args[6].Bool() { - style := ofdgo.TextStyle{Reflow: len(args) > 6 && args[6].Bool()} - if style.Reflow { - style.Size = args[4].Float() - } - if !args[3].IsNull() { - style.Font = object.TextObject.Font - } - if !args[5].IsNull() { - var fill ofdgo.FillColor - if err := setEditorColor(&fill, args[5].String()); err != nil { - return err - } - style.Color = fill.Value - } - return currentEditor.UpdateTextContent(page, id, args[2].String(), style) - } - object.TextObject.Size = args[4].Float() - _, layout := object.TextObject.TextLayout() - if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil { - return err - } - } - if !args[5].IsNull() { - if err := setTextColor(&object.TextObject, args[5].String()); err != nil { - return err - } - } - return currentEditor.UpdateObject(page, id, object) -} - -// checkFontGlyphs 只检查候选字体,不注册资源或改变文档 -// 入参: data 字体数据, value 待替换文字 -// 返回: error 缺字或字体格式错误 -func checkFontGlyphs(data []byte, value string) error { - missing, err := (FontFile{Data: data}).MissingGlyphs(0, value) - if err != nil { - return err - } - if missing != "" { - return &ofdgo.MissingGlyphError{Characters: missing} - } - return nil -} - -// checkTextFont 为画布输入预检候选字体 -// 入参: args 字体数据和输入文字 -// 返回: any 空结果, error 缺字或字体格式错误 -func checkTextFont(args []js.Value) (any, error) { - data, err := bytesFromJS(args[0]) - if err != nil { - return nil, err - } - return nil, checkFontGlyphs(data, args[1].String()) -} - -// styleText 批量修改文字样式,字体预检通过后交由库原子提交 -// 入参: args 页码、对象标识、字体数据、字号和颜色,null字体与颜色保持原值 -// 返回: any 文档信息, error 错误信息 -func styleText(args []js.Value) (any, error) { - return changeObjects(func() error { - page, ids := args[0].Int(), stringsFromJS(args[1]) - style := ofdgo.TextStyle{Size: args[3].Float()} - if !args[2].IsNull() { - data, err := bytesFromJS(args[2]) - if err != nil { - return err - } - var value strings.Builder - for _, id := range ids { - object, err := currentEditor.Object(page, id) - if err != nil { - return err - } - if object.Type != "TextObject" { - return fmt.Errorf("object %q is not text", id) - } - value.WriteString(object.TextObject.Text()) - } - if err := checkFontGlyphs(data, value.String()); err != nil { - return err - } - style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return err - } - } - if !args[4].IsNull() { - var color ofdgo.FillColor - if err := setEditorColor(&color, args[4].String()); err != nil { - return err - } - style.Color = color.Value - } - return currentEditor.StyleText(page, ids, style) - }) -} - -// editorRichText 将富文本复合图元作为一个文本框提供给编辑器 -// 入参: page 页面索引, item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称 -func editorRichText(page int, item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string) { - if len(value.Runs) == 0 { - return - } - local, matrix, err := currentEditor.RichTextFrame(page, object.CompositeGraphicUnit.ID) - if err != nil { - return - } - width, height := local.W, local.H - _, contentBounds, err := currentEditor.LayoutRichText(value, width) - if err != nil { - return - } - frame := matrix.TransformBox(contentBounds) - format, layout := value.Runs[0].Format, value.Layout - var content strings.Builder - for _, run := range value.Runs { - content.WriteString(run.Text) - } - item["actualType"], item["type"], item["richText"] = object.Type, "TextObject", browserRichText(value) - item["text"], item["font"], item["fontName"], item["size"] = content.String(), format.Font, fonts[format.Font], format.Size - item["color"] = richTextColorHex(format.Color) - item["bold"], item["italic"], item["underline"], item["strikeout"], item["shadow"] = format.Bold, format.Italic, format.Underline, format.Strikeout, format.Shadow - item["background"] = "" - if format.Background != "" { - item["background"] = richTextColorHex(format.Background) - } - item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight - item["letterSpacing"], item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LetterSpacing, layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent - item["paragraphWidth"], item["bounds"] = width, editorBox(frame) - item["x"], item["y"], item["width"], item["height"] = frame.X, frame.Y, frame.W, frame.H - item["textFrame"] = map[string]any{"width": width, "height": height, "matrix": editorMatrix(matrix), "content": editorBox(contentBounds)} - capabilities := item["capabilities"].(map[string]any) - for _, key := range []string{"textContent", "rewriteText", "replaceFont", "reflow", "layoutKnown", "paint"} { - capabilities[key] = true - } - capabilities["enter"], capabilities["ungroup"] = false, false - capabilities["stretch"] = false - if capabilities["reasonCode"] == string(ofdgo.EditUnsupportedObject) && capabilities["transform"] == true && capabilities["copy"] == true && object.CompositeGraphicUnit.Clips == nil { - delete(capabilities, "reason") - delete(capabilities, "reasonCode") - } -} - -// getRichText 读取文字分段与段落选项,颜色转换为浏览器RGB格式 -// 入参: args 页码和对象标识 -// 返回: any 富文本快照, error 错误信息 -func getRichText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - value, err := currentEditor.RichText(args[0].Int(), args[1].String()) - if err != nil { - return nil, err - } - return browserRichText(value), nil -} - -// browserRichText 提供独立富文本快照和浏览器颜色,不改变库层样式 -// 入参: value 库层富文本 -// 返回: map[string]any 文字分段和段落选项 -func browserRichText(value ofdgo.RichText) map[string]any { - runs := make([]any, len(value.Runs)) - for i, run := range value.Runs { - format := run.Format - background := "" - if format.Background != "" { - background = richTextColorHex(format.Background) - } - runs[i] = map[string]any{"text": run.Text, "format": map[string]any{ - "font": format.Font, "size": format.Size, "color": richTextColorHex(format.Color), - "bold": format.Bold, "italic": format.Italic, "underline": format.Underline, - "strikeout": format.Strikeout, "shadow": format.Shadow, "background": background, - }} - } - layout := value.Layout - return map[string]any{"runs": runs, "layout": map[string]any{ - "autoBidi": layout.AutoBidi, "shape": layout.Shape, - "shaping": map[string]any{ - "Bidi": layout.Shaping.Bidi, "Direction": layout.Shaping.Direction, - "Script": layout.Shaping.Script, "Language": layout.Shaping.Language, "Features": layout.Shaping.Features, - }, - "wrap": layout.Wrap, "align": layout.Align, "lineHeight": layout.LineHeight, - "letterSpacing": layout.LetterSpacing, "leftIndent": layout.LeftIndent, - "rightIndent": layout.RightIndent, "firstLineIndent": layout.FirstLineIndent, - }} -} - -// richTextColorHex 将富文本的RGB分量转换为浏览器颜色,不套用文档颜色空间 -// 入参: value RGB分量 -// 返回: string 十六进制颜色 -func richTextColorHex(value string) string { - var r, g, b int - fmt.Sscan(value, &r, &g, &b) - return fmt.Sprintf("#%02x%02x%02x", r, g, b) -} - -// readRichText 读取浏览器富文本并转换显式RGB颜色 -// 入参: value 富文本JSON -// 返回: ofdgo.RichText 库层快照, error 数据错误 -func readRichText(value js.Value) (ofdgo.RichText, error) { - var text ofdgo.RichText - if err := json.Unmarshal([]byte(value.String()), &text); err != nil { - return text, err - } - for i := range text.Runs { - format := &text.Runs[i].Format - for _, field := range []*string{&format.Color, &format.Background} { - if *field == "" { - continue - } - if len(*field) != 7 || (*field)[0] != '#' { - return text, fmt.Errorf("invalid RGB color %q", *field) - } - rgb, err := hex.DecodeString((*field)[1:]) - if err != nil { - return text, err - } - *field = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) - } - } - return text, nil -} - -// saveRichText 原子保存文字、样式和新增字体,空标识创建文本框 -// 入参: args 页码、标识、富文本JSON、新建边界和可选字体集合 -// 返回: any 文档信息及文字选区, error 错误信息 -func saveRichText(args []js.Value) (any, error) { - value, err := readRichText(args[2]) - if err != nil { - return nil, err - } - page, id := args[0].Int(), args[1].String() - result, err := changeObjects(func() error { - if len(args) > 4 { - if err := importRichTextFonts(&value, args[4]); err != nil { - return err - } - } - if id != "" { - if err := currentEditor.UpdateRichText(page, id, value); err != nil { - return err - } - box := args[3] - if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == js.TypeNumber { - offset := 0.0 - if box.Get("frameOffset").Type() == js.TypeNumber { - offset = box.Get("frameOffset").Float() - } - return currentEditor.ResizeRichTextFrame(page, id, offset, box.Get("frameWidth").Float()) - } - return nil - } - box := args[3] - id, err = currentEditor.AddRichText(page, ofdgo.Box{X: box.Get("x").Float(), Y: box.Get("y").Float(), W: box.Get("width").Float(), H: box.Get("height").Float()}, value) - return err - }) - if err != nil { - return nil, err - } - info, ok := result.(editorInfo) - if !ok { - return result, nil - } - return editorSelectionInfo{editorInfo: info, SelectedIDs: []string{id}}, nil -} - -// importRichTextFonts 在当前事务内注册实际使用的新字体并替换临时标识 -// 入参: value 富文本快照, fonts 浏览器字体集合 -// 返回: error 数据或字体错误 -func importRichTextFonts(value *ofdgo.RichText, fonts js.Value) error { - if fonts.IsNull() || fonts.IsUndefined() { - return nil - } - registered := make(map[string]string) - for i := 0; i < fonts.Length(); i++ { - entry := fonts.Index(i) - key := entry.Get("key").String() - if key == "" || registered[key] != "" { - return fmt.Errorf("invalid rich text font key") - } - used := false - for _, run := range value.Runs { - used = used || run.Format.Font == key - } - if !used { - continue - } - data, err := bytesFromJS(entry.Get("data")) - if err != nil { - return err - } - font, err := currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return err - } - registered[key] = font - } - for i := range value.Runs { - if font := registered[value.Runs[i].Format.Font]; font != "" { - value.Runs[i].Format.Font = font - } - } - return nil -} - -// previewRichText 使用库层排版校验富文本并测量边界,临时资源及历史始终回滚 -// 入参: args 页码、对象标识、富文本JSON、局部宽度和可选字体集合 -// 返回: any 局部边界, error 排版错误 -func previewRichText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - value, err := readRichText(args[2]) - if err != nil { - return nil, err - } - var result map[string]any - finished := errors.New("rich text preview complete") - err = currentEditor.Transaction(func(editor *ofdgo.Editor) error { - if len(args) > 4 { - if err := importRichTextFonts(&value, args[4]); err != nil { - return err - } - } - width := args[3].Float() - _, bounds, err := editor.LayoutRichText(value, width) - if err != nil { - return err - } - result = map[string]any{"x": bounds.X, "y": bounds.Y, "width": bounds.W, "height": bounds.H} - return finished - }) - if !errors.Is(err, finished) { - return nil, err - } - return result, nil -} - -// setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度 -// 入参: object 文字对象, value 十六进制色值 -// 返回: error 错误信息 -func setTextColor(object *ofdgo.TextObject, value string) error { - if object.FillColor == nil { - object.FillColor = &ofdgo.FillColor{} - } - return setEditorColor(object.FillColor, value) -} - -// insertText 使用选定字体创建文字对象 -// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距、字距及左右和首行缩进 -// 返回: any 文档信息, error 错误信息 -func insertText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - data, err := bytesFromJS(args[2]) - if err != nil { - return nil, err - } - fontID, err := currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return nil, err - } - page := args[0].Int() - content, err := currentSession.pageContent(page) - if err != nil { - return nil, err - } - box, err := currentSession.pageBox(page, content) - if err != nil { - return nil, err - } - x, y := args[3].Float(), args[4].Float() - width := args[7].Float() - if width == 0 { - width = 80 - } - box = ofdgo.Box{X: x, Y: y, W: math.Min(width, box.W-x), H: box.H - y} - object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{ - Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), - Font: fontID, Size: args[5].Float(), - }} - if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{AutoBidi: true, Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float(), LeftIndent: args[12].Float(), RightIndent: args[13].Float(), FirstLineIndent: args[14].Float()}); err != nil { - return nil, err - } - if err := setTextColor(&object.TextObject, args[6].String()); err != nil { - return nil, err - } - return insertEditorObject(page, object, args[15:]) -} - -// layoutText 调整文字框和段落排版,保留软换行前的原文 -// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距、字距及左右和首行缩进 -// 返回: any 文档信息, error 错误信息 -func layoutText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - page, id := args[0].Int(), args[1].String() - object, err := currentEditor.Object(page, id) - if err != nil { - return nil, err - } - if object.Type != "TextObject" { - return nil, fmt.Errorf("object is not text") - } - if !args[2].IsNull() { - object.TextObject, err = object.TextObject.ResizeTextFrame(args[2].Float(), args[3].Float()) - if err != nil { - return nil, err - } - } - value, _ := object.TextObject.TextLayout() - if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{AutoBidi: true, Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float(), LeftIndent: args[8].Float(), RightIndent: args[9].Float(), FirstLineIndent: args[10].Float()}); err != nil { - return nil, err - } - revision := currentEditor.Revision() - if err := currentEditor.UpdateObject(page, id, object); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// replaceImage 替换图片资源并更新预览 -// 入参: args 页码、对象标识、图片数据和适应方式 -// 返回: any 文档信息, error 错误信息 -func replaceImage(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - data, err := bytesFromJS(args[2]) - if err != nil { - return nil, err - } - revision := currentEditor.Revision() - if err := currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data, args[3].String()); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// alignObject 将对象对齐页面并更新预览 -// 入参: args 页码、对象标识和对齐方式 -// 返回: any 文档信息, error 错误信息 -func alignObject(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - revision := currentEditor.Revision() - if err := currentEditor.AlignObject(args[0].Int(), args[1].String(), args[2].String()); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// transformObject 等比缩放后平移对象 -// 入参: args 页码、对象标识、位移和缩放比例 -// 返回: any 文档信息, error 错误信息 -func transformObject(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - page, id := args[0].Int(), args[1].String() - revision := currentEditor.Revision() - if err := currentEditor.TransformObject(page, id, args[2].Float(), args[3].Float(), args[4].Float()); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// reshapeObject 调整基本图形的尺寸或直线端点,保留绘制样式 -// 入参: args 页码、对象标识和页面几何范围 -// 返回: any 文档信息, error 错误信息 -func reshapeObject(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - page, id := args[0].Int(), args[1].String() - object, err := currentEditor.Object(page, id) - if err != nil { - return nil, err - } - if object.Type != "PathObject" { - return nil, fmt.Errorf("object is not a path") - } - box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()} - if len(args) > 6 && args[6].Bool() { - object.PathObject, err = object.PathObject.ReshapeFrame(box) - } else { - object.PathObject, err = object.PathObject.Reshape(box) - } - if err != nil { - return nil, err - } - revision := currentEditor.Revision() - if err := currentEditor.UpdateObject(page, id, object); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// reshapeLine 修改直线端点与箭头类型,复用库层样式保留逻辑 -// 入参: args 页码、对象标识、页面端点及箭头类型 -// 返回: any 文档信息, error 错误信息 -func reshapeLine(args []js.Value) (any, error) { - return changeObjects(func() error { - page, id := args[0].Int(), args[1].String() - object, err := currentEditor.Object(page, id) - if err != nil { - return err - } - if object.Type != "PathObject" { - return fmt.Errorf("object is not a path") - } - object.PathObject, err = object.PathObject.ReshapeLine(ofdgo.ShapeKind(args[6].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) - if err != nil { - return err - } - return currentEditor.UpdateObject(page, id, object) - }) -} - -// copyObjects 复制选区并返回新对象标识,复用字体和图片资源 -// 入参: args 页码、对象标识列表和位移 -// 返回: any 文档信息, error 错误信息 -func copyObjects(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) - if err != nil { - return nil, err - } - return copyEditorObjects(args[0].Int(), objects, args[2].Float(), args[3].Float()) -} - -// captureObjects 保存独立选区快照,不修改文档或历史 -// 入参: args 页面索引、对象标识数组、剪贴板标识和可选父路径 -// 返回: any 空结果, error 错误信息 -func captureObjects(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - clipboard := &editorClipboard{token: args[2].String()} - var err error - if len(args) > 3 && args[3].String() != "" { - key := args[3].String() - path, pathErr := compositePath(key) - if pathErr != nil { - return nil, pathErr - } - indexes, indexErr := compositeIndexes(key, stringsFromJS(args[1])) - if indexErr != nil { - return nil, indexErr - } - clipboard.composite, err = currentEditor.CaptureCompositeObjects(args[0].Int(), path, indexes) - } else { - clipboard, err = captureEditorSelection(args[0].Int(), stringsFromJS(args[1])) - clipboard.token = args[2].String() - } - if err != nil { - return nil, err - } - copiedObjects = clipboard - return nil, nil -} - -// pasteObjects 将当前文档的对象快照粘贴到目标页 -// 入参: args 目标页、剪贴板标识、横纵位移和可选父路径 -// 返回: any 文档信息及新选区, error 错误信息 -func pasteObjects(args []js.Value) (any, error) { - if currentEditor == nil || copiedObjects == nil || copiedObjects.token != args[1].String() { - return nil, fmt.Errorf("object clipboard is no longer available") - } - key := "" - if len(args) > 4 { - key = args[4].String() - } - page, dx, dy := args[0].Int(), args[2].Float(), args[3].Float() - if key != "" { - path, err := compositePath(key) - if err != nil { - return nil, err - } - var indexes []int - if copiedObjects.composite != nil { - indexes, err = currentEditor.PasteCompositeObjects(page, path, copiedObjects.composite, dx, dy) - } else { - indexes, err = currentEditor.PasteSelectionToComposite(page, path, copiedObjects.selection, dx, dy) - } - if err != nil { - return nil, err - } - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - result := editorSelectionInfo{editorInfo: info} - for _, index := range indexes { - result.SelectedIDs = append(result.SelectedIDs, fmt.Sprintf("%s/%d", key, index)) - } - return result, err - } - if copiedObjects.composite != nil { - ids, err := currentEditor.PasteCompositeSelection(page, copiedObjects.composite, dx, dy) - if err != nil { - return nil, err - } - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - return editorSelectionInfo{info, ids}, err - } - ids, err := pasteEditorSelection(page, copiedObjects, dx, dy) - if err != nil { - return nil, err - } - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - return editorSelectionInfo{info, ids}, err -} - -// copyEditorObjects 复制快照并更新预览,返回新对象标识 -// 入参: page 目标页, objects 对象快照, dx 横向位移,单位为毫米, dy 纵向位移,单位为毫米 -// 返回: any 文档信息及新选区, error 错误信息 -func copyEditorObjects(page int, objects []ofdgo.GraphicObject, dx, dy float64) (any, error) { - ids, err := currentEditor.CopyObjects(page, objects, dx, dy) - if err != nil { - return nil, err - } - if len(ids) == 0 { - return editorSummary(), nil - } - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - return editorSelectionInfo{info, ids}, err -} - -// groupObjects 组合或解组并返回新的选区,复用单次历史提交 -// 入参: args 页面索引、对象标识列表和是否解组 -// 返回: any 文档及选区, error 错误信息 -func groupObjects(args []js.Value) (any, error) { - var ids []string - info, err := changeObjects(func() error { - page, selected := args[0].Int(), stringsFromJS(args[1]) - var err error - if args[2].Bool() { - if len(selected) != 1 { - return fmt.Errorf("ungrouping requires one object") - } - ids, err = currentEditor.UngroupObject(page, selected[0]) - } else { - var id string - id, err = currentEditor.GroupObjects(page, selected) - ids = []string{id} - } - return err - }) - if err != nil { - return nil, err - } - return editorSelectionInfo{info.(editorInfo), ids}, nil -} - -// orderObjects 调整选区的绘制顺序 -// 入参: args 页码、对象标识列表和层级动作 -// 返回: any 文档信息, error 错误信息 -func orderObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.OrderObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) - }) -} - -// distributeObjects 按指定轴等距分布选区 -// 入参: args 页面索引、对象标识数组和分布轴 -// 返回: any 文档信息, error 错误信息 -func distributeObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.DistributeObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) - }) -} - -// deleteObject 删除创作画布中选中的对象 -// 入参: args 页码和对象标识 -// 返回: any 文档信息, error 错误信息 -func deleteObject(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - if err := currentEditor.DeleteObject(args[0].Int(), args[1].String()); err != nil { - return nil, err - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// changePage 管理创作文档的页面 -// 入参: args 操作、页面索引及尺寸或目标索引 -// 返回: any 文档信息和目标页面, error 错误信息 -func changePage(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - index := args[1].Int() - revision := currentEditor.Revision() - var err error - switch action := args[0].String(); action { - case "add": - index, err = currentEditor.AddPage(args[2].Float(), args[3].Float()) - case "copy": - index, err = currentEditor.CopyPage(index) - case "delete": - if currentEditor.PageCount() == 1 { - return nil, fmt.Errorf("the document must contain at least one page") - } - err = currentEditor.DeletePage(index) - index = min(index, currentEditor.PageCount()-1) - case "move": - target := args[2].Int() - err = currentEditor.MovePage(index, target) - index = target - case "resize": - err = currentEditor.ResizePage(index, args[2].Float(), args[3].Float()) - default: - return nil, fmt.Errorf("unsupported page action %q", action) - } - if err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorPageInfo{editorSummary(), index}, nil - } - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - if err != nil { - return nil, err - } - return editorPageInfo{info, index}, nil -} - -// batchPages 按页码范围批量复制、删除、移动或调整页面尺寸 -// 入参: args 操作、页码表达式及插入位置或宽高 -// 返回: any 文档信息及目标页, error 错误信息 -func batchPages(args []js.Value) (any, error) { - index := 0 - info, err := changeObjects(func() error { - indexes, err := ofdgo.ParsePageRange(args[1].String(), currentEditor.PageCount()) - if err != nil { - return err - } - switch args[0].String() { - case "copy": - copies, err := currentEditor.CopyPages(indexes) - if err == nil && len(copies) > 0 { - index = copies[0] - } - return err - case "delete": - if len(indexes) == currentEditor.PageCount() { - return fmt.Errorf("the document must contain at least one page") - } - if err := currentEditor.DeletePages(indexes); err != nil { - return err - } - index = min(indexes[0], currentEditor.PageCount()-1) - return nil - case "move": - at := args[2].Int() - if at < 0 || at > currentEditor.PageCount() { - return fmt.Errorf("page index %d out of range", at) - } - index = at - for _, selected := range indexes { - if selected < at { - index-- - } - } - return currentEditor.MovePages(indexes, index) - case "resize": - index = indexes[0] - return currentEditor.ResizePages(indexes, args[2].Float(), args[3].Float()) - default: - return fmt.Errorf("unsupported page action %q", args[0].String()) - } - }) - if err != nil { - return nil, err - } - return editorPageInfo{info.(editorInfo), index}, nil -} - -// indexesFromJS 读取整数索引列表 -// 入参: value JavaScript数组 -// 返回: []int 索引列表 -func indexesFromJS(value js.Value) []int { - indexes := make([]int, value.Length()) - for i := range indexes { - indexes[i] = value.Index(i).Int() - } - return indexes -} - -// moveOutline 调整目录顺序或层级 -// 入参: args 源路径、目标父路径及插入索引 -// 返回: any 文档及新路径, error 错误信息 -func moveOutline(args []js.Value) (any, error) { - var path []int - info, err := changeObjects(func() error { - var err error - path, err = currentEditor.MoveOutline(indexesFromJS(args[0]), indexesFromJS(args[1]), args[2].Int()) - return err - }) - if err != nil { - return nil, err - } - return editorOutlineInfo{info.(editorInfo), path}, nil -} - -// captureStyle 保存单个对象样式来源,不改写文档 -// 入参: args 页面索引、对象标识及可选内部范围 -// 返回: any 空结果, error 错误信息 -func captureStyle(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - if len(args) > 2 && args[2].String() != "" { - key := args[2].String() - path, err := compositePath(key) - if err != nil { - return nil, err - } - indexes, err := compositeIndexes(key, []string{args[1].String()}) - if err != nil { - return nil, err - } - member, err := currentEditor.CompositeObject(args[0].Int(), path, indexes[0]) - if err != nil { - return nil, err - } - object := member.Style() - copiedStyle = &object - return nil, nil - } - object, err := currentEditor.Object(args[0].Int(), args[1].String()) - if err != nil { - return nil, err - } - copiedStyle = &object - return nil, nil -} - -// pasteStyle 将保存的样式应用到同类型选区 -// 入参: args 页面索引和对象标识列表 -// 返回: any 文档信息, error 错误信息 -func pasteStyle(args []js.Value) (any, error) { - return changeObjects(func() error { - if copiedStyle == nil { - return fmt.Errorf("style clipboard is empty") - } - return currentEditor.CopyStyle(args[0].Int(), stringsFromJS(args[1]), *copiedStyle) - }) -} - -// resizeObjects 设置选区位置及尺寸 -// 入参: args 页面索引、对象标识、横纵坐标及宽高 -// 返回: any 文档信息, error 错误信息 -func resizeObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.ResizeObjects(args[0].Int(), stringsFromJS(args[1]), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) - }) -} - -// changeOutline 新增、修改或删除层级目录 -// 入参: args 操作、层级索引、标题及目标页面索引 -// 返回: any 文档信息, error 错误信息 -func changeOutline(args []js.Value) (any, error) { - path := indexesFromJS(args[1]) - info, err := changeObjects(func() error { - switch args[0].String() { - case "add": - var err error - path, err = currentEditor.AddOutline(path, args[2].String(), args[3].Int()) - return err - case "update": - return currentEditor.UpdateOutline(path, args[2].String(), args[3].Int()) - case "delete": - err := currentEditor.DeleteOutline(path) - path = nil - return err - default: - return fmt.Errorf("unsupported outline action %q", args[0].String()) - } - }) - if err != nil { - return nil, err - } - return editorOutlineInfo{info.(editorInfo), path}, nil -} - -// styleObjects 原子更新对象外观和图案布局 -// 入参: args 页面索引、对象标识数组及样式,省略字段保持原值 -// 返回: any 文档信息, error 错误信息 -func styleObjects(args []js.Value) (any, error) { - style := objectStyle(args[2]) - linkField := args[2].Get("link") - var link *ofdgo.AnnotationLink - if !linkField.IsUndefined() && !linkField.IsNull() { - if err := json.Unmarshal([]byte(linkField.String()), &link); err != nil { - return nil, err - } - } - var border *ofdgo.ImageBorderStyle - if field := args[2].Get("borderStyle"); !field.IsUndefined() { - if err := json.Unmarshal([]byte(field.String()), &border); err != nil { - return nil, err - } - } - appearance := ofdgo.ObjectAppearance{SetLink: !linkField.IsUndefined(), Link: link, Border: border} - for i, key := range []string{"fillPatternStyle", "strokePatternStyle"} { - if field := args[2].Get(key); !field.IsUndefined() { - var pattern ofdgo.PatternStyle - if err := json.Unmarshal([]byte(field.String()), &pattern); err != nil { - return nil, err - } - if i == 0 { - appearance.FillPattern = &pattern - } else { - appearance.StrokePattern = &pattern - } - } - } - if field := args[2].Get("fillPaint"); !field.IsUndefined() { - if err := json.Unmarshal([]byte(field.String()), &style.FillColor); err != nil { - return nil, err - } - } - if field := args[2].Get("strokePaint"); !field.IsUndefined() { - if err := json.Unmarshal([]byte(field.String()), &style.StrokeColor); err != nil { - return nil, err - } - } - return changeObjects(func() error { - if border != nil { - if err := resolveStylePaint((*ofdgo.FillColor)(border.Color)); err != nil { - return err - } - } - for _, paint := range []*ofdgo.FillColor{style.FillColor, (*ofdgo.FillColor)(style.StrokeColor)} { - if err := resolveStylePaint(paint); err != nil { - return err - } - } - appearance.Style = style - return currentEditor.ApplyAppearance(args[0].Int(), stringsFromJS(args[1]), appearance) - }) -} - -// resolveStylePaint 将渐变色标中的浏览器色值转换为文档颜色空间 -// 入参: paint 颜色数据 -// 返回: error 错误信息 -func resolveStylePaint(paint *ofdgo.FillColor) error { - if paint == nil { - return nil - } - if strings.HasPrefix(paint.Value, "#") { - return setEditorColor(paint, paint.Value) - } - var segments []ofdgo.ShdSegment - if paint.AxialShd != nil { - segments = paint.AxialShd.Segment - } else if paint.RadialShd != nil { - segments = paint.RadialShd.Segment - } - for i := range segments { - stop := &segments[i].Color - if strings.HasPrefix(stop.Value, "#") { - color := ofdgo.FillColor{Alpha: stop.Alpha} - if err := setEditorColor(&color, stop.Value); err != nil { - return err - } - *stop = ofdgo.ShdColor{Value: color.Value, ColorSpace: color.ColorSpace, Alpha: color.Alpha} - } - } - return nil -} - -// editorDashPattern 将内部对象的虚线长度格式化为页面毫米 -// 入参: value 对象空间虚线长度, scale 页面描边倍率 -// 返回: string 页面空间虚线长度,无法解析时保留原文 -func editorDashPattern(value string, scale float64) string { - if scale == 1 || value == "" { - return value - } - fields := strings.Fields(value) - for i, field := range fields { - number, err := strconv.ParseFloat(field, 64) - if err != nil || math.IsNaN(number*scale) || math.IsInf(number*scale, 0) { - return value - } - fields[i] = strconv.FormatFloat(number*scale, 'f', -1, 64) - } - return strings.Join(fields, " ") -} - -// objectStyle 读取高级面板提交的外观字段,省略字段保持原值 -// 入参: value 前端样式 -// 返回: ofdgo.ObjectStyle 库层样式 -func objectStyle(value js.Value) ofdgo.ObjectStyle { - style := ofdgo.ObjectStyle{} - if field := value.Get("fill"); !field.IsUndefined() { - v := field.Bool() - style.Fill = &v - } - if field := value.Get("stroke"); !field.IsUndefined() { - v := field.Bool() - style.Stroke = &v - } - if field := value.Get("alpha"); !field.IsUndefined() { - v := field.Int() - style.Alpha = &v - } - if field := value.Get("dashPattern"); !field.IsUndefined() { - v := field.String() - style.DashPattern = &v - } - if field := value.Get("dashOffset"); !field.IsUndefined() { - v := field.Float() - style.DashOffset = &v - } - if field := value.Get("cap"); !field.IsUndefined() { - v := field.String() - style.Cap = &v - } - if field := value.Get("join"); !field.IsUndefined() { - v := field.String() - style.Join = &v - } - return style -} - -// clearImport 释放待导入文档 -func clearImport() { - if pendingImport != nil { - _ = pendingImport.Close() - pendingImport = nil - } -} - -// loadImport 加载待导入文件的索引,null参数清除待处理文件 -// 入参: args OFD字节数组或null -// 返回: any 文档列表、页数及签名提示, error 错误信息 -func loadImport(args []js.Value) (any, error) { - clearImport() - if args[0].IsNull() { - return nil, nil - } - data, err := bytesFromJS(args[0]) - if err != nil { - return nil, err - } - var options []ofdgo.ReaderOption - if len(args) > 1 && !args[1].IsNull() && !args[1].IsUndefined() { - credentials, err := credentialsFromJS(args[1]) - if err != nil { - return nil, err - } - defer clear(credentials.Password) - options = append(options, ofdgo.WithCredentials(credentials)) - } - reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data)), options...) - if err != nil { - return nil, err - } - doc, err := reader.Doc() - if err != nil { - reader.Close() - return nil, err - } - if len(doc.Pages.Page) == 0 { - reader.Close() - return nil, fmt.Errorf("document has no pages") - } - pendingImport = reader - entries := make([]DocumentEntry, reader.DocumentCount()) - signed := doc.Signatures != "" - for index, body := range reader.OFD.DocBody { - entries[index] = DocumentEntry{Index: index, Title: body.DocInfo.Title, Root: body.DocRoot, ID: body.DocInfo.DocID} - signed = signed || body.Signatures != "" - } - return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != "", "documentsSigned": signed, "encrypted": reader.Encryption().Encrypted, "documents": entries, "documentCount": len(entries)}, nil -} - -// importPages 插入指定来源页面并保持目标文档元数据 -// 入参: args 页码表达式,空值为全部、目标零基插入位置、是否导入对应目录和可选进度回调 -// 返回: any 文档信息及首个插入页, error 错误信息 -func importPages(args []js.Value) (any, error) { - if currentEditor == nil || pendingImport == nil { - return nil, fmt.Errorf("no document is ready for page import") - } - doc, err := pendingImport.Doc() - if err != nil { - return nil, err - } - var indexes []int - if value := strings.TrimSpace(args[0].String()); value != "" { - indexes, err = ofdgo.ParsePageRange(value, len(doc.Pages.Page)) - if err != nil { - return nil, err - } - } else { - for i := range doc.Pages.Page { - indexes = append(indexes, i) - } - } - at := args[1].Int() - options := ofdgo.PageImportOptions{Outlines: len(args) > 2 && args[2].Bool()} - ctx := context.Background() - progressIndex := 3 - if len(args) > 3 && args[3].Type() == js.TypeBoolean { - options.AllowDecrypted = args[3].Bool() - progressIndex = 4 - } - if len(args) > progressIndex { - ctx, options.OnProgress = newEditorOperationContext(args[progressIndex]) - } - if _, err := currentEditor.ImportPagesContext(ctx, pendingImport, indexes, at, options); err != nil { - return nil, err - } - clearImport() - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - return editorPageInfo{info, at}, err -} - -// importDocuments 导入指定文档及历史版本并选中首个导入文档 -// 入参: args 文档序号表达式、插入位置、解密确认和可选进度回调 -// 返回: any 文档信息及当前页, error 导入错误 -func importDocuments(args []js.Value) (any, error) { - if currentEditor == nil || pendingImport == nil { - return nil, fmt.Errorf("no document is ready for document import") - } - if len(args) < 3 { - return nil, fmt.Errorf("invalid document import arguments") - } - var indexes []int - var err error - if value := strings.TrimSpace(args[0].String()); value != "" { - indexes, err = ofdgo.ParsePageRange(value, pendingImport.DocumentCount()) - if err != nil { - return nil, err - } - } - options := ofdgo.DocumentImportOptions{AllowDecrypted: args[2].Bool()} - ctx := context.Background() - if len(args) > 3 { - ctx, options.OnProgress = newEditorOperationContext(args[3]) - } - if _, err := currentEditor.ImportDocumentsContext(ctx, pendingImport, indexes, args[1].Int(), options); err != nil { - return nil, err - } - clearImport() - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - return editorPageInfo{info, 0}, err -} - -// importImages 逐张读取图片并建页,负数位置表示新建文档 -// 入参: args 文件名、选项、插入位置、标题、注解设置、读取与进度回调 -// 返回: any 文档信息, error 错误信息 -func importImages(args []js.Value) (any, error) { - var options ofdgo.ImagePageOptions - if err := json.Unmarshal([]byte(js.Global().Get("JSON").Call("stringify", args[1]).String()), &options); err != nil { - return nil, err - } - at := args[2].Int() - editor := currentEditor - if at < 0 { - editor = ofdgo.NewEditor() - editor.Info.Title = args[3].String() - } else if editor == nil { - return nil, fmt.Errorf("document is not editable") - } - if args[0].Length() == 0 { - return nil, fmt.Errorf("请选择图片") - } - sources := make([]ofdgo.ImagePageSource, args[0].Length()) - for i := range sources { - sources[i] = ofdgo.ImagePageSource{Name: args[0].Index(i).String(), Read: func(context.Context) ([]byte, error) { - result := args[5].Invoke(i) - if message := result.Get("error"); message.Type() == js.TypeString { - return nil, fmt.Errorf("%s", message.String()) - } - return bytesFromJS(result.Get("bytes")) - }} - } - options.OnProgress = editorOperationProgress(args[6]) - position := max(at, 0) - if err := editor.ImportImages(context.Background(), sources, position, options); err != nil { - return nil, err - } - if at < 0 { - editor.SetHistoryLimit(100) - } - clearImport() - info, err := previewEditor(editor, args[4].Bool()) - return editorPageInfo{info, position}, err -} - -// createDocument 新建单页文档 -// 入参: args 标题、纸张宽高和注解设置 -// 返回: any 文档信息, error 错误信息 -func createDocument(args []js.Value) (any, error) { - clearImport() - editor := ofdgo.NewEditor() - editor.Info.Title = args[0].String() - if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil { - return nil, err - } - editor.SetHistoryLimit(100) - return previewEditor(editor, args[3].Bool()) -} - -// updateInfo 修改文档信息和自定义字段,保留其他元数据及创建程序标识 -// 入参: args 标题、作者、主题、可选自定义字段JSON及文档索引 -// 返回: any 编辑状态, error 错误信息 -func updateInfo(args []js.Value) (any, error) { - return changeObjects(func() error { - if len(args) < 3 || len(args) > 5 { - return fmt.Errorf("invalid document info arguments") - } - index := currentEditor.DocumentIndex() - if len(args) > 4 { - index = args[4].Int() - } - info, err := currentEditor.DocumentInfo(index) - if err != nil { - return err - } - info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String() - if len(args) > 3 { - var submitted []struct { - ofdgo.CustomData - Source *int - } - if err := json.Unmarshal([]byte(args[3].String()), &submitted); err != nil { - return err - } - var previous []ofdgo.CustomData - if info.CustomDatas != nil { - previous = info.CustomDatas.CustomData - } - fields := make([]ofdgo.CustomData, len(submitted)) - for i, field := range submitted { - if field.Source != nil { - if *field.Source < 0 || *field.Source >= len(previous) { - return fmt.Errorf("invalid custom data source") - } - fields[i] = previous[*field.Source] - } - fields[i].Name, fields[i].Value = field.Name, field.Value - } - if !slices.Equal(previous, fields) { - info.CustomDatas = &ofdgo.CustomDatas{CustomData: fields} - } - } - return currentEditor.SetDocumentInfo(index, info) - }) -} - -// packageDocuments 按需读取管理列表的元数据和页数,不切换当前文档或解析页面内容 -// 入参: args 保留参数 -// 返回: any 文档列表, error 编辑状态错误 -func packageDocuments(args []js.Value) (any, error) { - if currentSession == nil || currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - entries := make([]DocumentEntry, currentEditor.DocumentCount()) - for i := range entries { - info, err := currentEditor.DocumentInfo(i) - if err != nil { - return nil, err - } - entry := &entries[i] - body := currentSession.Reader.OFD.DocBody[i] - entry.Index, entry.Root, entry.ID = i, body.DocRoot, info.DocID - entry.Title, entry.Author, entry.Subject = info.Title, info.Author, info.Subject - if info.CustomDatas != nil { - entry.CustomData = info.CustomDatas.CustomData - } - if i == currentEditor.DocumentIndex() { - count := currentEditor.PageCount() - entry.PageCount = &count - } else if count, err := currentSession.Reader.DocumentPageCount(i); err == nil { - entry.PageCount = &count - } - } - return entries, nil -} - -// documentVersions 读取当前文档的版本列表,包含尚未保存的编辑内容 -// 入参: args 保留参数 -// 返回: any 版本列表, error 读取错误 -func documentVersions(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - reader := currentSession.Reader - if currentEditor != nil { - var err error - reader, err = currentEditor.Reader() - if err != nil { - return nil, err - } - } - versions, err := reader.DocumentVersions() - if err != nil { - return nil, err - } - entries := make([]VersionEntry, 0, len(versions)+1) - hasMain := reader.OFD.DocBody[reader.DocumentIndex()].DocRoot != "" - if hasMain { - entries = append(entries, VersionEntry{Default: true}) - } - for _, version := range versions { - entries = append(entries, VersionEntry{ID: version.ID, Index: version.Index, Name: version.Name, Default: version.Current}) - if version.Current && hasMain { - entries[0].Default = false - } - } - return entries, nil -} - -// styleResource 读取当前文档中的颜色空间或共享绘制参数 -// 入参: args 类型和资源标识 -// 返回: any 资源定义, error 读取错误 -func styleResource(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 2 { - return nil, fmt.Errorf("invalid style resource") - } - ctx := context.Background() - switch args[0].String() { - case "ColorSpace": - return currentSession.Reader.ColorSpace(ctx, args[1].String()) - case "DrawParam": - return currentSession.Reader.DrawParam(ctx, args[1].String(), false) - default: - return nil, fmt.Errorf("invalid style resource kind") - } -} - -// changeStyleResource 注册或替换共享样式资源,未重新选择ICC时保留既有配置 -// 入参: args 类型、标识、定义JSON、可选ICC字节和RGB颜色增量JSON -// 返回: any 编辑状态, error 校验或提交错误 -func changeStyleResource(args []js.Value) (any, error) { - if len(args) != 5 { - return nil, fmt.Errorf("invalid style resource arguments") - } - return changeAtomicObjects(func() error { - ctx := context.Background() - kind, id := args[0].String(), args[1].String() - if kind == "ColorSpace" { - var space ofdgo.ColorSpace - if err := json.Unmarshal([]byte(args[2].String()), &space); err != nil { - return err - } - var profile []byte - var err error - if !args[3].IsNull() && !args[3].IsUndefined() { - profile, err = bytesFromJS(args[3]) - } else if id != "" { - var original *ofdgo.ColorSpace - original, err = currentEditor.ColorSpace(id) - if err == nil && original.Profile != "" { - var stream io.ReadCloser - stream, err = currentSession.Reader.OpenResource(ctx, kind, id) - if err == nil { - defer stream.Close() - profile, err = io.ReadAll(stream) - } - } - } - if err != nil { - return err - } - if id == "" { - _, err = currentEditor.ImportColorSpace(ctx, space, profile) - return err - } - return currentEditor.ReplaceColorSpace(ctx, id, space, profile) - } - if kind != "DrawParam" { - return fmt.Errorf("invalid style resource kind") - } - var draw ofdgo.DrawParam - var colors map[string]string - if err := json.Unmarshal([]byte(args[2].String()), &draw); err != nil { - return err - } - if err := json.Unmarshal([]byte(args[4].String()), &colors); err != nil { - return err - } - for key, value := range colors { - paint := &ofdgo.FillColor{} - if key == "FillColor" && draw.FillColor != nil { - paint.Alpha = draw.FillColor.Alpha - } else if key == "StrokeColor" && draw.StrokeColor != nil { - paint.Alpha = draw.StrokeColor.Alpha - } - if err := setEditorColor(paint, value); err != nil { - return err - } - if key == "FillColor" { - draw.FillColor = paint - } else if key == "StrokeColor" { - draw.StrokeColor = (*ofdgo.StrokeColor)(paint) - } else { - return fmt.Errorf("invalid paint target") - } - } - if id == "" { - _, err := currentEditor.ImportDrawParam(ctx, draw) - return err - } - return currentEditor.ReplaceDrawParam(ctx, id, draw) - }) -} - -// applyStyleResource 为指定图元设置共享绘制参数或颜色,库层负责颜色及对象校验 -// 入参: args 类型、资源标识、页码、图元标识及颜色JSON -// 返回: any 编辑状态, error 校验或提交错误 -func applyStyleResource(args []js.Value) (any, error) { - if len(args) != 5 { - return nil, fmt.Errorf("invalid style application") - } - return changeAtomicObjects(func() error { - page, ids := args[2].Int(), []string{args[3].String()} - if args[0].String() == "DrawParam" { - return currentEditor.SetObjectDrawParam(page, ids, args[1].String()) - } - if args[0].String() != "ColorSpace" || args[1].String() == "" { - return fmt.Errorf("invalid color space") - } - var paint struct{ Target, Mode, Value string } - if err := json.Unmarshal([]byte(args[4].String()), &paint); err != nil { - return err - } - fill := &ofdgo.FillColor{ColorSpace: args[1].String()} - if paint.Mode == "index" { - index, err := strconv.Atoi(paint.Value) - if err != nil { - return err - } - fill.Index = &index - } else if paint.Mode == "value" { - fill.Value = paint.Value - } else { - return fmt.Errorf("invalid color mode") - } - style := ofdgo.ObjectStyle{} - if paint.Target == "fill" { - style.FillColor = fill - } else if paint.Target == "stroke" { - style.StrokeColor = (*ofdgo.StrokeColor)(fill) - } else { - return fmt.Errorf("invalid paint target") - } - return currentEditor.StyleObjects(page, ids, style) - }) -} - -// pageSettings 获取当前页设置,图层视图跳过全部普通页模板引用查询,不修改文档 -// 入参: args 页面索引及可选类型layers或templates,省略类型时返回全量数据 -// 返回: any 页面设置, error 读取错误 -func pageSettings(args []js.Value) (any, error) { - if currentSession == nil || len(args) < 1 || len(args) > 2 { - return nil, fmt.Errorf("invalid page settings") - } - kind := "templates" - if len(args) == 2 { - kind = args[1].String() - if kind != "layers" && kind != "templates" { - return nil, fmt.Errorf("invalid page settings type %q", kind) - } - } - reader := currentSession.Reader - layers, err := reader.Layers(args[0].Int()) - if err != nil { - return nil, err - } - templates, err := reader.Templates() - if err != nil { - return nil, err - } - page, err := reader.PageContentByIndex(args[0].Int()) - if err != nil { - return nil, err - } - references := []ofdgo.TemplateReference{} - if kind == "templates" { - references, err = reader.TemplateReferences() - if err != nil { - return nil, err - } - } - return map[string]any{"layers": layers, "templates": templates, "bindings": page.Template, "templateReferences": references}, nil -} - -// changeLayer 调用图层管理接口,组合修改使用同一次事务 -// 入参: args 操作、页码、图层标识、类型、绘制参数、顺序及图元标识 -// 返回: any 编辑状态, error 校验或提交错误 -func changeLayer(args []js.Value) (any, error) { - if len(args) != 7 { - return nil, fmt.Errorf("invalid layer arguments") - } - return changeAtomicObjects(func() error { - page, id := args[1].Int(), args[2].String() - switch args[0].String() { - case "add": - _, err := currentEditor.AddLayer(page, args[3].String(), args[4].String()) - return err - case "update": - return currentEditor.Transaction(func(editor *ofdgo.Editor) error { - if err := editor.SetLayerType(page, id, args[3].String()); err != nil { - return err - } - return editor.SetLayerDrawParam(page, id, args[4].String()) - }) - case "delete": - return currentEditor.DeleteLayer(page, id) - case "move": - return currentEditor.MoveLayer(page, id, args[5].Int()) - case "objects": - return currentEditor.MoveObjectsToLayer(page, stringsFromJS(args[6]), id) - default: - return fmt.Errorf("invalid layer operation") - } - }) -} - -// changeTemplate 调用模板及页面绑定接口,组合修改使用同一次事务 -// 入参: args 操作、页码、模板标识、名称、层次及顺序 -// 返回: any 编辑状态, error 校验或提交错误 -func changeTemplate(args []js.Value) (any, error) { - if len(args) != 6 { - return nil, fmt.Errorf("invalid template arguments") - } - return changeAtomicObjects(func() error { - page, id, order := args[1].Int(), args[2].String(), args[4].String() - switch args[0].String() { - case "add": - _, err := currentEditor.CreateTemplateFromPage(page, args[3].String(), order) - return err - case "update": - return currentEditor.Transaction(func(editor *ofdgo.Editor) error { - if err := editor.RenameTemplate(id, args[3].String()); err != nil { - return err - } - return editor.SetTemplateZOrder(id, order) - }) - case "replace": - return currentEditor.ReplaceTemplateFromPage(id, page) - case "delete": - return currentEditor.DeleteTemplate(id) - case "bind": - return currentEditor.BindTemplate(page, id, order) - case "unbind": - return currentEditor.UnbindTemplate(page, id) - case "order": - return currentEditor.SetPageTemplateZOrder(page, id, order) - case "move": - return currentEditor.MovePageTemplate(page, id, args[5].Int()) - default: - return fmt.Errorf("invalid template operation") - } - }) -} - -// documentNavigation 按需读取命名书签或通用扩展,不解释业务数据 -// 入参: args 设置类型 -// 返回: any 文档导航和扩展信息, error 读取错误 -func documentNavigation(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 1 { - return nil, fmt.Errorf("ofd document is not opened") - } - switch args[0].String() { - case "bookmarks", "actions": - bookmarks, err := currentSession.Reader.Bookmarks() - return map[string]any{"bookmarks": bookmarks}, err - case "extensions": - extensions, err := currentSession.Reader.Extensions() - return map[string]any{"extensions": extensions}, err - default: - return nil, fmt.Errorf("invalid navigation kind") - } -} - -// changeBookmark 调用库层书签操作,引用更新和版本隔离由编辑器完成 -// 入参: args 操作、原名称及书签JSON -// 返回: any 编辑状态, error 校验或提交错误 -func changeBookmark(args []js.Value) (any, error) { - if len(args) != 3 { - return nil, fmt.Errorf("invalid bookmark arguments") - } - var bookmark ofdgo.Bookmark - if err := json.Unmarshal([]byte(args[2].String()), &bookmark); err != nil { - return nil, err - } - return changeAtomicObjects(func() error { - switch args[0].String() { - case "add": - return currentEditor.AddBookmark(bookmark.Name, bookmark.Dest) - case "update": - return currentEditor.UpdateBookmark(args[1].String(), bookmark) - case "delete": - return currentEditor.DeleteBookmark(args[1].String()) - default: - return fmt.Errorf("invalid bookmark operation") - } - }) -} - -// actionList 获取编辑对象的动作序列,返回独立数据,不触发动作 -// 入参: args 归属、页码、图元标识和目录路径 -// 返回: any 动作列表, error 归属或读取错误 -func actionList(args []js.Value) (any, error) { - if currentEditor == nil || len(args) != 4 { - return nil, fmt.Errorf("invalid action selection") - } - switch args[0].String() { - case "document": - return currentEditor.DocumentActions(), nil - case "page": - return currentEditor.PageActions(args[1].Int()) - case "object": - return currentEditor.ObjectActions(args[1].Int(), args[2].String()) - case "outline": - return currentEditor.OutlineActions(indexesFromJS(args[3])) - default: - return nil, fmt.Errorf("invalid action scope") - } -} - -// changeActions 将动作列表交给库层校验和原子提交 -// 入参: args 归属、页码、图元标识、目录路径及动作JSON -// 返回: any 编辑状态, error 校验或提交错误 -func changeActions(args []js.Value) (any, error) { - if len(args) != 5 { - return nil, fmt.Errorf("invalid action arguments") - } - var actions []ofdgo.Action - if err := json.Unmarshal([]byte(args[4].String()), &actions); err != nil { - return nil, err - } - return changeAtomicObjects(func() error { - if err := currentEditor.ValidateActionTargets(context.Background(), actions); err != nil { - return err - } - switch args[0].String() { - case "document": - return currentEditor.SetDocumentActions(actions) - case "page": - return currentEditor.SetPageActions(args[1].Int(), actions) - case "object": - return currentEditor.SetObjectActions(args[1].Int(), []string{args[2].String()}, actions) - case "outline": - return currentEditor.SetOutlineActions(indexesFromJS(args[3]), actions) - default: - return fmt.Errorf("invalid action scope") - } - }) -} - -// annotationInfos 读取当前文档及版本的注释摘要,不解析页面图元 -// 入参: args 保留参数 -// 返回: any 注释摘要, error 读取错误 -func annotationInfos(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - return currentSession.Reader.AnnotationInfos() -} - -// exportVersion 提取指定历史版本并另存,不切换当前视图或修改编辑历史 -// 入参: args 版本标识、写出回调、进度回调及取消检查 -// 返回: any 保存结果, error 提取或写入错误 -func exportVersion(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 4 || args[0].Type() != js.TypeString || args[0].String() == "" { - return nil, fmt.Errorf("invalid version export") - } - ctx := newOutputContext(args[3]) - reader := currentSession.Reader - if currentEditor != nil { - var err error - reader, err = currentEditor.ReaderContext(ctx) - if err != nil { - return nil, err - } - } - version, err := reader.ExtractVersionContext(ctx, args[0].String()) - if err != nil { - return nil, err - } - defer version.Close() - editor, err := version.Editor() - if err != nil { - return nil, err - } - return saveEditor(editor, []js.Value{args[1], args[2], js.Null(), js.ValueOf(map[string]any{"mode": 0}), args[3]}) -} - -// selectDocumentVersion 切换阅读版本,已有编辑器时保留修改及历史,否则直接创建阅读视图 -// 入参: args 版本标识 -// 返回: any 文档信息, error 切换错误 -func selectDocumentVersion(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 1 || args[0].Type() != js.TypeString { - return nil, fmt.Errorf("invalid version selection") - } - id := args[0].String() - if currentEditor != nil { - return changeAtomicObjects(func() error { return currentEditor.SelectVersion(id) }) - } - session, err := currentSession.DocumentVersion(id) - if err != nil { - return nil, err - } - _ = currentSession.Close() - currentSession = session - clearImport() - copiedObjects, copiedStyle = nil, nil - return session.Summary(), nil -} - -// changeDocumentVersion 调用库层版本操作,沿用编辑历史和预览更新流程 -// 入参: args 操作、版本标识及可选名称 -// 返回: any 文档信息, error 操作错误 -func changeDocumentVersion(args []js.Value) (any, error) { - if len(args) != 3 { - return nil, fmt.Errorf("invalid version arguments") - } - return changeAtomicObjects(func() error { - id, name := args[1].String(), args[2].String() - switch args[0].String() { - case "add": - return currentEditor.Transaction(func(editor *ofdgo.Editor) error { - if err := editor.SelectVersion(id); err != nil { - return err - } - _, err := editor.AddVersion(name) - return err - }) - case "rename": - return currentEditor.RenameVersion(id, name) - case "delete": - return currentEditor.DeleteVersion(id) - case "default": - return currentEditor.SetDefaultVersion(id) - case "select": - return currentEditor.SelectVersion(id) - default: - return fmt.Errorf("invalid version operation") - } - }) -} - -// changeAttachment 编辑附件,逐个读取文件并复用库层事务和历史 -// 入参: args 操作、附件标识、名称列表、文件读取回调 -// 返回: any 编辑状态, error 错误信息 -func changeAttachment(args []js.Value) (any, error) { - return changeObjects(func() error { - action, id := args[0].String(), args[1].String() - switch action { - case "rename": - return currentEditor.RenameAttachment(id, args[2].Index(0).String()) - case "delete": - return currentEditor.DeleteAttachment(id) - case "add", "replace": - count := args[2].Length() - if count == 0 || action == "replace" && count != 1 { - return fmt.Errorf("请选择附件") - } - for i := range count { - result := args[3].Invoke(i) - if message := result.Get("error"); message.Type() == js.TypeString { - return fmt.Errorf("%s", message.String()) - } - data, err := bytesFromJS(result.Get("bytes")) - if err != nil { - return err - } - if action == "replace" { - return currentEditor.ReplaceAttachment(id, data) - } - if _, err := currentEditor.AddAttachment(args[2].Index(i).String(), data); err != nil { - return err - } - } - return nil - default: - return fmt.Errorf("unsupported attachment action %q", action) - } - }) -} - -// changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态 -// 入参: args 字体标识、保存方式及可选字体数据 -// 返回: any 编辑状态, error 字体处理错误 -func changeFontBinding(args []js.Value) (any, error) { - if len(args) < 2 { - return nil, fmt.Errorf("请选择字体") - } - return changeAtomicObjects(func() error { - id, mode := args[0].String(), args[1].Int() - if mode == int(ofdgo.FontExternal) { - return currentEditor.ExternalizeFont(context.Background(), id) - } - if mode != int(ofdgo.FontEmbed) { - return fmt.Errorf("字体方式无效") - } - var file ofdgo.FontFile - if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() { - data, err := bytesFromJS(args[2]) - if err != nil { - return err - } - file.Data = data - } - return currentEditor.EmbedFont(context.Background(), id, file, 0) - }) -} - -// changePackageDocument 调用库层文档增删、改名或排序,不在浏览器内重写包结构 -// 入参: args 操作及标题、页面尺寸、文档索引或移动位置 -// 返回: any 文档信息, error 错误信息 -func changePackageDocument(args []js.Value) (any, error) { - if currentEditor == nil || len(args) == 0 { - return nil, fmt.Errorf("no document is being edited") - } - return changeAtomicObjects(func() error { - var err error - switch args[0].String() { - case "add": - if len(args) != 5 { - return fmt.Errorf("invalid document arguments") - } - _, err = currentEditor.AddDocument(args[1].String(), args[2].Float(), args[3].Float(), args[4].Int()) - case "delete": - if len(args) != 2 { - return fmt.Errorf("invalid document arguments") - } - err = currentEditor.DeleteDocument(args[1].Int()) - case "move": - if len(args) != 3 { - return fmt.Errorf("invalid document arguments") - } - err = currentEditor.MoveDocument(args[1].Int(), args[2].Int()) - case "rename": - if len(args) != 3 { - return fmt.Errorf("invalid document arguments") - } - err = currentEditor.RenameDocument(args[1].Int(), args[2].String()) - default: - return fmt.Errorf("invalid document operation") - } - if err == nil { - copiedObjects, copiedStyle = nil, nil - clearImport() - } - return err - }) -} - -// selectDocument 切换包内文档,保留编辑快照并清除跨文档剪贴板 -// 入参: args 文档索引 -// 返回: any 文档信息, error 错误信息 -func selectDocument(args []js.Value) (any, error) { - if currentSession == nil || len(args) == 0 { - return nil, fmt.Errorf("ofd document is not opened") - } - session, err := currentSession.Document(args[0].Int()) - if err != nil { - return nil, err - } - _ = currentSession.Close() - currentSession, currentEditor = session, session.editor - if pendingImport != nil { - _ = pendingImport.Close() - pendingImport = nil - } - copiedObjects, copiedStyle = nil, nil - return editorSummary(), nil -} - -// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置 -// 入参: args 当前页索引,省略时使用首页 -// 返回: any 编辑状态, error 错误信息 -func editDocument(args []js.Value) (any, error) { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if currentEditor != nil { - currentSession.editing = true - return editorSummary(), nil - } - editor, err := currentSession.Reader.Editor() - if err != nil { - return nil, err - } - editor.SetHistoryLimit(100) - if currentSession.fontFS != nil { - editor.SetFontFS(currentSession.fontFS) - } - editor.SetRenderBackends(currentSession.Renderer.Backends()) - page := 0 - if len(args) != 0 { - page = args[0].Int() - } - content, err := editor.Page(page) - if err != nil { - return nil, err - } - if err := syncEditorPageIDs(page, content); err != nil { - return nil, err - } - currentEditor = editor - currentSession.editor = editor - currentSession.editing = true - copiedObjects = nil - copiedStyle = nil - return editorSummary(), nil -} - -// syncEditorPageIDs 同步编辑副本的对象编号,不改变原渲染属性或丢弃正常页面缓存 -// 入参: index 页面索引, content 编辑页面 -// 返回: error 页面读取错误 -func syncEditorPageIDs(index int, content *ofdgo.PageContent) error { - page, err := currentSession.pageContent(index) - if err != nil { - return err - } - for i, layer := range content.Content.Layer { - for j, object := range layer.Objects { - before := &page.Content.Layer[i].Objects[j] - if before.TextObject.ID != object.TextObject.ID || before.PathObject.ID != object.PathObject.ID || before.ImageObject.ID != object.ImageObject.ID || before.CompositeGraphicUnit.ID != object.CompositeGraphicUnit.ID { - before.TextObject.ID = object.TextObject.ID - before.PathObject.ID = object.PathObject.ID - before.ImageObject.ID = object.ImageObject.ID - before.CompositeGraphicUnit.ID = object.CompositeGraphicUnit.ID - delete(currentSession.textCache, index) - } - } - } - return nil -} - -// previewEditor 通过内存快照更新预览,不生成中间压缩包 -// 入参: editor 编辑文档, annotations 是否显示注解 -// 返回: editorInfo 文档信息, error 错误信息 -func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { - reader, err := editor.Reader() - if err != nil { - return editorInfo{}, err - } - session, err := newSession(reader, OpenOptions{RenderAnnotations: annotations}) - if err != nil { - return editorInfo{}, err - } - editor.SetFontFS() - if currentSession != nil { - previous := currentSession.Reader - oldBody, newBody := previous.OFD.DocBody[previous.DocumentIndex()], reader.OFD.DocBody[reader.DocumentIndex()] - oldVersion, _ := previous.DocumentVersionID() - newVersion, _ := reader.DocumentVersionID() - if oldBody.DocRoot != newBody.DocRoot || oldBody.DocInfo.DocID != newBody.DocInfo.DocID || oldVersion != newVersion { - copiedObjects, copiedStyle = nil, nil - clearImport() - } - ofdgo.WithRenderBackends(currentSession.Renderer.Backends())(session.Renderer) - if currentSession.fontFS != nil { - session.fontFS = currentSession.fontFS - session.Renderer.SetFontFS(session.fontFS) - editor.SetFontFS(session.fontFS) - } - _ = currentSession.Close() - } - editor.SetRenderBackends(session.Renderer.Backends()) - currentSession = session - session.editor = editor - session.editing = true - if currentEditor != editor { - copiedObjects = nil - copiedStyle = nil - } - currentEditor = editor - return editorSummary(), nil -} - -// setEditorColor 写入当前文档的RGB纯色,保留透明度并替换原颜色空间和索引 -// 入参: fill 颜色, value 十六进制色值 -// 返回: error 错误信息 -func setEditorColor(fill *ofdgo.FillColor, value string) error { - if len(value) != 7 || value[0] != '#' { - return fmt.Errorf("invalid RGB color %q", value) - } - rgb, err := hex.DecodeString(value[1:]) - if err != nil { - return err - } - converted, err := currentEditor.RGBColor(color.NRGBA{R: rgb[0], G: rgb[1], B: rgb[2], A: 255}) - if err != nil { - return err - } - converted.Alpha = fill.Alpha - *fill = *converted - return nil -} - -// editorColorHex 将当前文档纯色转换为浏览器色值 -// 入参: value 颜色 -// 返回: string 十六进制色值 -func editorColorHex(value *ofdgo.FillColor) string { - converted, err := currentEditor.Color(value) - if err != nil { - return "" - } - return fmt.Sprintf("#%02x%02x%02x", converted.R, converted.G, converted.B) -} - -// editorPathScale 获取路径线宽的页面缩放比例 -// 入参: object 路径对象 -// 返回: float64 缩放比例 -func editorPathScale(object ofdgo.PathObject) float64 { - m := ofdgo.NewMatrix(object.CTM) - x, y := m.Transform(0, 0) - ax, ay := m.Transform(1, 0) - bx, by := m.Transform(0, 1) - return math.Sqrt(math.Abs((ax-x)*(by-y) - (ay-y)*(bx-x))) -} - -// setPathStyle 应用图形填充、描边和线宽 -// 入参: object 路径对象, args 填充开关及色值、描边开关及色值、线宽,null保持原值 -// 返回: error 错误信息 -func setPathStyle(object *ofdgo.PathObject, args []js.Value) error { - fill, stroke := object.Fill != nil && *object.Fill, object.Stroke == nil || *object.Stroke - if !args[0].IsNull() { - fill = args[0].Bool() - } - if !args[2].IsNull() { - stroke = args[2].Bool() - } - if kind, _ := object.Shape(); kind == ofdgo.ShapeLine || kind == ofdgo.ShapeArrow || kind == ofdgo.ShapeDoubleArrow { - fill, stroke = false, true - } - if !fill && !stroke { - if args[0].IsNull() || args[2].IsNull() { - stroke = true - } else { - return fmt.Errorf("fill or stroke must be enabled") - } - } - if !args[0].IsNull() || fill != (object.Fill != nil && *object.Fill) { - object.Fill = &fill - } - if !args[2].IsNull() || stroke != (object.Stroke == nil || *object.Stroke) { - object.Stroke = &stroke - } - if !args[4].IsNull() { - width := args[4].Float() - if width < 0 || math.IsNaN(width) || math.IsInf(width, 0) { - return fmt.Errorf("line width must be nonnegative and finite") - } - if scale := editorPathScale(*object); width != object.LineWidth*scale { - object.LineWidth = width / scale - } - object.LineWidthSet = width == 0 - } - if !args[1].IsNull() { - if object.FillColor == nil { - object.FillColor = &ofdgo.FillColor{} - } - if err := setEditorColor(object.FillColor, args[1].String()); err != nil { - return err - } - } - if !args[3].IsNull() { - if object.StrokeColor == nil { - object.StrokeColor = &ofdgo.StrokeColor{} - } - if err := setEditorColor((*ofdgo.FillColor)(object.StrokeColor), args[3].String()); err != nil { - return err - } - } - return nil -} - -// insertShape 创建标准路径图形 -// 入参: args 页码、图形类型、范围和绘制样式 -// 返回: any 文档信息, error 错误信息 -func insertShape(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - path, err := ofdgo.NewShape(ofdgo.ShapeKind(args[1].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) - if err != nil { - return nil, err - } - if err := setPathStyle(&path, args[6:]); err != nil { - return nil, err - } - return insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[11:]) -} - -// updatePathStyle 原子更新选中路径的绘制样式,保留未修改属性 -// 入参: args 页码、对象标识列表和绘制样式 -// 返回: any 文档信息, error 错误信息 -func updatePathStyle(args []js.Value) (any, error) { - return changeObjects(func() error { - page, ids := args[0].Int(), stringsFromJS(args[1]) - objects := make([]ofdgo.GraphicObject, len(ids)) - for i, id := range ids { - object, err := currentEditor.Object(page, id) - if err != nil { - return err - } - if object.Type != "PathObject" { - return fmt.Errorf("object is not a path") - } - if err := setPathStyle(&object.PathObject, args[2:]); err != nil { - return err - } - objects[i] = object - } - return currentEditor.UpdateObjects(page, objects) - }) -} - -// editorBox 将库层毫米边界传递给画布交互层 -// 入参: box 毫米坐标中的矩形范围 -// 返回: map[string]any 可直接传递给JavaScript的横纵坐标和宽高 -func editorBox(box ofdgo.Box) map[string]any { - return map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H} -} - -// editorMatrix 将标准CTM转换为浏览器仿射矩阵分量 -// 入参: matrix 库层仿射矩阵 -// 返回: []any 可直接传递给JavaScript的a、b、c、d、e、f分量,位移单位为毫米 -func editorMatrix(matrix ofdgo.Matrix) []any { - x, y := matrix.Transform(0, 0) - ax, ay := matrix.Transform(1, 0) - bx, by := matrix.Transform(0, 1) - return []any{ax - x, ay - y, bx - x, by - y, x, y} -} - -// rotateObjects 旋转同页选区,通用几何与历史记录由库负责 -// 入参: args 页面索引、对象标识数组和顺时针角度 -// 返回: any 文档信息, error 错误信息 -func rotateObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.RotateObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Int()) - }) -} - -// flipObjects 镜像同页选区 -// 入参: args 页面索引、对象标识数组和镜像轴 -// 返回: any 文档信息, error 错误信息 -func flipObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.FlipObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) - }) -} - -// cropImage 设置图片裁剪范围,保留原始图片数据 -// 入参: args 页面索引、对象标识及页面毫米坐标中的横纵坐标和宽高 -// 返回: any 文档信息, error 错误信息 -func cropImage(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.CropImage(args[0].Int(), args[1].String(), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) - }) -} - -// resetImageCrop 还原跨范围复制图片的会话裁剪,不移除原始及父级裁剪 -// 入参: args 页面索引及对象标识 -// 返回: any 文档信息, error 错误信息 -func resetImageCrop(args []js.Value) (any, error) { - return changeObjects(func() error { return currentEditor.ResetImageCrop(args[0].Int(), args[1].String()) }) -} - -// fitImage 按原始比例适应或填充图片框 -// 入参: args 页面索引、对象标识和适应方式 -// 返回: any 文档信息, error 错误信息 -func fitImage(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.FitImage(args[0].Int(), args[1].String(), args[2].String()) - }) -} - -// previewImage 在页面副本中显示指定图片的完整内容,不改变正文、历史或缓存 -// 入参: args 页面索引和图片对象标识 -// 返回: any 页面SVG和分离的图片资源, error 错误信息 -func previewImage(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - page, err := currentEditor.Page(args[0].Int()) - if err != nil { - return nil, err - } - id := args[1].String() - found := false - for i := range page.Content.Layer { - for j := range page.Content.Layer[i].Objects { - object := &page.Content.Layer[i].Objects[j] - if object.Type == "ImageObject" && object.ImageObject.ID == id { - object.ImageObject.Clips = nil - found = true - } - } - } - if !found { - return nil, fmt.Errorf("image object %q not found", id) - } - var out bytes.Buffer - resources, err := currentSession.Renderer.RenderToSVGWithResources(page, &out) - if err != nil { - return nil, err - } - return successResult(map[string]any{"svg": out.String(), "images": svgImagesToJS(resources.Images)}), nil -} - -// svgImagesToJS 将图片资源以二进制数组传给前端,不使用base64 -// 入参: images SVG引用的图片资源 -// 返回: []any 图片标识、类型和数据 -func svgImagesToJS(images []ofdgo.SVGImage) []any { - items := make([]any, len(images)) - for i, image := range images { - items[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)} - } - return items -} - -// transformObjects 统一移动或缩放选区,生成一次撤销记录 -// 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例 -// 返回: any 文档信息, error 错误信息 -func transformObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.TransformObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Float(), args[3].Float(), args[4].Float()) - }) -} - -// alignObjects 对齐选区中的对象 -// 入参: args 页面索引、对象标识数组和对齐方式 -// 返回: any 文档信息, error 错误信息 -func alignObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.AlignObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) - }) -} - -// deleteObjects 一次删除选区中的对象 -// 入参: args 页面索引和对象标识数组 -// 返回: any 文档信息, error 错误信息 -func deleteObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.DeleteObjects(args[0].Int(), stringsFromJS(args[1])) - }) -} - -// eraseObjectsPath 按页面闭合折线范围擦除对象,保留局部裁剪之外的内容 -// 入参: args 页面索引、对象标识数组和坐标点数组 -// 返回: any 文档信息, error 错误信息 -func eraseObjectsPath(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.EraseObjectsPath(args[0].Int(), stringsFromJS(args[1]), pointsFromJS(args[2])) - }) -} - -// pointsFromJS 读取前端页面坐标点数组 -// 入参: value 坐标点数组 -// 返回: []ofdgo.Point 页面毫米坐标 -func pointsFromJS(value js.Value) []ofdgo.Point { - points := make([]ofdgo.Point, value.Length()) - for i := range points { - point := value.Index(i) - points[i] = ofdgo.Point{X: point.Get("x").Float(), Y: point.Get("y").Float()} - } - return points -} - -// eraseObjects 按页面矩形范围擦除对象,保留局部裁剪之外的内容 -// 入参: args 页面索引、对象标识数组和毫米坐标范围 -// 返回: any 文档信息, error 错误信息 -func eraseObjects(args []js.Value) (any, error) { - return changeObjects(func() error { - return currentEditor.EraseObjects(args[0].Int(), stringsFromJS(args[1]), ofdgo.Box{ - X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float(), - }) - }) -} - -// changeObjects 提交库层批量操作,无修改时复用当前预览 -// 入参: apply 待执行的编辑操作 -// 返回: any 文档信息, error 错误信息 -func changeObjects(apply func() error) (any, error) { - return changeAtomicObjects(func() error { - return currentEditor.Transaction(func(*ofdgo.Editor) error { return apply() }) - }) -} - -// changeAtomicObjects 执行库层原子操作,按文档版本更新预览 -// 入参: apply 已具备事务保证的编辑操作 -// 返回: any 文档信息, error 错误信息 -func changeAtomicObjects(apply func() error) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - revision := currentEditor.Revision() - if err := apply(); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) -} - -// insertImage 按原始比例创建图片对象 -// 入参: args 页码、图片数据、横纵坐标和宽度 -// 返回: any 文档信息, error 错误信息 -func insertImage(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - data, err := bytesFromJS(args[1]) - if err != nil { - return nil, err - } - resourceID, err := currentEditor.AddImage(data) - if err != nil { - return nil, err - } - size, err := currentEditor.ImageSize(resourceID) - if err != nil { - return nil, err - } - width := args[4].Float() - box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: width, H: width * float64(size.Y) / float64(size.X)} - content, err := currentSession.pageContent(args[0].Int()) - if err != nil { - return nil, err - } - pageBox, err := currentSession.pageBox(args[0].Int(), content) - if err != nil { - return nil, err - } - if height := pageBox.H - box.Y; box.H > height { - box.W *= height / box.H - box.H = height - } - object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ - ResourceID: resourceID, Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), - }} - return insertEditorObject(args[0].Int(), object, args[5:]) -} - -// insertEditorObject 复用顶层和内部范围插入流程,返回稳定选区 -// 入参: page 页码, object 新对象, scope 可选父路径 -// 返回: any 文档信息及新选区, error 错误信息 -func insertEditorObject(page int, object ofdgo.GraphicObject, scope []js.Value) (any, error) { - var id string - var err error - if len(scope) > 0 && scope[0].String() != "" { - key := scope[0].String() - path, parseErr := compositePath(key) - if parseErr != nil { - return nil, parseErr - } - var index int - index, err = currentEditor.AddCompositeObject(page, path, object) - id = fmt.Sprintf("%s/%d", key, index) - } else { - id, err = currentEditor.AddObject(page, object) - } - if err != nil { - return nil, err - } - info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) - return editorSelectionInfo{info, []string{id}}, err -} - -// editorOperationProgress 节流长操作检查点并向浏览器让出执行,保证阶段开始和结束可取消 -// 入参: callback 浏览器进度回调 -// 返回: func(string, int, int) error 进度回调 -func editorOperationProgress(callback js.Value) func(string, int, int) error { - var lastStage string - var lastProgress time.Time - return func(stage string, completed, total int) error { - if stage == lastStage && (total == 0 || completed != total) && time.Since(lastProgress) < 32*time.Millisecond { - return nil - } - lastStage, lastProgress = stage, time.Now() - return awaitExport(callback, stage, completed, total) - } -} - -// credentialsFromJS 解析本次打开的口令或本机证书私钥 -// 入参: value 解锁表单数据 -// 返回: ofdgo.Credentials 会话凭据, error 错误信息 -func credentialsFromJS(value js.Value) (ofdgo.Credentials, error) { - credentials := ofdgo.Credentials{UserName: value.Get("userName").String()} - if keyValue := value.Get("key"); !keyValue.IsNull() && !keyValue.IsUndefined() { - key, err := bytesFromJS(keyValue) - if err != nil { - return credentials, err - } - defer clear(key) - certificate, err := bytesFromJS(value.Get("certificate")) - if err != nil { - return credentials, err - } - var password []byte - if field := value.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { - password, err = bytesFromJS(field) - if err != nil { - return credentials, err - } - defer clear(password) - } - signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) - if err != nil { - return credentials, ofdgo.ErrInvalidCredentials - } - decrypter, ok := signer.(crypto.Decrypter) - if !ok { - return credentials, ofdgo.ErrInvalidCredentials - } - credentials.Certificate, credentials.Decrypter = certificate, decrypter - return credentials, nil - } - var err error - credentials.Password, err = bytesFromJS(value.Get("password")) - return credentials, err -} - -// securityFilesFromJS 读取会话内的本机证据文件 -// 入参: value 二进制数组列表 -// 返回: [][]byte 文件内容, error 错误信息 -func securityFilesFromJS(value js.Value) ([][]byte, error) { - if value.IsUndefined() || value.IsNull() { - return nil, nil - } - files := make([][]byte, value.Length()) - for i := range files { - var err error - files[i], err = bytesFromJS(value.Index(i)) - if err != nil { - return nil, err - } - } - return files, nil -} - -// verifySignatures 按本机信任锚和离线证据重新验签,不设置联网回调 -// 入参: args 信任根、辅助证书、时间戳及撤销证据 -// 返回: any 分项报告, error 错误信息 -func verifySignatures(args []js.Value) (any, error) { - if currentSession == nil || len(args) == 0 { - return nil, fmt.Errorf("missing signature verification arguments") - } - input := args[0] - files := make(map[string][][]byte) - for _, name := range []string{"roots", "certificates", "timestampRoots", "tokens", "crls", "ocsp"} { - data, err := securityFilesFromJS(input.Get(name)) - if err != nil { - return nil, err - } - files[name] = data - } - now := time.Now() - policy := ofdgo.SignaturePolicy{RejectWeakAlgorithms: true, RequireDocumentCoverage: true, - RequireTimestamp: input.Get("requireTimestamp").Bool(), RequireRevocation: input.Get("requireRevocation").Bool()} - options := []ofdgo.SignatureVerifyOption{ - ofdgo.WithSignatureVerifyTime(now), ofdgo.WithSignaturePolicy(policy), - ofdgo.WithSignatureTrustCerts(files["roots"]...), ofdgo.WithSignatureCerts(files["certificates"]...), - } - if policy.RequireTimestamp || len(files["timestampRoots"])+len(files["tokens"]) > 0 { - options = append(options, ofdgo.WithSignatureTimestamp(ofdgo.SignatureTimestampOptions{ - TrustCerts: files["timestampRoots"], Certificates: files["certificates"], Tokens: files["tokens"], Required: policy.RequireTimestamp, VerifyTime: &now, - })) - } - if policy.RequireRevocation || len(files["crls"])+len(files["ocsp"]) > 0 { - options = append(options, ofdgo.WithSignatureRevocation(ofdgo.SignatureRevocationOptions{ - SignatureRevocationEvidence: ofdgo.SignatureRevocationEvidence{CRLs: files["crls"], OCSPResponses: files["ocsp"]}, - Certificates: append(files["certificates"], files["roots"]...), Required: policy.RequireRevocation, VerifyTime: &now, - })) - } - reports, err := currentSession.Reader.VerifyDocumentSignatures(currentSession.Reader.DocumentIndex(), options...) - if err != nil { - return nil, err - } - infos := make([]SignatureInfo, len(reports)) - for i, report := range reports { - infos[i] = signatureInfo(report) - } - currentSession.signatures, currentSession.signatureError, currentSession.signaturesRead = infos, nil, true - return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil -} - -// setEncryption 设置当前文档的保存策略,不修改页面或撤销历史 -// 入参: args 加密选项,null选择明文保存 -// 返回: any 编辑状态, error 错误信息 -func setEncryption(args []js.Value) (any, error) { - if currentSession == nil || len(args) != 1 { - return nil, fmt.Errorf("missing encryption arguments") - } - options, err := encryptionOptionsFromJS(args[0]) - if err != nil { - return nil, err - } - if options != nil { - defer clear(options.Password) - } - if currentEditor == nil { - if _, err := editDocument(nil); err != nil { - return nil, err - } - } - if err := currentEditor.SetEncryption(options); err != nil { - return nil, err - } - return editorSummary(), nil -} - -// encryptionOptionsFromJS 读取加密选项并校验接收者证书 -// 入参: value 加密选项,null选择明文保存 -// 返回: *ofdgo.EncryptionOptions 加密选项, error 错误信息 -func encryptionOptionsFromJS(value js.Value) (*ofdgo.EncryptionOptions, error) { - if value.IsNull() { - return nil, nil - } - if value.Type() != js.TypeObject { - return nil, ofdgo.ErrEncryptionPolicyRequired - } - var password []byte - if field := value.Get("password"); !field.IsNull() && !field.IsUndefined() { - var err error - password, err = bytesFromJS(field) - if err != nil { - return nil, err - } - } - options := &ofdgo.EncryptionOptions{Password: password} - success := false - defer func() { - if !success { - clear(password) - } - }() - if user := value.Get("userName"); user.Type() == js.TypeString { - options.UserName = user.String() - } - if recipients := value.Get("recipients"); !recipients.IsUndefined() && !recipients.IsNull() { - for i := 0; i < recipients.Length(); i++ { - item := recipients.Index(i) - data, err := bytesFromJS(item.Get("certificate")) - if err != nil { - return nil, err - } - certificate, err := ofdgo.ParseEncryptionCertificate(data) - if err != nil { - return nil, fmt.Errorf("接收者证书无效或不支持SM2加密") - } - options.Recipients = append(options.Recipients, ofdgo.EncryptionRecipient{UserName: item.Get("userName").String(), Certificate: certificate}) - } - } - if len(password) == 0 && len(options.Recipients) == 0 { - return nil, ofdgo.ErrEncryptionPolicyRequired - } - success = true - return options, nil -} - -// previewSignatureSeal 渲染独立印章预览并读取所选页面区域,不接触私钥或修改文档 -// 入参: args 印章数据和页码范围 -// 返回: any 印章图像、声明尺寸和页面区域, error 错误信息 -func previewSignatureSeal(args []js.Value) (any, error) { - if currentSession == nil || len(args) < 2 { - return nil, fmt.Errorf("missing seal preview arguments") - } - data, err := bytesFromJS(args[0]) - if err != nil { - return nil, err - } - seal, err := ofdgo.ParseSignatureSeal(data) - if err != nil { - return nil, err - } - indices, err := ofdgo.ParsePageRange(args[1].String(), len(currentSession.doc.Pages.Page)) - if err != nil { - return nil, err - } - pages := make([]map[string]any, 0, len(indices)) - for _, index := range indices { - area, err := currentSession.Reader.PageArea(currentSession.doc.Pages.Page[index]) - if err != nil { - return nil, err - } - box, err := ofdgo.ParseBox(area.PhysicalBox) - if err != nil { - return nil, err - } - pages = append(pages, map[string]any{"index": index, "x": box.X, "y": box.Y, "width": box.W, "height": box.H}) - } - editor := ofdgo.NewEditor() - if _, err := editor.AddPage(seal.Width, seal.Height); err != nil { - return nil, err - } - reader, err := editor.Reader() - if err != nil { - return nil, err - } - defer reader.Close() - doc, err := reader.Doc() - if err != nil { - return nil, err - } - reader.Stamps = map[string][]ofdgo.Stamp{doc.Pages.Page[0].ID: {{Box: ofdgo.Box{W: seal.Width, H: seal.Height}, Type: seal.Type, Data: seal.Data}}} - renderer := ofdgo.NewRenderer(reader, ofdgo.WithRenderBackends(currentSession.Renderer.Backends())) - renderer.TransparentBackground = true - if currentSession.fontFS != nil { - renderer.SetFontFS(currentSession.fontFS) - } - page, err := reader.PageContentByIndex(0) - if err != nil { - return nil, err - } - var svg bytes.Buffer - if err := renderer.RenderToSVG(page, &svg); err != nil { - return nil, err - } - return map[string]any{"svg": svg.String(), "width": seal.Width, "height": seal.Height, "pages": pages}, nil -} - -// signatureStampsFromJS 复用库层坐标校验生成页面或骑缝印章位置 -// 入参: input 页面和毫米坐标参数 -// 返回: []ofdgo.SignatureStamp 印章位置, error 校验错误 -func signatureStampsFromJS(input js.Value) ([]ofdgo.SignatureStamp, error) { - count, err := currentSession.Reader.PageCount() - if err != nil { - return nil, err - } - indices, err := ofdgo.ParsePageRange(input.Get("pages").String(), count) - if err != nil { - return nil, err - } - pages := make([]int, len(indices)) - for i, index := range indices { - pages[i] = index + 1 - } - box := ofdgo.Box{X: input.Get("x").Float(), Y: input.Get("y").Float(), W: input.Get("width").Float(), H: input.Get("height").Float()} - switch input.Get("placement").String() { - case "normal": - return currentSession.Reader.SignaturePageStamps(pages, box) - case "seam": - return currentSession.Reader.SignatureSeamStamps(pages, box) - default: - return nil, fmt.Errorf("invalid stamp placement") - } -} - -// signaturePlacement 校验预览确认的印章范围,不生成签名 -// 入参: args 页面和毫米坐标参数 -// 返回: any 校验结果, error 错误信息 -func signaturePlacement(args []js.Value) (any, error) { - if currentSession == nil || len(args) == 0 { - return nil, fmt.Errorf("missing stamp placement arguments") - } - _, err := signatureStampsFromJS(args[0]) - return successResult(nil), err -} - -// saveSigned 调用库层最终包签署并在回验成功后交付浏览器 -// 入参: args 本机证书私钥、信任根、印章位置、写出回调及进度回调 -// 返回: any 保存结果, error 错误信息 -func saveSigned(args []js.Value) (any, error) { - if currentSession == nil || len(args) < 3 { - return nil, fmt.Errorf("missing signature arguments") - } - input := args[0] - key, err := bytesFromJS(input.Get("key")) - if err != nil { - return nil, err - } - defer clear(key) - certificate, err := bytesFromJS(input.Get("certificate")) - if err != nil { - return nil, err - } - var password []byte - if field := input.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { - password, err = bytesFromJS(field) - if err != nil { - return nil, err - } - defer clear(password) - } - signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) - if err != nil { - return nil, fmt.Errorf("证书、私钥或口令无效") - } - roots, err := securityFilesFromJS(input.Get("roots")) - if err != nil { - return nil, err - } - intermediates, err := securityFilesFromJS(input.Get("intermediates")) - if err != nil { - return nil, err - } - options := ofdgo.SignatureWriteOptions{Signer: signer, Certificate: certificate, TrustRoots: roots, - Intermediates: intermediates, Lock: input.Get("lock").Bool()} - if compression := input.Get("compression"); !compression.IsUndefined() && !compression.IsNull() { - if err := compressionFromJS(compression, &options.Compression); err != nil { - return nil, err - } - } - switch input.Get("mode").String() { - case "append": - options.Mode = ofdgo.SignatureAppend - case "replace": - options.Mode = ofdgo.SignatureReplace - default: - return nil, fmt.Errorf("invalid signature mode") - } - if seal := input.Get("seal"); !seal.IsNull() && !seal.IsUndefined() { - options.Seal, err = bytesFromJS(seal) - if err != nil { - return nil, err - } - options.Stamps, err = signatureStampsFromJS(input) - if err != nil { - return nil, err - } - } - progress := editorOperationProgress(args[2]) - if err := progress("sign", 0, 1); err != nil { - return nil, err - } - var output bytes.Buffer - if currentEditor != nil { - currentEditor.OnWriteProgress = progress - defer func() { currentEditor.OnWriteProgress = nil }() - _, err = ofdgo.SignEditorTo(&output, currentEditor, options) - } else { - _, err = currentSession.Reader.SignTo(&output, options) - } - if err != nil { - return nil, err - } - if err := progress("sign", 1, 1); err != nil { - return nil, err - } - if _, err := (exportWriter{write: args[1]}).Write(output.Bytes()); err != nil { - return nil, err - } - return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil -} - -// saveDocument 分块写出当前编辑文档或指定页面 -// 入参: args 数据写出回调、可选准备进度回调和可选页面索引 -// 返回: any 保存结果, error 错误信息 -func saveDocument(args []js.Value) (any, error) { - if currentEditor == nil { - if currentSession == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - editor, err := currentSession.Reader.Editor() - if err != nil { - return nil, err - } - return saveEditor(editor, args) - } - return saveEditor(currentEditor, args) -} - -// saveEditor 统一分块写出及进度取消,加密策略由库层执行 -// 入参: editor 待写出文档, args 写出回调、进度回调及可选页面索引、压缩配置、取消检查 -// 返回: any 保存结果, error 错误信息 -func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) { - if len(args) == 0 || args[0].Type() != js.TypeFunction { - return nil, fmt.Errorf("missing output callback") - } - if len(args) > 1 { - editor.OnWriteProgress = editorOperationProgress(args[1]) - defer func() { editor.OnWriteProgress = nil }() - } - writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) - var err error - var options ofdgo.WriteOptions - ctx := context.Background() - if len(args) > 3 { - if err := compressionFromJS(args[3], &options.Compression); err != nil { - return nil, err - } - } - if len(args) > 4 { - ctx = newOutputContext(args[4]) - } - if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() { - _, err = editor.WritePagesToWithOptions(ctx, writer, indexesFromJS(args[2]), options) - } else { - _, err = editor.WriteToWithOptions(ctx, writer, options) - } - if err != nil { - return nil, err - } - if err := writer.Flush(); err != nil { - return nil, err - } - return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil -} - -// compressionFromJS 读取压缩配置并交给库层校验 -// 入参: value 浏览器配置, options 目标配置 -// 返回: error 配置错误 -func compressionFromJS(value js.Value, options *ofdgo.CompressionOptions) error { - if err := json.Unmarshal([]byte(js.Global().Get("JSON").Call("stringify", value).String()), options); err != nil { - return err - } - return options.Validate() -} - -// newOutputContext 创建单次串行操作的取消上下文 -// 入参: checkpoint 浏览器取消检查回调 -// 返回: *outputContext 操作上下文 -func newOutputContext(checkpoint js.Value) *outputContext { - ctx, cancel := context.WithCancel(context.Background()) - return &outputContext{Context: ctx, cancel: cancel, checkpoint: func() error { return awaitExport(checkpoint) }} -} - -// newEditorOperationContext 在底层取消检查时保持最近的进度,避免版本处理期间界面无响应 -// 入参: callback 浏览器进度及取消回调 -// 返回: context.Context 操作上下文, func(string, int, int) error 阶段进度回调 -func newEditorOperationContext(callback js.Value) (context.Context, func(string, int, int) error) { - ctx, cancel := context.WithCancel(context.Background()) - stage, completed, total := "ids", 0, 0 - progress := editorOperationProgress(callback) - operation := &outputContext{Context: ctx, cancel: cancel, checkpoint: func() error { return progress(stage, completed, total) }} - return operation, func(next string, done, count int) error { - stage, completed, total = next, done, count - return progress(stage, completed, total) - } -} - -// Err 按操作间隔检查时间并处理浏览器消息,每次调用均检查已有取消状态 -// 返回: error 取消错误 -func (c *outputContext) Err() error { - if err := c.Context.Err(); err != nil { - return err - } - if c.pending != 0 { - c.pending-- - return nil - } - c.pending = 15 - if c.interval != 0 { - c.pending = c.interval - } - if time.Since(c.checked) >= 50*time.Millisecond { - c.checked = time.Now() - if err := c.checkpoint(); err != nil { - c.cancel() - } - } - return c.Context.Err() -} diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 4eb7ee7..6ae1eea 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -15,11 +15,21 @@ package webui import ( + "bufio" "bytes" + "context" + "crypto" + "encoding/hex" + "encoding/json" + "errors" "fmt" + "image/color" "io" + "io/fs" + "math" "reflect" "slices" + "strconv" "strings" "time" @@ -294,6 +304,126 @@ type EncryptionInfo struct { PolicyRequired bool `json:"policyRequired,omitempty"` } +// exportWriter 分块传递导出数据 +type exportWriter struct { + write platformValue +} + +// browserReaderAt 分块读取浏览器文件,只缓存最近的一个数据块 +type browserReaderAt struct { + read platformValue + checkpoint platformValue + size int64 + offset int64 + data []byte + checked time.Time +} + +// apiResult 浏览器接口返回结果 +type apiResult struct { + OK bool `json:"ok"` + Code string `json:"code,omitempty"` + Error string `json:"error,omitempty"` + Data any `json:"data,omitempty"` + MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"` + ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"` +} + +// editorClipboard 当前编辑文档中的对象快照与剪贴板标识 +type editorClipboard struct { + token string + selection *ofdgo.ObjectSelection + composite *ofdgo.CompositeSelection +} + +// editorInfo 编辑文档信息与操作状态 +type editorInfo struct { + DocumentInfo + Revision uint64 `json:"revision"` + CanUndo bool `json:"canUndo"` + CanRedo bool `json:"canRedo"` + PageCapabilities []uint `json:"pageCapabilities"` + EditWarnings []string `json:"editWarnings,omitempty"` +} + +// editorPageInfo 页面操作结果与目标页面 +type editorPageInfo struct { + editorInfo + PageIndex int `json:"pageIndex"` +} + +// editorSelectionInfo 对象复制结果及新选区 +type editorSelectionInfo struct { + editorInfo + SelectedIDs []string `json:"selectedIDs"` +} + +// editorOutlineInfo 目录操作后的位置 +type editorOutlineInfo struct { + editorInfo + OutlinePath []int `json:"outlinePath"` +} + +// invoiceRequest 发票操作参数 +type invoiceRequest struct { + Operation string `json:"operation"` + Operations []invoiceRequest `json:"operations"` + Source ofdgo.InvoiceSource `json:"source"` + Index int `json:"index"` + Digest string `json:"digest"` + Invoice ofdgo.Invoice `json:"invoice"` + Changes []ofdgo.InvoiceFieldChange `json:"changes"` + Bindings []ofdgo.InvoiceBinding `json:"bindings"` + XML string `json:"xml"` + Options ofdgo.InvoiceCreateOptions `json:"options"` + Layout ofdgo.InvoiceLayout `json:"layout"` + FontData []byte `json:"fontData"` + Synchronize bool `json:"synchronize"` + DocumentIndex *int `json:"documentIndex"` + Version *string `json:"version"` + Revision *uint64 `json:"revision"` + indexProvided bool +} + +// invoiceEditorInfo 发票操作结果与编辑状态 +type invoiceEditorInfo struct { + editorInfo + InvoiceResult ofdgo.InvoiceUpdateResult `json:"invoiceResult"` +} + +// annotationOptions 保存注解面板的输入,资源字节单独传输 +type annotationOptions struct { + Kind, ID, Text, Old, Creator, Pages, URI, Color string + Base string + X, Y, Width, Height, Size, Angle float64 + Alpha int + Tile bool + Keep bool + Target int + Area *ofdgo.Box +} + +// outputContext 定期让出浏览器线程,将取消请求传入库层长操作 +type outputContext struct { + context.Context + cancel context.CancelFunc + checkpoint func() error + checked time.Time + pending uint8 + interval uint8 +} + +// businessEngine 独立文档、编辑、剪贴板及操作上下文 +type businessEngine struct { + currentSession *Session + currentEditor *ofdgo.Editor + pendingImport *ofdgo.Reader + copiedObjects *editorClipboard + copiedStyle *ofdgo.GraphicObject + ctx context.Context + resolveSource func(context.Context, any) (io.ReaderAt, int64, error) +} + // Open 打开浏览器内存中的OFD文档 // 入参: data OFD文件数据, opts 打开选项 // 返回: *Session 文档会话, error 错误信息 @@ -1099,3 +1229,6211 @@ func signatureStampInfos(positions []ofdgo.SignatureStampPosition) []SignatureSt } return infos } + +// Write 将数据块交给浏览器保存 +// 入参: data 导出数据 +// 返回: int 写入长度, error 错误信息 +func (w exportWriter) Write(data []byte) (int, error) { + size := len(data) + for len(data) > 0 { + n := min(len(data), 1<<20) + if err := awaitExport(w.write, bytesToJS(data[:n])); err != nil { + return size - len(data), err + } + data = data[n:] + } + return size, nil +} + +// ReadAt 按需读取文件片段并定期交付取消检查点 +// 入参: data 目标缓冲, offset 文件偏移 +// 返回: int 读取字节数, error 读取错误 +func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { + if offset < 0 { + return 0, fmt.Errorf("negative file offset") + } + n := 0 + for len(data) > 0 { + if offset >= r.size { + return n, io.EOF + } + if time.Since(r.checked) >= 50*time.Millisecond { + if err := awaitExport(r.checkpoint); err != nil { + return n, err + } + r.checked = time.Now() + } + if len(r.data) == 0 || offset < r.offset || offset-r.offset >= int64(len(r.data)) { + r.offset = offset / (256 << 10) * (256 << 10) + size := int(min(int64(256<<10), r.size-r.offset)) + if cap(r.data) < size { + r.data = make([]byte, size) + } else { + r.data = r.data[:size] + } + value := r.read.Invoke(float64(r.offset), size) + if platformCopyBytes(r.data, value) != size { + return n, io.ErrUnexpectedEOF + } + } + copied := copy(data, r.data[offset-r.offset:]) + n += copied + offset += int64(copied) + data = data[copied:] + } + return n, nil +} + +// renderBackends 返回库提供的内置后端能力 +// 入参: args 浏览器参数 +// 返回: any 后端能力, error 错误信息 +func (e *businessEngine) renderBackends(args []platformValue) (any, error) { + return ofdgo.RenderBackendInfos(), nil +} + +// safeCall 调用浏览器回调并转换异常 +// 入参: fn 回调函数, args 回调参数 +// 返回: any 回调结果, error 错误信息 +func (e *businessEngine) safeCall(fn func([]platformValue) (any, error), args []platformValue) (data any, err error) { + defer func() { + if r := recover(); r != nil { + if failure, ok := r.(error); ok { + err = failure + } else { + err = fmt.Errorf("%v", r) + } + } + }() + return fn(args) +} + +// detectDocumentFormat 通过库按需识别输入,不加载或替换文档会话 +// 入参: args 随机读取回调、文件大小及检查点 +// 返回: any 格式标识, error 读取或识别错误 +func (e *businessEngine) detectDocumentFormat(args []platformValue) (any, error) { + if len(args) != 3 { + return nil, fmt.Errorf("missing detection arguments") + } + source := &browserReaderAt{read: args[0], size: int64(args[1].Float()), checkpoint: args[2]} + return ofdgo.DetectFormat(source, source.size) +} + +// convertPDFDocument 通过库转换PDF,不替换当前文档会话 +// 入参: args PDF数据、外部字体、UTF-8密码、字体目录、进度及字体读取回调 +// 返回: any OFD数据及转换警告, error 错误信息 +func (e *businessEngine) convertPDFDocument(args []platformValue) (any, error) { + if len(args) != 6 { + return nil, fmt.Errorf("missing PDF data") + } + source, size, err := e.sourceFromValue(args[0]) + if err != nil { + return nil, err + } + fonts, err := e.fontsFromJS(args[1]) + if err != nil { + return nil, err + } + var output bytes.Buffer + options := ofdgo.PDFImportOptions{OnProgress: e.editorOperationProgress(args[4]), PasswordUTF8: true} + if !args[2].IsNull() && !args[2].IsUndefined() { + options.Password, err = e.bytesFromJS(args[2]) + if err != nil { + return nil, err + } + defer clear(options.Password) + } + fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} + if names := e.stringsFromJS(args[3]); len(names) > 0 { + fontSources = append(fontSources, ofdgo.NewLazyFontFS(names, func(name string) ([]byte, error) { + return e.loadConversionFont(args[5], name) + })) + } + options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(fontSources...)} + editor, report, err := ofdgo.ImportPDF(e.operationContext(), source, size, options) + if err != nil { + return nil, err + } + if len(options.Password) != 0 { + if err := editor.SetEncryption(&ofdgo.EncryptionOptions{Password: options.Password}); err != nil { + return nil, err + } + } + editor.OnWriteProgress = func(stage string, completed, total int) error { + return options.OnProgress("write."+stage, completed, total) + } + if _, err := editor.WriteTo(&output); err != nil { + return nil, err + } + warnings, err := json.Marshal(report.Warnings) + if err != nil { + return nil, err + } + return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "warnings": string(warnings)}), nil +} + +// convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话 +// 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查、字体及按需字体读取 +// 返回: any 转换报告, error 转换错误 +func (e *businessEngine) convertFile(args []platformValue) (any, error) { + if len(args) != 9 { + return nil, fmt.Errorf("missing conversion arguments") + } + settings := args[2] + backends, err := ofdgo.NewRenderBackends(settings.Get("backend").String()) + if err != nil { + return nil, err + } + fonts, err := e.fontsFromJS(args[7]) + if err != nil { + return nil, err + } + options := ofdgo.ConvertOptions{ + Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(), + SkipUnchanged: true, + Backends: &backends, + RendererOptions: []ofdgo.RendererOption{ofdgo.WithDPI(settings.Get("dpi").Float())}, + OnProgress: func(progress ofdgo.ConvertProgress) error { + return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) + }, + } + if value := settings.Get("fontMode"); value.Type() == platformTypeNumber { + mode := value.Int() + if mode < 0 || mode > int(ofdgo.FontExternal) { + return nil, fmt.Errorf("invalid font mode") + } + options.Fonts = ofdgo.FontMode(mode) + } + if value := settings.Get("compression"); !value.IsNull() && !value.IsUndefined() { + if err := e.compressionFromJS(value, &options.Compression); err != nil { + return nil, err + } + } + fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} + if names := settings.Get("fontNames"); !names.IsUndefined() && names.Length() > 0 { + fontSources = append(fontSources, ofdgo.NewLazyFontFS(e.stringsFromJS(names), func(name string) ([]byte, error) { + return e.loadConversionFont(args[8], name) + })) + } + options.RendererOptions = append(options.RendererOptions, ofdgo.WithFontFS(fontSources...)) + if value := settings.Get("credentials"); !value.IsNull() && !value.IsUndefined() { + credentials, err := e.credentialsFromJS(value) + if err != nil { + return nil, err + } + defer clear(credentials.Password) + options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) + options.PDF.Password = credentials.Password + options.PDF.PasswordUTF8 = true + } + var format ofdgo.OutputFormat + for _, item := range ofdgo.OutputFormats() { + if item.Value == options.Format { + format = item + } + } + writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) + if format.Paged { + options.PageOutput = func(index int, export func(io.Writer) error) error { + name := fmt.Sprintf("%06d.%s", index+1, format.Extension) + if err := awaitExport(args[5], "open", name); err != nil { + return err + } + if err := export(writer); err != nil { + return err + } + if err := writer.Flush(); err != nil { + return err + } + return awaitExport(args[5], "close", name) + } + } else if err := awaitExport(args[5], "open", "document."+format.Extension); err != nil { + return nil, err + } + source := &browserReaderAt{read: args[0], checkpoint: args[6], size: int64(args[1].Float())} + report, err := ofdgo.Convert(e.newOutputContext(args[6]), source, source.size, writer, options) + if err != nil { + return nil, err + } + if report.Unchanged && report.Bytes == 0 { + return report, nil + } + if err := writer.Flush(); err != nil { + return nil, err + } + if !format.Paged { + if err := awaitExport(args[5], "close", "document."+format.Extension); err != nil { + return nil, err + } + } + return report, nil +} + +// packFiles 将已经成功转换的文件逐个打包,不重复转换或读取整个文件 +// 入参: args 文件列表、分块读取、分块写出、进度及取消检查 +// 返回: any 输出格式, error 打包错误 +func (e *businessEngine) packFiles(args []platformValue) (any, error) { + if len(args) < 5 { + return nil, fmt.Errorf("missing archive arguments") + } + writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20) + files := args[0] + entries := make([]ofdgo.ArchiveFile, files.Length()) + for i := 0; i < files.Length(); i++ { + file := files.Index(i) + name := file.Get("name").String() + read := args[1].Invoke(i) + source := &browserReaderAt{read: read, checkpoint: args[4], size: int64(file.Get("size").Float())} + entries[i] = ofdgo.ArchiveFile{Name: name, Source: source, Size: source.size} + } + options := ofdgo.ArchiveOptions{OnProgress: func(completed, total int) error { + if completed > 0 { + entries[completed-1].Source.(*browserReaderAt).data = nil + } + return awaitExport(args[3], completed, total) + }} + if len(args) > 5 && !args[5].IsUndefined() && !args[5].IsNull() { + if err := e.compressionFromJS(args[5], &options.Compression); err != nil { + return nil, err + } + } + if _, err := ofdgo.WriteArchive(e.newOutputContext(args[4]), writer, entries, options); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return map[string]any{"mime": "application/zip"}, nil +} + +// openDocument 打开OFD文档 +// 入参: args 浏览器参数 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) openDocument(args []platformValue) (any, error) { + if len(args) < 3 { + return nil, fmt.Errorf("missing open arguments") + } + source, size, err := e.sourceFromValue(args[0]) + if err != nil { + return nil, err + } + fonts, err := e.fontsFromJS(args[1]) + if err != nil { + return nil, err + } + renderAnnotations := args[2].Bool() + options := OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations} + if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { + credentials, err := e.credentialsFromJS(args[3]) + if err != nil { + return nil, err + } + defer clear(credentials.Password) + options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) + } + reader, err := ofdgo.NewReader(source, size, options.ReaderOptions...) + if err != nil { + return nil, err + } + session, err := newSession(reader, options) + if err != nil { + return nil, err + } + e.clearImport() + e.currentEditor = nil + e.copiedObjects = nil + e.copiedStyle = nil + if e.currentSession != nil { + _ = e.currentSession.Close() + e.currentSession = nil + } + e.currentSession = session + return e.currentSession.Summary(), nil +} + +// configureDocument 更新当前会话的字体和注解设置 +// 入参: args 浏览器参数 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) configureDocument(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if !args[0].IsNull() && !args[0].IsUndefined() { + fonts, err := e.fontsFromJS(args[0]) + if err != nil { + return nil, err + } + if err := e.currentSession.SetFonts(fonts); err != nil { + return nil, err + } + if e.currentEditor != nil { + e.currentEditor.SetFontFS() + if e.currentSession.fontFS != nil { + e.currentEditor.SetFontFS(e.currentSession.fontFS) + } + } + } + if e.currentSession.Renderer.RenderAnnotations != args[1].Bool() { + e.currentSession.Renderer.RenderAnnotations = args[1].Bool() + clear(e.currentSession.textCache) + e.currentSession.textOrder = nil + } + return e.currentSession.Summary(), nil +} + +// documentInfo 获取字体统计和验签结果,首个参数为true时分批扫描 +// 入参: args 浏览器参数 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) documentInfo(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) > 0 && args[0].Bool() { + if !e.currentSession.scanFontInfoBatch() { + return map[string]bool{"detailsPending": true}, nil + } + return map[string]any{"fonts": e.currentSession.fontInfos, "fontCount": len(e.currentSession.fontInfos), "detailsPending": false}, nil + } + return e.currentSession.Details(), nil +} + +// documentImages 读取当前文档的原始图片目录 +// 入参: args 浏览器参数 +// 返回: any 图片目录, error 读取错误 +func (e *businessEngine) documentImages(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) != 0 { + return e.currentSession.Reader.Image(e.operationContext(), args[0].String()) + } + return e.currentSession.Reader.Images(e.operationContext()) +} + +// exportImages 分块保存单幅原始图片或图片归档 +// 入参: args 图片标识和写出回调,空标识保存全部 +// 返回: any 文件类型, error 读写错误 +func (e *businessEngine) exportImages(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) + mime := "application/octet-stream" + if id := args[0].String(); id != "" { + input, err := e.currentSession.Reader.OpenImage(e.operationContext(), id) + if err != nil { + return nil, err + } + defer input.Close() + if _, err := io.Copy(writer, input); err != nil { + return nil, err + } + } else { + mime = "application/zip" + if err := e.currentSession.Reader.WriteImages(e.operationContext(), writer); err != nil { + return nil, err + } + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": mime}), nil +} + +// exportAttachment 分块导出附件 +// 入参: args 浏览器参数 +// 返回: any 附件数据, error 错误信息 +func (e *businessEngine) exportAttachment(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + stream, err := e.currentSession.Reader.OpenAttachment(args[0].String()) + if err != nil { + return nil, err + } + defer stream.Close() + writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) + if _, err := io.Copy(writer, stream); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{ + "mime": "application/octet-stream", + }), nil +} + +// exportMedia 分块读取文档音视频,播放由浏览器负责 +// 入参: args 资源标识和分块接收函数 +// 返回: any 媒体格式, error 资源或读取错误 +func (e *businessEngine) exportMedia(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + media, err := e.currentSession.Reader.Media(args[0].String()) + if err != nil { + return nil, err + } + stream, err := e.currentSession.Reader.OpenMedia(media.ID) + if err != nil { + return nil, err + } + defer stream.Close() + writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) + if _, err := io.Copy(writer, stream); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"format": media.Format, "kind": media.Type}), nil +} + +// documentResources 读取当前文档和版本的资源摘要及引用总数,不执行媒体动作 +// 入参: args 可选的取消检查回调 +// 返回: any 资源摘要, error 读取错误 +func (e *businessEngine) documentResources(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + ctx := e.operationContext() + if len(args) == 1 { + operation := e.newOutputContext(args[0]) + operation.interval = 255 + ctx = operation + } + return e.currentSession.Reader.ResourceSummaries(ctx) +} + +// documentResourceReferences 分页读取选中资源的引用位置 +// 入参: args 资源类型、标识、零基起始位置和最大数量 +// 返回: any 引用列表, error 参数或读取错误 +func (e *businessEngine) documentResourceReferences(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 4 { + return nil, fmt.Errorf("invalid resource reference request") + } + return e.currentSession.Reader.ResourceReferences(e.operationContext(), args[0].String(), args[1].String(), args[2].Int(), args[3].Int()) +} + +// resourceTargets 列出当前页可绑定指定资源的图元及已有绑定状态 +// 入参: args 零基页码、资源类型和标识 +// 返回: any 图元列表, error 页面读取错误 +func (e *businessEngine) resourceTargets(args []platformValue) (any, error) { + if e.currentEditor == nil || len(args) != 3 { + return nil, fmt.Errorf("no document is being edited") + } + return e.currentEditor.ResourceTargets(e.operationContext(), args[1].String(), args[2].String(), args[0].Int()) +} + +// documentInvoices 获取发票列表与格式能力 +// 入参: args 空参数 +// 返回: any 发票提取结果与格式列表, error 读取错误 +func (e *businessEngine) documentInvoices(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 0 { + return nil, fmt.Errorf("ofd document is not opened") + } + r, close, err := e.currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + report, err := r.Invoices() + if err != nil { + return nil, err + } + return map[string]any{"report": report, "formats": ofdgo.InvoiceFormats()}, nil +} + +// currentInvoiceReader 获取当前发票读取器 +// 返回: *ofdgo.Reader 当前读取器, func() 快照释放函数, error 快照错误 +func (e *businessEngine) currentInvoiceReader() (*ofdgo.Reader, func(), error) { + if e.currentEditor != nil { + r, err := e.currentEditor.Reader() + if err != nil { + return nil, nil, err + } + return r, func() { _ = r.Close() }, nil + } + if e.currentSession == nil { + return nil, nil, fmt.Errorf("ofd document is not opened") + } + return e.currentSession.Reader, func() {}, nil +} + +// decodeInvoiceRequest 解析发票参数并校验文档状态 +// 入参: args 单个JSON操作对象 +// 返回: invoiceRequest 操作参数, error 参数或过期状态错误 +func (e *businessEngine) decodeInvoiceRequest(args []platformValue) (invoiceRequest, error) { + var request invoiceRequest + if e.currentSession == nil || len(args) != 1 || args[0].Type() != platformTypeString { + return request, fmt.Errorf("invalid invoice arguments") + } + data := args[0].String() + if len(data) > 64<<20 { + return request, fmt.Errorf("invoice request exceeds size limit") + } + if err := json.Unmarshal([]byte(data), &request); err != nil { + return request, err + } + var presence struct { + Operations []struct { + Index *int `json:"index"` + } `json:"operations"` + } + if err := json.Unmarshal([]byte(data), &presence); err != nil { + return request, err + } + for i := range presence.Operations { + request.Operations[i].indexProvided = presence.Operations[i].Index != nil + } + index := e.currentSession.Reader.DocumentIndex() + if e.currentEditor != nil { + index = e.currentEditor.DocumentIndex() + } + if request.DocumentIndex != nil && *request.DocumentIndex != index { + return request, fmt.Errorf("invoice document has changed") + } + if request.Version != nil { + version, err := e.currentSession.Reader.DocumentVersionID() + if e.currentEditor != nil { + version, err = e.currentEditor.DocumentVersionID() + } + if err != nil { + return request, err + } + if *request.Version != version { + return request, fmt.Errorf("invoice document version has changed") + } + } + if request.Revision != nil && (e.currentEditor == nil || *request.Revision != e.currentEditor.Revision()) { + return request, fmt.Errorf("invoice editing revision has changed") + } + return request, nil +} + +// documentInvoice 获取发票详情与票面关联 +// 入参: args 来源及来源内发票索引,新建时提供发票模型 +// 返回: any 发票详情, error 来源、索引或读取错误 +func (e *businessEngine) documentInvoice(args []platformValue) (any, error) { + request, err := e.decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + ctx := e.operationContext() + if request.Source.Kind == "" { + return map[string]any{"invoice": request.Invoice, "fields": ofdgo.InvoiceFields(request.Invoice)}, nil + } + r, close, err := e.currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + data, err := r.ExportInvoice(ctx, request.Source) + if err != nil { + return nil, err + } + report, err := ofdgo.DecodeInvoices(ctx, data) + if request.Source.Kind == "tag" && len(report.Invoices) == 0 { + report, err = r.ReadInvoice(ctx, request.Source) + } + if err != nil { + return nil, err + } + if request.Index < 0 || request.Index >= len(report.Invoices) { + return nil, fmt.Errorf("invoice index out of range") + } + value := report.Invoices[request.Index] + standardLabel := value.Standard + switch value.Standard { + case ofdgo.InvoiceStandardEDRM2019: + standardLabel = "电子发票/2019" + case ofdgo.InvoiceStandardObjectReference: + standardLabel = "发票标引" + } + capabilities, err := r.InvoiceCapabilities(ctx, request.Source, request.Index) + if err != nil { + return nil, err + } + bindings, bindingErr := r.InvoiceBindings(ctx, request.Source, request.Index) + if bindingErr != nil { + report.Issues = append(report.Issues, ofdgo.InvoiceIssue{Code: "binding-error", Message: bindingErr.Error(), Source: request.Source}) + } + return map[string]any{"invoice": value, "standardLabel": standardLabel, "fields": ofdgo.InvoiceFields(value), "capabilities": capabilities, "bindings": bindings, "digest": ofdgo.InvoiceDigest(data), "xml": string(data), "issues": report.Issues}, nil +} + +// exportDocumentInvoice 导出发票原文 +// 入参: args 发票来源 +// 返回: any 原始字节, error 来源或读取错误 +func (e *businessEngine) exportDocumentInvoice(args []platformValue) (any, error) { + request, err := e.decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + r, close, err := e.currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + data, err := r.ExportInvoice(e.operationContext(), request.Source) + if err != nil { + return nil, err + } + return successResult(map[string]any{"bytes": bytesToJS(data)}), nil +} + +// validateDocumentInvoice 校验发票草稿 +// 入参: args 发票模型或来源、草稿操作与原始摘要 +// 返回: any 字段与票面诊断, error 校验或读取错误 +func (e *businessEngine) validateDocumentInvoice(args []platformValue) (any, error) { + request, err := e.decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + ctx := e.operationContext() + if request.Operation == "" { + switch { + case len(request.Operations) != 0: + request.Operation = "batch" + case len(request.Changes) != 0: + request.Operation = "patch" + case request.XML != "": + request.Operation = "replace" + if request.Source.Kind == "" { + request.Operation = "import" + } + } + } + if request.Operation != "" { + return e.validateInvoiceDraft(ctx, request) + } + issues, err := ofdgo.ValidateInvoice(ctx, request.Invoice) + if err != nil { + return nil, err + } + var appearance []ofdgo.InvoiceIssue + if request.Source.Kind != "" { + r, close, err := e.currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + appearance, err = r.CheckInvoiceAppearance(ctx, request.Source, request.Index) + if err != nil { + appearance = append(appearance, ofdgo.InvoiceIssue{Code: "appearance-error", Message: err.Error(), Source: request.Source}) + } + } + return map[string]any{"issues": issues, "appearance": appearance}, nil +} + +// validateInvoiceDraft 在独立快照中校验发票修改 +// 入参: ctx 取消上下文, request 已核对当前文档状态的操作 +// 返回: any 草稿发票及诊断, error 演练或读取错误 +func (e *businessEngine) validateInvoiceDraft(ctx context.Context, request invoiceRequest) (any, error) { + r, close, err := e.currentInvoiceReader() + if err != nil { + return nil, err + } + defer close() + editor, err := r.EditorContext(ctx) + if err != nil { + return nil, err + } + request.Operations = append([]invoiceRequest(nil), request.Operations...) + for i := range request.Operations { + operation := &request.Operations[i] + if operation.Revision != nil && (e.currentEditor == nil || *operation.Revision != e.currentEditor.Revision()) { + return nil, fmt.Errorf("invoice editing revision has changed") + } + operation.Revision = nil + } + var result ofdgo.InvoiceUpdateResult + if err := editor.Transaction(func(next *ofdgo.Editor) error { + var err error + result, err = e.applyInvoiceRequest(ctx, next, request) + return err + }); err != nil { + return nil, err + } + issues := append([]ofdgo.InvoiceIssue(nil), result.Issues...) + var appearance []ofdgo.InvoiceIssue + if request.Operation == "delete" { + return map[string]any{"issues": issues, "appearance": appearance}, nil + } + source := result.Source + if source.Kind == "" { + source = request.Source + } + view, err := editor.ReaderContext(ctx) + if err != nil { + return nil, err + } + defer view.Close() + data, err := view.ExportInvoice(ctx, source) + if err != nil { + return nil, err + } + report, err := ofdgo.DecodeInvoices(ctx, data) + if source.Kind == "tag" && len(report.Invoices) == 0 { + report, err = view.ReadInvoice(ctx, source) + } + if err != nil { + return nil, err + } + issues = append(issues, report.Issues...) + for index, invoice := range report.Invoices { + validation, err := ofdgo.ValidateInvoice(ctx, invoice) + if err != nil { + return nil, err + } + for i := range validation { + validation[i].Source = source + } + issues = append(issues, validation...) + checks, err := view.CheckInvoiceAppearance(ctx, source, index) + if err != nil { + appearance = append(appearance, ofdgo.InvoiceIssue{Code: "appearance-error", Message: err.Error(), Source: source}) + } else { + appearance = append(appearance, checks...) + } + } + return map[string]any{"issues": issues, "appearance": appearance, "invoices": report.Invoices}, nil +} + +// changeDocumentInvoice 提交发票修改 +// 入参: args 操作及业务参数 +// 返回: any 编辑状态与签名诊断, error 校验或提交错误 +func (e *businessEngine) changeDocumentInvoice(args []platformValue) (any, error) { + request, err := e.decodeInvoiceRequest(args) + if err != nil { + return nil, err + } + ctx := e.operationContext() + var result ofdgo.InvoiceUpdateResult + info, err := e.changeAtomicObjects(func() error { + return e.currentEditor.Transaction(func(editor *ofdgo.Editor) error { + var err error + result, err = e.applyInvoiceRequest(ctx, editor, request) + return err + }) + }) + if err != nil { + return nil, err + } + return invoiceEditorInfo{editorInfo: info.(editorInfo), InvoiceResult: result}, nil +} + +// prepareInvoiceLayout 在发票创建事务内导入票面字体,不修改调用方布局 +// 入参: ctx 取消上下文, editor 事务编辑器, request 发票请求 +// 返回: error 字体处理错误 +func (e *businessEngine) prepareInvoiceLayout(ctx context.Context, editor *ofdgo.Editor, request *invoiceRequest) error { + if request.Options.Layout == nil || len(request.FontData) == 0 { + return nil + } + layout := *request.Options.Layout + request.Options.Layout = &layout + file := ofdgo.FontFile{Data: request.FontData} + if layout.Font != "" { + return editor.EmbedFont(ctx, layout.Font, file, 0) + } + id, err := editor.AddFont(file, 0) + if err != nil { + return err + } + layout.Font = id + return nil +} + +// applyInvoiceRequest 校验来源摘要并执行发票操作 +// 入参: ctx 取消上下文, editor 事务编辑器, request 操作参数 +// 返回: ofdgo.InvoiceUpdateResult 提交结果, error 校验或写入错误 +func (e *businessEngine) applyInvoiceRequest(ctx context.Context, editor *ofdgo.Editor, request invoiceRequest) (ofdgo.InvoiceUpdateResult, error) { + var result ofdgo.InvoiceUpdateResult + if request.Operation == "batch" { + return e.applyInvoiceBatch(ctx, editor, request) + } + if len(request.Operations) != 0 { + return result, fmt.Errorf("invoice operations require batch action") + } + err := func() error { + if request.Operation != "create" && request.Operation != "import" { + data, err := editor.ExportInvoice(ctx, request.Source) + if err != nil { + return err + } + if request.Digest == "" || ofdgo.InvoiceDigest(data) != request.Digest { + return fmt.Errorf("invoice source has changed") + } + } + switch request.Operation { + case "create", "import": + var source ofdgo.InvoiceSource + var err error + if err = e.prepareInvoiceLayout(ctx, editor, &request); err != nil { + return err + } + if request.Operation == "create" { + source, err = editor.CreateInvoice(ctx, request.Invoice, request.Options) + } else { + source, err = editor.ImportInvoice(ctx, []byte(request.XML), request.Options) + } + result.Source = source + return err + case "set": + var err error + result, err = editor.SetInvoice(ctx, request.Source, request.Digest, request.Index, request.Invoice, request.Synchronize) + return err + case "patch": + for i := range request.Changes { + request.Changes[i].Source.Kind = request.Source.Kind + request.Changes[i].Source.ID = request.Source.ID + request.Changes[i].Source.FileLoc = request.Source.FileLoc + if request.Source.NodePath != "" { + request.Changes[i].Source.NodePath = request.Source.NodePath + request.Changes[i].Source.NodePath + } + } + var err error + result, err = editor.EditInvoice(ctx, request.Source, request.Digest, request.Changes, request.Synchronize) + return err + case "replace": + var err error + result, err = editor.UpdateInvoice(ctx, request.Source, request.Digest, []byte(request.XML), request.Synchronize) + return err + case "delete": + return editor.DeleteInvoice(ctx, request.Source, request.Digest) + case "bindings": + return editor.SetInvoiceBindings(ctx, request.Source, request.Index, request.Bindings) + case "generate": + if len(request.FontData) != 0 { + file := ofdgo.FontFile{Data: request.FontData} + if request.Layout.Font == "" { + id, err := editor.AddFont(file, 0) + if err != nil { + return err + } + request.Layout.Font = id + } else if err := editor.EmbedFont(ctx, request.Layout.Font, file, 0); err != nil { + return err + } + } + return editor.GenerateInvoicePages(ctx, request.Source, request.Index, request.Layout) + case "sync": + data, err := editor.ExportInvoice(ctx, request.Source) + if err != nil { + return err + } + result, err = editor.UpdateInvoice(ctx, request.Source, request.Digest, data, true) + return err + default: + return fmt.Errorf("unsupported invoice action") + } + }() + if err != nil { + return ofdgo.InvoiceUpdateResult{}, err + } + return result, nil +} + +// applyInvoiceBatch 合并发票修改并同步票面 +// 入参: ctx 取消上下文, editor 事务编辑器, request 批次及原始来源 +// 返回: ofdgo.InvoiceUpdateResult 提交结果, error 冲突或写入错误 +func (e *businessEngine) applyInvoiceBatch(ctx context.Context, editor *ofdgo.Editor, request invoiceRequest) (ofdgo.InvoiceUpdateResult, error) { + var result ofdgo.InvoiceUpdateResult + data, err := editor.ExportInvoice(ctx, request.Source) + if err != nil { + return result, err + } + if request.Digest == "" || ofdgo.InvoiceDigest(data) != request.Digest { + return result, fmt.Errorf("invoice source has changed") + } + if len(request.Operations) == 0 || len(request.Operations) > 256 { + return result, fmt.Errorf("invalid invoice batch size") + } + report, err := ofdgo.DecodeInvoices(ctx, data) + if request.Source.Kind == "tag" && len(report.Invoices) == 0 { + view, viewErr := editor.ReaderContext(ctx) + if viewErr != nil { + return result, viewErr + } + defer view.Close() + report, err = view.ReadInvoice(ctx, request.Source) + } + if err != nil { + return result, err + } + if request.Index < 0 || request.Index >= len(report.Invoices) { + return result, fmt.Errorf("invoice index out of range") + } + paths := make(map[string]bool) + for _, fact := range report.Invoices[request.Index].Facts { + paths[fact.Source.NodePath] = true + } + for _, item := range report.Invoices[request.Index].Items { + for _, fact := range item.Fields { + paths[fact.Source.NodePath] = true + } + } + request.Operations = append([]invoiceRequest(nil), request.Operations...) + for i := range request.Operations { + operation := &request.Operations[i] + if (operation.indexProvided || operation.Index != 0) && operation.Index != request.Index { + return result, fmt.Errorf("invoice batch cannot span candidates") + } + operation.Index = request.Index + } + document := editor.DocumentIndex() + version, err := editor.DocumentVersionID() + if err != nil { + return result, err + } + revision := editor.Revision() + models, bindings := make(map[int]bool), make(map[int]bool) + patched := make(map[string]bool) + hasSet, hasPatch := false, false + for _, operation := range request.Operations { + if err := ctx.Err(); err != nil { + return result, err + } + if len(operation.Operations) != 0 || operation.Operation == "batch" || operation.Synchronize { + return result, fmt.Errorf("nested invoice batch or synchronization is not supported") + } + if operation.Source != (ofdgo.InvoiceSource{}) && operation.Source != request.Source { + return result, fmt.Errorf("invoice batch cannot span sources") + } + if operation.Digest != "" && operation.Digest != request.Digest { + return result, fmt.Errorf("invoice batch requires one original digest") + } + if operation.DocumentIndex != nil && *operation.DocumentIndex != document || operation.Version != nil && *operation.Version != version || operation.Revision != nil && *operation.Revision != revision { + return result, fmt.Errorf("invoice batch scope has changed") + } + switch operation.Operation { + case "set": + if models[operation.Index] { + return result, fmt.Errorf("duplicate invoice model update") + } + models[operation.Index], hasSet = true, true + case "patch": + if len(operation.Changes) == 0 { + return result, fmt.Errorf("empty invoice field update") + } + hasPatch = true + for _, change := range operation.Changes { + if change.Source.Kind != "" && change.Source.Kind != request.Source.Kind || change.Source.ID != "" && change.Source.ID != request.Source.ID || change.Source.FileLoc != "" && change.Source.FileLoc != request.Source.FileLoc { + return result, fmt.Errorf("invoice patch cannot span sources") + } + if change.Source.NodePath == "" || patched[change.Source.NodePath] { + return result, fmt.Errorf("duplicate or missing invoice field path") + } + if !paths[change.Source.NodePath] { + return result, fmt.Errorf("invoice patch cannot span candidates") + } + if change.Revision != "" && change.Revision != request.Digest { + return result, fmt.Errorf("invoice field revision has changed") + } + patched[change.Source.NodePath] = true + } + case "bindings": + if bindings[operation.Index] { + return result, fmt.Errorf("duplicate invoice binding update") + } + bindings[operation.Index] = true + default: + return result, fmt.Errorf("unsupported invoice batch action") + } + } + if hasSet && hasPatch { + return result, fmt.Errorf("invoice batch cannot mix model and raw field updates") + } + var changes []ofdgo.InvoiceFieldChange + for _, operation := range request.Operations { + if err := ctx.Err(); err != nil { + return result, err + } + switch operation.Operation { + case "set": + data, err = ofdgo.UpdateInvoiceXML(ctx, data, operation.Index, operation.Invoice) + case "patch": + changes = append(changes, operation.Changes...) + } + if err != nil { + return result, err + } + } + if len(changes) != 0 { + for i := range changes { + changes[i].Revision = request.Digest + changes[i].Source.Kind = "" + changes[i].Source.ID = "" + changes[i].Source.FileLoc = "" + } + data, err = ofdgo.PatchInvoice(ctx, data, changes) + if err != nil { + return result, err + } + } + for _, operation := range request.Operations { + if operation.Operation == "bindings" { + if err := editor.SetInvoiceBindings(ctx, request.Source, operation.Index, operation.Bindings); err != nil { + return result, err + } + } + } + return editor.UpdateInvoice(ctx, request.Source, request.Digest, data, request.Synchronize) +} + +// resourceLocations 定位当前文档与版本中的资源引用实例 +// 入参: args 资源类型、标识和引用索引 +// 返回: any 页面图元范围, error 引用或度量错误 +func (e *businessEngine) resourceLocations(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 3 { + return nil, fmt.Errorf("ofd document is not opened") + } + return e.currentSession.Renderer.ResourceLocations(e.operationContext(), args[0].String(), args[1].String(), args[2].Int()) +} + +// fontPreview 使用当前文档和版本的字形轮廓生成独立预览 +// 入参: args 字体资源标识及可选字形映射标记 +// 返回: any SVG数据, error 字体或绘制错误 +func (e *businessEngine) fontPreview(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 1 { + return nil, fmt.Errorf("ofd document is not opened") + } + var output bytes.Buffer + if err := e.currentSession.Renderer.RenderFontPreview(e.operationContext(), args[0].String(), &output); err != nil { + return nil, err + } + return successResult(map[string]any{"bytes": bytesToJS(output.Bytes())}), nil +} + +// changeResource 调用库层资源编辑,浏览器不改写资源声明和包路径 +// 入参: args 操作、类型、标识、格式、数据、页码和图元标识 +// 返回: any 编辑状态, error 资源操作错误 +func (e *businessEngine) changeResource(args []platformValue) (any, error) { + if len(args) != 7 { + return nil, fmt.Errorf("invalid resource arguments") + } + return e.changeAtomicObjects(func() error { + ctx := e.operationContext() + action, kind, id := args[0].String(), args[1].String(), args[2].String() + switch action { + case "delete": + return e.currentEditor.DeleteResource(ctx, kind, id) + case "bind", "unbind": + if kind == "Attachment" { + return e.currentEditor.BindAttachment(ctx, id, args[5].Int(), args[6].String(), action == "bind") + } + if kind != "Audio" && kind != "Video" { + return fmt.Errorf("resource does not support actions") + } + return e.currentEditor.BindMedia(ctx, id, args[5].Int(), args[6].String(), action == "bind") + case "add", "replace": + data, err := e.bytesFromJS(args[4]) + if err != nil { + return err + } + if action == "add" { + _, err = e.currentEditor.ImportMedia(ctx, kind, args[3].String(), data) + return err + } + return e.currentEditor.ReplaceResource(ctx, kind, id, args[3].String(), data) + default: + return fmt.Errorf("unsupported resource action") + } + }) +} + +// exportResource 分块导出当前资源的原始文件,不接收任意包路径 +// 入参: args 类型及标识的JSON数组、分块接收函数 +// 返回: any 输出格式, error 读取或写入错误 +func (e *businessEngine) exportResource(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 2 { + return nil, fmt.Errorf("ofd document is not opened") + } + var key [2]string + if err := json.Unmarshal([]byte(args[0].String()), &key); err != nil { + return nil, err + } + stream, err := e.currentSession.Reader.OpenResource(e.operationContext(), key[0], key[1]) + if err != nil { + return nil, err + } + defer stream.Close() + writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) + if _, err := io.Copy(writer, stream); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": "application/octet-stream"}), nil +} + +// svgFontData 读取SVG字体资源 +// 入参: args 浏览器参数 +// 返回: any 字体数据, error 错误信息 +func (e *businessEngine) svgFontData(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + data, err := e.currentSession.SVGFontData(args[0].String()) + if err != nil { + return nil, err + } + return successResult(map[string]any{"bytes": bytesToJS(data)}), nil +} + +// renderPage 渲染OFD页面 +// 入参: args 页面索引、后端组合、DPI和光栅显示开关 +// 返回: any 页面SVG结果, error 错误信息 +func (e *businessEngine) renderPage(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) == 0 { + return nil, fmt.Errorf("missing page index") + } + backend, dpi := "canvas", 150.0 + if len(args) > 1 { + backend = args[1].String() + } + if len(args) > 2 { + dpi = args[2].Float() + } + raster := len(args) > 3 && args[3].Bool() + if e.currentEditor != nil { + page, err := e.currentEditor.Page(args[0].Int()) + if err != nil { + return nil, err + } + if err := e.syncEditorPageIDs(args[0].Int(), page); err != nil { + return nil, err + } + } + page, err := e.currentSession.RenderPage(args[0].Int(), backend, dpi, raster) + if err != nil { + return nil, err + } + if e.currentEditor != nil && !reflect.DeepEqual(e.currentEditor.Backends(), e.currentSession.Renderer.Backends()) { + e.currentEditor.SetRenderBackends(e.currentSession.Renderer.Backends()) + } + text, err := e.currentSession.PageText(args[0].Int()) + if err != nil { + return nil, err + } + textData, err := json.Marshal(text) + if err != nil { + return nil, err + } + objects, err := e.editorObjects(args[0].Int(), text) + if err != nil { + return nil, err + } + links, err := e.linkResults(page.Links) + if err != nil { + return nil, err + } + fonts := make([]any, len(page.Fonts)) + for i, font := range page.Fonts { + fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style} + } + annotations, err := json.Marshal(page.Annotations) + if err != nil { + return nil, err + } + return successResult(map[string]any{ + "annotations": string(annotations), + "index": page.Index, + "number": page.Number, + "id": page.ID, + "width": page.Width, + "height": page.Height, + "svg": page.SVG, + "links": links, + "fonts": fonts, + "images": e.svgImagesToJS(page.Images), + "text": string(textData), + "objects": objects, + }), nil +} + +// openLinks 获取文档或页面打开动作,不执行动作 +// 入参: args 可选页面索引,省略时读取文档打开动作 +// 返回: any 动作目标, error 文档或页码错误 +func (e *businessEngine) openLinks(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + var links []ofdgo.PageLink + var err error + if len(args) == 0 { + links, err = e.currentSession.Renderer.DocumentOpenLinks() + } else if len(args) == 1 { + page, readErr := e.currentSession.Reader.PageContentByIndex(args[0].Int()) + if readErr != nil { + return nil, readErr + } + links, err = e.currentSession.Renderer.PageOpenLinks(page) + } else { + return nil, fmt.Errorf("unexpected open action arguments") + } + if err != nil { + return nil, err + } + return e.linkResults(links) +} + +// outlineLinks 获取目录节点的有序动作目标 +// 入参: args 各级目录索引 +// 返回: any 动作目标, error 目录或文档错误 +func (e *businessEngine) outlineLinks(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) != 1 { + return nil, fmt.Errorf("missing outline path") + } + links, err := e.currentSession.Renderer.OutlineLinks(e.indexesFromJS(args[0])) + if err != nil { + return nil, err + } + return e.linkResults(links) +} + +// changeStructure 提交标引操作,XML、目标校验及历史维护均由库完成 +// 入参: args JSON操作参数 +// 返回: any 编辑状态, error 参数、校验或提交错误 +func (e *businessEngine) changeStructure(args []platformValue) (any, error) { + if len(args) != 1 || args[0].Type() != platformTypeString { + return nil, fmt.Errorf("invalid structure arguments") + } + var request struct { + Action string `json:"action"` + Index int `json:"index"` + Path []int `json:"path"` + TypeID string `json:"typeId"` + Node ofdgo.StructureNode `json:"node"` + References []ofdgo.StructureReference `json:"references"` + } + if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil { + return nil, err + } + return e.changeObjects(func() error { + if request.Action != "create" { + structures, err := e.currentEditor.Structure() + if err != nil { + return err + } + if request.Index >= 0 && request.Index < len(structures) && structures[request.Index].Internal { + return fmt.Errorf("internal text metadata must be edited through the text editor") + } + } + switch request.Action { + case "create": + _, err := e.currentEditor.AddStructure(ofdgo.CustomTag{TypeID: request.TypeID, NameSpace: request.Node.Namespace}, request.Node) + return err + case "add": + _, err := e.currentEditor.AddStructureNode(request.Index, request.Path, request.Node) + return err + case "update": + return e.currentEditor.UpdateStructureNode(request.Index, request.Path, request.Node) + case "delete": + if len(request.Path) == 0 { + return e.currentEditor.DeleteStructure(request.Index) + } + return e.currentEditor.DeleteStructureNode(request.Index, request.Path) + case "bind": + return e.currentEditor.SetStructureReferences(request.Index, request.Path, request.References) + default: + return fmt.Errorf("unsupported structure action %q", request.Action) + } + }) +} + +// structureObjects 获取当前编辑文档的绑定候选,不允许阅读模式调用编辑入口 +// 入参: args 从0开始的页面索引 +// 返回: any 图元标识和原文, error 参数或读取错误 +func (e *businessEngine) structureObjects(args []platformValue) (any, error) { + if e.currentEditor == nil || e.currentSession == nil || len(args) != 1 { + return nil, fmt.Errorf("no document is being edited") + } + return e.currentSession.Renderer.StructureObjects(args[0].Int()) +} + +// structureTargets 查询当前文档标引节点的原文与定位范围 +// 入参: args 标引清单索引和节点路径 +// 返回: any 标引目标, error 参数或解析错误 +func (e *businessEngine) structureTargets(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) != 2 { + return nil, fmt.Errorf("missing structure path") + } + return e.currentSession.Renderer.StructureTargets(args[0].Int(), e.indexesFromJS(args[1])) +} + +// linkResults 将库层动作目标转换为浏览器数据 +// 入参: source 动作目标 +// 返回: []any 浏览器动作数据, error 附件读取错误 +func (e *businessEngine) linkResults(source []ofdgo.PageLink) ([]any, error) { + links := make([]any, len(source)) + attachmentNames := make(map[string]string) + for _, link := range source { + if link.Attachment == "" { + continue + } + attachments, err := e.currentSession.Reader.Attachments() + if err != nil { + return nil, err + } + for _, attachment := range attachments { + attachmentNames[attachment.ID] = attachment.FileName() + } + break + } + for i, link := range source { + item := map[string]any{ + "uri": link.URI, + "target": link.Target, + "path": link.Path, + "group": link.Group, + "x": link.Box.X, + "y": link.Box.Y, + "width": link.Box.W, + "height": link.Box.H, + } + if link.Attachment != "" { + item["attachment"] = link.Attachment + item["fileName"] = attachmentNames[link.Attachment] + } + if sound := link.Sound; sound != nil { + volume := 100 + if sound.Volume != nil { + volume = *sound.Volume + } + item["sound"] = map[string]any{"resourceID": sound.ResourceID, "volume": volume, "repeat": sound.Repeat, "synchronous": sound.Synchronous} + } + if movie := link.Movie; movie != nil { + item["movie"] = map[string]any{"resourceID": movie.ResourceID, "operator": movie.Operator} + if box := link.PlaybackBox; box != nil { + item["playbackBox"] = map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H} + } + } + if dest := link.Dest; dest != nil { + item["dest"] = map[string]any{"type": dest.Type, "pageID": e.resolvedPageID(dest.PageID), "left": dest.Left, "top": dest.Top, + "right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom, + "omitLeft": dest.OmitLeft, "omitTop": dest.OmitTop, "omitZoom": dest.OmitZoom} + } + links[i] = item + } + return links, nil +} + +// resolvedPageID 将跳转目标转换为界面页面列表中的标识,未找到时保留原值 +// 入参: id 目标页面标识 +// 返回: string 页面列表标识或原值 +func (e *businessEngine) resolvedPageID(id string) string { + if e.currentSession != nil { + if index := e.currentSession.doc.PageIndex(id); index >= 0 { + return e.currentSession.doc.Pages.Page[index].ID + } + } + return id +} + +// pageTextString 提取OFD页面原文 +// 入参: args 页面索引 +// 返回: any 页面原文, error 错误信息 +func (e *businessEngine) pageTextString(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + return e.currentSession.PageTextString(args[0].Int()) +} + +// searchPage 搜索OFD页面文字 +// 入参: args 浏览器参数 +// 返回: any 搜索结果, error 错误信息 +func (e *businessEngine) searchPage(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + return e.currentSession.SearchPage(args[0].Int(), args[1].String()) +} + +// exportFormats 获取导出格式 +// 入参: args 浏览器参数 +// 返回: any 导出格式, error 错误信息 +func (e *businessEngine) exportFormats(args []platformValue) (any, error) { + return ExportFormats(), nil +} + +// parsePageRange 解析当前文档的导出页码 +// 入参: args 浏览器参数 +// 返回: any 页面索引, error 错误信息 +func (e *businessEngine) parsePageRange(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + count, err := e.currentSession.Reader.PageCount() + if err != nil { + return nil, err + } + return ofdgo.ParsePageRange(args[0].String(), count) +} + +// exportPage 导出OFD单页 +// 入参: args 浏览器参数 +// 返回: any 导出结果, error 错误信息 +func (e *businessEngine) exportPage(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) < 4 { + return nil, fmt.Errorf("missing export page arguments") + } + session, writerIndex, err := e.exportSession(args) + if err != nil { + return nil, err + } + writer := bufio.NewWriterSize(exportWriter{write: args[writerIndex]}, 1<<20) + format, err := session.ExportPage(args[0].Int(), args[1].String(), args[2].Float(), writer) + if err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{ + "label": format.Label, + "mime": format.MIME, + }), nil +} + +// exportDocument 导出OFD文档 +// 入参: args 浏览器参数 +// 返回: any 导出结果, error 错误信息 +func (e *businessEngine) exportDocument(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + session, writerIndex, err := e.exportSession(args) + if err != nil { + return nil, err + } + renderer := session.Renderer + renderer.OnExportProgress = func(completed, total int) error { + return awaitExport(args[writerIndex+1], completed, total) + } + defer func() { renderer.OnExportProgress = nil }() + var indices []int + if !args[2].IsNull() { + indices = make([]int, args[2].Length()) + for i := range indices { + indices[i] = args[2].Index(i).Int() + } + } + writer := bufio.NewWriterSize(exportWriter{write: args[writerIndex]}, 1<<20) + format, err := session.ExportDocument(args[0].String(), args[1].Float(), writer, indices...) + if err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{ + "label": format.Label, + "mime": format.MIME, + }), nil +} + +// exportSession 创建独立导出配置,写入回调前可指定光栅后端 +// 入参: args 浏览器参数 +// 返回: *Session 导出会话, int 写入回调位置, error 错误信息 +func (e *businessEngine) exportSession(args []platformValue) (*Session, int, error) { + session := *e.currentSession + renderer := *session.Renderer + session.Renderer = &renderer + writerIndex := 3 + if args[3].Type() == platformTypeString { + if err := session.SetRenderBackend(args[3].String()); err != nil { + return nil, 0, err + } + writerIndex++ + } + if args[writerIndex].Type() == platformTypeObject { + if err := e.compressionFromJS(args[writerIndex], &renderer.Compression); err != nil { + return nil, 0, err + } + writerIndex++ + if len(args) > writerIndex+1 { + renderer.OutputContext = e.newOutputContext(args[len(args)-1]) + } + } + return &session, writerIndex, nil +} + +// matchFontFiles 匹配文档所需的字体文件 +// 入参: args 浏览器参数 +// 返回: any 匹配的字体文件名称, error 错误信息 +func (e *businessEngine) matchFontFiles(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + ids := make([]string, 0, len(e.currentSession.fontInfos)) + for _, info := range e.currentSession.fontInfos { + if info.Used > 0 { + ids = append(ids, info.ID) + } + } + return e.currentSession.Reader.MatchFontFiles(e.stringsFromJS(args[0]), ids...) +} + +// fontFaces 读取上传字体的名称和集合索引 +// 入参: args 字体数据 +// 返回: any 字体列表, error 错误信息 +func (e *businessEngine) fontFaces(args []platformValue) (any, error) { + data, err := e.bytesFromJS(args[0]) + if err != nil { + return nil, err + } + return (ofdgo.FontFile{Data: data}).Faces() +} + +// fontFace 提取编辑时选定的字体,供输入预览和保存共用 +// 入参: args 字体数据和零起始索引 +// 返回: any 独立字体数据, error 错误信息 +func (e *businessEngine) fontFace(args []platformValue) (any, error) { + data, err := e.bytesFromJS(args[0]) + if err != nil { + return nil, err + } + data, err = (ofdgo.FontFile{Data: data}).Face(args[1].Int()) + if err != nil { + return nil, err + } + return successResult(map[string]any{"bytes": bytesToJS(data)}), nil +} + +// fontsFromJS 从浏览器值读取字体文件 +// 入参: value 浏览器值 +// 返回: []FontFile 字体文件, error 错误信息 +func (e *businessEngine) fontsFromJS(value platformValue) ([]FontFile, error) { + if value.IsUndefined() || value.IsNull() { + return nil, nil + } + length := value.Get("length").Int() + fonts := make([]FontFile, 0, length) + for i := 0; i < length; i++ { + item := value.Index(i) + data, err := e.bytesFromJS(item.Get("data")) + if err != nil { + return nil, err + } + fonts = append(fonts, FontFile{Name: item.Get("name").String(), Data: data}) + } + return fonts, nil +} + +// stringsFromJS 从浏览器值读取字符串列表 +// 入参: value 浏览器值 +// 返回: []string 字符串列表 +func (e *businessEngine) stringsFromJS(value platformValue) []string { + items := make([]string, value.Length()) + for i := range items { + items[i] = value.Index(i).String() + } + return items +} + +// encodeResult 编码浏览器接口返回结果 +// 入参: result 返回结果 +// 返回: string JSON字符串 +func (e *businessEngine) encodeResult(result apiResult) string { + data, err := json.Marshal(result) + if err != nil { + fallback, _ := json.Marshal(apiResult{OK: false, Error: err.Error()}) + return string(fallback) + } + return string(data) +} + +// editorSummary 获取当前编辑文档状态 +// 返回: editorInfo 文档信息 +func (e *businessEngine) editorSummary() editorInfo { + info := editorInfo{DocumentInfo: e.currentSession.Summary(), Revision: e.currentEditor.Revision(), CanUndo: e.currentEditor.CanUndo(), CanRedo: e.currentEditor.CanRedo()} + if !e.currentSession.doc.Permissions.Edit { + info.EditWarnings = append(info.EditWarnings, "原文件声明不允许编辑") + } + if slices.ContainsFunc(e.currentSession.Reader.OFD.DocBody, func(body ofdgo.DocBody) bool { return body.Signatures != "" }) { + info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效") + } + info.PageCapabilities = make([]uint, e.currentEditor.PageCount()) + for i := range info.PageCapabilities { + capability, _ := e.currentEditor.PageCapabilities(i) + info.PageCapabilities[i] = e.pageCapabilityMask(capability) + } + return info +} + +// pageCapabilityMask 按插入、复制、删除、移动和尺寸的固定顺序编码页面能力,避免重复传输字段名 +// 入参: capability 页面能力 +// 返回: uint 能力位标记 +func (e *businessEngine) pageCapabilityMask(capability ofdgo.PageCapabilities) uint { + var mask uint + for i, enabled := range [...]bool{capability.Insert, capability.Copy, capability.Delete, capability.Move, capability.Resize} { + if enabled { + mask |= 1 << i + } + } + return mask +} + +// restoreEditor 撤销或重做并更新预览 +// 入参: redo 是否重做 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) restoreEditor(redo bool) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + apply := e.currentEditor.Undo + if redo { + apply = e.currentEditor.Redo + } + if !apply() { + return e.editorSummary(), nil + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// editorObjects 提供编辑画布的对象范围、文字排版及原始图片边界 +// 入参: index 页面索引, text 页面文字 +// 返回: []any 对象区域, error 错误信息 +func (e *businessEngine) editorObjects(index int, text *ofdgo.PageText) ([]any, error) { + objects := []any{} + if e.currentEditor == nil { + return objects, nil + } + page, err := e.currentEditor.Page(index) + if err != nil { + return nil, err + } + info, err := e.currentEditor.PageObjectInfo(index) + if err != nil { + return nil, err + } + textBoxes := make(map[string]ofdgo.Box) + for _, run := range text.Runs { + for _, box := range run.Boxes { + if box.W <= 0 || box.H <= 0 { + continue + } + prev, exists := textBoxes[run.ID] + if !exists { + textBoxes[run.ID] = box + continue + } + x, y := math.Min(prev.X, box.X), math.Min(prev.Y, box.Y) + textBoxes[run.ID] = ofdgo.Box{X: x, Y: y, W: math.Max(prev.X+prev.W, box.X+box.W) - x, H: math.Max(prev.Y+prev.H, box.Y+box.H) - y} + } + } + fonts, err := e.currentSession.Reader.Fonts() + if err != nil { + return nil, err + } + fontNames := make(map[string]string, len(fonts)) + for _, font := range fonts { + fontNames[font.ID] = font.FontName + } + for _, layer := range page.Content.Layer { + for order, object := range layer.Objects { + var id string + switch object.Type { + case "TextObject": + id = object.TextObject.ID + case "ImageObject": + id = object.ImageObject.ID + case "PathObject": + id = object.PathObject.ID + case "CompositeObject", "CompositeGraphicUnit": + id = object.CompositeGraphicUnit.ID + default: + continue + } + capability := info[id].Capabilities + var box ofdgo.Box + var contours []ofdgo.ObjectContour + switch object.Type { + case "TextObject": + box = textBoxes[id] + default: + box, contours, err = e.currentSession.Renderer.ObjectGeometry(object, layer.DrawParam) + } + if err != nil { + continue + } + if box.W > 0 && box.H > 0 { + position := info[id].Position + item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, + "capabilities": e.editorCapabilities(capability, object.Type)} + e.editorAppearance(item, object, capability.Paint, e.editorPathScale(object.PathObject)) + if object.Type == "PathObject" { + path := object.PathObject + kind, geometry := path.Shape() + if kind != "" && capability.Update { + item["shape"] = string(kind) + item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H} + } else if outline, err := e.currentSession.Renderer.PathOutline(path); err == nil { + item["outline"] = outline + } + if kind == "" && capability.Update { + if kind, box, matrix := path.ShapeFrame(); kind != "" { + item["oriented"] = map[string]any{"kind": string(kind), "box": e.editorBox(box), "matrix": e.editorMatrix(matrix)} + } + } + } + if object.Type == "PathObject" || object.Type == "ImageObject" || object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + paths := make([]any, len(contours)) + for i, contour := range contours { + paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} + } + item["contours"] = paths + } + if object.Type == "ImageObject" && capability.Update { + full, err := object.ImageObject.ImageBounds() + if err != nil { + return nil, err + } + item["imageBounds"] = e.editorBox(full) + } + if object.Type == "ImageObject" { + item["imageResource"] = object.ImageObject.ResourceID + } + if object.Type == "TextObject" { + item["bounds"] = e.editorBox(box) + value, layout := object.TextObject.TextLayout() + item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size + item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight + item["letterSpacing"] = layout.LetterSpacing + item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent + if capability.Reflow { + frame, err := object.TextObject.TextFrame() + if err != nil { + return nil, err + } + boundary, _ := ofdgo.ParseBox(object.TextObject.Boundary) + matrix := ofdgo.TranslationMatrix(boundary.X, boundary.Y).Multiply(ofdgo.NewMatrix(object.TextObject.CTM)) + inverse, _ := matrix.Invert() + local := inverse.TransformBox(box) + width := math.Max(object.TextObject.Size, local.X+local.W) + if layout.Wrap { + item["paragraphWidth"] = frame.W + } + frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)} + item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": e.editorMatrix(matrix)} + if layout.Wrap { + bounds := matrix.TransformBox(frame) + item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H + } + } + if codes := object.TextObject.TextCode; len(codes) > 1 { + first, _ := strconv.ParseFloat(codes[0].Y, 64) + second, _ := strconv.ParseFloat(codes[1].Y, 64) + item["lineHeight"] = second - first + } + } + if object.Type == "CompositeObject" { + if value, richErr := e.currentEditor.RichText(index, id); richErr == nil { + e.editorRichText(index, item, object, value, fontNames) + } + } + objects = append(objects, item) + } + } + } + for _, annotation := range e.currentSession.Reader.Annots[page.ID] { + if annotation.Visible != nil && !*annotation.Visible { + continue + } + box, contours, err := e.currentSession.Renderer.AnnotationGeometry(annotation) + capabilities := map[string]any{"update": true, "transform": true, "arrange": true, "copy": true, "delete": true, "enter": len(annotation.Appearance.Objects) != 0} + if err != nil { + capabilities["transform"], capabilities["arrange"], capabilities["enter"] = false, false, false + capabilities["reason"], capabilities["reasonCode"] = err.Error(), "invalidObject" + } + if box.W <= 0 || box.H <= 0 { + box, _ = ofdgo.ParseBox(annotation.Appearance.Boundary) + } + paths := make([]any, len(contours)) + for i, contour := range contours { + paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} + } + alpha, mixed := 255, false + for i, object := range annotation.Appearance.Objects { + var value *int + switch object.Type { + case "TextObject": + value = object.TextObject.Alpha + case "PathObject": + value = object.PathObject.Alpha + case "ImageObject": + value = object.ImageObject.Alpha + case "CompositeObject", "CompositeGraphicUnit": + value = object.CompositeGraphicUnit.Alpha + } + current := 255 + if value != nil { + current = *value + } + if i == 0 { + alpha = current + } else if current != alpha { + mixed = true + } + } + item := map[string]any{"id": "annotation:" + annotation.ID, "type": "Annotation", "annotationType": annotation.Type, + "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "remark": annotation.Remark, "creator": annotation.Creator, + "alpha": alpha, "alphaMixed": mixed, "capabilities": capabilities} + if len(annotation.Appearance.Objects) == 0 && strings.TrimSpace(annotation.Remark) != "" { + item["note"] = true + } else { + item["contours"] = paths + } + objects = append(objects, item) + } + return objects, nil +} + +// changeAnnotations 修改注解说明、平移或删除同页注解,复用会话历史与预览 +// 入参: args 页面索引、注解标识数组、操作及参数 +// 返回: any 编辑状态, error 错误信息 +func (e *businessEngine) changeAnnotations(args []platformValue) (any, error) { + return e.changeObjects(func() error { + ids := e.stringsFromJS(args[1]) + for i := range ids { + ids[i] = strings.TrimPrefix(ids[i], "annotation:") + } + switch args[2].String() { + case "update": + if len(ids) != 1 { + return fmt.Errorf("select one annotation to update") + } + return e.currentEditor.UpdateAnnotation(args[0].Int(), ids[0], args[3].String(), args[4].String()) + case "move": + return e.currentEditor.MoveAnnotations(args[0].Int(), ids, args[3].Float(), args[4].Float()) + case "delete": + return e.currentEditor.DeleteAnnotations(args[0].Int(), ids) + default: + return fmt.Errorf("unsupported annotation action %q", args[2].String()) + } + }) +} + +// readAnnotation 获取编辑面板所需的注解内容,不修改文件 +// 入参: args 页面索引与注解标识 +// 返回: any 面板数据, error 错误信息 +func (e *businessEngine) readAnnotation(args []platformValue) (any, error) { + annotation, err := e.currentEditor.Annotation(args[0].Int(), strings.TrimPrefix(args[1].String(), "annotation:")) + if err != nil { + return nil, err + } + texts, err := e.currentEditor.AnnotationTexts(args[0].Int(), annotation.ID) + if err != nil { + return nil, err + } + info := map[string]any{"texts": texts, "remark": annotation.Remark, "creator": annotation.Creator} + if annotation.Type == "Link" { + info["linkKind"] = "keep" + link, err := e.currentEditor.AnnotationLink(args[0].Int(), annotation.ID) + if err == nil && link.URI != nil { + info["linkKind"], info["linkURI"], info["linkBase"] = "uri", link.URI.URI, link.URI.Base + info["linkTarget"] = link.URI.Target + } else if err == nil && link.Goto != nil && link.Goto.Dest != nil { + if index := e.currentSession.doc.PageIndex(link.Goto.Dest.PageID); index >= 0 { + info["linkKind"], info["linkPage"] = "page", index+1 + } + } + } + return info, nil +} + +// writeAnnotation 原子创建多页注解或修改现有链接、重复文字 +// 入参: args 当前页面、JSON选项、字体字节、图片字节 +// 返回: any 文档信息及新选区, error 错误信息 +func (e *businessEngine) writeAnnotation(args []platformValue) (any, error) { + var options annotationOptions + if err := json.Unmarshal([]byte(args[1].String()), &options); err != nil { + return nil, err + } + if options.Kind == "link" && options.ID != "" && options.Keep { + return e.editorSummary(), nil + } + page := args[0].Int() + var ids []string + result, err := e.changeObjects(func() error { + font, resource := "", "" + if !args[2].IsNull() { + data, err := e.bytesFromJS(args[2]) + if err != nil { + return err + } + font, err = e.currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + } + if !args[3].IsNull() { + data, err := e.bytesFromJS(args[3]) + if err != nil { + return err + } + resource, err = e.currentEditor.AddImage(data) + if err != nil { + return err + } + size, err := e.currentEditor.ImageSize(resource) + if err != nil { + return err + } + options.Height = options.Width * float64(size.Y) / float64(size.X) + } + if options.Kind == "replace" { + style := ofdgo.TextStyle{Font: font} + _, err := e.currentEditor.ReplaceAnnotationText(page, options.ID, options.Old, options.Text, style) + return err + } + target := ofdgo.AnnotationLink{URI: &ofdgo.URI{URI: options.URI, Base: options.Base}} + if options.Target > 0 { + if options.Target > e.currentEditor.PageCount() { + return fmt.Errorf("destination page is out of range") + } + page, err := e.currentEditor.Page(options.Target - 1) + if err != nil { + return err + } + target = ofdgo.AnnotationLink{Dest: &ofdgo.Dest{Type: "Fit", PageID: page.ID}} + } + if options.Kind == "link" && options.ID != "" { + if target.URI != nil { + previous, err := e.currentEditor.AnnotationLink(page, options.ID) + if err != nil { + return err + } + if previous.URI != nil { + target.URI.Target = previous.URI.Target + } + } + return e.currentEditor.UpdateAnnotationLink(page, options.ID, target) + } + if options.Width <= 0 || options.Height <= 0 || options.Alpha < 0 || options.Alpha > 255 { + return fmt.Errorf("invalid annotation dimensions or opacity") + } + pages, err := ofdgo.ParsePageRange(options.Pages, e.currentEditor.PageCount()) + if err != nil { + return err + } + if options.Kind == "watermark" { + boundary := fmt.Sprintf("0 0 %g %g", options.Width, options.Height) + var object ofdgo.GraphicObject + if resource != "" { + object = ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: boundary, Alpha: &options.Alpha}} + } else { + text := ofdgo.TextObject{Font: font, Size: options.Size, Boundary: boundary, Alpha: &options.Alpha} + if err := e.currentEditor.LayoutText(&text, options.Text, ofdgo.TextLayout{Align: "center"}); err != nil { + return err + } + if err := e.setTextColor(&text, options.Color); err != nil { + return err + } + object = ofdgo.GraphicObject{Type: "TextObject", TextObject: text} + } + added, err := e.currentEditor.AddWatermarks(e.operationContext(), pages, object, ofdgo.WatermarkOptions{Creator: options.Creator, Angle: options.Angle, Tile: options.Tile, Area: options.Area}) + if err != nil { + return err + } + for i, index := range pages { + if index == page { + ids = append(ids, "annotation:"+added[i]) + } + } + return nil + } + for _, index := range pages { + box := ofdgo.Box{X: options.X, Y: options.Y, W: options.Width, H: options.Height} + var id string + if options.Kind == "link" { + id, err = e.currentEditor.AddLinkAnnotation(index, box, target, options.Creator) + } else { + annotation := ofdgo.Annotation{Creator: options.Creator, Appearance: ofdgo.Appearance{Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)}} + switch options.Kind { + case "note": + annotation.Type, annotation.Remark = "Path", options.Text + case "stamp": + annotation.Type = "Stamp" + if resource == "" { + return fmt.Errorf("stamp requires an image") + } + object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), CTM: fmt.Sprintf("%g 0 0 %g 0 0", box.W, box.H), Alpha: &options.Alpha}} + angle := options.Angle * math.Pi / 180 + matrix := ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle))) + matrix = ofdgo.TranslationMatrix(box.W/2, box.H/2).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-box.W/2, -box.H/2)) + object.ImageObject.CTM = matrix.Multiply(ofdgo.NewMatrix(object.ImageObject.CTM)).String() + annotation.Appearance.Objects = []ofdgo.GraphicObject{object} + default: + return fmt.Errorf("unsupported annotation kind %q", options.Kind) + } + id, err = e.currentEditor.AddAnnotation(index, annotation) + } + if err != nil { + return err + } + if index == page { + ids = append(ids, "annotation:"+id) + } + } + return nil + }) + if err != nil { + return nil, err + } + return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: ids}, nil +} + +// insertInk 将一笔采样写为一个标准矢量对象,复用内部插入与撤销逻辑 +// 入参: args 页面、JSON采样、线宽、压感开关、颜色及可选父路径 +// 返回: any 文档和选区, error 错误信息 +func (e *businessEngine) insertInk(args []platformValue) (any, error) { + var points []ofdgo.InkPoint + if err := json.Unmarshal([]byte(args[1].String()), &points); err != nil { + return nil, err + } + path, err := ofdgo.NewInk(points, args[2].Float(), args[3].Bool()) + if err != nil { + return nil, err + } + if path.Fill != nil && *path.Fill { + err = e.setEditorColor(path.FillColor, args[4].String()) + } else { + err = e.setEditorColor((*ofdgo.FillColor)(path.StrokeColor), args[4].String()) + } + if err != nil { + return nil, err + } + return e.insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[5:]) +} + +// splitEditorSelection 区分页面对象与注解标识,不以外观大小或类型猜测 +// 入参: ids 画布选区 +// 返回: []string 正文标识, []string 注解标识 +func (e *businessEngine) splitEditorSelection(ids []string) ([]string, []string) { + var objects, annotations []string + for _, id := range ids { + if value, ok := strings.CutPrefix(id, "annotation:"); ok { + annotations = append(annotations, value) + } else { + objects = append(objects, id) + } + } + return objects, annotations +} + +// changeSelection 原子处理正文和注解混选,保持绘制顺序及统一历史 +// 入参: args 页面索引、标识列表、操作与参数 +// 返回: any 文档与新选区, error 错误信息 +func (e *businessEngine) changeSelection(args []platformValue) (any, error) { + var selected []string + result, err := e.changeAtomicObjects(func() error { + page, operation := args[0].Int(), args[2].String() + objects, annotations := e.splitEditorSelection(e.stringsFromJS(args[1])) + switch operation { + case "align", "distribute": + return e.arrangeEditorSelection(page, e.stringsFromJS(args[1]), operation, args[3].String()) + case "delete": + return e.currentEditor.DeleteSelection(page, objects, annotations) + case "copy": + clipboard, err := e.captureEditorSelection(page, e.stringsFromJS(args[1])) + if err != nil { + return err + } + selected, err = e.pasteEditorSelection(page, clipboard, args[3].Float(), args[4].Float()) + return err + case "style": + return e.currentEditor.StyleSelection(page, objects, annotations, e.objectStyle(args[3])) + case "erase": + return e.currentEditor.EraseSelection(page, objects, annotations, ofdgo.Box{ + X: args[3].Float(), Y: args[4].Float(), W: args[5].Float(), H: args[6].Float(), + }) + case "erasePath": + return e.currentEditor.EraseSelectionPath(page, objects, annotations, e.pointsFromJS(args[3])) + case "rotate": + return e.currentEditor.RotateSelection(page, objects, annotations, args[3].Int()) + case "flip": + return e.currentEditor.FlipSelection(page, objects, annotations, args[3].String()) + case "resize": + return e.currentEditor.ResizeSelection(page, objects, annotations, ofdgo.Box{ + X: args[3].Float(), Y: args[4].Float(), W: args[5].Float(), H: args[6].Float(), + }) + case "transform": + dx, dy, scale := args[3].Float(), args[4].Float(), args[5].Float() + if scale <= 0 { + return fmt.Errorf("scale must be positive") + } + matrix := ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", scale, scale, dx, dy)) + return e.currentEditor.TransformSelection(page, objects, annotations, matrix) + default: + return fmt.Errorf("unsupported mixed selection operation %q", operation) + } + }) + if err != nil || selected == nil { + return result, err + } + return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil +} + +// arrangeEditorSelection 对正文和注解统一对齐或分布,不改变各自绘制层级 +// 入参: page 页面, ids 标识, operation 操作, direction 方向 +// 返回: error 错误信息 +func (e *businessEngine) arrangeEditorSelection(page int, ids []string, operation, direction string) error { + objects, annotations := e.splitEditorSelection(ids) + switch operation { + case "align": + return e.currentEditor.AlignSelection(page, objects, annotations, direction) + case "distribute": + return e.currentEditor.DistributeSelection(page, objects, annotations, direction) + default: + return fmt.Errorf("invalid arrangement operation %q", operation) + } +} + +// captureEditorSelection 捕获正文与注解的独立副本 +// 入参: page 页面索引, ids 画布标识 +// 返回: *editorClipboard 快照, error 错误信息 +func (e *businessEngine) captureEditorSelection(page int, ids []string) (*editorClipboard, error) { + objects, annotations := e.splitEditorSelection(ids) + result := &editorClipboard{} + var err error + result.selection, err = e.currentEditor.CaptureSelection(page, objects, annotations) + return result, err +} + +// pasteEditorSelection 原子粘贴正文和注解,共享字体与图片数据 +// 入参: page 目标页, clipboard 快照, dx 横向位移, dy 纵向位移 +// 返回: []string 新画布标识, error 错误信息 +func (e *businessEngine) pasteEditorSelection(page int, clipboard *editorClipboard, dx, dy float64) ([]string, error) { + ids, annotations, err := e.currentEditor.PasteSelection(page, clipboard.selection, dx, dy) + for _, id := range annotations { + ids = append(ids, "annotation:"+id) + } + return ids, err +} + +// editorCapabilities 统一顶层与内部对象的操作能力和受限原因 +// 入参: capability 库层能力, kind 对象类型 +// 返回: map[string]any 前端能力 +func (e *businessEngine) editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any { + result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode), + "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "stretch": capability.Stretch, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} + result["textContent"] = capability.TextContent + result["rewriteText"] = capability.RewriteText + if missing := capability.MissingGlyphs; missing != nil { + result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} + } + return result +} + +// editorAppearance 提供对象自身透明度及有效绘制外观,不混入父对象透明度 +// 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率 +func (e *businessEngine) editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) { + links := 0 + for _, action := range object.Actions() { + if action.Event == "CLICK" && (action.URI != nil || action.Goto != nil) { + links++ + if action.Goto != nil && action.Goto.Dest != nil { + gotoAction, dest := *action.Goto, *action.Goto.Dest + dest.PageID = e.resolvedPageID(dest.PageID) + gotoAction.Dest, action.Goto = &dest, &gotoAction + } + if data, err := json.Marshal(action); err == nil { + item["link"] = string(data) + } + } + } + item["linkCount"] = links + var alpha *int + var fill, stroke *ofdgo.FillColor + switch object.Type { + case "TextObject": + fill = object.TextObject.FillColor + stroke = (*ofdgo.FillColor)(object.TextObject.StrokeColor) + alpha = object.TextObject.Alpha + if paint { + item["color"] = e.editorColorHex(object.TextObject.FillColor) + } + case "ImageObject": + alpha = object.ImageObject.Alpha + item["imageBorder"] = object.ImageObject.Border != nil + item["borderColor"] = "#000000" + if border := object.ImageObject.Border; border != nil { + item["borderColor"] = e.editorColorHex((*ofdgo.FillColor)(border.BorderColor)) + } + if data, err := json.Marshal(object.ImageObject.Border); err == nil { + item["borderStyle"] = string(data) + } + case "PathObject": + path := object.PathObject + fill = path.FillColor + stroke = (*ofdgo.FillColor)(path.StrokeColor) + if box, err := ofdgo.ParseBox(path.Boundary); err == nil { + item["paintSize"] = []any{box.W, box.H} + } + alpha = path.Alpha + item["dashPattern"], item["cap"], item["join"], item["dashOffset"] = path.DashPattern, path.Cap, path.Join, 0.0 + if path.DashOffset != nil { + item["dashOffset"] = *path.DashOffset + } + if item["scoped"] == true { + item["dashPattern"] = e.editorDashPattern(path.DashPattern, scale) + if path.DashOffset != nil { + item["dashOffset"] = *path.DashOffset * scale + } + } + if paint { + item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke + item["fillColor"], item["strokeColor"] = e.editorColorHex(path.FillColor), e.editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) + item["lineWidth"] = path.LineWidth * scale + } + case "CompositeObject", "CompositeGraphicUnit": + alpha = object.CompositeGraphicUnit.Alpha + } + item["alpha"] = 255 + if alpha != nil { + item["alpha"] = *alpha + } + for key, paint := range map[string]*ofdgo.FillColor{"fill": fill, "stroke": stroke} { + if paint == nil { + continue + } + if p := paint.Pattern; p != nil { + item[key+"Pattern"] = map[string]any{"width": p.Width, "height": p.Height, "xStep": p.XStep, "yStep": p.YStep, "ctm": p.CTM} + } + var segments []ofdgo.ShdSegment + if paint.AxialShd != nil { + segments = paint.AxialShd.Segment + } else if paint.RadialShd != nil { + segments = paint.RadialShd.Segment + } + if len(segments) == 0 { + continue + } + stops, err := e.currentEditor.GradientStops(segments, nil) + if err != nil { + continue + } + values := make([]any, len(stops)) + for i, stop := range stops { + c := segments[i].Color + values[i] = map[string]any{"position": stop.Offset * 100, "color": e.editorColorHex(&ofdgo.FillColor{Value: c.Value, Index: c.Index, ColorSpace: c.ColorSpace}), "transparency": (1 - float64(stop.Color.A)/255) * 100} + } + if data, err := json.Marshal(paint); err == nil { + item[key+"Gradient"] = map[string]any{"paint": string(data), "stops": values} + } + } +} + +// compositePath 解析画布复合对象路径 +// 入参: value 顶层标识与逐层序号 +// 返回: ofdgo.ObjectPath 库层路径, error 错误信息 +func (e *businessEngine) compositePath(value string) (ofdgo.ObjectPath, error) { + parts := strings.Split(value, "/") + path := ofdgo.ObjectPath{ID: parts[0]} + if id, ok := strings.CutPrefix(path.ID, "annotation:"); ok { + path.ID, path.Annotation = "", id + } + for _, part := range parts[1:] { + index, err := strconv.Atoi(part) + if err != nil || index < 0 { + return path, fmt.Errorf("invalid composite path") + } + path.Children = append(path.Children, index) + } + return path, nil +} + +// compositeObjects 获取内部编辑范围,不重新渲染页面或传输图片 +// 入参: args 页面索引与复合路径 +// 返回: any 画布对象列表, error 错误信息 +func (e *businessEngine) compositeObjects(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + key := args[1].String() + path, err := e.compositePath(key) + if err != nil { + return nil, err + } + members, err := e.currentEditor.CompositeObjects(args[0].Int(), path) + if err != nil { + return nil, err + } + fonts, err := e.currentSession.Reader.Fonts() + if err != nil { + return nil, err + } + fontNames := make(map[string]string, len(fonts)) + for _, font := range fonts { + fontNames[font.ID] = font.FontName + } + objects := make([]any, 0, len(members)) + for i, member := range members { + box := member.Bounds + if box.W <= 0 || box.H <= 0 { + continue + } + item := map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": member.Position.Index, "count": member.Position.Count, "container": key + ":" + member.Position.Container, "scoped": true, "contours": member.Contours, + "capabilities": e.editorCapabilities(member.Capabilities, member.Object.Type)} + e.editorAppearance(item, member.Object, member.Capabilities.Paint, member.StrokeScale) + if member.Object.Type == "ImageObject" { + item["imageResource"] = member.Object.ImageObject.ResourceID + } + if kind, geometry := member.Shape(); kind != "" { + item["shape"], item["geometry"] = string(kind), e.editorBox(geometry) + } else if kind, box, matrix := member.ShapeFrame(); kind != "" { + item["oriented"] = map[string]any{"kind": string(kind), "box": e.editorBox(box), "matrix": e.editorMatrix(matrix)} + } + if member.Object.Type == "TextObject" { + object := member.Object.TextObject + value, layout := object.TextLayout() + item["text"] = value + item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight + item["letterSpacing"] = layout.LetterSpacing + item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent + item["font"], item["fontName"], item["size"] = object.Font, fontNames[object.Font], object.Size + item["bounds"] = e.editorBox(box) + if member.Capabilities.Reflow { + inverse, _ := member.Matrix.Invert() + local := inverse.TransformBox(box) + width := math.Max(object.Size, local.X+local.W) + if layout.Wrap { + frame, err := object.TextFrame() + if err != nil { + return nil, err + } + item["paragraphWidth"] = frame.W + } + frame := ofdgo.Box{W: width, H: math.Max(object.Size, local.Y+local.H)} + item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": e.editorMatrix(member.Matrix)} + if layout.Wrap { + bounds := member.Matrix.TransformBox(frame) + item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H + } + } + } + if member.Capabilities.CropImage { + item["imageBounds"] = e.editorBox(box) + item["cropped"] = member.Cropped + } + objects = append(objects, item) + } + return objects, nil +} + +// changeCompositeObjects 提交内部选区操作,结构变化后返回新的选区路径 +// 入参: args 页面索引、父路径、成员路径列表、操作及参数 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) changeCompositeObjects(args []platformValue) (any, error) { + var selected []int + result, err := e.changeObjects(func() error { + key := args[1].String() + path, err := e.compositePath(key) + if err != nil { + return err + } + indexes, err := e.compositeIndexes(key, e.stringsFromJS(args[2])) + if err != nil { + return err + } + page := args[0].Int() + operation := args[3].String() + switch operation { + case "group": + if args[4].Bool() { + if len(indexes) != 1 { + return fmt.Errorf("ungrouping requires one member") + } + selected, err = e.currentEditor.UngroupCompositeObject(page, path, indexes[0]) + } else { + var index int + index, err = e.currentEditor.GroupCompositeObjects(page, path, indexes) + selected = []int{index} + } + return err + case "pasteStyle": + if e.copiedStyle == nil { + return fmt.Errorf("style clipboard is empty") + } + return e.currentEditor.CopyCompositeStyle(page, path, indexes, *e.copiedStyle) + case "reshape", "line": + if len(indexes) != 1 { + return fmt.Errorf("shape editing requires one member") + } + box := ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()} + if operation == "line" { + return e.currentEditor.ReshapeCompositeLine(page, path, indexes[0], ofdgo.ShapeKind(args[8].String()), box) + } + if len(args) > 8 && args[8].Bool() { + return e.currentEditor.ReshapeCompositeFrame(page, path, indexes[0], box) + } + return e.currentEditor.ReshapeCompositeObject(page, path, indexes[0], box) + case "erase": + return e.currentEditor.EraseCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) + case "erasePath": + return e.currentEditor.EraseCompositeObjectsPath(page, path, indexes, e.pointsFromJS(args[4])) + case "delete": + return e.currentEditor.DeleteCompositeObjects(page, path, indexes) + case "copy": + selected, err = e.currentEditor.CopyCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float()) + return err + case "order": + selected, err = e.currentEditor.OrderCompositeObjects(page, path, indexes, args[4].String()) + return err + case "text", "textStyle": + return e.changeCompositeText(page, path, indexes, operation, args[4:]) + case "layout": + if len(indexes) != 1 { + return fmt.Errorf("text layout requires one member") + } + layout := ofdgo.TextLayout{AutoBidi: true, Wrap: args[6].Bool(), Align: args[7].String(), LineHeight: args[8].Float(), LetterSpacing: args[9].Float(), LeftIndent: args[10].Float(), RightIndent: args[11].Float(), FirstLineIndent: args[12].Float()} + if !args[4].IsNull() { + return e.currentEditor.ResizeCompositeTextFrame(page, path, indexes[0], args[4].Float(), args[5].Float(), layout) + } + member, err := e.currentEditor.CompositeObject(page, path, indexes[0]) + if err != nil { + return err + } + value, _ := member.Object.TextObject.TextLayout() + return e.currentEditor.LayoutCompositeText(page, path, indexes[0], value, layout) + case "fit", "resetCrop": + if len(indexes) != 1 { + return fmt.Errorf("image operation requires one member") + } + if operation == "fit" { + return e.currentEditor.FitCompositeImage(page, path, indexes[0], args[4].String()) + } + return e.currentEditor.ResetCompositeImageCrop(page, path, indexes[0]) + case "crop": + if len(indexes) != 1 { + return fmt.Errorf("image crop requires one member") + } + return e.currentEditor.CropCompositeImage(page, path, indexes[0], ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) + case "image": + if len(indexes) != 1 { + return fmt.Errorf("image replacement requires one member") + } + data, err := e.bytesFromJS(args[4]) + if err != nil { + return err + } + return e.currentEditor.ReplaceCompositeImage(page, path, indexes[0], data) + case "transform": + return e.currentEditor.TransformCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float(), args[6].Float()) + case "rotate": + return e.currentEditor.RotateCompositeObjects(page, path, indexes, args[4].Int()) + case "flip": + return e.currentEditor.FlipCompositeObjects(page, path, indexes, args[4].String()) + case "resize": + return e.currentEditor.ResizeCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) + case "align": + return e.currentEditor.AlignCompositeObjects(page, path, indexes, args[4].String()) + case "distribute": + return e.currentEditor.DistributeCompositeObjects(page, path, indexes, args[4].String()) + case "style": + return e.currentEditor.StyleCompositeObjects(page, path, indexes, e.objectStyle(args[4])) + case "paint", "textColor": + style := ofdgo.ObjectStyle{} + if operation == "textColor" { + style.FillColor = &ofdgo.FillColor{} + if err := e.setEditorColor(style.FillColor, args[4].String()); err != nil { + return err + } + } else { + if !args[4].IsNull() { + value := args[4].Bool() + style.Fill = &value + } + if !args[6].IsNull() { + value := args[6].Bool() + style.Stroke = &value + } + var colors [2]*ofdgo.FillColor + for i := range colors { + if !args[5+i*2].IsNull() { + colors[i] = &ofdgo.FillColor{} + if err := e.setEditorColor(colors[i], args[5+i*2].String()); err != nil { + return err + } + } + } + style.FillColor, style.StrokeColor = colors[0], (*ofdgo.StrokeColor)(colors[1]) + if !args[8].IsNull() { + value := args[8].Float() + style.LineWidth = &value + } + } + return e.currentEditor.StyleCompositeObjects(page, path, indexes, style) + default: + return fmt.Errorf("unsupported composite operation") + } + }) + if err != nil { + return nil, err + } + if selected == nil { + return result, nil + } + doc := editorSelectionInfo{editorInfo: result.(editorInfo)} + for _, index := range selected { + doc.SelectedIDs = append(doc.SelectedIDs, fmt.Sprintf("%s/%d", args[1].String(), index)) + } + return doc, nil +} + +// compositeIndexes 将画布成员路径转换为同一范围的序号 +// 入参: key 父路径, ids 成员路径 +// 返回: []int 成员序号, error 错误信息 +func (e *businessEngine) compositeIndexes(key string, ids []string) ([]int, error) { + indexes := make([]int, 0, len(ids)) + for _, id := range ids { + part, found := strings.CutPrefix(id, key+"/") + if !found { + return nil, fmt.Errorf("selection is outside composite") + } + i, err := strconv.Atoi(part) + if err != nil || i < 0 { + return nil, fmt.Errorf("invalid composite member") + } + indexes = append(indexes, i) + } + return indexes, nil +} + +// changeCompositeText 修改内部文字,嵌入字体前校验选区用字 +// 入参: page 页面索引, path 父路径, indexes 成员序号, operation 操作, args 内容及样式 +// 返回: error 错误信息 +func (e *businessEngine) changeCompositeText(page int, path ofdgo.ObjectPath, indexes []int, operation string, args []platformValue) error { + var value *string + if operation == "text" { + if len(indexes) != 1 { + return fmt.Errorf("text editing requires one member") + } + if !args[0].IsNull() { + text := args[0].String() + value = &text + } + args = args[1:] + } + style := ofdgo.TextStyle{Size: args[1].Float()} + if len(args) > 3 { + style.Reflow = args[3].Bool() + } + if !args[0].IsNull() { + data, err := e.bytesFromJS(args[0]) + if err != nil { + return err + } + members, err := e.currentEditor.CompositeObjectsAt(page, path, indexes) + if err != nil { + return err + } + var text strings.Builder + for _, member := range members { + if !member.Capabilities.ReplaceFont { + return fmt.Errorf("composite font replacement is not supported") + } + if value != nil { + text.WriteString(*value) + } else { + text.WriteString(member.Object.TextObject.Text()) + } + } + if err := e.checkFontGlyphs(data, text.String()); err != nil { + return err + } + style.Font, err = e.currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + } + if !args[2].IsNull() { + var fill ofdgo.FillColor + if err := e.setEditorColor(&fill, args[2].String()); err != nil { + return err + } + style.Color = fill.Value + } + if value != nil { + return e.currentEditor.UpdateCompositeText(page, path, indexes[0], *value, style) + } + return e.currentEditor.StyleCompositeText(page, path, indexes, style) +} + +// editorFont 读取对象编辑使用的字体,匹配的外部字体不嵌入文档 +// 入参: args 字体资源标识 +// 返回: any 字体数据, error 错误信息 +func (e *businessEngine) editorFont(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + data, err := e.currentEditor.FontData(args[0].String()) + if err != nil { + return nil, err + } + if len(args) > 1 && args[1].Bool() { + _, data, _, _, err = ofdgo.FixFontDataAggressive(data, true, false) + if err != nil { + return nil, err + } + } + return successResult(map[string]any{"bytes": bytesToJS(data)}), nil +} + +// editorObjectAt 读取页面对象或复合对象成员的有效样式快照 +// 入参: page 页面索引, key 界面对象路径 +// 返回: ofdgo.GraphicObject 对象快照, error 错误信息 +func (e *businessEngine) editorObjectAt(page int, key string) (ofdgo.GraphicObject, error) { + if e.currentEditor == nil { + return ofdgo.GraphicObject{}, fmt.Errorf("no document is being edited") + } + if separator := strings.LastIndex(key, "/"); separator >= 0 { + path, err := e.compositePath(key[:separator]) + if err != nil { + return ofdgo.GraphicObject{}, err + } + member, err := strconv.Atoi(key[separator+1:]) + if err != nil { + return ofdgo.GraphicObject{}, err + } + members, err := e.currentEditor.CompositeObjectsAt(page, path, []int{member}) + if err != nil { + return ofdgo.GraphicObject{}, err + } + return members[0].Object, nil + } + return e.currentEditor.Object(page, key) +} + +// previewText 生成保留原定位的输入预览,不修改文档或历史 +// 入参: args 页码、对象路径、新文字、可选字体数据、字号及重排标记 +// 返回: any 定位段及字体样式, error 错误信息 +func (e *businessEngine) previewText(args []platformValue) (any, error) { + key := args[1].String() + object, err := e.editorObjectAt(args[0].Int(), key) + if err != nil { + return nil, err + } + text := object.TextObject + if object.Type != "TextObject" { + return nil, fmt.Errorf("object is not text") + } + editor := e.currentEditor + fontID := "" + if len(args) > 3 && !args[3].IsNull() { + data, err := e.bytesFromJS(args[3]) + if err != nil { + return nil, err + } + editor = ofdgo.NewEditor() + fontID, err = editor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return nil, err + } + text.Font = "" + } + if len(args) > 4 && args[4].Float() != text.Size || len(args) > 5 && args[5].Bool() { + if fontID != "" { + text.Font = fontID + } + var x, y float64 + if strings.Contains(key, "/") { + x, _ = strconv.ParseFloat(text.TextCode[0].X, 64) + y, _ = strconv.ParseFloat(text.TextCode[0].Y, 64) + } + text.Size = args[4].Float() + _, layout := text.TextLayout() + if err := editor.LayoutText(&text, args[2].String(), layout); err != nil { + return nil, err + } + if strings.Contains(key, "/") { + baseline, _ := strconv.ParseFloat(text.TextCode[0].Y, 64) + for i := range text.TextCode { + code := &text.TextCode[i] + cx, _ := strconv.ParseFloat(code.X, 64) + cy, _ := strconv.ParseFloat(code.Y, 64) + code.X, code.Y = fmt.Sprint(cx+x), fmt.Sprint(cy+y-baseline) + } + } + } else if err := editor.RewriteTextWithFont(&text, args[2].String(), fontID); err != nil { + return nil, err + } + var runs []any + var x, y float64 + value, offset := []rune(text.Text()), 0 + for _, code := range text.TextCode { + if code.X != "" { + x, _ = strconv.ParseFloat(code.X, 64) + } + if code.Y != "" { + y, _ = strconv.ParseFloat(code.Y, 64) + } + line := offset < len(value) && value[offset] == '\n' + if line { + offset++ + } + content := (ofdgo.TextObject{TextCode: []ofdgo.TextCode{code}}).Text() + code.X, code.Y = fmt.Sprint(x), fmt.Sprint(y) + positioner := ofdgo.NewTextPositioner(code) + positions := make([][2]float64, len([]rune(content))) + for i := range positions { + point := positioner.Next() + positions[i] = [2]float64{point.X, point.Y} + } + runs = append(runs, map[string]any{"text": content, "x": x, "y": y, "positions": positions, "line": line}) + offset += len(positions) + } + scale := text.HScale + if scale == 0 { + scale = 1 + } + glyphs := make(map[int]string) + if fontID == "" { + for _, transform := range text.CGTransform { + if transform.CodeCount != transform.GlyphCount { + continue + } + for i, value := range strings.Fields(transform.Glyphs) { + glyph, err := strconv.ParseUint(value, 10, 16) + if err != nil { + return nil, err + } + glyphs[transform.CodePosition+i] = string(rune(0xF0000 + glyph)) + } + } + } + return map[string]any{"runs": runs, "glyphs": glyphs, "scale": scale, "vscale": text.VScale, "rotation": text.CharDirection, "weight": text.Weight, "italic": text.Italic}, nil +} + +// readTextSelection 将画布路径转换为同一容器的库层文字选区 +// 入参: ids 图元路径列表 +// 返回: ofdgo.TextSelection 文字选区, error 路径错误 +func (e *businessEngine) readTextSelection(ids []string) (ofdgo.TextSelection, error) { + selection := ofdgo.TextSelection{IDs: ids} + if e.currentEditor == nil { + return selection, fmt.Errorf("no document is being edited") + } + if len(ids) == 0 { + return selection, fmt.Errorf("text selection is empty") + } + if index := strings.LastIndex(ids[0], "/"); index >= 0 { + key := ids[0][:index] + path, err := e.compositePath(key) + if err != nil { + return selection, err + } + indexes, err := e.compositeIndexes(key, ids) + return ofdgo.TextSelection{Root: &path, Indexes: indexes}, err + } + for _, id := range ids { + if strings.Contains(id, "/") { + return selection, fmt.Errorf("text selection crosses containers") + } + } + return selection, nil +} + +// textSelection 分析联合文字并加载预览所需字体,不改变文档资源 +// 入参: args 页码和图元路径列表 +// 返回: any 文字选区、定位预览和字体, error 分析错误 +func (e *businessEngine) textSelection(args []platformValue) (any, error) { + selection, err := e.readTextSelection(e.stringsFromJS(args[1])) + if err != nil { + return nil, err + } + analysis, err := e.currentEditor.AnalyzeTextSelection(args[0].Int(), selection) + if err != nil { + return nil, err + } + source, err := e.textSelectionSource(analysis.Parts, analysis.Bounds, analysis.Text, true) + if err != nil { + return nil, err + } + first := analysis.Parts[0].TextObject + _, origin := analysis.Parts[0].Matrix.Transform(0, 0) + _, unit := analysis.Parts[0].Matrix.Transform(0, 1) + item := e.editorBox(analysis.Bounds) + item["text"], item["font"], item["size"] = analysis.Text, first.Font, first.Size*(unit-origin) + item["color"] = e.richTextColorHex(analysis.Parts[0].Color) + item["alpha"] = 255 + if first.Alpha != nil { + item["alpha"] = *first.Alpha + } + item["capabilities"] = map[string]any{"textContent": true, "rewriteText": analysis.Replace, "replaceFont": true, "paint": true, "reflow": analysis.Reflow, "layoutKnown": false} + item["textFrame"] = map[string]any{"width": analysis.Bounds.W, "height": analysis.Bounds.H, "matrix": e.editorMatrix(ofdgo.TranslationMatrix(analysis.Bounds.X, analysis.Bounds.Y))} + available, err := e.currentSession.Reader.Fonts() + if err != nil { + return nil, err + } + for _, font := range available { + if font.ID == first.Font { + item["fontName"] = font.FontName + break + } + } + data, err := json.Marshal(map[string]any{"item": item, "source": source}) + if err != nil { + return nil, err + } + result := platformParseJSON(data) + fonts := []any{} + seen := make(map[string]bool) + for _, part := range analysis.Parts { + id := part.TextObject.Font + if seen[id] { + continue + } + seen[id] = true + data, err := e.currentEditor.FontData(id) + if err != nil { + return nil, err + } + _, data, _, _, err = ofdgo.FixFontDataAggressive(data, true, false) + if err != nil { + return nil, err + } + font := map[string]any{} + font["id"] = id + font["bytes"] = bytesToJS(data) + fonts = append(fonts, font) + } + result["fonts"] = fonts + return successResult(result), nil +} + +// textSelectionSource 将库层逐字定位转换为选区局部坐标及分段字体样式 +// 入参: parts 预览文字图元, box 原始选区边界, value 完整原文, mapped 是否使用原字形映射 +// 返回: map[string]any 定位预览, error 字形映射错误 +func (e *businessEngine) textSelectionSource(parts []ofdgo.TextSelectionPart, box ofdgo.Box, value string, mapped bool) (map[string]any, error) { + runs := make([]any, 0, len(parts)) + glyphs := make(map[int]string) + offset := 0 + logical, cursor := []rune(value), 0 + for _, part := range parts { + if part.Deleted { + continue + } + text := part.TextObject + originX, originY := part.Matrix.Transform(0, 0) + xAxis, _ := part.Matrix.Transform(1, 0) + _, yAxis := part.Matrix.Transform(0, 1) + sx, sy := xAxis-originX, yAxis-originY + scale := text.HScale + if scale == 0 { + scale = 1 + } + for _, transform := range text.CGTransform { + if !mapped { + break + } + if transform.CodeCount != transform.GlyphCount { + return nil, fmt.Errorf("text selection contains ambiguous glyph mapping") + } + for i, value := range strings.Fields(transform.Glyphs) { + glyph, err := strconv.ParseUint(value, 10, 16) + if err != nil { + return nil, err + } + glyphs[offset+transform.CodePosition+i] = string(rune(0xF0000 + glyph)) + } + } + alpha := 255 + if text.Alpha != nil { + alpha = int(*text.Alpha) + } + chars := []rune(strings.ReplaceAll(text.Text(), "\n", "")) + if len(chars) != len(part.Points) { + return nil, fmt.Errorf("text selection positions do not match characters") + } + for i, char := range chars { + x, y := part.Matrix.Transform(part.Points[i].X, part.Points[i].Y) + line := false + for cursor < len(logical) && logical[cursor] == '\n' { + if line || len(runs) == 0 { + runs = append(runs, map[string]any{"text": "", "x": x - box.X, "y": y - box.Y - text.Size*sy, + "positions": [][2]float64{}, "line": line, "font": text.Font, "size": text.Size * sy}) + } + line = true + cursor++ + } + if cursor >= len(logical) || logical[cursor] != char { + return nil, fmt.Errorf("text selection preview does not match content") + } + cursor++ + runs = append(runs, map[string]any{"text": string(char), "x": x - box.X, "y": y - box.Y, + "positions": [][2]float64{{x - box.X, y - box.Y}}, "line": line, "font": text.Font, + "size": text.Size * sy, "scale": scale * sx / sy, "vscale": 1, "rotation": 0, + "weight": text.Weight, "italic": text.Italic, "alpha": alpha, "color": e.richTextColorHex(part.Color)}) + } + offset += len(chars) + } + for cursor < len(logical) && logical[cursor] == '\n' && len(runs) > 0 { + last := runs[len(runs)-1].(map[string]any) + runs = append(runs, map[string]any{"text": "", "x": float64(0), "y": last["y"].(float64) + last["size"].(float64), + "positions": [][2]float64{}, "line": true, "font": last["font"], "size": last["size"]}) + cursor++ + } + if cursor != len(logical) { + return nil, fmt.Errorf("text selection preview is incomplete") + } + return map[string]any{"runs": runs, "glyphs": glyphs}, nil +} + +// readTextSelectionEdit 将完整草稿转为码点范围修改或显式富文本重排 +// 入参: args 编辑参数, analysis 原始选区 +// 返回: ofdgo.TextSelectionEdit 修改内容, error 参数错误 +func (e *businessEngine) readTextSelectionEdit(args []platformValue, analysis ofdgo.TextSelectionAnalysis) (ofdgo.TextSelectionEdit, error) { + old, next := []rune(analysis.Text), []rune(args[2].String()) + start, end, tail := 0, len(old), len(next) + for start < end && start < tail && old[start] == next[start] { + start++ + } + for end > start && tail > start && old[end-1] == next[tail-1] { + end-- + tail-- + } + edit := ofdgo.TextSelectionEdit{Start: start, End: end, Text: string(next[start:tail])} + if !args[3].IsNull() { + var err error + edit.FontData, err = e.bytesFromJS(args[3]) + if err != nil { + return edit, err + } + } + if !args[4].IsNull() { + edit.Size = args[4].Float() + } + if !args[5].IsNull() { + value := args[5].String() + if len(value) != 7 || value[0] != '#' { + return edit, fmt.Errorf("invalid RGB color %q", value) + } + rgb, err := hex.DecodeString(value[1:]) + if err != nil { + return edit, err + } + edit.Color = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) + } + if args[6].Bool() && len(next) > 0 { + value := analysis.RichText + var replacement []ofdgo.RichTextRun + if tail > start { + part, err := value.Slice(start, start) + if tail-start == end-start { + part, err = value.Slice(start, end) + } + if err != nil { + return edit, err + } + if tail-start == end-start { + offset := start + for _, run := range part.Runs { + count := len([]rune(run.Text)) + replacement = append(replacement, ofdgo.RichTextRun{Text: string(next[offset : offset+count]), Format: run.Format}) + offset += count + } + } else { + replacement = []ofdgo.RichTextRun{{Text: edit.Text, Format: part.Runs[0].Format}} + } + } + if err := value.Replace(start, end, replacement); err != nil { + return edit, err + } + value.Layout.AutoBidi, value.Layout.Wrap = true, true + edit.Reflow = &value + } + return edit, nil +} + +// previewTextSelection 预览联合修改,不改变文档资源及撤销记录 +// 入参: args 页码、图元路径、内容、字体数据、字号、颜色和重排标记 +// 返回: any 定位预览, error 编辑错误 +func (e *businessEngine) previewTextSelection(args []platformValue) (any, error) { + selection, err := e.readTextSelection(e.stringsFromJS(args[1])) + if err != nil { + return nil, err + } + analysis, err := e.currentEditor.AnalyzeTextSelection(args[0].Int(), selection) + if err != nil { + return nil, err + } + edit, err := e.readTextSelectionEdit(args, analysis) + if err != nil { + return nil, err + } + preview, err := e.currentEditor.PreviewTextSelection(args[0].Int(), analysis.Selection, edit) + if err != nil { + return nil, err + } + return e.textSelectionSource(preview.Parts, analysis.Bounds, preview.Text, len(edit.FontData) == 0) +} + +// updateTextSelection 原子提交联合修改并恢复删除或重排后的选区 +// 入参: args 页码、图元路径、内容、字体数据、字号、颜色和重排标记 +// 返回: any 文档及选区信息, error 编辑错误 +func (e *businessEngine) updateTextSelection(args []platformValue) (any, error) { + ids := e.stringsFromJS(args[1]) + selection, err := e.readTextSelection(ids) + if err != nil { + return nil, err + } + page := args[0].Int() + analysis, err := e.currentEditor.AnalyzeTextSelection(page, selection) + if err != nil { + return nil, err + } + edit, err := e.readTextSelectionEdit(args, analysis) + if err != nil { + return nil, err + } + preview, err := e.currentEditor.PreviewTextSelection(page, analysis.Selection, edit) + if err != nil { + return nil, err + } + result, err := e.changeObjects(func() error { return e.currentEditor.UpdateTextSelection(page, analysis.Selection, edit) }) + if err != nil { + return nil, err + } + selected := append([]string{}, preview.Selection.IDs...) + for _, index := range preview.Selection.Indexes { + key := ids[0][:strings.LastIndex(ids[0], "/")] + selected = append(selected, fmt.Sprintf("%s/%d", key, index)) + } + return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil +} + +// previewPositionedText 预览保留定位的文字替换,不修改编辑器或撤销记录 +// 入参: args 页面、对象路径和内容 +// 返回: any SVG预览, error 错误信息 +func (e *businessEngine) previewPositionedText(args []platformValue) (any, error) { + object, err := e.editorObjectAt(args[0].Int(), args[1].String()) + if err != nil { + return nil, err + } + if object.Type != "TextObject" { + return nil, fmt.Errorf("object is not text") + } + if err := e.currentEditor.RewriteText(&object.TextObject, args[2].String()); err != nil { + return nil, err + } + measured, err := e.currentSession.Renderer.MeasureObject(object, ofdgo.MeasureOptions{}) + if err != nil { + return nil, err + } + box := measured.Bounds + margin := object.TextObject.Size / 4 + box.X, box.Y, box.W, box.H = box.X-margin, box.Y-margin, box.W+2*margin, box.H+2*margin + boundary, err := ofdgo.ParseBox(object.TextObject.Boundary) + if err != nil { + return nil, err + } + object.TextObject.Boundary = fmt.Sprintf("%g %g %g %g", boundary.X-box.X, boundary.Y-box.Y, boundary.W, boundary.H) + page := &ofdgo.PageContent{Area: ofdgo.PageArea{PhysicalBox: fmt.Sprintf("0 0 %g %g", box.W, box.H)}, Content: ofdgo.Content{Layer: []ofdgo.Layer{{Objects: []ofdgo.GraphicObject{object}}}}} + var output bytes.Buffer + if err := e.currentSession.Renderer.RenderToSVG(page, &output); err != nil { + return nil, err + } + return output.String(), nil +} + +// updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性 +// 入参: args 页码、对象标识、内容、字体数据、字号、颜色及可选重排标记 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) updateText(args []platformValue) (any, error) { + return e.changeObjects(func() error { return e.applyTextUpdate(args) }) +} + +// applyTextUpdate 在当前事务中修改文字及字体,不刷新会话 +// 入参: args 页码、对象标识、内容、字体数据、字号、颜色及可选重排标记 +// 返回: error 字体、排版或提交错误 +func (e *businessEngine) applyTextUpdate(args []platformValue) error { + page, id := args[0].Int(), args[1].String() + object, err := e.currentEditor.Object(page, id) + if err != nil { + return err + } + if object.Type != "TextObject" { + return fmt.Errorf("object %q is not text", id) + } + if !args[3].IsNull() { + data, err := e.bytesFromJS(args[3]) + if err != nil { + return err + } + value := object.TextObject.Text() + if !args[2].IsNull() { + value = args[2].String() + } + if err := e.checkFontGlyphs(data, value); err != nil { + return err + } + object.TextObject.Font, err = e.currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + } + if !args[2].IsNull() { + if args[4].Float() == object.TextObject.Size || len(args) > 6 && args[6].Bool() { + style := ofdgo.TextStyle{Reflow: len(args) > 6 && args[6].Bool()} + if style.Reflow { + style.Size = args[4].Float() + } + if !args[3].IsNull() { + style.Font = object.TextObject.Font + } + if !args[5].IsNull() { + var fill ofdgo.FillColor + if err := e.setEditorColor(&fill, args[5].String()); err != nil { + return err + } + style.Color = fill.Value + } + return e.currentEditor.UpdateTextContent(page, id, args[2].String(), style) + } + object.TextObject.Size = args[4].Float() + _, layout := object.TextObject.TextLayout() + if err := e.currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil { + return err + } + } + if !args[5].IsNull() { + if err := e.setTextColor(&object.TextObject, args[5].String()); err != nil { + return err + } + } + return e.currentEditor.UpdateObject(page, id, object) +} + +// checkFontGlyphs 只检查候选字体,不注册资源或改变文档 +// 入参: data 字体数据, value 待替换文字 +// 返回: error 缺字或字体格式错误 +func (e *businessEngine) checkFontGlyphs(data []byte, value string) error { + missing, err := (FontFile{Data: data}).MissingGlyphs(0, value) + if err != nil { + return err + } + if missing != "" { + return &ofdgo.MissingGlyphError{Characters: missing} + } + return nil +} + +// checkTextFont 为画布输入预检候选字体 +// 入参: args 字体数据和输入文字 +// 返回: any 空结果, error 缺字或字体格式错误 +func (e *businessEngine) checkTextFont(args []platformValue) (any, error) { + data, err := e.bytesFromJS(args[0]) + if err != nil { + return nil, err + } + return nil, e.checkFontGlyphs(data, args[1].String()) +} + +// styleText 批量修改文字样式,字体预检通过后交由库原子提交 +// 入参: args 页码、对象标识、字体数据、字号和颜色,null字体与颜色保持原值 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) styleText(args []platformValue) (any, error) { + return e.changeObjects(func() error { + page, ids := args[0].Int(), e.stringsFromJS(args[1]) + style := ofdgo.TextStyle{Size: args[3].Float()} + if !args[2].IsNull() { + data, err := e.bytesFromJS(args[2]) + if err != nil { + return err + } + var value strings.Builder + for _, id := range ids { + object, err := e.currentEditor.Object(page, id) + if err != nil { + return err + } + if object.Type != "TextObject" { + return fmt.Errorf("object %q is not text", id) + } + value.WriteString(object.TextObject.Text()) + } + if err := e.checkFontGlyphs(data, value.String()); err != nil { + return err + } + style.Font, err = e.currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + } + if !args[4].IsNull() { + var color ofdgo.FillColor + if err := e.setEditorColor(&color, args[4].String()); err != nil { + return err + } + style.Color = color.Value + } + return e.currentEditor.StyleText(page, ids, style) + }) +} + +// editorRichText 将富文本复合图元作为一个文本框提供给编辑器 +// 入参: page 页面索引, item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称 +func (e *businessEngine) editorRichText(page int, item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string) { + if len(value.Runs) == 0 { + return + } + local, matrix, err := e.currentEditor.RichTextFrame(page, object.CompositeGraphicUnit.ID) + if err != nil { + return + } + width, height := local.W, local.H + _, contentBounds, err := e.currentEditor.LayoutRichText(value, width) + if err != nil { + return + } + frame := matrix.TransformBox(contentBounds) + format, layout := value.Runs[0].Format, value.Layout + var content strings.Builder + for _, run := range value.Runs { + content.WriteString(run.Text) + } + item["actualType"], item["type"], item["richText"] = object.Type, "TextObject", e.browserRichText(value) + item["text"], item["font"], item["fontName"], item["size"] = content.String(), format.Font, fonts[format.Font], format.Size + item["color"] = e.richTextColorHex(format.Color) + item["bold"], item["italic"], item["underline"], item["strikeout"], item["shadow"] = format.Bold, format.Italic, format.Underline, format.Strikeout, format.Shadow + item["background"] = "" + if format.Background != "" { + item["background"] = e.richTextColorHex(format.Background) + } + item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight + item["letterSpacing"], item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LetterSpacing, layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent + item["paragraphWidth"], item["bounds"] = width, e.editorBox(frame) + item["x"], item["y"], item["width"], item["height"] = frame.X, frame.Y, frame.W, frame.H + item["textFrame"] = map[string]any{"width": width, "height": height, "matrix": e.editorMatrix(matrix), "content": e.editorBox(contentBounds)} + capabilities := item["capabilities"].(map[string]any) + for _, key := range []string{"textContent", "rewriteText", "replaceFont", "reflow", "layoutKnown", "paint"} { + capabilities[key] = true + } + capabilities["enter"], capabilities["ungroup"] = false, false + capabilities["stretch"] = false + if capabilities["reasonCode"] == string(ofdgo.EditUnsupportedObject) && capabilities["transform"] == true && capabilities["copy"] == true && object.CompositeGraphicUnit.Clips == nil { + delete(capabilities, "reason") + delete(capabilities, "reasonCode") + } +} + +// getRichText 读取文字分段与段落选项,颜色转换为浏览器RGB格式 +// 入参: args 页码和对象标识 +// 返回: any 富文本快照, error 错误信息 +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()) + if err != nil { + return nil, err + } + return e.browserRichText(value), nil +} + +// browserRichText 提供独立富文本快照和浏览器颜色,不改变库层样式 +// 入参: value 库层富文本 +// 返回: map[string]any 文字分段和段落选项 +func (e *businessEngine) browserRichText(value ofdgo.RichText) map[string]any { + runs := make([]any, len(value.Runs)) + for i, run := range value.Runs { + format := run.Format + background := "" + if format.Background != "" { + background = e.richTextColorHex(format.Background) + } + runs[i] = map[string]any{"text": run.Text, "format": map[string]any{ + "font": format.Font, "size": format.Size, "color": e.richTextColorHex(format.Color), + "bold": format.Bold, "italic": format.Italic, "underline": format.Underline, + "strikeout": format.Strikeout, "shadow": format.Shadow, "background": background, + }} + } + layout := value.Layout + return map[string]any{"runs": runs, "layout": map[string]any{ + "autoBidi": layout.AutoBidi, "shape": layout.Shape, + "shaping": map[string]any{ + "Bidi": layout.Shaping.Bidi, "Direction": layout.Shaping.Direction, + "Script": layout.Shaping.Script, "Language": layout.Shaping.Language, "Features": layout.Shaping.Features, + }, + "wrap": layout.Wrap, "align": layout.Align, "lineHeight": layout.LineHeight, + "letterSpacing": layout.LetterSpacing, "leftIndent": layout.LeftIndent, + "rightIndent": layout.RightIndent, "firstLineIndent": layout.FirstLineIndent, + }} +} + +// richTextColorHex 将富文本的RGB分量转换为浏览器颜色,不套用文档颜色空间 +// 入参: value RGB分量 +// 返回: string 十六进制颜色 +func (e *businessEngine) richTextColorHex(value string) string { + var r, g, b int + fmt.Sscan(value, &r, &g, &b) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +// readRichText 读取浏览器富文本并转换显式RGB颜色 +// 入参: value 富文本JSON +// 返回: ofdgo.RichText 库层快照, error 数据错误 +func (e *businessEngine) readRichText(value platformValue) (ofdgo.RichText, error) { + var text ofdgo.RichText + if err := json.Unmarshal([]byte(value.String()), &text); err != nil { + return text, err + } + for i := range text.Runs { + format := &text.Runs[i].Format + for _, field := range []*string{&format.Color, &format.Background} { + if *field == "" { + continue + } + if len(*field) != 7 || (*field)[0] != '#' { + return text, fmt.Errorf("invalid RGB color %q", *field) + } + rgb, err := hex.DecodeString((*field)[1:]) + if err != nil { + return text, err + } + *field = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) + } + } + return text, nil +} + +// saveRichText 原子保存文字、样式和新增字体,空标识创建文本框 +// 入参: args 页码、标识、富文本JSON、新建边界和可选字体集合 +// 返回: any 文档信息及文字选区, error 错误信息 +func (e *businessEngine) saveRichText(args []platformValue) (any, error) { + value, err := e.readRichText(args[2]) + if err != nil { + return nil, err + } + page, id := args[0].Int(), args[1].String() + result, err := e.changeObjects(func() error { + if len(args) > 4 { + if err := e.importRichTextFonts(&value, args[4]); err != nil { + return err + } + } + if id != "" { + if err := e.currentEditor.UpdateRichText(page, id, value); err != nil { + return err + } + box := args[3] + if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == platformTypeNumber { + offset := 0.0 + if box.Get("frameOffset").Type() == platformTypeNumber { + offset = box.Get("frameOffset").Float() + } + return e.currentEditor.ResizeRichTextFrame(page, id, offset, box.Get("frameWidth").Float()) + } + return nil + } + box := args[3] + id, err = e.currentEditor.AddRichText(page, ofdgo.Box{X: box.Get("x").Float(), Y: box.Get("y").Float(), W: box.Get("width").Float(), H: box.Get("height").Float()}, value) + return err + }) + if err != nil { + return nil, err + } + info, ok := result.(editorInfo) + if !ok { + return result, nil + } + return editorSelectionInfo{editorInfo: info, SelectedIDs: []string{id}}, nil +} + +// importRichTextFonts 在当前事务内注册实际使用的新字体并替换临时标识 +// 入参: value 富文本快照, fonts 浏览器字体集合 +// 返回: error 数据或字体错误 +func (e *businessEngine) importRichTextFonts(value *ofdgo.RichText, fonts platformValue) error { + if fonts.IsNull() || fonts.IsUndefined() { + return nil + } + registered := make(map[string]string) + for i := 0; i < fonts.Length(); i++ { + entry := fonts.Index(i) + key := entry.Get("key").String() + if key == "" || registered[key] != "" { + return fmt.Errorf("invalid rich text font key") + } + used := false + for _, run := range value.Runs { + used = used || run.Format.Font == key + } + if !used { + continue + } + data, err := e.bytesFromJS(entry.Get("data")) + if err != nil { + return err + } + font, err := e.currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + registered[key] = font + } + for i := range value.Runs { + if font := registered[value.Runs[i].Format.Font]; font != "" { + value.Runs[i].Format.Font = font + } + } + return nil +} + +// previewRichText 使用库层排版校验富文本并测量边界,临时资源及历史始终回滚 +// 入参: args 页码、对象标识、富文本JSON、局部宽度和可选字体集合 +// 返回: any 局部边界, error 排版错误 +func (e *businessEngine) previewRichText(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + value, err := e.readRichText(args[2]) + if err != nil { + return nil, err + } + var result map[string]any + finished := errors.New("rich text preview complete") + err = e.currentEditor.Transaction(func(editor *ofdgo.Editor) error { + if len(args) > 4 { + if err := e.importRichTextFonts(&value, args[4]); err != nil { + return err + } + } + width := args[3].Float() + _, bounds, err := editor.LayoutRichText(value, width) + if err != nil { + return err + } + result = map[string]any{"x": bounds.X, "y": bounds.Y, "width": bounds.W, "height": bounds.H} + return finished + }) + if !errors.Is(err, finished) { + return nil, err + } + return result, nil +} + +// setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度 +// 入参: object 文字对象, value 十六进制色值 +// 返回: error 错误信息 +func (e *businessEngine) setTextColor(object *ofdgo.TextObject, value string) error { + if object.FillColor == nil { + object.FillColor = &ofdgo.FillColor{} + } + return e.setEditorColor(object.FillColor, value) +} + +// insertText 使用选定字体创建文字对象 +// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距、字距及左右和首行缩进 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) insertText(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + data, err := e.bytesFromJS(args[2]) + if err != nil { + return nil, err + } + fontID, err := e.currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return nil, err + } + page := args[0].Int() + content, err := e.currentSession.pageContent(page) + if err != nil { + return nil, err + } + box, err := e.currentSession.pageBox(page, content) + if err != nil { + return nil, err + } + x, y := args[3].Float(), args[4].Float() + width := args[7].Float() + if width == 0 { + width = 80 + } + box = ofdgo.Box{X: x, Y: y, W: math.Min(width, box.W-x), H: box.H - y} + object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{ + Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), + Font: fontID, Size: args[5].Float(), + }} + if err := e.currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{AutoBidi: true, Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float(), LeftIndent: args[12].Float(), RightIndent: args[13].Float(), FirstLineIndent: args[14].Float()}); err != nil { + return nil, err + } + if err := e.setTextColor(&object.TextObject, args[6].String()); err != nil { + return nil, err + } + return e.insertEditorObject(page, object, args[15:]) +} + +// layoutText 调整文字框和段落排版,保留软换行前的原文 +// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距、字距及左右和首行缩进 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) layoutText(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + page, id := args[0].Int(), args[1].String() + object, err := e.currentEditor.Object(page, id) + if err != nil { + return nil, err + } + if object.Type != "TextObject" { + return nil, fmt.Errorf("object is not text") + } + if !args[2].IsNull() { + object.TextObject, err = object.TextObject.ResizeTextFrame(args[2].Float(), args[3].Float()) + if err != nil { + return nil, err + } + } + value, _ := object.TextObject.TextLayout() + if err := e.currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{AutoBidi: true, Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float(), LeftIndent: args[8].Float(), RightIndent: args[9].Float(), FirstLineIndent: args[10].Float()}); err != nil { + return nil, err + } + revision := e.currentEditor.Revision() + if err := e.currentEditor.UpdateObject(page, id, object); err != nil { + return nil, err + } + if e.currentEditor.Revision() == revision { + return e.editorSummary(), nil + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// replaceImage 替换图片资源并更新预览 +// 入参: args 页码、对象标识、图片数据和适应方式 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) replaceImage(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + data, err := e.bytesFromJS(args[2]) + if err != nil { + return nil, err + } + revision := e.currentEditor.Revision() + if err := e.currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data, args[3].String()); err != nil { + return nil, err + } + if e.currentEditor.Revision() == revision { + return e.editorSummary(), nil + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// alignObject 将对象对齐页面并更新预览 +// 入参: args 页码、对象标识和对齐方式 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) alignObject(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + revision := e.currentEditor.Revision() + if err := e.currentEditor.AlignObject(args[0].Int(), args[1].String(), args[2].String()); err != nil { + return nil, err + } + if e.currentEditor.Revision() == revision { + return e.editorSummary(), nil + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// transformObject 等比缩放后平移对象 +// 入参: args 页码、对象标识、位移和缩放比例 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) transformObject(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + page, id := args[0].Int(), args[1].String() + revision := e.currentEditor.Revision() + if err := e.currentEditor.TransformObject(page, id, args[2].Float(), args[3].Float(), args[4].Float()); err != nil { + return nil, err + } + if e.currentEditor.Revision() == revision { + return e.editorSummary(), nil + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// reshapeObject 调整基本图形的尺寸或直线端点,保留绘制样式 +// 入参: args 页码、对象标识和页面几何范围 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) reshapeObject(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + page, id := args[0].Int(), args[1].String() + object, err := e.currentEditor.Object(page, id) + if err != nil { + return nil, err + } + if object.Type != "PathObject" { + return nil, fmt.Errorf("object is not a path") + } + box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()} + if len(args) > 6 && args[6].Bool() { + object.PathObject, err = object.PathObject.ReshapeFrame(box) + } else { + object.PathObject, err = object.PathObject.Reshape(box) + } + if err != nil { + return nil, err + } + revision := e.currentEditor.Revision() + if err := e.currentEditor.UpdateObject(page, id, object); err != nil { + return nil, err + } + if e.currentEditor.Revision() == revision { + return e.editorSummary(), nil + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// reshapeLine 修改直线端点与箭头类型,复用库层样式保留逻辑 +// 入参: args 页码、对象标识、页面端点及箭头类型 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) reshapeLine(args []platformValue) (any, error) { + return e.changeObjects(func() error { + page, id := args[0].Int(), args[1].String() + object, err := e.currentEditor.Object(page, id) + if err != nil { + return err + } + if object.Type != "PathObject" { + return fmt.Errorf("object is not a path") + } + object.PathObject, err = object.PathObject.ReshapeLine(ofdgo.ShapeKind(args[6].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + if err != nil { + return err + } + return e.currentEditor.UpdateObject(page, id, object) + }) +} + +// copyObjects 复制选区并返回新对象标识,复用字体和图片资源 +// 入参: args 页码、对象标识列表和位移 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) copyObjects(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + objects, err := e.currentEditor.Objects(args[0].Int(), e.stringsFromJS(args[1])) + if err != nil { + return nil, err + } + return e.copyEditorObjects(args[0].Int(), objects, args[2].Float(), args[3].Float()) +} + +// captureObjects 保存独立选区快照,不修改文档或历史 +// 入参: args 页面索引、对象标识数组、剪贴板标识和可选父路径 +// 返回: any 空结果, error 错误信息 +func (e *businessEngine) captureObjects(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + clipboard := &editorClipboard{token: args[2].String()} + var err error + if len(args) > 3 && args[3].String() != "" { + key := args[3].String() + path, pathErr := e.compositePath(key) + if pathErr != nil { + return nil, pathErr + } + indexes, indexErr := e.compositeIndexes(key, e.stringsFromJS(args[1])) + if indexErr != nil { + return nil, indexErr + } + clipboard.composite, err = e.currentEditor.CaptureCompositeObjects(args[0].Int(), path, indexes) + } else { + clipboard, err = e.captureEditorSelection(args[0].Int(), e.stringsFromJS(args[1])) + clipboard.token = args[2].String() + } + if err != nil { + return nil, err + } + e.copiedObjects = clipboard + return nil, nil +} + +// pasteObjects 将当前文档的对象快照粘贴到目标页 +// 入参: args 目标页、剪贴板标识、横纵位移和可选父路径 +// 返回: any 文档信息及新选区, error 错误信息 +func (e *businessEngine) pasteObjects(args []platformValue) (any, error) { + if e.currentEditor == nil || e.copiedObjects == nil || e.copiedObjects.token != args[1].String() { + return nil, fmt.Errorf("object clipboard is no longer available") + } + key := "" + if len(args) > 4 { + key = args[4].String() + } + page, dx, dy := args[0].Int(), args[2].Float(), args[3].Float() + if key != "" { + path, err := e.compositePath(key) + if err != nil { + return nil, err + } + var indexes []int + if e.copiedObjects.composite != nil { + indexes, err = e.currentEditor.PasteCompositeObjects(page, path, e.copiedObjects.composite, dx, dy) + } else { + indexes, err = e.currentEditor.PasteSelectionToComposite(page, path, e.copiedObjects.selection, dx, dy) + } + if err != nil { + return nil, err + } + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + result := editorSelectionInfo{editorInfo: info} + for _, index := range indexes { + result.SelectedIDs = append(result.SelectedIDs, fmt.Sprintf("%s/%d", key, index)) + } + return result, err + } + if e.copiedObjects.composite != nil { + ids, err := e.currentEditor.PasteCompositeSelection(page, e.copiedObjects.composite, dx, dy) + if err != nil { + return nil, err + } + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + return editorSelectionInfo{info, ids}, err + } + ids, err := e.pasteEditorSelection(page, e.copiedObjects, dx, dy) + if err != nil { + return nil, err + } + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + return editorSelectionInfo{info, ids}, err +} + +// copyEditorObjects 复制快照并更新预览,返回新对象标识 +// 入参: page 目标页, objects 对象快照, dx 横向位移,单位为毫米, dy 纵向位移,单位为毫米 +// 返回: any 文档信息及新选区, error 错误信息 +func (e *businessEngine) copyEditorObjects(page int, objects []ofdgo.GraphicObject, dx, dy float64) (any, error) { + ids, err := e.currentEditor.CopyObjects(page, objects, dx, dy) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return e.editorSummary(), nil + } + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + return editorSelectionInfo{info, ids}, err +} + +// groupObjects 组合或解组并返回新的选区,复用单次历史提交 +// 入参: args 页面索引、对象标识列表和是否解组 +// 返回: any 文档及选区, error 错误信息 +func (e *businessEngine) groupObjects(args []platformValue) (any, error) { + var ids []string + info, err := e.changeObjects(func() error { + page, selected := args[0].Int(), e.stringsFromJS(args[1]) + var err error + if args[2].Bool() { + if len(selected) != 1 { + return fmt.Errorf("ungrouping requires one object") + } + ids, err = e.currentEditor.UngroupObject(page, selected[0]) + } else { + var id string + id, err = e.currentEditor.GroupObjects(page, selected) + ids = []string{id} + } + return err + }) + if err != nil { + return nil, err + } + return editorSelectionInfo{info.(editorInfo), ids}, nil +} + +// orderObjects 调整选区的绘制顺序 +// 入参: args 页码、对象标识列表和层级动作 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) orderObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.OrderObjects(args[0].Int(), e.stringsFromJS(args[1]), args[2].String()) + }) +} + +// distributeObjects 按指定轴等距分布选区 +// 入参: args 页面索引、对象标识数组和分布轴 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) distributeObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.DistributeObjects(args[0].Int(), e.stringsFromJS(args[1]), args[2].String()) + }) +} + +// deleteObject 删除创作画布中选中的对象 +// 入参: args 页码和对象标识 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) deleteObject(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + if err := e.currentEditor.DeleteObject(args[0].Int(), args[1].String()); err != nil { + return nil, err + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// changePage 管理创作文档的页面 +// 入参: args 操作、页面索引及尺寸或目标索引 +// 返回: any 文档信息和目标页面, error 错误信息 +func (e *businessEngine) changePage(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + index := args[1].Int() + revision := e.currentEditor.Revision() + var err error + switch action := args[0].String(); action { + case "add": + index, err = e.currentEditor.AddPage(args[2].Float(), args[3].Float()) + case "copy": + index, err = e.currentEditor.CopyPage(index) + case "delete": + if e.currentEditor.PageCount() == 1 { + return nil, fmt.Errorf("the document must contain at least one page") + } + err = e.currentEditor.DeletePage(index) + index = min(index, e.currentEditor.PageCount()-1) + case "move": + target := args[2].Int() + err = e.currentEditor.MovePage(index, target) + index = target + case "resize": + err = e.currentEditor.ResizePage(index, args[2].Float(), args[3].Float()) + default: + return nil, fmt.Errorf("unsupported page action %q", action) + } + if err != nil { + return nil, err + } + if e.currentEditor.Revision() == revision { + return editorPageInfo{e.editorSummary(), index}, nil + } + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + if err != nil { + return nil, err + } + return editorPageInfo{info, index}, nil +} + +// batchPages 按页码范围批量复制、删除、移动或调整页面尺寸 +// 入参: args 操作、页码表达式及插入位置或宽高 +// 返回: any 文档信息及目标页, error 错误信息 +func (e *businessEngine) batchPages(args []platformValue) (any, error) { + index := 0 + info, err := e.changeObjects(func() error { + indexes, err := ofdgo.ParsePageRange(args[1].String(), e.currentEditor.PageCount()) + if err != nil { + return err + } + switch args[0].String() { + case "copy": + copies, err := e.currentEditor.CopyPages(indexes) + if err == nil && len(copies) > 0 { + index = copies[0] + } + return err + case "delete": + if len(indexes) == e.currentEditor.PageCount() { + return fmt.Errorf("the document must contain at least one page") + } + if err := e.currentEditor.DeletePages(indexes); err != nil { + return err + } + index = min(indexes[0], e.currentEditor.PageCount()-1) + return nil + case "move": + at := args[2].Int() + if at < 0 || at > e.currentEditor.PageCount() { + return fmt.Errorf("page index %d out of range", at) + } + index = at + for _, selected := range indexes { + if selected < at { + index-- + } + } + return e.currentEditor.MovePages(indexes, index) + case "resize": + index = indexes[0] + return e.currentEditor.ResizePages(indexes, args[2].Float(), args[3].Float()) + default: + return fmt.Errorf("unsupported page action %q", args[0].String()) + } + }) + if err != nil { + return nil, err + } + return editorPageInfo{info.(editorInfo), index}, nil +} + +// indexesFromJS 读取整数索引列表 +// 入参: value JavaScript数组 +// 返回: []int 索引列表 +func (e *businessEngine) indexesFromJS(value platformValue) []int { + indexes := make([]int, value.Length()) + for i := range indexes { + indexes[i] = value.Index(i).Int() + } + return indexes +} + +// moveOutline 调整目录顺序或层级 +// 入参: args 源路径、目标父路径及插入索引 +// 返回: any 文档及新路径, error 错误信息 +func (e *businessEngine) moveOutline(args []platformValue) (any, error) { + var path []int + info, err := e.changeObjects(func() error { + var err error + path, err = e.currentEditor.MoveOutline(e.indexesFromJS(args[0]), e.indexesFromJS(args[1]), args[2].Int()) + return err + }) + if err != nil { + return nil, err + } + return editorOutlineInfo{info.(editorInfo), path}, nil +} + +// captureStyle 保存单个对象样式来源,不改写文档 +// 入参: args 页面索引、对象标识及可选内部范围 +// 返回: any 空结果, error 错误信息 +func (e *businessEngine) captureStyle(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + if len(args) > 2 && args[2].String() != "" { + key := args[2].String() + path, err := e.compositePath(key) + if err != nil { + return nil, err + } + indexes, err := e.compositeIndexes(key, []string{args[1].String()}) + if err != nil { + return nil, err + } + member, err := e.currentEditor.CompositeObject(args[0].Int(), path, indexes[0]) + if err != nil { + return nil, err + } + object := member.Style() + e.copiedStyle = &object + return nil, nil + } + object, err := e.currentEditor.Object(args[0].Int(), args[1].String()) + if err != nil { + return nil, err + } + e.copiedStyle = &object + return nil, nil +} + +// pasteStyle 将保存的样式应用到同类型选区 +// 入参: args 页面索引和对象标识列表 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) pasteStyle(args []platformValue) (any, error) { + return e.changeObjects(func() error { + if e.copiedStyle == nil { + return fmt.Errorf("style clipboard is empty") + } + return e.currentEditor.CopyStyle(args[0].Int(), e.stringsFromJS(args[1]), *e.copiedStyle) + }) +} + +// resizeObjects 设置选区位置及尺寸 +// 入参: args 页面索引、对象标识、横纵坐标及宽高 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) resizeObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.ResizeObjects(args[0].Int(), e.stringsFromJS(args[1]), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + }) +} + +// changeOutline 新增、修改或删除层级目录 +// 入参: args 操作、层级索引、标题及目标页面索引 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) changeOutline(args []platformValue) (any, error) { + path := e.indexesFromJS(args[1]) + info, err := e.changeObjects(func() error { + switch args[0].String() { + case "add": + var err error + path, err = e.currentEditor.AddOutline(path, args[2].String(), args[3].Int()) + return err + case "update": + return e.currentEditor.UpdateOutline(path, args[2].String(), args[3].Int()) + case "delete": + err := e.currentEditor.DeleteOutline(path) + path = nil + return err + default: + return fmt.Errorf("unsupported outline action %q", args[0].String()) + } + }) + if err != nil { + return nil, err + } + return editorOutlineInfo{info.(editorInfo), path}, nil +} + +// styleObjects 原子更新对象外观和图案布局 +// 入参: args 页面索引、对象标识数组及样式,省略字段保持原值 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) styleObjects(args []platformValue) (any, error) { + style := e.objectStyle(args[2]) + linkField := args[2].Get("link") + var link *ofdgo.AnnotationLink + if !linkField.IsUndefined() && !linkField.IsNull() { + if err := json.Unmarshal([]byte(linkField.String()), &link); err != nil { + return nil, err + } + } + var border *ofdgo.ImageBorderStyle + if field := args[2].Get("borderStyle"); !field.IsUndefined() { + if err := json.Unmarshal([]byte(field.String()), &border); err != nil { + return nil, err + } + } + appearance := ofdgo.ObjectAppearance{SetLink: !linkField.IsUndefined(), Link: link, Border: border} + for i, key := range []string{"fillPatternStyle", "strokePatternStyle"} { + if field := args[2].Get(key); !field.IsUndefined() { + var pattern ofdgo.PatternStyle + if err := json.Unmarshal([]byte(field.String()), &pattern); err != nil { + return nil, err + } + if i == 0 { + appearance.FillPattern = &pattern + } else { + appearance.StrokePattern = &pattern + } + } + } + if field := args[2].Get("fillPaint"); !field.IsUndefined() { + if err := json.Unmarshal([]byte(field.String()), &style.FillColor); err != nil { + return nil, err + } + } + if field := args[2].Get("strokePaint"); !field.IsUndefined() { + if err := json.Unmarshal([]byte(field.String()), &style.StrokeColor); err != nil { + return nil, err + } + } + return e.changeObjects(func() error { + if border != nil { + if err := e.resolveStylePaint((*ofdgo.FillColor)(border.Color)); err != nil { + return err + } + } + for _, paint := range []*ofdgo.FillColor{style.FillColor, (*ofdgo.FillColor)(style.StrokeColor)} { + if err := e.resolveStylePaint(paint); err != nil { + return err + } + } + appearance.Style = style + return e.currentEditor.ApplyAppearance(args[0].Int(), e.stringsFromJS(args[1]), appearance) + }) +} + +// resolveStylePaint 将渐变色标中的浏览器色值转换为文档颜色空间 +// 入参: paint 颜色数据 +// 返回: error 错误信息 +func (e *businessEngine) resolveStylePaint(paint *ofdgo.FillColor) error { + if paint == nil { + return nil + } + if strings.HasPrefix(paint.Value, "#") { + return e.setEditorColor(paint, paint.Value) + } + var segments []ofdgo.ShdSegment + if paint.AxialShd != nil { + segments = paint.AxialShd.Segment + } else if paint.RadialShd != nil { + segments = paint.RadialShd.Segment + } + for i := range segments { + stop := &segments[i].Color + if strings.HasPrefix(stop.Value, "#") { + color := ofdgo.FillColor{Alpha: stop.Alpha} + if err := e.setEditorColor(&color, stop.Value); err != nil { + return err + } + *stop = ofdgo.ShdColor{Value: color.Value, ColorSpace: color.ColorSpace, Alpha: color.Alpha} + } + } + return nil +} + +// editorDashPattern 将内部对象的虚线长度格式化为页面毫米 +// 入参: value 对象空间虚线长度, scale 页面描边倍率 +// 返回: string 页面空间虚线长度,无法解析时保留原文 +func (e *businessEngine) editorDashPattern(value string, scale float64) string { + if scale == 1 || value == "" { + return value + } + fields := strings.Fields(value) + for i, field := range fields { + number, err := strconv.ParseFloat(field, 64) + if err != nil || math.IsNaN(number*scale) || math.IsInf(number*scale, 0) { + return value + } + fields[i] = strconv.FormatFloat(number*scale, 'f', -1, 64) + } + return strings.Join(fields, " ") +} + +// objectStyle 读取高级面板提交的外观字段,省略字段保持原值 +// 入参: value 前端样式 +// 返回: ofdgo.ObjectStyle 库层样式 +func (e *businessEngine) objectStyle(value platformValue) ofdgo.ObjectStyle { + style := ofdgo.ObjectStyle{} + if field := value.Get("fill"); !field.IsUndefined() { + v := field.Bool() + style.Fill = &v + } + if field := value.Get("stroke"); !field.IsUndefined() { + v := field.Bool() + style.Stroke = &v + } + if field := value.Get("alpha"); !field.IsUndefined() { + v := field.Int() + style.Alpha = &v + } + if field := value.Get("dashPattern"); !field.IsUndefined() { + v := field.String() + style.DashPattern = &v + } + if field := value.Get("dashOffset"); !field.IsUndefined() { + v := field.Float() + style.DashOffset = &v + } + if field := value.Get("cap"); !field.IsUndefined() { + v := field.String() + style.Cap = &v + } + if field := value.Get("join"); !field.IsUndefined() { + v := field.String() + style.Join = &v + } + return style +} + +// clearImport 释放待导入文档 +func (e *businessEngine) clearImport() { + if e.pendingImport != nil { + _ = e.pendingImport.Close() + e.pendingImport = nil + } +} + +// loadImport 加载待导入文件的索引,null参数清除待处理文件 +// 入参: args OFD字节数组或null +// 返回: any 文档列表、页数及签名提示, error 错误信息 +func (e *businessEngine) loadImport(args []platformValue) (any, error) { + e.clearImport() + if args[0].IsNull() { + return nil, nil + } + source, size, err := e.sourceFromValue(args[0]) + if err != nil { + return nil, err + } + var options []ofdgo.ReaderOption + if len(args) > 1 && !args[1].IsNull() && !args[1].IsUndefined() { + credentials, err := e.credentialsFromJS(args[1]) + if err != nil { + return nil, err + } + defer clear(credentials.Password) + options = append(options, ofdgo.WithCredentials(credentials)) + } + reader, err := ofdgo.NewReader(source, size, options...) + if err != nil { + return nil, err + } + doc, err := reader.Doc() + if err != nil { + reader.Close() + return nil, err + } + if len(doc.Pages.Page) == 0 { + reader.Close() + return nil, fmt.Errorf("document has no pages") + } + e.pendingImport = reader + entries := make([]DocumentEntry, reader.DocumentCount()) + signed := doc.Signatures != "" + for index, body := range reader.OFD.DocBody { + entries[index] = DocumentEntry{Index: index, Title: body.DocInfo.Title, Root: body.DocRoot, ID: body.DocInfo.DocID} + signed = signed || body.Signatures != "" + } + return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != "", "documentsSigned": signed, "encrypted": reader.Encryption().Encrypted, "documents": entries, "documentCount": len(entries)}, nil +} + +// importPages 插入指定来源页面并保持目标文档元数据 +// 入参: args 页码表达式,空值为全部、目标零基插入位置、是否导入对应目录和可选进度回调 +// 返回: any 文档信息及首个插入页, error 错误信息 +func (e *businessEngine) importPages(args []platformValue) (any, error) { + if e.currentEditor == nil || e.pendingImport == nil { + return nil, fmt.Errorf("no document is ready for page import") + } + doc, err := e.pendingImport.Doc() + if err != nil { + return nil, err + } + var indexes []int + if value := strings.TrimSpace(args[0].String()); value != "" { + indexes, err = ofdgo.ParsePageRange(value, len(doc.Pages.Page)) + if err != nil { + return nil, err + } + } else { + for i := range doc.Pages.Page { + indexes = append(indexes, i) + } + } + at := args[1].Int() + options := ofdgo.PageImportOptions{Outlines: len(args) > 2 && args[2].Bool()} + ctx := e.operationContext() + progressIndex := 3 + if len(args) > 3 && args[3].Type() == platformTypeBoolean { + options.AllowDecrypted = args[3].Bool() + progressIndex = 4 + } + if len(args) > progressIndex { + ctx, options.OnProgress = e.newEditorOperationContext(args[progressIndex]) + } + if _, err := e.currentEditor.ImportPagesContext(ctx, e.pendingImport, indexes, at, options); err != nil { + return nil, err + } + e.clearImport() + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + return editorPageInfo{info, at}, err +} + +// importDocuments 导入指定文档及历史版本并选中首个导入文档 +// 入参: args 文档序号表达式、插入位置、解密确认和可选进度回调 +// 返回: any 文档信息及当前页, error 导入错误 +func (e *businessEngine) importDocuments(args []platformValue) (any, error) { + if e.currentEditor == nil || e.pendingImport == nil { + return nil, fmt.Errorf("no document is ready for document import") + } + if len(args) < 3 { + return nil, fmt.Errorf("invalid document import arguments") + } + var indexes []int + var err error + if value := strings.TrimSpace(args[0].String()); value != "" { + indexes, err = ofdgo.ParsePageRange(value, e.pendingImport.DocumentCount()) + if err != nil { + return nil, err + } + } + options := ofdgo.DocumentImportOptions{AllowDecrypted: args[2].Bool()} + ctx := e.operationContext() + if len(args) > 3 { + ctx, options.OnProgress = e.newEditorOperationContext(args[3]) + } + if _, err := e.currentEditor.ImportDocumentsContext(ctx, e.pendingImport, indexes, args[1].Int(), options); err != nil { + return nil, err + } + e.clearImport() + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + return editorPageInfo{info, 0}, err +} + +// importImages 逐张读取图片并建页,负数位置表示新建文档 +// 入参: args 文件名、选项、插入位置、标题、注解设置、读取与进度回调 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) importImages(args []platformValue) (any, error) { + var options ofdgo.ImagePageOptions + if err := json.Unmarshal([]byte(platformJSON(args[1])), &options); err != nil { + return nil, err + } + at := args[2].Int() + editor := e.currentEditor + if at < 0 { + editor = ofdgo.NewEditor() + editor.Info.Title = args[3].String() + } else if editor == nil { + return nil, fmt.Errorf("document is not editable") + } + if args[0].Length() == 0 { + return nil, fmt.Errorf("请选择图片") + } + sources := make([]ofdgo.ImagePageSource, args[0].Length()) + for i := range sources { + sources[i] = ofdgo.ImagePageSource{Name: args[0].Index(i).String(), Read: func(context.Context) ([]byte, error) { + result := args[5].Invoke(i) + if message := result.Get("error"); message.Type() == platformTypeString { + return nil, fmt.Errorf("%s", message.String()) + } + return e.bytesFromJS(result.Get("bytes")) + }} + } + options.OnProgress = e.editorOperationProgress(args[6]) + position := max(at, 0) + if err := editor.ImportImages(e.operationContext(), sources, position, options); err != nil { + return nil, err + } + if at < 0 { + editor.SetHistoryLimit(100) + } + e.clearImport() + info, err := e.previewEditor(editor, args[4].Bool()) + return editorPageInfo{info, position}, err +} + +// createDocument 新建单页文档 +// 入参: args 标题、纸张宽高和注解设置 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) createDocument(args []platformValue) (any, error) { + e.clearImport() + editor := ofdgo.NewEditor() + editor.Info.Title = args[0].String() + if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil { + return nil, err + } + editor.SetHistoryLimit(100) + return e.previewEditor(editor, args[3].Bool()) +} + +// updateInfo 修改文档信息和自定义字段,保留其他元数据及创建程序标识 +// 入参: args 标题、作者、主题、可选自定义字段JSON及文档索引 +// 返回: any 编辑状态, error 错误信息 +func (e *businessEngine) updateInfo(args []platformValue) (any, error) { + return e.changeObjects(func() error { + if len(args) < 3 || len(args) > 5 { + return fmt.Errorf("invalid document info arguments") + } + index := e.currentEditor.DocumentIndex() + if len(args) > 4 { + index = args[4].Int() + } + info, err := e.currentEditor.DocumentInfo(index) + if err != nil { + return err + } + info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String() + if len(args) > 3 { + var submitted []struct { + ofdgo.CustomData + Source *int + } + if err := json.Unmarshal([]byte(args[3].String()), &submitted); err != nil { + return err + } + var previous []ofdgo.CustomData + if info.CustomDatas != nil { + previous = info.CustomDatas.CustomData + } + fields := make([]ofdgo.CustomData, len(submitted)) + for i, field := range submitted { + if field.Source != nil { + if *field.Source < 0 || *field.Source >= len(previous) { + return fmt.Errorf("invalid custom data source") + } + fields[i] = previous[*field.Source] + } + fields[i].Name, fields[i].Value = field.Name, field.Value + } + if !slices.Equal(previous, fields) { + info.CustomDatas = &ofdgo.CustomDatas{CustomData: fields} + } + } + return e.currentEditor.SetDocumentInfo(index, info) + }) +} + +// packageDocuments 按需读取管理列表的元数据和页数,不切换当前文档或解析页面内容 +// 入参: args 保留参数 +// 返回: any 文档列表, error 编辑状态错误 +func (e *businessEngine) packageDocuments(args []platformValue) (any, error) { + if e.currentSession == nil || e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + entries := make([]DocumentEntry, e.currentEditor.DocumentCount()) + for i := range entries { + info, err := e.currentEditor.DocumentInfo(i) + if err != nil { + return nil, err + } + entry := &entries[i] + body := e.currentSession.Reader.OFD.DocBody[i] + entry.Index, entry.Root, entry.ID = i, body.DocRoot, info.DocID + entry.Title, entry.Author, entry.Subject = info.Title, info.Author, info.Subject + if info.CustomDatas != nil { + entry.CustomData = info.CustomDatas.CustomData + } + if i == e.currentEditor.DocumentIndex() { + count := e.currentEditor.PageCount() + entry.PageCount = &count + } else if count, err := e.currentSession.Reader.DocumentPageCount(i); err == nil { + entry.PageCount = &count + } + } + return entries, nil +} + +// documentVersions 读取当前文档的版本列表,包含尚未保存的编辑内容 +// 入参: args 保留参数 +// 返回: any 版本列表, error 读取错误 +func (e *businessEngine) documentVersions(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + reader := e.currentSession.Reader + if e.currentEditor != nil { + var err error + reader, err = e.currentEditor.Reader() + if err != nil { + return nil, err + } + } + versions, err := reader.DocumentVersions() + if err != nil { + return nil, err + } + entries := make([]VersionEntry, 0, len(versions)+1) + hasMain := reader.OFD.DocBody[reader.DocumentIndex()].DocRoot != "" + if hasMain { + entries = append(entries, VersionEntry{Default: true}) + } + for _, version := range versions { + entries = append(entries, VersionEntry{ID: version.ID, Index: version.Index, Name: version.Name, Default: version.Current}) + if version.Current && hasMain { + entries[0].Default = false + } + } + return entries, nil +} + +// styleResource 读取当前文档中的颜色空间或共享绘制参数 +// 入参: args 类型和资源标识 +// 返回: any 资源定义, error 读取错误 +func (e *businessEngine) styleResource(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 2 { + return nil, fmt.Errorf("invalid style resource") + } + ctx := e.operationContext() + switch args[0].String() { + case "ColorSpace": + return e.currentSession.Reader.ColorSpace(ctx, args[1].String()) + case "DrawParam": + return e.currentSession.Reader.DrawParam(ctx, args[1].String(), false) + default: + return nil, fmt.Errorf("invalid style resource kind") + } +} + +// changeStyleResource 注册或替换共享样式资源,未重新选择ICC时保留既有配置 +// 入参: args 类型、标识、定义JSON、可选ICC字节和RGB颜色增量JSON +// 返回: any 编辑状态, error 校验或提交错误 +func (e *businessEngine) changeStyleResource(args []platformValue) (any, error) { + if len(args) != 5 { + return nil, fmt.Errorf("invalid style resource arguments") + } + return e.changeAtomicObjects(func() error { + ctx := e.operationContext() + kind, id := args[0].String(), args[1].String() + if kind == "ColorSpace" { + var space ofdgo.ColorSpace + if err := json.Unmarshal([]byte(args[2].String()), &space); err != nil { + return err + } + var profile []byte + var err error + if !args[3].IsNull() && !args[3].IsUndefined() { + profile, err = e.bytesFromJS(args[3]) + } else if id != "" { + var original *ofdgo.ColorSpace + original, err = e.currentEditor.ColorSpace(id) + if err == nil && original.Profile != "" { + var stream io.ReadCloser + stream, err = e.currentSession.Reader.OpenResource(ctx, kind, id) + if err == nil { + defer stream.Close() + profile, err = io.ReadAll(stream) + } + } + } + if err != nil { + return err + } + if id == "" { + _, err = e.currentEditor.ImportColorSpace(ctx, space, profile) + return err + } + return e.currentEditor.ReplaceColorSpace(ctx, id, space, profile) + } + if kind != "DrawParam" { + return fmt.Errorf("invalid style resource kind") + } + var draw ofdgo.DrawParam + var colors map[string]string + if err := json.Unmarshal([]byte(args[2].String()), &draw); err != nil { + return err + } + if err := json.Unmarshal([]byte(args[4].String()), &colors); err != nil { + return err + } + for key, value := range colors { + paint := &ofdgo.FillColor{} + if key == "FillColor" && draw.FillColor != nil { + paint.Alpha = draw.FillColor.Alpha + } else if key == "StrokeColor" && draw.StrokeColor != nil { + paint.Alpha = draw.StrokeColor.Alpha + } + if err := e.setEditorColor(paint, value); err != nil { + return err + } + if key == "FillColor" { + draw.FillColor = paint + } else if key == "StrokeColor" { + draw.StrokeColor = (*ofdgo.StrokeColor)(paint) + } else { + return fmt.Errorf("invalid paint target") + } + } + if id == "" { + _, err := e.currentEditor.ImportDrawParam(ctx, draw) + return err + } + return e.currentEditor.ReplaceDrawParam(ctx, id, draw) + }) +} + +// applyStyleResource 为指定图元设置共享绘制参数或颜色,库层负责颜色及对象校验 +// 入参: args 类型、资源标识、页码、图元标识及颜色JSON +// 返回: any 编辑状态, error 校验或提交错误 +func (e *businessEngine) applyStyleResource(args []platformValue) (any, error) { + if len(args) != 5 { + return nil, fmt.Errorf("invalid style application") + } + return e.changeAtomicObjects(func() error { + page, ids := args[2].Int(), []string{args[3].String()} + if args[0].String() == "DrawParam" { + return e.currentEditor.SetObjectDrawParam(page, ids, args[1].String()) + } + if args[0].String() != "ColorSpace" || args[1].String() == "" { + return fmt.Errorf("invalid color space") + } + var paint struct{ Target, Mode, Value string } + if err := json.Unmarshal([]byte(args[4].String()), &paint); err != nil { + return err + } + fill := &ofdgo.FillColor{ColorSpace: args[1].String()} + if paint.Mode == "index" { + index, err := strconv.Atoi(paint.Value) + if err != nil { + return err + } + fill.Index = &index + } else if paint.Mode == "value" { + fill.Value = paint.Value + } else { + return fmt.Errorf("invalid color mode") + } + style := ofdgo.ObjectStyle{} + if paint.Target == "fill" { + style.FillColor = fill + } else if paint.Target == "stroke" { + style.StrokeColor = (*ofdgo.StrokeColor)(fill) + } else { + return fmt.Errorf("invalid paint target") + } + return e.currentEditor.StyleObjects(page, ids, style) + }) +} + +// pageSettings 获取当前页设置,图层视图跳过全部普通页模板引用查询,不修改文档 +// 入参: args 页面索引及可选类型layers或templates,省略类型时返回全量数据 +// 返回: any 页面设置, error 读取错误 +func (e *businessEngine) pageSettings(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) < 1 || len(args) > 2 { + return nil, fmt.Errorf("invalid page settings") + } + kind := "templates" + if len(args) == 2 { + kind = args[1].String() + if kind != "layers" && kind != "templates" { + return nil, fmt.Errorf("invalid page settings type %q", kind) + } + } + reader := e.currentSession.Reader + layers, err := reader.Layers(args[0].Int()) + if err != nil { + return nil, err + } + templates, err := reader.Templates() + if err != nil { + return nil, err + } + page, err := reader.PageContentByIndex(args[0].Int()) + if err != nil { + return nil, err + } + references := []ofdgo.TemplateReference{} + if kind == "templates" { + references, err = reader.TemplateReferences() + if err != nil { + return nil, err + } + } + return map[string]any{"layers": layers, "templates": templates, "bindings": page.Template, "templateReferences": references}, nil +} + +// changeLayer 调用图层管理接口,组合修改使用同一次事务 +// 入参: args 操作、页码、图层标识、类型、绘制参数、顺序及图元标识 +// 返回: any 编辑状态, error 校验或提交错误 +func (e *businessEngine) changeLayer(args []platformValue) (any, error) { + if len(args) != 7 { + return nil, fmt.Errorf("invalid layer arguments") + } + return e.changeAtomicObjects(func() error { + page, id := args[1].Int(), args[2].String() + switch args[0].String() { + case "add": + _, err := e.currentEditor.AddLayer(page, args[3].String(), args[4].String()) + return err + case "update": + return e.currentEditor.Transaction(func(editor *ofdgo.Editor) error { + if err := editor.SetLayerType(page, id, args[3].String()); err != nil { + return err + } + return editor.SetLayerDrawParam(page, id, args[4].String()) + }) + case "delete": + return e.currentEditor.DeleteLayer(page, id) + case "move": + return e.currentEditor.MoveLayer(page, id, args[5].Int()) + case "objects": + return e.currentEditor.MoveObjectsToLayer(page, e.stringsFromJS(args[6]), id) + default: + return fmt.Errorf("invalid layer operation") + } + }) +} + +// changeTemplate 调用模板及页面绑定接口,组合修改使用同一次事务 +// 入参: args 操作、页码、模板标识、名称、层次及顺序 +// 返回: any 编辑状态, error 校验或提交错误 +func (e *businessEngine) changeTemplate(args []platformValue) (any, error) { + if len(args) != 6 { + return nil, fmt.Errorf("invalid template arguments") + } + return e.changeAtomicObjects(func() error { + page, id, order := args[1].Int(), args[2].String(), args[4].String() + switch args[0].String() { + case "add": + _, err := e.currentEditor.CreateTemplateFromPage(page, args[3].String(), order) + return err + case "update": + return e.currentEditor.Transaction(func(editor *ofdgo.Editor) error { + if err := editor.RenameTemplate(id, args[3].String()); err != nil { + return err + } + return editor.SetTemplateZOrder(id, order) + }) + case "replace": + return e.currentEditor.ReplaceTemplateFromPage(id, page) + case "delete": + return e.currentEditor.DeleteTemplate(id) + case "bind": + return e.currentEditor.BindTemplate(page, id, order) + case "unbind": + return e.currentEditor.UnbindTemplate(page, id) + case "order": + return e.currentEditor.SetPageTemplateZOrder(page, id, order) + case "move": + return e.currentEditor.MovePageTemplate(page, id, args[5].Int()) + default: + return fmt.Errorf("invalid template operation") + } + }) +} + +// documentNavigation 按需读取命名书签或通用扩展,不解释业务数据 +// 入参: args 设置类型 +// 返回: any 文档导航和扩展信息, error 读取错误 +func (e *businessEngine) documentNavigation(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 1 { + return nil, fmt.Errorf("ofd document is not opened") + } + switch args[0].String() { + case "bookmarks", "actions": + bookmarks, err := e.currentSession.Reader.Bookmarks() + return map[string]any{"bookmarks": bookmarks}, err + case "extensions": + extensions, err := e.currentSession.Reader.Extensions() + return map[string]any{"extensions": extensions}, err + default: + return nil, fmt.Errorf("invalid navigation kind") + } +} + +// changeBookmark 调用库层书签操作,引用更新和版本隔离由编辑器完成 +// 入参: args 操作、原名称及书签JSON +// 返回: any 编辑状态, error 校验或提交错误 +func (e *businessEngine) changeBookmark(args []platformValue) (any, error) { + if len(args) != 3 { + return nil, fmt.Errorf("invalid bookmark arguments") + } + var bookmark ofdgo.Bookmark + if err := json.Unmarshal([]byte(args[2].String()), &bookmark); err != nil { + return nil, err + } + return e.changeAtomicObjects(func() error { + switch args[0].String() { + case "add": + return e.currentEditor.AddBookmark(bookmark.Name, bookmark.Dest) + case "update": + return e.currentEditor.UpdateBookmark(args[1].String(), bookmark) + case "delete": + return e.currentEditor.DeleteBookmark(args[1].String()) + default: + return fmt.Errorf("invalid bookmark operation") + } + }) +} + +// actionList 获取编辑对象的动作序列,返回独立数据,不触发动作 +// 入参: args 归属、页码、图元标识和目录路径 +// 返回: any 动作列表, error 归属或读取错误 +func (e *businessEngine) actionList(args []platformValue) (any, error) { + if e.currentEditor == nil || len(args) != 4 { + return nil, fmt.Errorf("invalid action selection") + } + switch args[0].String() { + case "document": + return e.currentEditor.DocumentActions(), nil + case "page": + return e.currentEditor.PageActions(args[1].Int()) + case "object": + return e.currentEditor.ObjectActions(args[1].Int(), args[2].String()) + case "outline": + return e.currentEditor.OutlineActions(e.indexesFromJS(args[3])) + default: + return nil, fmt.Errorf("invalid action scope") + } +} + +// changeActions 将动作列表交给库层校验和原子提交 +// 入参: args 归属、页码、图元标识、目录路径及动作JSON +// 返回: any 编辑状态, error 校验或提交错误 +func (e *businessEngine) changeActions(args []platformValue) (any, error) { + if len(args) != 5 { + return nil, fmt.Errorf("invalid action arguments") + } + var actions []ofdgo.Action + if err := json.Unmarshal([]byte(args[4].String()), &actions); err != nil { + return nil, err + } + return e.changeAtomicObjects(func() error { + if err := e.currentEditor.ValidateActionTargets(e.operationContext(), actions); err != nil { + return err + } + switch args[0].String() { + case "document": + return e.currentEditor.SetDocumentActions(actions) + case "page": + return e.currentEditor.SetPageActions(args[1].Int(), actions) + case "object": + return e.currentEditor.SetObjectActions(args[1].Int(), []string{args[2].String()}, actions) + case "outline": + return e.currentEditor.SetOutlineActions(e.indexesFromJS(args[3]), actions) + default: + return fmt.Errorf("invalid action scope") + } + }) +} + +// annotationInfos 读取当前文档及版本的注释摘要,不解析页面图元 +// 入参: args 保留参数 +// 返回: any 注释摘要, error 读取错误 +func (e *businessEngine) annotationInfos(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + return e.currentSession.Reader.AnnotationInfos() +} + +// exportVersion 提取指定历史版本并另存,不切换当前视图或修改编辑历史 +// 入参: args 版本标识、写出回调、进度回调及取消检查 +// 返回: any 保存结果, error 提取或写入错误 +func (e *businessEngine) exportVersion(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 4 || args[0].Type() != platformTypeString || args[0].String() == "" { + return nil, fmt.Errorf("invalid version export") + } + ctx := e.newOutputContext(args[3]) + reader := e.currentSession.Reader + if e.currentEditor != nil { + var err error + reader, err = e.currentEditor.ReaderContext(ctx) + if err != nil { + return nil, err + } + } + version, err := reader.ExtractVersionContext(ctx, args[0].String()) + if err != nil { + return nil, err + } + defer version.Close() + editor, err := version.Editor() + if err != nil { + return nil, err + } + return e.saveEditor(editor, []platformValue{args[1], args[2], platformNull(), platformValueOf(map[string]any{"mode": 0}), args[3]}) +} + +// selectDocumentVersion 切换阅读版本,已有编辑器时保留修改及历史,否则直接创建阅读视图 +// 入参: args 版本标识 +// 返回: any 文档信息, error 切换错误 +func (e *businessEngine) selectDocumentVersion(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 1 || args[0].Type() != platformTypeString { + return nil, fmt.Errorf("invalid version selection") + } + id := args[0].String() + if e.currentEditor != nil { + return e.changeAtomicObjects(func() error { return e.currentEditor.SelectVersion(id) }) + } + session, err := e.currentSession.DocumentVersion(id) + if err != nil { + return nil, err + } + _ = e.currentSession.Close() + e.currentSession = session + e.clearImport() + e.copiedObjects, e.copiedStyle = nil, nil + return session.Summary(), nil +} + +// changeDocumentVersion 调用库层版本操作,沿用编辑历史和预览更新流程 +// 入参: args 操作、版本标识及可选名称 +// 返回: any 文档信息, error 操作错误 +func (e *businessEngine) changeDocumentVersion(args []platformValue) (any, error) { + if len(args) != 3 { + return nil, fmt.Errorf("invalid version arguments") + } + return e.changeAtomicObjects(func() error { + id, name := args[1].String(), args[2].String() + switch args[0].String() { + case "add": + return e.currentEditor.Transaction(func(editor *ofdgo.Editor) error { + if err := editor.SelectVersion(id); err != nil { + return err + } + _, err := editor.AddVersion(name) + return err + }) + case "rename": + return e.currentEditor.RenameVersion(id, name) + case "delete": + return e.currentEditor.DeleteVersion(id) + case "default": + return e.currentEditor.SetDefaultVersion(id) + case "select": + return e.currentEditor.SelectVersion(id) + default: + return fmt.Errorf("invalid version operation") + } + }) +} + +// changeAttachment 编辑附件,逐个读取文件并复用库层事务和历史 +// 入参: args 操作、附件标识、名称列表、文件读取回调 +// 返回: any 编辑状态, error 错误信息 +func (e *businessEngine) changeAttachment(args []platformValue) (any, error) { + return e.changeObjects(func() error { + action, id := args[0].String(), args[1].String() + switch action { + case "rename": + return e.currentEditor.RenameAttachment(id, args[2].Index(0).String()) + case "delete": + return e.currentEditor.DeleteAttachment(id) + case "add", "replace": + count := args[2].Length() + if count == 0 || action == "replace" && count != 1 { + return fmt.Errorf("请选择附件") + } + for i := range count { + result := args[3].Invoke(i) + if message := result.Get("error"); message.Type() == platformTypeString { + return fmt.Errorf("%s", message.String()) + } + data, err := e.bytesFromJS(result.Get("bytes")) + if err != nil { + return err + } + if action == "replace" { + return e.currentEditor.ReplaceAttachment(id, data) + } + if _, err := e.currentEditor.AddAttachment(args[2].Index(i).String(), data); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("unsupported attachment action %q", action) + } + }) +} + +// changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态 +// 入参: args 字体标识、保存方式及可选字体数据 +// 返回: any 编辑状态, error 字体处理错误 +func (e *businessEngine) changeFontBinding(args []platformValue) (any, error) { + if len(args) < 2 { + return nil, fmt.Errorf("请选择字体") + } + return e.changeAtomicObjects(func() error { + id, mode := args[0].String(), args[1].Int() + if mode == int(ofdgo.FontExternal) { + return e.currentEditor.ExternalizeFont(e.operationContext(), id) + } + if mode != int(ofdgo.FontEmbed) { + return fmt.Errorf("字体方式无效") + } + var file ofdgo.FontFile + if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() { + data, err := e.bytesFromJS(args[2]) + if err != nil { + return err + } + file.Data = data + } + return e.currentEditor.EmbedFont(e.operationContext(), id, file, 0) + }) +} + +// changePackageDocument 调用库层文档增删、改名或排序,不在浏览器内重写包结构 +// 入参: args 操作及标题、页面尺寸、文档索引或移动位置 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) changePackageDocument(args []platformValue) (any, error) { + if e.currentEditor == nil || len(args) == 0 { + return nil, fmt.Errorf("no document is being edited") + } + return e.changeAtomicObjects(func() error { + var err error + switch args[0].String() { + case "add": + if len(args) != 5 { + return fmt.Errorf("invalid document arguments") + } + _, err = e.currentEditor.AddDocument(args[1].String(), args[2].Float(), args[3].Float(), args[4].Int()) + case "delete": + if len(args) != 2 { + return fmt.Errorf("invalid document arguments") + } + err = e.currentEditor.DeleteDocument(args[1].Int()) + case "move": + if len(args) != 3 { + return fmt.Errorf("invalid document arguments") + } + err = e.currentEditor.MoveDocument(args[1].Int(), args[2].Int()) + case "rename": + if len(args) != 3 { + return fmt.Errorf("invalid document arguments") + } + err = e.currentEditor.RenameDocument(args[1].Int(), args[2].String()) + default: + return fmt.Errorf("invalid document operation") + } + if err == nil { + e.copiedObjects, e.copiedStyle = nil, nil + e.clearImport() + } + return err + }) +} + +// selectDocument 切换包内文档,保留编辑快照并清除跨文档剪贴板 +// 入参: args 文档索引 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) selectDocument(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) == 0 { + return nil, fmt.Errorf("ofd document is not opened") + } + session, err := e.currentSession.Document(args[0].Int()) + if err != nil { + return nil, err + } + _ = e.currentSession.Close() + e.currentSession, e.currentEditor = session, session.editor + if e.pendingImport != nil { + _ = e.pendingImport.Close() + e.pendingImport = nil + } + e.copiedObjects, e.copiedStyle = nil, nil + return e.editorSummary(), nil +} + +// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置 +// 入参: args 当前页索引,省略时使用首页 +// 返回: any 编辑状态, error 错误信息 +func (e *businessEngine) editDocument(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if e.currentEditor != nil { + e.currentSession.editing = true + return e.editorSummary(), nil + } + editor, err := e.currentSession.Reader.Editor() + if err != nil { + return nil, err + } + editor.SetHistoryLimit(100) + if e.currentSession.fontFS != nil { + editor.SetFontFS(e.currentSession.fontFS) + } + editor.SetRenderBackends(e.currentSession.Renderer.Backends()) + page := 0 + if len(args) != 0 { + page = args[0].Int() + } + content, err := editor.Page(page) + if err != nil { + return nil, err + } + if err := e.syncEditorPageIDs(page, content); err != nil { + return nil, err + } + e.currentEditor = editor + e.currentSession.editor = editor + e.currentSession.editing = true + e.copiedObjects = nil + e.copiedStyle = nil + return e.editorSummary(), nil +} + +// syncEditorPageIDs 同步编辑副本的对象编号,不改变原渲染属性或丢弃正常页面缓存 +// 入参: index 页面索引, content 编辑页面 +// 返回: error 页面读取错误 +func (e *businessEngine) syncEditorPageIDs(index int, content *ofdgo.PageContent) error { + page, err := e.currentSession.pageContent(index) + if err != nil { + return err + } + for i, layer := range content.Content.Layer { + for j, object := range layer.Objects { + before := &page.Content.Layer[i].Objects[j] + if before.TextObject.ID != object.TextObject.ID || before.PathObject.ID != object.PathObject.ID || before.ImageObject.ID != object.ImageObject.ID || before.CompositeGraphicUnit.ID != object.CompositeGraphicUnit.ID { + before.TextObject.ID = object.TextObject.ID + before.PathObject.ID = object.PathObject.ID + before.ImageObject.ID = object.ImageObject.ID + before.CompositeGraphicUnit.ID = object.CompositeGraphicUnit.ID + delete(e.currentSession.textCache, index) + } + } + } + return nil +} + +// previewEditor 通过内存快照更新预览,不生成中间压缩包 +// 入参: editor 编辑文档, annotations 是否显示注解 +// 返回: editorInfo 文档信息, error 错误信息 +func (e *businessEngine) previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { + reader, err := editor.Reader() + if err != nil { + return editorInfo{}, err + } + session, err := newSession(reader, OpenOptions{RenderAnnotations: annotations}) + if err != nil { + return editorInfo{}, err + } + editor.SetFontFS() + if e.currentSession != nil { + previous := e.currentSession.Reader + oldBody, newBody := previous.OFD.DocBody[previous.DocumentIndex()], reader.OFD.DocBody[reader.DocumentIndex()] + oldVersion, _ := previous.DocumentVersionID() + newVersion, _ := reader.DocumentVersionID() + if oldBody.DocRoot != newBody.DocRoot || oldBody.DocInfo.DocID != newBody.DocInfo.DocID || oldVersion != newVersion { + e.copiedObjects, e.copiedStyle = nil, nil + e.clearImport() + } + ofdgo.WithRenderBackends(e.currentSession.Renderer.Backends())(session.Renderer) + if e.currentSession.fontFS != nil { + session.fontFS = e.currentSession.fontFS + session.Renderer.SetFontFS(session.fontFS) + editor.SetFontFS(session.fontFS) + } + _ = e.currentSession.Close() + } + editor.SetRenderBackends(session.Renderer.Backends()) + e.currentSession = session + session.editor = editor + session.editing = true + if e.currentEditor != editor { + e.copiedObjects = nil + e.copiedStyle = nil + } + e.currentEditor = editor + return e.editorSummary(), nil +} + +// setEditorColor 写入当前文档的RGB纯色,保留透明度并替换原颜色空间和索引 +// 入参: fill 颜色, value 十六进制色值 +// 返回: error 错误信息 +func (e *businessEngine) setEditorColor(fill *ofdgo.FillColor, value string) error { + if len(value) != 7 || value[0] != '#' { + return fmt.Errorf("invalid RGB color %q", value) + } + rgb, err := hex.DecodeString(value[1:]) + if err != nil { + return err + } + converted, err := e.currentEditor.RGBColor(color.NRGBA{R: rgb[0], G: rgb[1], B: rgb[2], A: 255}) + if err != nil { + return err + } + converted.Alpha = fill.Alpha + *fill = *converted + return nil +} + +// editorColorHex 将当前文档纯色转换为浏览器色值 +// 入参: value 颜色 +// 返回: string 十六进制色值 +func (e *businessEngine) editorColorHex(value *ofdgo.FillColor) string { + converted, err := e.currentEditor.Color(value) + if err != nil { + return "" + } + return fmt.Sprintf("#%02x%02x%02x", converted.R, converted.G, converted.B) +} + +// editorPathScale 获取路径线宽的页面缩放比例 +// 入参: object 路径对象 +// 返回: float64 缩放比例 +func (e *businessEngine) editorPathScale(object ofdgo.PathObject) float64 { + m := ofdgo.NewMatrix(object.CTM) + x, y := m.Transform(0, 0) + ax, ay := m.Transform(1, 0) + bx, by := m.Transform(0, 1) + return math.Sqrt(math.Abs((ax-x)*(by-y) - (ay-y)*(bx-x))) +} + +// setPathStyle 应用图形填充、描边和线宽 +// 入参: object 路径对象, args 填充开关及色值、描边开关及色值、线宽,null保持原值 +// 返回: error 错误信息 +func (e *businessEngine) setPathStyle(object *ofdgo.PathObject, args []platformValue) error { + fill, stroke := object.Fill != nil && *object.Fill, object.Stroke == nil || *object.Stroke + if !args[0].IsNull() { + fill = args[0].Bool() + } + if !args[2].IsNull() { + stroke = args[2].Bool() + } + if kind, _ := object.Shape(); kind == ofdgo.ShapeLine || kind == ofdgo.ShapeArrow || kind == ofdgo.ShapeDoubleArrow { + fill, stroke = false, true + } + if !fill && !stroke { + if args[0].IsNull() || args[2].IsNull() { + stroke = true + } else { + return fmt.Errorf("fill or stroke must be enabled") + } + } + if !args[0].IsNull() || fill != (object.Fill != nil && *object.Fill) { + object.Fill = &fill + } + if !args[2].IsNull() || stroke != (object.Stroke == nil || *object.Stroke) { + object.Stroke = &stroke + } + if !args[4].IsNull() { + width := args[4].Float() + if width < 0 || math.IsNaN(width) || math.IsInf(width, 0) { + return fmt.Errorf("line width must be nonnegative and finite") + } + if scale := e.editorPathScale(*object); width != object.LineWidth*scale { + object.LineWidth = width / scale + } + object.LineWidthSet = width == 0 + } + if !args[1].IsNull() { + if object.FillColor == nil { + object.FillColor = &ofdgo.FillColor{} + } + if err := e.setEditorColor(object.FillColor, args[1].String()); err != nil { + return err + } + } + if !args[3].IsNull() { + if object.StrokeColor == nil { + object.StrokeColor = &ofdgo.StrokeColor{} + } + if err := e.setEditorColor((*ofdgo.FillColor)(object.StrokeColor), args[3].String()); err != nil { + return err + } + } + return nil +} + +// insertShape 创建标准路径图形 +// 入参: args 页码、图形类型、范围和绘制样式 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) insertShape(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + path, err := ofdgo.NewShape(ofdgo.ShapeKind(args[1].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + if err != nil { + return nil, err + } + if err := e.setPathStyle(&path, args[6:]); err != nil { + return nil, err + } + return e.insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[11:]) +} + +// updatePathStyle 原子更新选中路径的绘制样式,保留未修改属性 +// 入参: args 页码、对象标识列表和绘制样式 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) updatePathStyle(args []platformValue) (any, error) { + return e.changeObjects(func() error { + page, ids := args[0].Int(), e.stringsFromJS(args[1]) + objects := make([]ofdgo.GraphicObject, len(ids)) + for i, id := range ids { + object, err := e.currentEditor.Object(page, id) + if err != nil { + return err + } + if object.Type != "PathObject" { + return fmt.Errorf("object is not a path") + } + if err := e.setPathStyle(&object.PathObject, args[2:]); err != nil { + return err + } + objects[i] = object + } + return e.currentEditor.UpdateObjects(page, objects) + }) +} + +// editorBox 将库层毫米边界传递给画布交互层 +// 入参: box 毫米坐标中的矩形范围 +// 返回: map[string]any 可直接传递给JavaScript的横纵坐标和宽高 +func (e *businessEngine) editorBox(box ofdgo.Box) map[string]any { + return map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H} +} + +// editorMatrix 将标准CTM转换为浏览器仿射矩阵分量 +// 入参: matrix 库层仿射矩阵 +// 返回: []any 可直接传递给JavaScript的a、b、c、d、e、f分量,位移单位为毫米 +func (e *businessEngine) editorMatrix(matrix ofdgo.Matrix) []any { + x, y := matrix.Transform(0, 0) + ax, ay := matrix.Transform(1, 0) + bx, by := matrix.Transform(0, 1) + return []any{ax - x, ay - y, bx - x, by - y, x, y} +} + +// rotateObjects 旋转同页选区,通用几何与历史记录由库负责 +// 入参: args 页面索引、对象标识数组和顺时针角度 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) rotateObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.RotateObjects(args[0].Int(), e.stringsFromJS(args[1]), args[2].Int()) + }) +} + +// flipObjects 镜像同页选区 +// 入参: args 页面索引、对象标识数组和镜像轴 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) flipObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.FlipObjects(args[0].Int(), e.stringsFromJS(args[1]), args[2].String()) + }) +} + +// cropImage 设置图片裁剪范围,保留原始图片数据 +// 入参: args 页面索引、对象标识及页面毫米坐标中的横纵坐标和宽高 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) cropImage(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.CropImage(args[0].Int(), args[1].String(), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + }) +} + +// resetImageCrop 还原跨范围复制图片的会话裁剪,不移除原始及父级裁剪 +// 入参: args 页面索引及对象标识 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) resetImageCrop(args []platformValue) (any, error) { + return e.changeObjects(func() error { return e.currentEditor.ResetImageCrop(args[0].Int(), args[1].String()) }) +} + +// fitImage 按原始比例适应或填充图片框 +// 入参: args 页面索引、对象标识和适应方式 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) fitImage(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.FitImage(args[0].Int(), args[1].String(), args[2].String()) + }) +} + +// previewImage 在页面副本中显示指定图片的完整内容,不改变正文、历史或缓存 +// 入参: args 页面索引和图片对象标识 +// 返回: any 页面SVG和分离的图片资源, error 错误信息 +func (e *businessEngine) previewImage(args []platformValue) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + page, err := e.currentEditor.Page(args[0].Int()) + if err != nil { + return nil, err + } + id := args[1].String() + found := false + for i := range page.Content.Layer { + for j := range page.Content.Layer[i].Objects { + object := &page.Content.Layer[i].Objects[j] + if object.Type == "ImageObject" && object.ImageObject.ID == id { + object.ImageObject.Clips = nil + found = true + } + } + } + if !found { + return nil, fmt.Errorf("image object %q not found", id) + } + var out bytes.Buffer + resources, err := e.currentSession.Renderer.RenderToSVGWithResources(page, &out) + if err != nil { + return nil, err + } + return successResult(map[string]any{"svg": out.String(), "images": e.svgImagesToJS(resources.Images)}), nil +} + +// svgImagesToJS 将图片资源以二进制数组传给前端,不使用base64 +// 入参: images SVG引用的图片资源 +// 返回: []any 图片标识、类型和数据 +func (e *businessEngine) svgImagesToJS(images []ofdgo.SVGImage) []any { + items := make([]any, len(images)) + for i, image := range images { + items[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)} + } + return items +} + +// transformObjects 统一移动或缩放选区,生成一次撤销记录 +// 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) transformObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.TransformObjects(args[0].Int(), e.stringsFromJS(args[1]), args[2].Float(), args[3].Float(), args[4].Float()) + }) +} + +// alignObjects 对齐选区中的对象 +// 入参: args 页面索引、对象标识数组和对齐方式 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) alignObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.AlignObjects(args[0].Int(), e.stringsFromJS(args[1]), args[2].String()) + }) +} + +// deleteObjects 一次删除选区中的对象 +// 入参: args 页面索引和对象标识数组 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) deleteObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.DeleteObjects(args[0].Int(), e.stringsFromJS(args[1])) + }) +} + +// eraseObjectsPath 按页面闭合折线范围擦除对象,保留局部裁剪之外的内容 +// 入参: args 页面索引、对象标识数组和坐标点数组 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) eraseObjectsPath(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.EraseObjectsPath(args[0].Int(), e.stringsFromJS(args[1]), e.pointsFromJS(args[2])) + }) +} + +// pointsFromJS 读取前端页面坐标点数组 +// 入参: value 坐标点数组 +// 返回: []ofdgo.Point 页面毫米坐标 +func (e *businessEngine) pointsFromJS(value platformValue) []ofdgo.Point { + points := make([]ofdgo.Point, value.Length()) + for i := range points { + point := value.Index(i) + points[i] = ofdgo.Point{X: point.Get("x").Float(), Y: point.Get("y").Float()} + } + return points +} + +// eraseObjects 按页面矩形范围擦除对象,保留局部裁剪之外的内容 +// 入参: args 页面索引、对象标识数组和毫米坐标范围 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) eraseObjects(args []platformValue) (any, error) { + return e.changeObjects(func() error { + return e.currentEditor.EraseObjects(args[0].Int(), e.stringsFromJS(args[1]), ofdgo.Box{ + X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float(), + }) + }) +} + +// changeObjects 提交库层批量操作,无修改时复用当前预览 +// 入参: apply 待执行的编辑操作 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) changeObjects(apply func() error) (any, error) { + return e.changeAtomicObjects(func() error { + return e.currentEditor.Transaction(func(*ofdgo.Editor) error { return apply() }) + }) +} + +// changeAtomicObjects 执行库层原子操作,按文档版本更新预览 +// 入参: apply 已具备事务保证的编辑操作 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) changeAtomicObjects(apply func() error) (any, error) { + if e.currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + revision := e.currentEditor.Revision() + if err := apply(); err != nil { + return nil, err + } + if e.currentEditor.Revision() == revision { + return e.editorSummary(), nil + } + return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) +} + +// insertImage 按原始比例创建图片对象 +// 入参: args 页码、图片数据、横纵坐标和宽度 +// 返回: any 文档信息, error 错误信息 +func (e *businessEngine) insertImage(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 + } + resourceID, err := e.currentEditor.AddImage(data) + if err != nil { + return nil, err + } + size, err := e.currentEditor.ImageSize(resourceID) + if err != nil { + return nil, err + } + width := args[4].Float() + box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: width, H: width * float64(size.Y) / float64(size.X)} + content, err := e.currentSession.pageContent(args[0].Int()) + if err != nil { + return nil, err + } + pageBox, err := e.currentSession.pageBox(args[0].Int(), content) + if err != nil { + return nil, err + } + if height := pageBox.H - box.Y; box.H > height { + box.W *= height / box.H + box.H = height + } + object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ + ResourceID: resourceID, Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), + }} + return e.insertEditorObject(args[0].Int(), object, args[5:]) +} + +// insertEditorObject 复用顶层和内部范围插入流程,返回稳定选区 +// 入参: page 页码, object 新对象, scope 可选父路径 +// 返回: any 文档信息及新选区, error 错误信息 +func (e *businessEngine) insertEditorObject(page int, object ofdgo.GraphicObject, scope []platformValue) (any, error) { + var id string + var err error + if len(scope) > 0 && scope[0].String() != "" { + key := scope[0].String() + path, parseErr := e.compositePath(key) + if parseErr != nil { + return nil, parseErr + } + var index int + index, err = e.currentEditor.AddCompositeObject(page, path, object) + id = fmt.Sprintf("%s/%d", key, index) + } else { + id, err = e.currentEditor.AddObject(page, object) + } + if err != nil { + return nil, err + } + info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations) + return editorSelectionInfo{info, []string{id}}, err +} + +// editorOperationProgress 节流长操作检查点并向浏览器让出执行,保证阶段开始和结束可取消 +// 入参: callback 浏览器进度回调 +// 返回: func(string, int, int) error 进度回调 +func (e *businessEngine) editorOperationProgress(callback platformValue) func(string, int, int) error { + var lastStage string + var lastProgress time.Time + return func(stage string, completed, total int) error { + if stage == lastStage && (total == 0 || completed != total) && time.Since(lastProgress) < 32*time.Millisecond { + return nil + } + lastStage, lastProgress = stage, time.Now() + return awaitExport(callback, stage, completed, total) + } +} + +// credentialsFromJS 解析本次打开的口令或本机证书私钥 +// 入参: value 解锁表单数据 +// 返回: ofdgo.Credentials 会话凭据, error 错误信息 +func (e *businessEngine) credentialsFromJS(value platformValue) (ofdgo.Credentials, error) { + credentials := ofdgo.Credentials{UserName: value.Get("userName").String()} + if keyValue := value.Get("key"); !keyValue.IsNull() && !keyValue.IsUndefined() { + key, err := e.bytesFromJS(keyValue) + if err != nil { + return credentials, err + } + defer clear(key) + certificate, err := e.bytesFromJS(value.Get("certificate")) + if err != nil { + return credentials, err + } + var password []byte + if field := value.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { + password, err = e.bytesFromJS(field) + if err != nil { + return credentials, err + } + defer clear(password) + } + signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) + if err != nil { + return credentials, ofdgo.ErrInvalidCredentials + } + decrypter, ok := signer.(crypto.Decrypter) + if !ok { + return credentials, ofdgo.ErrInvalidCredentials + } + credentials.Certificate, credentials.Decrypter = certificate, decrypter + return credentials, nil + } + var err error + credentials.Password, err = e.bytesFromJS(value.Get("password")) + return credentials, err +} + +// securityFilesFromJS 读取会话内的本机证据文件 +// 入参: value 二进制数组列表 +// 返回: [][]byte 文件内容, error 错误信息 +func (e *businessEngine) securityFilesFromJS(value platformValue) ([][]byte, error) { + if value.IsUndefined() || value.IsNull() { + return nil, nil + } + files := make([][]byte, value.Length()) + for i := range files { + var err error + files[i], err = e.bytesFromJS(value.Index(i)) + if err != nil { + return nil, err + } + } + return files, nil +} + +// verifySignatures 按本机信任锚和离线证据重新验签,不设置联网回调 +// 入参: args 信任根、辅助证书、时间戳及撤销证据 +// 返回: any 分项报告, error 错误信息 +func (e *businessEngine) verifySignatures(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) == 0 { + return nil, fmt.Errorf("missing signature verification arguments") + } + input := args[0] + files := make(map[string][][]byte) + for _, name := range []string{"roots", "certificates", "timestampRoots", "tokens", "crls", "ocsp"} { + data, err := e.securityFilesFromJS(input.Get(name)) + if err != nil { + return nil, err + } + files[name] = data + } + now := time.Now() + policy := ofdgo.SignaturePolicy{RejectWeakAlgorithms: true, RequireDocumentCoverage: true, + RequireTimestamp: input.Get("requireTimestamp").Bool(), RequireRevocation: input.Get("requireRevocation").Bool()} + options := []ofdgo.SignatureVerifyOption{ + ofdgo.WithSignatureVerifyTime(now), ofdgo.WithSignaturePolicy(policy), + ofdgo.WithSignatureTrustCerts(files["roots"]...), ofdgo.WithSignatureCerts(files["certificates"]...), + } + if policy.RequireTimestamp || len(files["timestampRoots"])+len(files["tokens"]) > 0 { + options = append(options, ofdgo.WithSignatureTimestamp(ofdgo.SignatureTimestampOptions{ + TrustCerts: files["timestampRoots"], Certificates: files["certificates"], Tokens: files["tokens"], Required: policy.RequireTimestamp, VerifyTime: &now, + })) + } + if policy.RequireRevocation || len(files["crls"])+len(files["ocsp"]) > 0 { + options = append(options, ofdgo.WithSignatureRevocation(ofdgo.SignatureRevocationOptions{ + SignatureRevocationEvidence: ofdgo.SignatureRevocationEvidence{CRLs: files["crls"], OCSPResponses: files["ocsp"]}, + Certificates: append(files["certificates"], files["roots"]...), Required: policy.RequireRevocation, VerifyTime: &now, + })) + } + reports, err := e.currentSession.Reader.VerifyDocumentSignatures(e.currentSession.Reader.DocumentIndex(), options...) + if err != nil { + return nil, err + } + infos := make([]SignatureInfo, len(reports)) + for i, report := range reports { + infos[i] = signatureInfo(report) + } + e.currentSession.signatures, e.currentSession.signatureError, e.currentSession.signaturesRead = infos, nil, true + return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil +} + +// setEncryption 设置当前文档的保存策略,不修改页面或撤销历史 +// 入参: args 加密选项,null选择明文保存 +// 返回: any 编辑状态, error 错误信息 +func (e *businessEngine) setEncryption(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) != 1 { + return nil, fmt.Errorf("missing encryption arguments") + } + options, err := e.encryptionOptionsFromJS(args[0]) + if err != nil { + return nil, err + } + if options != nil { + defer clear(options.Password) + } + if e.currentEditor == nil { + if _, err := e.editDocument(nil); err != nil { + return nil, err + } + } + if err := e.currentEditor.SetEncryption(options); err != nil { + return nil, err + } + return e.editorSummary(), nil +} + +// encryptionOptionsFromJS 读取加密选项并校验接收者证书 +// 入参: value 加密选项,null选择明文保存 +// 返回: *ofdgo.EncryptionOptions 加密选项, error 错误信息 +func (e *businessEngine) encryptionOptionsFromJS(value platformValue) (*ofdgo.EncryptionOptions, error) { + if value.IsNull() { + return nil, nil + } + if value.Type() != platformTypeObject { + return nil, ofdgo.ErrEncryptionPolicyRequired + } + var password []byte + if field := value.Get("password"); !field.IsNull() && !field.IsUndefined() { + var err error + password, err = e.bytesFromJS(field) + if err != nil { + return nil, err + } + } + options := &ofdgo.EncryptionOptions{Password: password} + success := false + defer func() { + if !success { + clear(password) + } + }() + if user := value.Get("userName"); user.Type() == platformTypeString { + options.UserName = user.String() + } + if recipients := value.Get("recipients"); !recipients.IsUndefined() && !recipients.IsNull() { + for i := 0; i < recipients.Length(); i++ { + item := recipients.Index(i) + data, err := e.bytesFromJS(item.Get("certificate")) + if err != nil { + return nil, err + } + certificate, err := ofdgo.ParseEncryptionCertificate(data) + if err != nil { + return nil, fmt.Errorf("接收者证书无效或不支持SM2加密") + } + options.Recipients = append(options.Recipients, ofdgo.EncryptionRecipient{UserName: item.Get("userName").String(), Certificate: certificate}) + } + } + if len(password) == 0 && len(options.Recipients) == 0 { + return nil, ofdgo.ErrEncryptionPolicyRequired + } + success = true + return options, nil +} + +// previewSignatureSeal 渲染独立印章预览并读取所选页面区域,不接触私钥或修改文档 +// 入参: args 印章数据和页码范围 +// 返回: any 印章图像、声明尺寸和页面区域, error 错误信息 +func (e *businessEngine) previewSignatureSeal(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) < 2 { + return nil, fmt.Errorf("missing seal preview arguments") + } + data, err := e.bytesFromJS(args[0]) + if err != nil { + return nil, err + } + seal, err := ofdgo.ParseSignatureSeal(data) + if err != nil { + return nil, err + } + indices, err := ofdgo.ParsePageRange(args[1].String(), len(e.currentSession.doc.Pages.Page)) + if err != nil { + return nil, err + } + pages := make([]map[string]any, 0, len(indices)) + for _, index := range indices { + area, err := e.currentSession.Reader.PageArea(e.currentSession.doc.Pages.Page[index]) + if err != nil { + return nil, err + } + box, err := ofdgo.ParseBox(area.PhysicalBox) + if err != nil { + return nil, err + } + pages = append(pages, map[string]any{"index": index, "x": box.X, "y": box.Y, "width": box.W, "height": box.H}) + } + editor := ofdgo.NewEditor() + if _, err := editor.AddPage(seal.Width, seal.Height); err != nil { + return nil, err + } + reader, err := editor.Reader() + if err != nil { + return nil, err + } + defer reader.Close() + doc, err := reader.Doc() + if err != nil { + return nil, err + } + reader.Stamps = map[string][]ofdgo.Stamp{doc.Pages.Page[0].ID: {{Box: ofdgo.Box{W: seal.Width, H: seal.Height}, Type: seal.Type, Data: seal.Data}}} + renderer := ofdgo.NewRenderer(reader, ofdgo.WithRenderBackends(e.currentSession.Renderer.Backends())) + renderer.TransparentBackground = true + if e.currentSession.fontFS != nil { + renderer.SetFontFS(e.currentSession.fontFS) + } + page, err := reader.PageContentByIndex(0) + if err != nil { + return nil, err + } + var svg bytes.Buffer + if err := renderer.RenderToSVG(page, &svg); err != nil { + return nil, err + } + return map[string]any{"svg": svg.String(), "width": seal.Width, "height": seal.Height, "pages": pages}, nil +} + +// signatureStampsFromJS 复用库层坐标校验生成页面或骑缝印章位置 +// 入参: input 页面和毫米坐标参数 +// 返回: []ofdgo.SignatureStamp 印章位置, error 校验错误 +func (e *businessEngine) signatureStampsFromJS(input platformValue) ([]ofdgo.SignatureStamp, error) { + count, err := e.currentSession.Reader.PageCount() + if err != nil { + return nil, err + } + indices, err := ofdgo.ParsePageRange(input.Get("pages").String(), count) + if err != nil { + return nil, err + } + pages := make([]int, len(indices)) + for i, index := range indices { + pages[i] = index + 1 + } + box := ofdgo.Box{X: input.Get("x").Float(), Y: input.Get("y").Float(), W: input.Get("width").Float(), H: input.Get("height").Float()} + switch input.Get("placement").String() { + case "normal": + return e.currentSession.Reader.SignaturePageStamps(pages, box) + case "seam": + return e.currentSession.Reader.SignatureSeamStamps(pages, box) + default: + return nil, fmt.Errorf("invalid stamp placement") + } +} + +// signaturePlacement 校验预览确认的印章范围,不生成签名 +// 入参: args 页面和毫米坐标参数 +// 返回: any 校验结果, error 错误信息 +func (e *businessEngine) signaturePlacement(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) == 0 { + return nil, fmt.Errorf("missing stamp placement arguments") + } + _, err := e.signatureStampsFromJS(args[0]) + return successResult(nil), err +} + +// saveSigned 调用库层最终包签署并在回验成功后交付浏览器 +// 入参: args 本机证书私钥、信任根、印章位置、写出回调及进度回调 +// 返回: any 保存结果, error 错误信息 +func (e *businessEngine) saveSigned(args []platformValue) (any, error) { + if e.currentSession == nil || len(args) < 3 { + return nil, fmt.Errorf("missing signature arguments") + } + input := args[0] + key, err := e.bytesFromJS(input.Get("key")) + if err != nil { + return nil, err + } + defer clear(key) + certificate, err := e.bytesFromJS(input.Get("certificate")) + if err != nil { + return nil, err + } + var password []byte + if field := input.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { + password, err = e.bytesFromJS(field) + if err != nil { + return nil, err + } + defer clear(password) + } + signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) + if err != nil { + return nil, fmt.Errorf("证书、私钥或口令无效") + } + roots, err := e.securityFilesFromJS(input.Get("roots")) + if err != nil { + return nil, err + } + intermediates, err := e.securityFilesFromJS(input.Get("intermediates")) + if err != nil { + return nil, err + } + options := ofdgo.SignatureWriteOptions{Signer: signer, Certificate: certificate, TrustRoots: roots, + Intermediates: intermediates, Lock: input.Get("lock").Bool()} + if compression := input.Get("compression"); !compression.IsUndefined() && !compression.IsNull() { + if err := e.compressionFromJS(compression, &options.Compression); err != nil { + return nil, err + } + } + switch input.Get("mode").String() { + case "append": + options.Mode = ofdgo.SignatureAppend + case "replace": + options.Mode = ofdgo.SignatureReplace + default: + return nil, fmt.Errorf("invalid signature mode") + } + if seal := input.Get("seal"); !seal.IsNull() && !seal.IsUndefined() { + options.Seal, err = e.bytesFromJS(seal) + if err != nil { + return nil, err + } + options.Stamps, err = e.signatureStampsFromJS(input) + if err != nil { + return nil, err + } + } + progress := e.editorOperationProgress(args[2]) + if err := progress("sign", 0, 1); err != nil { + return nil, err + } + var output bytes.Buffer + if e.currentEditor != nil { + e.currentEditor.OnWriteProgress = progress + defer func() { e.currentEditor.OnWriteProgress = nil }() + _, err = ofdgo.SignEditorTo(&output, e.currentEditor, options) + } else { + _, err = e.currentSession.Reader.SignTo(&output, options) + } + if err != nil { + return nil, err + } + if err := progress("sign", 1, 1); err != nil { + return nil, err + } + if _, err := (exportWriter{write: args[1]}).Write(output.Bytes()); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil +} + +// saveDocument 分块写出当前编辑文档或指定页面 +// 入参: args 数据写出回调、可选准备进度回调和可选页面索引 +// 返回: any 保存结果, error 错误信息 +func (e *businessEngine) saveDocument(args []platformValue) (any, error) { + if e.currentEditor == nil { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + editor, err := e.currentSession.Reader.Editor() + if err != nil { + return nil, err + } + return e.saveEditor(editor, args) + } + return e.saveEditor(e.currentEditor, args) +} + +// saveEditor 统一分块写出及进度取消,加密策略由库层执行 +// 入参: editor 待写出文档, args 写出回调、进度回调及可选页面索引、压缩配置、取消检查 +// 返回: any 保存结果, error 错误信息 +func (e *businessEngine) saveEditor(editor *ofdgo.Editor, args []platformValue) (any, error) { + if len(args) == 0 || args[0].Type() != platformTypeFunction { + return nil, fmt.Errorf("missing output callback") + } + if len(args) > 1 { + editor.OnWriteProgress = e.editorOperationProgress(args[1]) + defer func() { editor.OnWriteProgress = nil }() + } + writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) + var err error + var options ofdgo.WriteOptions + ctx := e.operationContext() + if len(args) > 3 { + if err := e.compressionFromJS(args[3], &options.Compression); err != nil { + return nil, err + } + } + if len(args) > 4 { + ctx = e.newOutputContext(args[4]) + } + if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() { + _, err = editor.WritePagesToWithOptions(ctx, writer, e.indexesFromJS(args[2]), options) + } else { + _, err = editor.WriteToWithOptions(ctx, writer, options) + } + if err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil +} + +// compressionFromJS 读取压缩配置并交给库层校验 +// 入参: value 浏览器配置, options 目标配置 +// 返回: error 配置错误 +func (e *businessEngine) compressionFromJS(value platformValue, options *ofdgo.CompressionOptions) error { + if err := json.Unmarshal([]byte(platformJSON(value)), options); err != nil { + return err + } + return options.Validate() +} + +// newOutputContext 创建单次串行操作的取消上下文 +// 入参: checkpoint 浏览器取消检查回调 +// 返回: *outputContext 操作上下文 +func (e *businessEngine) newOutputContext(checkpoint platformValue) *outputContext { + ctx, cancel := context.WithCancel(e.operationContext()) + return &outputContext{Context: ctx, cancel: cancel, checkpoint: func() error { return awaitExport(checkpoint) }} +} + +// newEditorOperationContext 在底层取消检查时保持最近的进度,避免版本处理期间界面无响应 +// 入参: callback 浏览器进度及取消回调 +// 返回: context.Context 操作上下文, func(string, int, int) error 阶段进度回调 +func (e *businessEngine) newEditorOperationContext(callback platformValue) (context.Context, func(string, int, int) error) { + ctx, cancel := context.WithCancel(e.operationContext()) + stage, completed, total := "ids", 0, 0 + progress := e.editorOperationProgress(callback) + operation := &outputContext{Context: ctx, cancel: cancel, checkpoint: func() error { return progress(stage, completed, total) }} + return operation, func(next string, done, count int) error { + stage, completed, total = next, done, count + return progress(stage, completed, total) + } +} + +// Err 按操作间隔检查时间并处理浏览器消息,每次调用均检查已有取消状态 +// 返回: error 取消错误 +func (c *outputContext) Err() error { + if err := c.Context.Err(); err != nil { + return err + } + if c.pending != 0 { + c.pending-- + return nil + } + c.pending = 15 + if c.interval != 0 { + c.pending = c.interval + } + if time.Since(c.checked) >= 50*time.Millisecond { + c.checked = time.Now() + if err := c.checkpoint(); err != nil { + c.cancel() + } + } + return c.Context.Err() +} + +// businessCommands 共用业务命令表 +func (e *businessEngine) businessCommands() map[string]func([]platformValue) (any, error) { + return map[string]func([]platformValue) (any, error){ + "ofdgoDetectFormat": e.detectDocumentFormat, + "ofdgoOpen": e.openDocument, + "ofdgoSelectDocument": e.selectDocument, + "ofdgoDocumentVersions": e.documentVersions, + "ofdgoExportVersion": e.exportVersion, + "ofdgoAnnotationInfos": e.annotationInfos, + "ofdgoDocumentNavigation": e.documentNavigation, + "ofdgoInvoices": e.documentInvoices, + "ofdgoInvoice": e.documentInvoice, + "ofdgoExportInvoice": e.exportDocumentInvoice, + "ofdgoValidateInvoice": e.validateDocumentInvoice, + "ofdgoChangeInvoice": e.changeDocumentInvoice, + "ofdgoChangeBookmark": e.changeBookmark, + "ofdgoActionList": e.actionList, + "ofdgoChangeActions": e.changeActions, + "ofdgoStyleResource": e.styleResource, + "ofdgoChangeStyleResource": e.changeStyleResource, + "ofdgoApplyStyleResource": e.applyStyleResource, + "ofdgoPageSettings": e.pageSettings, + "ofdgoChangeLayer": e.changeLayer, + "ofdgoChangeTemplate": e.changeTemplate, + "ofdgoSelectVersion": e.selectDocumentVersion, + "ofdgoChangeVersion": e.changeDocumentVersion, + "ofdgoChangeDocument": e.changePackageDocument, + "ofdgoPackageDocuments": e.packageDocuments, + "ofdgoConvertPDF": e.convertPDFDocument, + "ofdgoConvertFile": e.convertFile, + "ofdgoPackFiles": e.packFiles, + "ofdgoOutputFormats": func([]platformValue) (any, error) { return ofdgo.OutputFormats(), nil }, + "ofdgoConfigure": e.configureDocument, + "ofdgoDocumentInfo": e.documentInfo, + "ofdgoVerifySignatures": e.verifySignatures, + "ofdgoRenderPage": e.renderPage, + "ofdgoOutlineLinks": e.outlineLinks, + "ofdgoStructureTargets": e.structureTargets, + "ofdgoStructureObjects": e.structureObjects, + "ofdgoChangeStructure": e.changeStructure, + "ofdgoOpenLinks": e.openLinks, + "ofdgoRenderBackends": e.renderBackends, + "ofdgoSVGFontData": e.svgFontData, + "ofdgoSearchPage": e.searchPage, + "ofdgoPageTextString": e.pageTextString, + "ofdgoExportFormats": e.exportFormats, + "ofdgoParsePageRange": e.parsePageRange, + "ofdgoExportPage": e.exportPage, + "ofdgoExportDocument": e.exportDocument, + "ofdgoExportAttachment": e.exportAttachment, + "ofdgoExportMedia": e.exportMedia, + "ofdgoResources": e.documentResources, + "ofdgoResourceReferences": e.documentResourceReferences, + "ofdgoResourceTargets": e.resourceTargets, + "ofdgoResourceLocations": e.resourceLocations, + "ofdgoFontPreview": e.fontPreview, + "ofdgoChangeResource": e.changeResource, + "ofdgoExportResource": e.exportResource, + "ofdgoImages": e.documentImages, + "ofdgoExportImages": e.exportImages, + "ofdgoMatchFontFiles": e.matchFontFiles, + "ofdgoChangeFontBinding": e.changeFontBinding, + "ofdgoFontFaces": e.fontFaces, + "ofdgoFontFace": e.fontFace, + "ofdgoCreateDocument": e.createDocument, + "ofdgoImportImages": e.importImages, + "ofdgoEditDocument": e.editDocument, + "ofdgoUpdateInfo": e.updateInfo, + "ofdgoChangeAttachment": e.changeAttachment, + "ofdgoChangePage": e.changePage, + "ofdgoBatchPages": e.batchPages, + "ofdgoChangeOutline": e.changeOutline, + "ofdgoChangeAnnotations": e.changeAnnotations, + "ofdgoChangeSelection": e.changeSelection, + "ofdgoWriteAnnotation": e.writeAnnotation, + "ofdgoReadAnnotation": e.readAnnotation, + "ofdgoInsertInk": e.insertInk, + "ofdgoStyleObjects": e.styleObjects, + "ofdgoPreviewPositionedText": e.previewPositionedText, + "ofdgoMoveOutline": e.moveOutline, + "ofdgoCaptureStyle": e.captureStyle, + "ofdgoGroupObjects": e.groupObjects, + "ofdgoPasteStyle": e.pasteStyle, + "ofdgoResizeObjects": e.resizeObjects, + "ofdgoLoadImport": e.loadImport, + "ofdgoImportPages": e.importPages, + "ofdgoImportDocuments": e.importDocuments, + "ofdgoInsertText": e.insertText, + "ofdgoUpdateText": e.updateText, + "ofdgoPreviewText": e.previewText, + "ofdgoTextSelection": e.textSelection, + "ofdgoPreviewTextSelection": e.previewTextSelection, + "ofdgoUpdateTextSelection": e.updateTextSelection, + "ofdgoStyleText": e.styleText, + "ofdgoGetRichText": e.getRichText, + "ofdgoSaveRichText": e.saveRichText, + "ofdgoPreviewRichText": e.previewRichText, + "ofdgoCheckTextFont": e.checkTextFont, + "ofdgoInsertImage": e.insertImage, + "ofdgoInsertShape": e.insertShape, + "ofdgoUpdatePathStyle": e.updatePathStyle, + "ofdgoReplaceImage": e.replaceImage, + "ofdgoEditorFont": e.editorFont, + "ofdgoAlignObject": e.alignObject, + "ofdgoTransformObject": e.transformObject, + "ofdgoReshapeObject": e.reshapeObject, + "ofdgoReshapeLine": e.reshapeLine, + "ofdgoResetImageCrop": e.resetImageCrop, + "ofdgoLayoutText": e.layoutText, + "ofdgoTransformObjects": e.transformObjects, + "ofdgoCompositeObjects": e.compositeObjects, + "ofdgoChangeCompositeObjects": e.changeCompositeObjects, + "ofdgoAlignObjects": e.alignObjects, + "ofdgoDeleteObjects": e.deleteObjects, + "ofdgoEraseObjects": e.eraseObjects, + "ofdgoEraseObjectsPath": e.eraseObjectsPath, + "ofdgoCopyObjects": e.copyObjects, + "ofdgoCaptureObjects": e.captureObjects, + "ofdgoPasteObjects": e.pasteObjects, + "ofdgoOrderObjects": e.orderObjects, + "ofdgoDistributeObjects": e.distributeObjects, + "ofdgoRotateObjects": e.rotateObjects, + "ofdgoFlipObjects": e.flipObjects, + "ofdgoCropImage": e.cropImage, + "ofdgoFitImage": e.fitImage, + "ofdgoPreviewImage": e.previewImage, + "ofdgoDeleteObject": e.deleteObject, + "ofdgoUndo": func([]platformValue) (any, error) { return e.restoreEditor(false) }, + "ofdgoRedo": func([]platformValue) (any, error) { return e.restoreEditor(true) }, + "ofdgoSaveDocument": e.saveDocument, + "ofdgoSetEncryption": e.setEncryption, + "ofdgoSaveSigned": e.saveSigned, + "ofdgoPreviewSignatureSeal": e.previewSignatureSeal, + "ofdgoSignaturePlacement": e.signaturePlacement, + } +} + +// operationContext 返回当前操作上下文 +func (e *businessEngine) operationContext() context.Context { + if e.ctx != nil { + return e.ctx + } + return context.Background() +} + +// failureResult 统一原生及浏览器业务错误协议 +func failureResult(err error) apiResult { + result := apiResult{Error: err.Error()} + switch { + case errors.Is(err, ofdgo.ErrPDFPassword): + result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误" + case errors.Is(err, ofdgo.ErrCredentialsRequired): + result.Code, result.Error = "credentialsRequired", "文档已加密" + case errors.Is(err, ofdgo.ErrInvalidCredentials): + result.Code, result.Error = "invalidCredentials", "凭据无效或文件损坏" + case errors.Is(err, ofdgo.ErrUnsupportedEncryption): + result.Code, result.Error = "unsupportedEncryption", "不支持此加密方式" + case errors.Is(err, ofdgo.ErrEncryptionPolicyRequired): + result.Code, result.Error = "encryptionPolicyRequired", "请设置加密方式" + } + var failure *ofdgo.EditError + if errors.As(err, &failure) { + result.ReasonCode = failure.Code + } + if errors.As(err, &result.MissingGlyphs) { + result.ReasonCode = ofdgo.EditMissingGlyphs + } + + return result +} diff --git a/internal/webui/webui_native.go b/internal/webui/webui_native.go new file mode 100644 index 0000000..eb4e635 --- /dev/null +++ b/internal/webui/webui_native.go @@ -0,0 +1,908 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !js || !wasm + +package webui + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + "sync" + + "github.com/xiaoqidun/ofdgo" +) + +const ( + platformTypeUndefined = iota + platformTypeNumber + platformTypeString + platformTypeBoolean + platformTypeObject + platformTypeFunction +) + +// NativeResolver 将授权标识解析为随机读取源,生命周期由宿主管理 +type NativeResolver func(token string) (io.ReaderAt, int64, error) + +// NativeOutputRequest 请求为整次批量转换预留唯一输出入口 +type NativeOutputRequest struct { + Destination any + Base, Extension, MIME string + Paged bool +} + +// NativeOutputTarget 保存预留目标,Name为根目录下可整体删除的入口名 +// Destination交给OpenWriter,分页写入名为Base/页文件名,单文件写入名为Base.Extension +type NativeOutputTarget struct { + Destination any + Base, Name string +} + +// NativeEngine 保存独立会话,不同实例可并行,同一实例的回调不可重入Call或Close +// hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭 +type NativeEngine struct { + businessEngine + Resolve NativeResolver + LoadFont func(context.Context, string) ([]byte, error) + // OpenWriter 必须防止覆盖已有文件,成功时Close提交,失败时仅调用可选Abort + // 不支持Abort的输出由宿主负责回收,宿主也负责回滚已完成的批量输出 + OpenWriter func(context.Context, any, string) (io.WriteCloser, error) + // ReserveOutput 为单次批量预留整体入口,返回错误时由宿主释放未交付的预留 + ReserveOutput func(context.Context, NativeOutputRequest) (NativeOutputTarget, error) + // CleanupOutput 必须幂等,以兼容前端收到output事件后重复removeEntry + CleanupOutput func(context.Context, NativeOutputTarget) error + gate chan struct{} + once sync.Once + closed bool +} + +// nativeReader 在会话当前操作上下文中分块读取,不捕获已结束请求的上下文 +type nativeReader struct { + engine *NativeEngine + source io.ReaderAt +} + +// nativeCall 保存单次worker适配状态 +type nativeCall struct { + engine *NativeEngine + ctx context.Context + emit func(any) + output io.WriteCloser + buffer bytes.Buffer + destination any + files []any + currentName string + mime string + size int64 + exporting bool + batch bool + base string + extension string + paged bool + reserved *NativeOutputTarget + committed bool +} + +// platformValue 仅承载JSON参数、字节与宿主回调,不执行脚本 +type platformValue struct{ value any } + +// nativeCallback 同步宿主回调 +type nativeCallback func(...any) (any, error) + +// NewNativeEngine 创建独立业务会话,resolver不接收未经授权的文件路径 +func NewNativeEngine(resolve NativeResolver) *NativeEngine { return &NativeEngine{Resolve: resolve} } + +// Call 执行worker输入协议,返回ok/data/error,字节保持[]byte,emit不含请求id +// 入参: ctx 取消上下文, name 原操作名, args worker参数, emit 同步进度通知 +// 返回: map[string]any 操作结果,宿主负责将二进制转换为资源句柄 +// nativeFile只接受授权标识,size以Resolve结果为准,不接受路径字符串或nativeData输出标识 +// 输出目标为nil时导出返回data.blob,批量返回data.files中的name、mime及file字节 +// 批量输出到非nil目标时必须同时提供ReserveOutput和CleanupOutput,避免误清理既有文件 +// 取消为协作式检查,不能抢占宿主阻塞的ReadAt、字体读取或加密库内部计算 +func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit func(any)) (result map[string]any) { + e.once.Do(func() { e.gate = make(chan struct{}, 1); e.resolveSource = e.source }) + if ctx == nil { + ctx = context.Background() + } + select { + case e.gate <- struct{}{}: + case <-ctx.Done(): + return nativeFailure(ctx.Err()) + } + defer func() { <-e.gate }() + if err := ctx.Err(); err != nil { + return nativeFailure(err) + } + if e.closed { + return nativeFailure(fmt.Errorf("native engine is closed")) + } + e.ctx = ctx + if e.currentSession != nil { + e.currentSession.Renderer.OutputContext = ctx + } + defer func() { + if e.currentSession != nil { + e.currentSession.Renderer.OutputContext = context.Background() + } + e.ctx = nil + if failure := recover(); failure != nil { + if err, ok := failure.(error); ok { + result = nativeFailure(err) + } else { + result = nativeFailure(fmt.Errorf("%v", failure)) + } + } + }() + fn := e.businessCommands()[name] + if fn == nil { + return nativeFailure(fmt.Errorf("unknown operation: %s", name)) + } + call := &nativeCall{engine: e, ctx: ctx, emit: emit} + defer func() { + if err := call.abort(); err != nil { + if result == nil || result["ok"] == true { + result = nativeFailure(err) + } else { + result["error"] = fmt.Sprintf("%v; cleanup: %v", result["error"], err) + } + } + }() + prepared, err := call.prepare(name, append([]any(nil), args...)) + if err != nil { + return nativeFailure(err) + } + values := make([]platformValue, len(prepared)) + for i, arg := range prepared { + values[i] = platformValueOf(arg) + } + data, err := e.safeCall(fn, values) + if err != nil { + return nativeFailure(err) + } + if err := ctx.Err(); err != nil { + return nativeFailure(err) + } + if wrapped, ok := data.(platformValue); ok { + result = nativePlain(wrapped).(map[string]any) + } else { + result = map[string]any{"ok": true, "data": nativePlain(data)} + } + if err := call.finish(name, result); err != nil { + return nativeFailure(err) + } + return result +} + +// Close 释放会话,不关闭宿主持有的ReaderAt资源 +func (e *NativeEngine) Close() error { + e.once.Do(func() { e.gate = make(chan struct{}, 1); e.resolveSource = e.source }) + e.gate <- struct{}{} + defer func() { <-e.gate }() + e.closed = true + var err error + if e.currentSession != nil { + err = e.currentSession.Close() + } + if e.pendingImport != nil { + err = errors.Join(err, e.pendingImport.Close()) + } + e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.copiedStyle = nil, nil, nil, nil, nil + return err +} + +// source 读取授权源并以宿主报告的大小为准 +func (e *NativeEngine) source(ctx context.Context, value any) (io.ReaderAt, int64, error) { + if err := ctx.Err(); err != nil { + return nil, 0, err + } + if p, ok := value.(platformValue); ok { + value = p.value + } + if data, ok := value.([]byte); ok { + return nativeReader{engine: e, source: bytes.NewReader(data)}, int64(len(data)), nil + } + descriptor, ok := value.(map[string]any) + if !ok { + return nil, 0, fmt.Errorf("expected bytes or nativeFile descriptor") + } + token, ok := descriptor["nativeFile"].(string) + if !ok || token == "" || e.Resolve == nil { + return nil, 0, fmt.Errorf("unauthorized native file") + } + source, size, err := e.Resolve(token) + if err != nil { + return nil, 0, err + } + if source == nil || size < 0 { + return nil, 0, fmt.Errorf("invalid native file source") + } + return nativeReader{engine: e, source: source}, size, nil +} + +// ReadAt 分块传递取消检查,宿主ReadAt自身阻塞时由宿主控制超时 +func (r nativeReader) ReadAt(data []byte, offset int64) (int, error) { + total := 0 + for len(data) > 0 { + if err := r.engine.operationContext().Err(); err != nil { + return total, err + } + n := min(len(data), 256<<10) + got, err := r.source.ReadAt(data[:n], offset) + total += got + if err != nil { + return total, err + } + if got != n { + return total, io.ErrUnexpectedEOF + } + data, offset = data[n:], offset+int64(n) + } + return total, nil +} + +// readBytes 读取需要完整数据的资源,每块检查取消 +func (e *NativeEngine) readBytes(ctx context.Context, value any) ([]byte, error) { + source, size, err := e.source(ctx, value) + if err != nil { + return nil, err + } + var output bytes.Buffer + chunk := make([]byte, 256<<10) + for offset := int64(0); offset < size; { + if err := ctx.Err(); err != nil { + return nil, err + } + n := int(min(int64(len(chunk)), size-offset)) + got, err := source.ReadAt(chunk[:n], offset) + if got != n { + return nil, io.ErrUnexpectedEOF + } + if err != nil && err != io.EOF { + return nil, err + } + output.Write(chunk[:got]) + offset += int64(got) + } + return output.Bytes(), nil +} + +// nativePlain 保留字节并转换JSON结构 +func nativePlain(value any) any { + switch v := value.(type) { + case platformValue: + return nativePlain(v.value) + case []byte: + return v + case map[string]any: + result := make(map[string]any, len(v)) + for k, item := range v { + result[k] = nativePlain(item) + } + return result + case nil: + return nil + } + v := reflect.ValueOf(value) + if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { + result := make([]any, v.Len()) + for i := range result { + result[i] = nativePlain(v.Index(i).Interface()) + } + return result + } + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil + } + return nativePlain(v.Elem().Interface()) + } + if v.Kind() == reflect.Struct { + data, err := json.Marshal(value) + if err != nil { + panic(err) + } + var result any + if err := json.Unmarshal(data, &result); err != nil { + panic(err) + } + if fields, ok := result.(map[string]any); ok { + for i := 0; i < v.NumField(); i++ { + field := v.Type().Field(i) + if !field.IsExported() { + continue + } + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "-" { + continue + } + if field.Anonymous && name == "" { + if embedded, ok := nativePlain(v.Field(i).Interface()).(map[string]any); ok { + for key, value := range embedded { + if _, exists := fields[key]; exists { + fields[key] = value + } + } + } + continue + } + if name == "" { + name = field.Name + } + if _, exists := fields[name]; exists { + fields[name] = nativePlain(v.Field(i).Interface()) + } + } + } + return result + } + return value +} + +// nativeFailure 转换结构化业务错误 +func nativeFailure(err error) map[string]any { + result := nativePlain(failureResult(err)).(map[string]any) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + result["canceled"] = true + } + return result +} + +// event 发送标准worker进度 +func (c *nativeCall) event(value map[string]any) { + if c.emit != nil { + c.emit(value) + } +} + +// checkpoint 检查操作取消 +func (c *nativeCall) checkpoint(...any) (any, error) { return nil, c.ctx.Err() } + +// progress 创建标准进度回调 +func (c *nativeCall) progress(kind, stage string, phased bool) nativeCallback { + return func(args ...any) (any, error) { + if err := c.ctx.Err(); err != nil { + return nil, err + } + event := map[string]any{"type": kind} + if stage != "" { + event["stage"] = stage + } + if phased { + event["phase"], event["completed"], event["total"] = args[0], args[1], args[2] + } else { + event["completed"], event["total"] = args[0], args[1] + } + c.event(event) + return nil, c.ctx.Err() + } +} + +// readCallback 创建有界随机读取回调 +func (c *nativeCall) readCallback(value any) (nativeCallback, int64, error) { + source, size, err := c.engine.source(c.ctx, value) + if err != nil { + return nil, 0, err + } + return func(args ...any) (any, error) { + if err := c.ctx.Err(); err != nil { + return nil, err + } + offset, n := int64(platformValueOf(args[0]).Float()), platformValueOf(args[1]).Int() + if offset < 0 || n < 0 || n > 256<<10 || offset > size || int64(n) > size-offset { + return nil, fmt.Errorf("invalid native read range") + } + data := make([]byte, n) + got, err := source.ReadAt(data, offset) + if got != n { + return nil, io.ErrUnexpectedEOF + } + if err != nil && err != io.EOF { + return nil, err + } + return data, nil + }, size, nil +} + +// write 写出数据块 +func (c *nativeCall) write(args ...any) (any, error) { + if err := c.ctx.Err(); err != nil { + return nil, err + } + value := platformValueOf(args[0]) + data, ok := value.value.([]byte) + if !ok { + return nil, fmt.Errorf("expected output bytes") + } + var writer io.Writer = &c.buffer + if c.output != nil { + writer = c.output + } + n, err := writer.Write(data) + c.size += int64(n) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + return nil, err +} + +// open 创建宿主输出,目标只交给授权hook解释 +func (c *nativeCall) open(name string) error { + c.currentName = name + c.buffer.Reset() + if c.destination == nil { + return nil + } + if c.engine.OpenWriter == nil { + return fmt.Errorf("native output resolver is not configured") + } + var err error + c.output, err = c.engine.OpenWriter(c.ctx, c.destination, name) + if err == nil && c.output == nil { + return fmt.Errorf("native output resolver returned nil") + } + return err +} + +// boundary 处理批量输出文件边界 +func (c *nativeCall) boundary(args ...any) (any, error) { + if err := c.ctx.Err(); err != nil { + return nil, err + } + action, name := args[0].(string), args[1].(string) + if action == "open" { + if c.paged { + name = c.base + "/" + name + } else { + name = c.base + "." + c.extension + } + return nil, c.open(name) + } + file := map[string]any{"name": c.currentName, "mime": c.mime} + if c.output != nil { + if err := c.output.Close(); err != nil { + return nil, err + } + c.output = nil + } else { + file["file"] = append([]byte(nil), c.buffer.Bytes()...) + } + c.files = append(c.files, file) + c.buffer.Reset() + return nil, nil +} + +// font 按需请求授权字体 +func (c *nativeCall) font(args ...any) (any, error) { + if err := c.ctx.Err(); err != nil { + return nil, err + } + if c.engine.LoadFont == nil { + return nil, fmt.Errorf("native font resolver is not configured") + } + return c.engine.LoadFont(c.ctx, args[0].(string)) +} + +// fileCallback 逐个读取图片或附件 +func (c *nativeCall) fileCallback(files platformValue) nativeCallback { + return func(args ...any) (any, error) { + data, err := c.engine.readBytes(c.ctx, files.Index(platformValueOf(args[0]).Int()).value) + if err != nil { + return nil, err + } + return map[string]any{"bytes": data}, nil + } +} + +// prepare 将worker输入转换为共用业务参数 +func (c *nativeCall) prepare(name string, args []any) ([]any, error) { + checkpoint := nativeCallback(c.checkpoint) + write := nativeCallback(c.write) + switch name { + case "ofdgoDetectFormat": + if len(args) != 1 { + return nil, fmt.Errorf("missing detection input") + } + read, size, err := c.readCallback(args[0]) + return []any{read, size, checkpoint}, err + case "ofdgoConvertFile": + if len(args) != 4 { + return nil, fmt.Errorf("missing conversion input") + } + read, size, err := c.readCallback(args[0]) + if err != nil { + return nil, err + } + c.destination, c.batch = args[3], true + options := platformValueOf(args[1]) + c.base = nativeOutputBase(options.Get("base")) + for _, f := range ofdgo.OutputFormats() { + if f.Value == options.Get("format").String() { + c.mime = f.MIME + c.extension, c.paged = f.Extension, f.Paged + } + } + if c.extension == "" { + return nil, fmt.Errorf("unsupported conversion format") + } + if c.destination != nil { + if c.engine.ReserveOutput == nil || c.engine.CleanupOutput == nil { + return nil, fmt.Errorf("batch destination requires reservation and cleanup hooks") + } + target, err := c.engine.ReserveOutput(c.ctx, NativeOutputRequest{Destination: c.destination, Base: c.base, Extension: c.extension, MIME: c.mime, Paged: c.paged}) + if err != nil { + return nil, err + } + c.reserved = &target + if target.Destination == nil || target.Base == "" || target.Name == "" || nativeOutputBase(platformValueOf(target.Base)) != target.Base { + return nil, fmt.Errorf("invalid reserved output target") + } + c.destination, c.base = target.Destination, target.Base + c.event(map[string]any{"type": "output", "name": target.Name}) + } + return []any{read, size, args[1], write, c.progress("conversion", "", true), nativeCallback(c.boundary), checkpoint, args[2], nativeCallback(c.font)}, nil + case "ofdgoPackFiles": + if len(args) != 3 { + return nil, fmt.Errorf("missing archive input") + } + entries := platformValueOf(args[0]) + metadata := make([]any, entries.Length()) + readers := make([]nativeCallback, entries.Length()) + for i := range readers { + entry := entries.Index(i) + read, size, err := c.readCallback(entry.Get("file").value) + if err != nil { + return nil, err + } + readers[i] = read + metadata[i] = map[string]any{"name": entry.Get("name").String(), "size": size} + } + c.destination, c.exporting = args[1], true + if err := c.open(""); err != nil { + return nil, err + } + progress := nativeCallback(func(values ...any) (any, error) { + c.event(map[string]any{"type": "conversion", "phase": "pack", "completed": values[0], "total": values[1]}) + return nil, c.ctx.Err() + }) + return []any{metadata, nativeCallback(func(values ...any) (any, error) { return readers[platformValueOf(values[0]).Int()], nil }), write, progress, checkpoint, args[2]}, nil + case "ofdgoChangeAttachment": + if len(args) > 3 && (args[0] == "add" || args[0] == "replace") { + args[3] = c.fileCallback(platformValueOf(args[3])) + } + case "ofdgoChangeResource": + if len(args) > 4 && (args[0] == "add" || args[0] == "replace") { + data, err := c.engine.readBytes(c.ctx, args[4]) + if err != nil { + return nil, err + } + args[4] = data + } + case "ofdgoImportImages": + if len(args) != 5 { + return nil, fmt.Errorf("missing image import input") + } + files := platformValueOf(args[0]) + names := make([]any, files.Length()) + for i := range names { + names[i] = files.Index(i).Get("name").String() + } + args[0] = names + args = append(args, c.fileCallback(files)) + } + switch name { + case "ofdgoResources": + return append(args, checkpoint), nil + case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages": + return append(args, c.progress("import", "", true)), nil + case "ofdgoConvertPDF": + return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil + case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned": + if len(args) == 0 { + return nil, fmt.Errorf("missing output arguments") + } + c.destination, c.exporting = args[len(args)-1], true + args = args[:len(args)-1] + if err := c.open(""); err != nil { + return nil, err + } + saving := name == "ofdgoSaveDocument" || name == "ofdgoSaveSigned" + var indices, compression any + if saving { + if name != "ofdgoSaveSigned" && len(args) > 0 { + if m, ok := args[len(args)-1].(map[string]any); ok && m["mode"] != nil { + compression = m + args = args[:len(args)-1] + } + } + if len(args) == 0 { + return nil, fmt.Errorf("missing save page selection") + } + indices, args = args[0], args[1:] + } + args = append(args, write) + if name == "ofdgoExportDocument" { + args = append(args, c.progress("export", "pages", false)) + } + if name == "ofdgoExportVersion" { + args = append(args, c.progress("export", "prepare", true), checkpoint) + } + if saving { + args = append(args, c.progress("export", "prepare", true)) + if compression != nil { + args = append(args, indices, compression, checkpoint) + } else if indices != nil { + args = append(args, indices) + } + } else { + for _, arg := range args { + if m, ok := arg.(map[string]any); ok && m["mode"] != nil { + args = append(args, checkpoint) + break + } + } + } + } + return args, nil +} + +// finish 完成输出并保留原生字节 +func (c *nativeCall) finish(name string, result map[string]any) error { + if !c.exporting && !c.batch { + return nil + } + data, ok := result["data"].(map[string]any) + if !ok { + return fmt.Errorf("invalid output result") + } + if c.batch { + if data["unchanged"] == true && c.size == 0 { + c.files = []any{} + if err := c.abort(); err != nil { + return err + } + } + data["files"] = c.files + } else if c.output != nil { + if err := c.output.Close(); err != nil { + return err + } + c.output = nil + } else { + data["blob"] = append([]byte(nil), c.buffer.Bytes()...) + } + data["size"] = c.size + c.committed = true + if c.exporting && name != "ofdgoExportMedia" && name != "ofdgoPackFiles" { + c.event(map[string]any{"type": "export", "stage": "save"}) + } + return nil +} + +// abort 失败时释放尚未提交的宿主输出,支持可选Abort回滚 +func (c *nativeCall) abort() error { + var err error + if c.output != nil { + if output, ok := c.output.(interface{ Abort() error }); ok { + err = output.Abort() + } + c.output = nil + } + if c.reserved != nil && !c.committed { + target := *c.reserved + c.reserved = nil + err = errors.Join(err, c.engine.CleanupOutput(context.WithoutCancel(c.ctx), target)) + } + return err +} + +// nativeOutputBase 清理worker输出文件名,冲突后缀由宿主排他创建决定 +func nativeOutputBase(value platformValue) string { + base := "document" + if value.Type() == platformTypeString { + base = value.String() + } + base = strings.Map(func(r rune) rune { + if r < 32 || strings.ContainsRune("<>:\"/\\|?*", r) { + return '_' + } + return r + }, base) + base = strings.TrimRight(base, ". ") + if base == "" { + return "document" + } + return base +} + +// sourceFromValue 读取宿主授权的随机读取源 +func (e *businessEngine) sourceFromValue(value platformValue) (io.ReaderAt, int64, error) { + return e.resolveSource(e.operationContext(), value.value) +} + +// platformValueOf 包装参数 +func platformValueOf(v any) platformValue { + if p, ok := v.(platformValue); ok { + return p + } + return platformValue{v} +} + +// platformNull 返回空参数 +func platformNull() platformValue { return platformValue{} } + +// Type 返回协议参数类型 +func (v platformValue) Type() int { + switch v.value.(type) { + case nil: + return platformTypeUndefined + case string: + return platformTypeString + case bool: + return platformTypeBoolean + case nativeCallback: + return platformTypeFunction + case float64, float32, int, int64, uint64, json.Number: + return platformTypeNumber + default: + return platformTypeObject + } +} + +// IsNull 检查空参数 +func (v platformValue) IsNull() bool { return v.value == nil } + +// IsUndefined 检查缺省参数 +func (v platformValue) IsUndefined() bool { return v.value == nil } + +// String 读取字符串 +func (v platformValue) String() string { + if v.value == nil { + return "" + } + return fmt.Sprint(v.value) +} + +// Bool 读取布尔参数 +func (v platformValue) Bool() bool { + b, ok := v.value.(bool) + if !ok { + panic("expected boolean") + } + return b +} + +// Float 读取数值参数 +func (v platformValue) Float() float64 { + switch n := v.value.(type) { + case float64: + return n + case float32: + return float64(n) + case int: + return float64(n) + case int64: + return float64(n) + case uint64: + return float64(n) + case json.Number: + f, err := n.Float64() + if err != nil { + panic(err) + } + return f + default: + panic("expected number") + } +} + +// Int 读取整数参数 +func (v platformValue) Int() int { return int(v.Float()) } + +// Get 读取对象字段 +func (v platformValue) Get(key string) platformValue { + if m, ok := v.value.(map[string]any); ok { + return platformValueOf(m[key]) + } + if key == "length" || key == "byteLength" { + return platformValueOf(v.Length()) + } + return platformNull() +} + +// Length 读取数组长度 +func (v platformValue) Length() int { + if v.value == nil { + return 0 + } + return reflect.ValueOf(v.value).Len() +} + +// Index 读取数组元素 +func (v platformValue) Index(i int) platformValue { + return platformValueOf(reflect.ValueOf(v.value).Index(i).Interface()) +} + +// Invoke 调用宿主函数 +func (v platformValue) Invoke(args ...any) platformValue { + result, err := v.value.(nativeCallback)(args...) + if err != nil { + panic(err) + } + return platformValueOf(result) +} + +// platformCopyBytes 复制读取块 +func platformCopyBytes(dst []byte, src platformValue) int { + return copy(dst, src.value.([]byte)) +} + +// platformJSON 编码小参数 +func platformJSON(v platformValue) string { + data, err := json.Marshal(v.value) + if err != nil { + panic(err) + } + return string(data) +} + +// platformParseJSON 解析小参数 +func platformParseJSON(data []byte) map[string]any { + var v map[string]any + if err := json.Unmarshal(data, &v); err != nil { + panic(err) + } + return v +} + +// bytesToJS 保留原生二进制数据 +func bytesToJS(data []byte) platformValue { return platformValueOf(data) } + +// bytesFromJS 读取字节或授权资源 +func (e *businessEngine) bytesFromJS(v platformValue) ([]byte, error) { + if data, ok := v.value.([]byte); ok { + return append([]byte(nil), data...), nil + } + source, size, err := e.sourceFromValue(v) + if err != nil { + return nil, err + } + return io.ReadAll(io.NewSectionReader(source, 0, size)) +} + +// successResult 保留二进制返回结构 +func successResult(data any) platformValue { + return platformValueOf(map[string]any{"ok": true, "data": data}) +} + +// awaitExport 执行可取消宿主回调 +func awaitExport(fn platformValue, args ...any) error { + _, err := fn.value.(nativeCallback)(args...) + return err +} + +// loadConversionFont 读取宿主授权字体 +func (e *businessEngine) loadConversionFont(fn platformValue, name string) ([]byte, error) { + result, err := fn.value.(nativeCallback)(name) + if err != nil { + return nil, err + } + return e.bytesFromJS(platformValueOf(result)) +} diff --git a/internal/webui/webui_wasm.go b/internal/webui/webui_wasm.go new file mode 100644 index 0000000..b009094 --- /dev/null +++ b/internal/webui/webui_wasm.go @@ -0,0 +1,225 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build js && wasm + +package webui + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "syscall/js" +) + +const ( + platformTypeNumber = js.TypeNumber + platformTypeString = js.TypeString + platformTypeBoolean = js.TypeBoolean + platformTypeObject = js.TypeObject + platformTypeFunction = js.TypeFunction +) + +// platformValue 浏览器参数值 +type platformValue = js.Value + +// RunWASM 注册浏览器WASM接口并阻塞运行 +func RunWASM() { + e := &businessEngine{} + asynchronous := map[string]bool{ + "ofdgoDetectFormat": true, + "ofdgoExportVersion": true, + "ofdgoConvertPDF": true, + "ofdgoConvertFile": true, + "ofdgoPackFiles": true, + "ofdgoExportPage": true, + "ofdgoExportDocument": true, + "ofdgoExportAttachment": true, + "ofdgoExportMedia": true, + "ofdgoResources": true, + "ofdgoExportResource": true, + "ofdgoExportImages": true, + "ofdgoImportImages": true, + "ofdgoImportPages": true, + "ofdgoImportDocuments": true, + "ofdgoSaveDocument": true, + "ofdgoSetEncryption": true, + "ofdgoSaveSigned": true, + } + for name, callback := range e.businessCommands() { + if asynchronous[name] { + registerAsyncCallback(name, callback) + } else { + registerCallback(name, callback) + } + } + select {} +} + +// awaitExport 等待浏览器处理导出检查点 +// 入参: fn 浏览器回调, args 回调参数 +// 返回: error 写入错误或取消原因 +func awaitExport(fn js.Value, args ...any) error { + done := make(chan error) + callback := js.FuncOf(func(this js.Value, args []js.Value) any { + var err error + if args[1].Bool() { + err = context.Canceled + } else if message := args[0].String(); message != "" { + err = fmt.Errorf("%s", message) + } + done <- err + return nil + }) + defer callback.Release() + fn.Invoke(append(args, callback)...) + return <-done +} + +// registerCallback 注册浏览器回调函数 +// 入参: name 回调名称, fn 回调函数 +func registerCallback(name string, fn func([]js.Value) (any, error)) { + cb := js.FuncOf(func(this js.Value, args []js.Value) any { + return callbackResult(fn, args) + }) + js.Global().Set(name, cb) +} + +// registerAsyncCallback 注册异步回调,为浏览器处理数据和取消请求让出事件循环 +// 入参: name 回调名称, fn 回调函数 +func registerAsyncCallback(name string, fn func([]js.Value) (any, error)) { + cb := js.FuncOf(func(this js.Value, args []js.Value) any { + executor := js.FuncOf(func(this js.Value, callbacks []js.Value) any { + resolve := callbacks[0] + go func() { + resolve.Invoke(callbackResult(fn, args)) + }() + return nil + }) + defer executor.Release() + return js.Global().Get("Promise").New(executor) + }) + js.Global().Set(name, cb) +} + +// callbackResult 转换浏览器接口结果 +// 入参: fn 回调函数, args 回调参数 +// 返回: any 浏览器结果 +func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any { + data, err := (&businessEngine{}).safeCall(fn, args) + if err != nil { + result := failureResult(err) + return (&businessEngine{}).encodeResult(result) + } + if result, ok := data.(js.Value); ok { + return result + } + return (&businessEngine{}).encodeResult(apiResult{OK: true, Data: data}) +} + +// loadConversionFont 按需读取浏览器已授权字体,不预读系统字体目录 +// 入参: load 浏览器读取函数, name 字体文件名 +// 返回: []byte 字体内容, error 读取错误或取消原因 +func (e *businessEngine) loadConversionFont(load js.Value, name string) ([]byte, error) { + done := make(chan error, 1) + var data []byte + callback := js.FuncOf(func(this js.Value, args []js.Value) any { + var err error + if args[2].Bool() { + err = context.Canceled + } else if message := args[1].String(); message != "" { + err = fmt.Errorf("%s", message) + } else { + data, err = e.bytesFromJS(args[0]) + } + done <- err + return nil + }) + defer callback.Release() + load.Invoke(name, callback) + err := <-done + return data, err +} + +// successResult 创建成功接口结果 +// 入参: data 返回数据 +// 返回: js.Value 接口结果 +func successResult(data any) js.Value { + result := js.Global().Get("Object").New() + result.Set("ok", true) + result.Set("data", data) + return result +} + +// bytesToJS 将字节数据转换为Uint8Array +// 入参: data 字节数据 +// 返回: js.Value Uint8Array对象 +func bytesToJS(data []byte) js.Value { + value := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(value, data) + return value +} + +// bytesFromJS 从浏览器值读取二进制数据 +// 入参: value 浏览器值 +// 返回: []byte 二进制数据, error 错误信息 +func (e *businessEngine) bytesFromJS(value js.Value) ([]byte, error) { + if value.IsUndefined() || value.IsNull() { + return nil, fmt.Errorf("missing binary data") + } + uint8Array := js.Global().Get("Uint8Array") + arrayBuffer := js.Global().Get("ArrayBuffer") + if value.InstanceOf(arrayBuffer) { + value = uint8Array.New(value) + } + if !value.InstanceOf(uint8Array) { + return nil, fmt.Errorf("binary data must be Uint8Array or ArrayBuffer") + } + data := make([]byte, value.Get("byteLength").Int()) + if n := js.CopyBytesToGo(data, value); n != len(data) { + return nil, fmt.Errorf("copied %d of %d bytes", n, len(data)) + } + return data, nil +} + +// platformValueOf 转换浏览器参数 +func platformValueOf(v any) platformValue { return js.ValueOf(v) } + +// platformNull 返回空参数 +func platformNull() platformValue { return js.Null() } + +// platformCopyBytes 复制浏览器字节 +func platformCopyBytes(dst []byte, src platformValue) int { return js.CopyBytesToGo(dst, src) } + +// platformJSON 编码小参数 +func platformJSON(v platformValue) string { + return js.Global().Get("JSON").Call("stringify", v).String() +} + +// platformParseJSON 解析结构参数 +func platformParseJSON(data []byte) map[string]any { + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + panic(err) + } + return result +} + +// sourceFromValue 读取浏览器二进制源 +func (e *businessEngine) sourceFromValue(value platformValue) (io.ReaderAt, int64, error) { + data, err := e.bytesFromJS(value) + return bytes.NewReader(data), int64(len(data)), err +} diff --git a/internal/webview/webview.go b/internal/webview/webview.go new file mode 100644 index 0000000..5fb93a2 --- /dev/null +++ b/internal/webview/webview.go @@ -0,0 +1,685 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && amd64 + +package webview + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "image/png" + "io" + "io/fs" + "mime" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/tdewolff/canvas" + "github.com/tdewolff/canvas/renderers/rasterizer" + "github.com/xiaoqidun/guigo" + "github.com/xiaoqidun/ofdgo" + webuiassets "github.com/xiaoqidun/ofdgo/assets/webui" + "github.com/xiaoqidun/ofdgo/internal/webui" +) + +// applicationID 标识原生应用,与版本及可执行文件路径无关 +const applicationID = "me.aite.ofdgo" + +// Host 连接桌面系统能力和共用文档业务 +type Host struct { + mu sync.Mutex + window *guigo.Window + files *fileStore + engines map[string]*webui.NativeEngine + cancels map[string]context.CancelFunc + fontOnce sync.Once + fonts []map[string]any + fontFiles map[string]string + fontError error + pendingPaths []string +} + +// resourceReader 保持资源独立的读取位置,文件生命周期由窗口管理 +type resourceReader struct { + *io.SectionReader +} + +// Run 启动共用WebUI的原生入口 +// 入参: ctx 运行上下文, paths 初始文件路径, dataDir 浏览器数据目录 +// 返回: error 启动或运行错误 +func Run(ctx context.Context, paths []string, dataDir string) error { + options, err := windowOptions(dataDir) + if err != nil { + return err + } + files, err := newFileStore() + if err != nil { + return err + } + h := &Host{files: files, engines: make(map[string]*webui.NativeEngine), cancels: make(map[string]context.CancelFunc), fontFiles: make(map[string]string), pendingPaths: append([]string(nil), paths...)} + defer h.close() + bindings := make(map[string]guigo.HandlerFunc) + for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.cancel", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} { + bindings["ofdgo.host."+name] = func(ctx context.Context, raw json.RawMessage) (any, error) { return h.call(ctx, name, raw) } + } + static := http.FileServerFS(webuiassets.NativeFS) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/__native/") { + files.serve(w, r) + return + } + if strings.HasSuffix(r.URL.Path, ".wasm") || strings.HasSuffix(r.URL.Path, "wasm_exec.js") || strings.HasSuffix(r.URL.Path, "ofdgo_work.js") { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "no-store") + static.ServeHTTP(w, r) + }) + options.Handler = handler + options.ResourceHandler = h.resource + options.Bindings = bindings + options.OnReady = func(window *guigo.Window) { + h.mu.Lock() + h.window = window + h.mu.Unlock() + } + options.OnClose = func() { + h.mu.Lock() + for _, cancel := range h.cancels { + cancel() + } + h.mu.Unlock() + } + options.OnDrop = h.openPaths + options.OnError = func(err error) { + h.emit("ofdgo.error", err.Error()) + } + return guigo.Run(ctx, options) +} + +// Close 结束本次资源读取 +func (r *resourceReader) Close() error { return nil } + +// resource 将大文件直接交给宿主资源流 +func (h *Host) resource(r *http.Request) (*guigo.Resource, error) { + if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Header.Get("Range") != "" { + return nil, nil + } + id := strings.TrimPrefix(r.URL.Path, "/__native/data/") + reader, size, err := h.files.resolve(id) + if err != nil { + return nil, nil + } + header := make([]byte, min(size, 512)) + _, err = reader.ReadAt(header, 0) + if err != nil && err != io.EOF { + return nil, err + } + return &guigo.Resource{ + Body: &resourceReader{io.NewSectionReader(reader, 0, size)}, + Header: http.Header{"Content-Type": {http.DetectContentType(header)}, "Content-Length": {strconv.FormatInt(size, 10)}, "Cache-Control": {"no-store"}}, + }, nil +} + +// emit 发送当前窗口事件 +func (h *Host) emit(name string, value any) { + h.mu.Lock() + w := h.window + h.mu.Unlock() + if w != nil { + _ = w.Emit(name, value) + } +} + +// openPaths 将操作系统提供的文件登记后交给界面 +func (h *Host) openPaths(names []string) { + files := make([]any, 0, len(names)) + for _, name := range names { + handle, err := h.files.add(name, false) + if err != nil { + h.emit("ofdgo.error", err.Error()) + continue + } + file, err := h.files.file(handle["nativeHandle"].(string)) + if err == nil { + files = append(files, file) + } + } + h.emit("ofdgo.files", files) +} + +// call 执行宿主协议并保留业务结果结构 +func (h *Host) call(ctx context.Context, name string, raw json.RawMessage) (any, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + text := func(key string) string { value, _ := args[key].(string); return value } + flag := func(key string) bool { value, _ := args[key].(bool); return value } + switch name { + case "engine.open": + engine := webui.NewNativeEngine(h.files.resolve) + engine.OpenWriter = h.openWriter + engine.ReserveOutput = h.reserveOutput + engine.CleanupOutput = h.cleanupOutput + engine.LoadFont = h.loadFont + id := token() + h.mu.Lock() + h.engines[id] = engine + h.mu.Unlock() + return map[string]any{"engine": id}, nil + case "engine.close": + id := text("engine") + h.mu.Lock() + engine := h.engines[id] + delete(h.engines, id) + for key, cancel := range h.cancels { + if strings.HasPrefix(key, id+"/") { + cancel() + } + } + h.mu.Unlock() + if engine != nil { + return nil, engine.Close() + } + return nil, nil + case "engine.cancel": + key := text("engine") + "/" + fmt.Sprint(args["id"]) + h.mu.Lock() + cancel := h.cancels[key] + h.mu.Unlock() + if cancel != nil { + cancel() + } + return nil, nil + case "engine.call": + id := text("engine") + key := id + "/" + fmt.Sprint(args["id"]) + operation, cancel := context.WithCancel(ctx) + defer cancel() + h.mu.Lock() + engine := h.engines[id] + if _, exists := h.cancels[key]; exists { + h.mu.Unlock() + return nil, fmt.Errorf("请求重复") + } + h.cancels[key] = cancel + h.mu.Unlock() + defer func() { h.mu.Lock(); delete(h.cancels, key); h.mu.Unlock() }() + if engine == nil { + return nil, fmt.Errorf("会话已关闭") + } + values, _ := args["args"].([]any) + for i, value := range values { + var err error + values[i], err = nativeBytes(value) + if err != nil { + return nil, err + } + } + result := engine.Call(operation, text("name"), values, func(event any) { + if value, ok := event.(map[string]any); ok { + value["id"] = args["id"] + h.emit("ofdgo.engine."+id, value) + } + }) + return h.encode(result), nil + case "open", "save", "directory": + h.mu.Lock() + window := h.window + h.mu.Unlock() + if window == nil { + return nil, fmt.Errorf("窗口尚未就绪") + } + options := guigo.DialogOptions{DefaultName: text("suggestedName"), Filters: dialogFilters(args)} + if name == "open" { + var names []string + var err error + if flag("directory") { + var directory string + directory, err = window.OpenDirectory(options) + if err == nil { + err = filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return err + } + if entry.Type().IsRegular() { + names = append(names, path) + } + return nil + }) + } + } else if flag("multiple") { + names, err = window.OpenFiles(options) + } else { + var file string + file, err = window.OpenFile(options) + if err == nil { + names = []string{file} + } + } + if err != nil { + return nil, dialogError(err) + } + result := make([]any, 0, len(names)) + for _, file := range names { + handle, err := h.files.add(file, false) + if err != nil { + return nil, err + } + value, err := h.files.file(handle["nativeHandle"].(string)) + if err != nil { + return nil, err + } + result = append(result, value) + } + return result, nil + } + if name == "save" { + file, err := window.SaveFile(options) + if err != nil { + return nil, dialogError(err) + } + return h.files.save(file), nil + } + file, err := window.OpenDirectory(options) + if err != nil { + return nil, dialogError(err) + } + return h.files.add(file, true) + case "file": + return h.files.file(text("handle")) + case "release": + h.files.release(text("file")) + return nil, nil + case "child": + value, err := h.files.child(text("handle"), text("name"), flag("directory"), flag("create")) + if errors.Is(err, os.ErrNotExist) { + return nil, &guigo.BridgeError{Name: "NotFoundError", Message: err.Error()} + } + return value, err + case "remove": + return nil, h.files.remove(text("handle"), text("name"), flag("recursive")) + case "write.begin": + writer, err := h.files.begin(text("handle")) + if err != nil { + return nil, err + } + return map[string]any{"writer": writer.id}, nil + case "write.data", "write.commit", "write.abort": + id := text("writer") + h.files.mu.Lock() + writer := h.files.writers[id] + h.files.mu.Unlock() + if writer == nil { + return nil, fmt.Errorf("写入已结束") + } + if name == "write.commit" { + return nil, writer.Close() + } + if name == "write.abort" { + return nil, writer.Abort() + } + descriptor, _ := args["data"].(map[string]any) + if value, err := nativeBytes(descriptor); err != nil { + return nil, err + } else if data, ok := value.([]byte); ok { + _, err = writer.Write(data) + return nil, err + } + key, _ := descriptor["nativeFile"].(string) + source, size, err := h.files.resolve(key) + if err != nil { + return nil, err + } + _, err = io.Copy(writer, io.NewSectionReader(source, 0, size)) + return nil, err + case "fonts": + h.fontOnce.Do(h.readFonts) + return h.fonts, h.fontError + case "title": + h.mu.Lock() + window := h.window + h.mu.Unlock() + if window == nil { + return nil, nil + } + return nil, window.SetTitle(text("title")) + case "ready": + h.mu.Lock() + paths := h.pendingPaths + h.pendingPaths = nil + h.mu.Unlock() + if len(paths) != 0 { + h.openPaths(paths) + } + return nil, nil + } + return nil, fmt.Errorf("未知宿主操作") +} + +// nativeBytes 还原仅在消息内传递的小型二进制参数 +func nativeBytes(value any) (any, error) { + switch value := value.(type) { + case map[string]any: + if raw, exists := value["nativeBytes"]; exists { + items, ok := raw.([]any) + if !ok || len(value) != 1 || len(items) > 1<<20 { + return nil, fmt.Errorf("二进制参数无效") + } + data := make([]byte, len(items)) + for i, item := range items { + number, ok := item.(float64) + if !ok || number < 0 || number > 255 || float64(byte(number)) != number { + return nil, fmt.Errorf("二进制参数无效") + } + data[i] = byte(number) + } + return data, nil + } + for key, item := range value { + decoded, err := nativeBytes(item) + if err != nil { + return nil, err + } + value[key] = decoded + } + return value, nil + case []any: + for i, item := range value { + decoded, err := nativeBytes(item) + if err != nil { + return nil, err + } + value[i] = decoded + } + return value, nil + default: + return value, nil + } +} + +// dialogFilters 将共用界面的文件类型映射到系统对话框 +func dialogFilters(args map[string]any) []guigo.FileFilter { + patterns := func(values []string) string { + var result []string + seen := make(map[string]bool) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if !strings.HasPrefix(value, ".") || strings.ContainsAny(value, "/\\;*?\x00") || seen[value] { + continue + } + seen[value] = true + result = append(result, "*"+value) + } + return strings.Join(result, ";") + } + var filters []guigo.FileFilter + if accept, ok := args["accept"].(string); ok { + var extensions []string + for _, value := range strings.Split(accept, ",") { + value = strings.ToLower(strings.TrimSpace(value)) + if strings.HasPrefix(value, ".") { + extensions = append(extensions, value) + continue + } + switch value { + case "image/*": + extensions = append(extensions, ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".gif", ".webp", ".jb2", ".jbig2") + case "font/*": + extensions = append(extensions, ".ttf", ".otf", ".ttc", ".otc") + default: + values, err := mime.ExtensionsByType(value) + if err == nil { + sort.Strings(values) + extensions = append(extensions, values...) + } + } + } + if pattern := patterns(extensions); pattern != "" { + filters = append(filters, guigo.FileFilter{Name: "支持的文件", Pattern: pattern}) + } + } + types, _ := args["types"].([]any) + for _, item := range types { + value, _ := item.(map[string]any) + accept, _ := value["accept"].(map[string]any) + var extensions []string + var names []string + for name := range accept { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + values := accept[name] + list, _ := values.([]any) + for _, extension := range list { + if text, ok := extension.(string); ok { + extensions = append(extensions, text) + } + } + } + if pattern := patterns(extensions); pattern != "" { + name, _ := value["description"].(string) + if name == "" { + name = pattern + } + filters = append(filters, guigo.FileFilter{Name: name, Pattern: pattern}) + } + } + return append(filters, guigo.FileFilter{Name: "所有文件", Pattern: "*.*"}) +} + +// dialogError 将用户取消与业务错误区分 +func dialogError(err error) error { + if errors.Is(err, guigo.ErrCanceled) { + return &guigo.BridgeError{Name: "AbortError", Message: "已取消"} + } + return err +} + +// encode 将二进制输出转为窗口内资源 +func (h *Host) encode(value any) any { + switch v := value.(type) { + case []byte: + return h.files.bytes(v) + case map[string]any: + out := make(map[string]any, len(v)) + for key, item := range v { + out[key] = h.encode(item) + } + return out + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = h.encode(item) + } + return out + default: + return value + } +} + +// openWriter 将授权目标转换为原生事务写入器 +func (h *Host) openWriter(ctx context.Context, value any, name string) (io.WriteCloser, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + handle, _ := value.(map[string]any) + id, _ := handle["nativeHandle"].(string) + h.files.mu.Lock() + entry := h.files.entries[id] + directory := entry != nil && entry.directory + h.files.mu.Unlock() + if directory { + parts := strings.Split(strings.ReplaceAll(name, "\\", "/"), "/") + for i, part := range parts { + child, err := h.files.child(id, part, i < len(parts)-1, true) + if err != nil { + return nil, err + } + id = child["nativeHandle"].(string) + } + h.files.mu.Lock() + owned := h.files.entries[id].owned + h.files.mu.Unlock() + if !owned { + return nil, fmt.Errorf("输出文件已存在") + } + } + return h.files.begin(id) +} + +// reserveOutput 为一次转换预留独立输出文件或目录 +func (h *Host) reserveOutput(ctx context.Context, request webui.NativeOutputRequest) (webui.NativeOutputTarget, error) { + if err := ctx.Err(); err != nil { + return webui.NativeOutputTarget{}, err + } + handle, _ := request.Destination.(map[string]any) + id, _ := handle["nativeHandle"].(string) + base, name, err := h.files.reserve(id, request.Base, request.Extension, request.Paged) + return webui.NativeOutputTarget{Destination: request.Destination, Base: base, Name: name}, err +} + +// cleanupOutput 回滚本次转换创建的输出入口 +func (h *Host) cleanupOutput(ctx context.Context, target webui.NativeOutputTarget) error { + handle, _ := target.Destination.(map[string]any) + id, _ := handle["nativeHandle"].(string) + return h.files.remove(id, target.Name, true) +} + +// readFonts 建立系统字体目录并登记按需读取资源 +func (h *Host) readFonts() { + for _, name := range ofdgo.FontSystemFiles() { + data, err := os.ReadFile(name) + if err != nil { + continue + } + faces, err := (ofdgo.FontFile{Name: filepath.Base(name), Data: data}).Faces() + if err != nil { + continue + } + handle, err := h.files.add(name, false) + if err != nil { + continue + } + file, err := h.files.file(handle["nativeHandle"].(string)) + if err != nil { + continue + } + for _, face := range faces { + h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "file": file}) + for _, alias := range []string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)} { + if alias != "" { + h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index) + } + } + } + } +} + +// loadFont 按转换请求读取单个系统字体 +func (h *Host) loadFont(ctx context.Context, name string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + h.fontOnce.Do(h.readFonts) + parts := strings.Split(h.fontFiles[name], "\x00") + if len(parts) != 2 { + return nil, fmt.Errorf("系统字体不可用") + } + data, err := os.ReadFile(parts[0]) + if err != nil { + return nil, err + } + index, _ := strconv.Atoi(parts[1]) + return (ofdgo.FontFile{Data: data}).Face(index) +} + +// close 取消任务并释放当前窗口持有的会话 +func (h *Host) close() { + h.mu.Lock() + for _, cancel := range h.cancels { + cancel() + } + engines := h.engines + h.engines = make(map[string]*webui.NativeEngine) + h.mu.Unlock() + for _, engine := range engines { + _ = engine.Close() + } + _ = h.files.close() +} + +// windowOptions 创建原生窗口配置,默认沿用既有数据目录 +// 入参: dataDir 自定义浏览器数据目录 +// 返回: guigo.Options 窗口配置, error 配置错误 +func windowOptions(dataDir string) (guigo.Options, error) { + if dataDir == "" { + cache, err := os.UserCacheDir() + if err != nil { + return guigo.Options{}, err + } + dataDir = filepath.Join(cache, "OFDGo", "webview") + } + dataDir, err := filepath.Abs(dataDir) + if err != nil { + return guigo.Options{}, err + } + icon, err := windowIcon() + if err != nil { + return guigo.Options{}, err + } + return guigo.Options{ + AppID: applicationID, + Title: "OFDGo", + Icon: icon, + Width: 1440, + Height: 900, + MinWidth: 360, + MinHeight: 240, + UserDataDir: dataDir, + }, nil +} + +// windowIcon 将共用SVG图标转换为透明PNG,不生成外置文件 +// 返回: []byte PNG图标, error 转换错误 +func windowIcon() ([]byte, error) { + data, err := webuiassets.NativeFS.ReadFile("ofdgo.svg") + if err != nil { + return nil, err + } + graphic, err := canvas.ParseSVG(bytes.NewReader(data)) + if err != nil { + return nil, err + } + image := rasterizer.Draw(graphic, canvas.DPMM(512/graphic.W), canvas.DefaultColorSpace) + var buffer bytes.Buffer + if err := png.Encode(&buffer, image); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} diff --git a/internal/webview/webview_files.go b/internal/webview/webview_files.go new file mode 100644 index 0000000..ed6f880 --- /dev/null +++ b/internal/webview/webview_files.go @@ -0,0 +1,486 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webview + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// fileEntry 保存窗口授权的文件或目录 +type fileEntry struct { + path string + name string + data []byte + file *os.File + directory bool + owned bool +} + +// fileStore 管理窗口范围内的文件授权和临时数据 +type fileStore struct { + mu sync.Mutex + entries map[string]*fileEntry + writers map[string]*fileWriter + temporary string + closed bool +} + +// fileWriter 在同目录临时文件中保存输出 +type fileWriter struct { + id string + mu sync.Mutex + store *fileStore + file *os.File + target string + err error + closed bool +} + +// newFileStore 创建独立文件授权表 +func newFileStore() (*fileStore, error) { + dir, err := os.MkdirTemp("", "ofdgo-webview-") + if err != nil { + return nil, err + } + return &fileStore{entries: make(map[string]*fileEntry), writers: make(map[string]*fileWriter), temporary: dir}, nil +} + +// token 生成不暴露本机路径的资源标识 +func token() string { return rand.Text() } + +// add 登记已有授权文件 +func (s *fileStore) add(name string, directory bool) (map[string]any, error) { + info, err := os.Stat(name) + if err != nil { + return nil, err + } + if info.IsDir() != directory { + return nil, fmt.Errorf("文件类型不匹配") + } + s.mu.Lock() + defer s.mu.Unlock() + id := token() + s.entries[id] = &fileEntry{path: name, name: filepath.Base(name), directory: directory} + return s.descriptor(id), nil +} + +// save 登记用户选择的输出路径 +func (s *fileStore) save(name string) map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + id := token() + s.entries[id] = &fileEntry{path: name, name: filepath.Base(name)} + return s.descriptor(id) +} + +// descriptor 返回文件句柄,不暴露本机路径 +func (s *fileStore) descriptor(id string) map[string]any { + entry := s.entries[id] + kind := "file" + if entry.directory { + kind = "directory" + } + return map[string]any{"nativeHandle": id, "name": entry.name, "kind": kind} +} + +// file 创建独立读取快照并返回资源描述 +func (s *fileStore) file(id string) (map[string]any, error) { + s.mu.Lock() + defer s.mu.Unlock() + entry := s.entries[id] + if entry == nil || entry.directory { + return nil, fmt.Errorf("文件授权无效") + } + size := int64(len(entry.data)) + if entry.data == nil { + file, err := openReadFile(entry.path) + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + file.Close() + return nil, err + } + size = info.Size() + id = token() + entry = &fileEntry{path: entry.path, name: entry.name, file: file} + s.entries[id] = entry + } + return map[string]any{"nativeFile": id, "name": entry.name, "size": size, "url": "/__native/data/" + id}, nil +} + +// resolve 按授权标识读取文件并保持随机读取句柄 +func (s *fileStore) resolve(id string) (io.ReaderAt, int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + entry := s.entries[id] + if entry == nil || entry.directory { + return nil, 0, fmt.Errorf("文件授权无效") + } + if entry.data != nil { + return bytes.NewReader(entry.data), int64(len(entry.data)), nil + } + if entry.file == nil { + file, err := openReadFile(entry.path) + if err != nil { + return nil, 0, err + } + entry.file = file + } + info, err := entry.file.Stat() + if err != nil { + return nil, 0, err + } + return entry.file, info.Size(), nil +} + +// bytes 登记原生输出,前端通过资源通道读取 +func (s *fileStore) bytes(data []byte) map[string]any { + if data == nil { + data = []byte{} + } + s.mu.Lock() + defer s.mu.Unlock() + id := token() + s.entries[id] = &fileEntry{data: data, name: "data"} + return map[string]any{"nativeFile": id, "size": len(data), "name": "data", "url": "/__native/data/" + id, "temporary": true} +} + +// release 释放已传递完成的内存结果 +func (s *fileStore) release(id string) { + s.mu.Lock() + defer s.mu.Unlock() + if entry := s.entries[id]; entry != nil && entry.data != nil { + delete(s.entries, id) + } +} + +// child 在已授权目录中查找或创建直接子项 +func (s *fileStore) child(id, name string, directory, create bool) (map[string]any, error) { + if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") { + return nil, fmt.Errorf("文件名无效") + } + s.mu.Lock() + defer s.mu.Unlock() + parent := s.entries[id] + if parent == nil || !parent.directory { + return nil, fmt.Errorf("目录授权无效") + } + path := filepath.Join(parent.path, name) + for key, entry := range s.entries { + if entry.path == path && entry.directory == directory { + if _, err := os.Stat(path); err == nil { + return s.descriptor(key), nil + } + } + } + root, err := os.OpenRoot(parent.path) + if err != nil { + return nil, err + } + defer root.Close() + info, err := root.Stat(name) + created := false + if errors.Is(err, os.ErrNotExist) && create { + if directory { + err = root.Mkdir(name, 0755) + } else { + var file *os.File + file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err == nil { + err = file.Close() + } + } + if err != nil { + return nil, err + } + created = true + info, err = root.Stat(name) + } + if err != nil { + return nil, err + } + if info.IsDir() != directory { + return nil, fmt.Errorf("文件类型不匹配") + } + key := token() + s.entries[key] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: created} + return s.descriptor(key), nil +} + +// reserve 为批量输出独占创建入口并避开已有文件 +func (s *fileStore) reserve(id, base, extension string, directory bool) (string, string, error) { + if !fs.ValidPath(base) || base == "." || strings.ContainsAny(base+extension, "/\\:") { + return "", "", fmt.Errorf("文件名无效") + } + s.mu.Lock() + defer s.mu.Unlock() + parent := s.entries[id] + if parent == nil || !parent.directory { + return "", "", fmt.Errorf("目录授权无效") + } + root, err := os.OpenRoot(parent.path) + if err != nil { + return "", "", err + } + defer root.Close() + for suffix := 0; ; suffix++ { + stem := base + if suffix > 0 { + stem = fmt.Sprintf("%s (%d)", base, suffix+1) + } + name := stem + if !directory { + name += "." + extension + } + if directory { + err = root.Mkdir(name, 0755) + } else { + var file *os.File + file, err = root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err == nil { + err = file.Close() + if err != nil { + root.Remove(name) + } + } + } + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return "", "", err + } + s.entries[token()] = &fileEntry{path: filepath.Join(parent.path, name), name: name, directory: directory, owned: true} + return stem, name, nil + } +} + +// begin 创建文件写入事务 +func (s *fileStore) begin(id string) (*fileWriter, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, os.ErrClosed + } + entry := s.entries[id] + if entry == nil || entry.directory || entry.path == "" { + return nil, fmt.Errorf("输出授权无效") + } + file, err := os.CreateTemp(filepath.Dir(entry.path), ".ofdgo-*") + if err != nil { + return nil, err + } + writer := &fileWriter{id: token(), store: s, file: file, target: entry.path} + s.writers[writer.id] = writer + return writer, nil +} + +// remove 清理由当前窗口创建的目录子项 +func (s *fileStore) remove(id, name string, recursive bool) error { + if !fs.ValidPath(name) || name == "." || strings.ContainsAny(name, "/\\:") { + return fmt.Errorf("文件名无效") + } + s.mu.Lock() + defer s.mu.Unlock() + parent := s.entries[id] + if parent == nil || !parent.directory { + return fmt.Errorf("目录授权无效") + } + target := filepath.Join(parent.path, name) + owned := false + for _, entry := range s.entries { + if entry.path == target && entry.owned { + owned = true + break + } + } + if !owned { + if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("只能清理本次创建的输出") + } + root, err := os.OpenRoot(parent.path) + if err != nil { + return err + } + defer root.Close() + for key, entry := range s.entries { + if entry.path == target || recursive && strings.HasPrefix(entry.path, target+string(filepath.Separator)) { + if entry.file != nil { + entry.file.Close() + } + delete(s.entries, key) + } + } + if recursive { + return root.RemoveAll(name) + } + return root.Remove(name) +} + +// Write 追加输出数据 +func (w *fileWriter) Write(data []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return 0, os.ErrClosed + } + if w.err != nil { + return 0, w.err + } + n, err := w.file.Write(data) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + w.err = err + return n, err +} + +// Close 同步并替换目标文件 +func (w *fileWriter) Close() error { + defer w.release() + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return os.ErrClosed + } + w.closed = true + if w.err != nil { + return errors.Join(w.err, w.file.Close(), os.Remove(w.file.Name())) + } + if err := w.file.Sync(); err != nil { + w.file.Close() + os.Remove(w.file.Name()) + return err + } + if err := w.file.Close(); err != nil { + os.Remove(w.file.Name()) + return err + } + if err := replaceFile(w.file.Name(), w.target); err != nil { + os.Remove(w.file.Name()) + return err + } + return nil +} + +// Abort 删除未提交的临时输出 +func (w *fileWriter) Abort() error { + defer w.release() + w.mu.Lock() + defer w.mu.Unlock() + if w.closed { + return nil + } + w.closed = true + return errors.Join(w.file.Close(), os.Remove(w.file.Name())) +} + +// release 移除已结束的写入事务 +func (w *fileWriter) release() { + w.store.mu.Lock() + defer w.store.mu.Unlock() + delete(w.store.writers, w.id) +} + +// serve 提供窗口内资源,不建立网络监听器 +func (s *fileStore) serve(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.URL.Path == "/__native/input" && r.Method == http.MethodPost { + file, err := os.CreateTemp(s.temporary, "input-") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + _, err = io.Copy(file, r.Body) + err = errors.Join(err, file.Close()) + if err != nil { + os.Remove(file.Name()) + http.Error(w, err.Error(), 500) + return + } + handle, err := s.add(file.Name(), false) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + value, err := s.file(handle["nativeHandle"].(string)) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(value) + return + } + if !strings.HasPrefix(r.URL.Path, "/__native/data/") || r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + id := strings.TrimPrefix(r.URL.Path, "/__native/data/") + reader, size, err := s.resolve(id) + if err != nil { + http.NotFound(w, r) + return + } + s.mu.Lock() + entry := s.entries[id] + s.mu.Unlock() + if entry == nil { + http.NotFound(w, r) + return + } + http.ServeContent(w, r, entry.name, time.Time{}, io.NewSectionReader(reader, 0, size)) +} + +// close 释放授权文件与未提交输出 +func (s *fileStore) close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + writers := s.writers + entries := s.entries + s.writers = make(map[string]*fileWriter) + s.entries = make(map[string]*fileEntry) + s.mu.Unlock() + var err error + for _, writer := range writers { + err = errors.Join(err, writer.Abort()) + } + for _, entry := range entries { + if entry.file != nil { + err = errors.Join(err, entry.file.Close()) + } + } + return errors.Join(err, os.RemoveAll(s.temporary)) +} diff --git a/internal/webview/webview_files_other.go b/internal/webview/webview_files_other.go new file mode 100644 index 0000000..573f210 --- /dev/null +++ b/internal/webview/webview_files_other.go @@ -0,0 +1,29 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package webview + +import "os" + +// openReadFile 打开独立读取句柄 +func openReadFile(name string) (*os.File, error) { + return os.Open(name) +} + +// replaceFile 替换目标文件并保留已有读取句柄 +func replaceFile(source, target string) error { + return os.Rename(source, target) +} diff --git a/internal/webview/webview_files_windows.go b/internal/webview/webview_files_windows.go new file mode 100644 index 0000000..b8a5507 --- /dev/null +++ b/internal/webview/webview_files_windows.go @@ -0,0 +1,75 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package webview + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "unsafe" +) + +// replaceFileProc 保留目标文件属性并替换文件内容 +var replaceFileProc = syscall.NewLazyDLL("kernel32.dll").NewProc("ReplaceFileW") + +// openReadFile 允许原子替换路径,同时保持已有读取句柄有效 +func openReadFile(name string) (*os.File, error) { + path, err := nativeFilePath(name) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + handle, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return nil, &os.PathError{Op: "open", Path: name, Err: err} + } + return os.NewFile(uintptr(handle), name), nil +} + +// replaceFile 替换已有文件,目标不存在时直接重命名 +func replaceFile(source, target string) error { + from, err := nativeFilePath(source) + if err != nil { + return err + } + to, err := nativeFilePath(target) + if err != nil { + return err + } + result, _, err := replaceFileProc.Call(uintptr(unsafe.Pointer(to)), uintptr(unsafe.Pointer(from)), 0, 0, 0, 0) + if result != 0 { + return nil + } + if err == syscall.ERROR_FILE_NOT_FOUND { + return os.Rename(source, target) + } + return &os.LinkError{Op: "replace", Old: source, New: target, Err: err} +} + +// nativeFilePath 转换绝对路径并支持长路径与网络共享 +func nativeFilePath(name string) (*uint16, error) { + path, err := filepath.Abs(name) + if err != nil { + return nil, err + } + if !strings.HasPrefix(path, `\\?\`) { + if strings.HasPrefix(path, `\\`) { + path = `\\?\UNC\` + path[2:] + } else { + path = `\\?\` + path + } + } + return syscall.UTF16PtrFromString(path) +} diff --git a/ofdgo_font_source.go b/ofdgo_font_source.go index e4aa12d..aa6c6ef 100644 --- a/ofdgo_font_source.go +++ b/ofdgo_font_source.go @@ -377,9 +377,12 @@ func systemFontDirs() []string { return nil } -// indexSystemFonts 递归读取系统字体名称,保留集合索引 -// 返回: []basefont.FileCandidate 字体候选 -func indexSystemFonts() []basefont.FileCandidate { +// FontSystemFiles 获取当前系统可访问的字体文件,不读取字体内容 +// 返回: []string 去重后的字体路径,浏览器WASM环境返回空列表 +func FontSystemFiles() []string { + if !canLoadSystemFonts() { + return nil + } var files []string seen := make(map[string]bool) for _, dir := range systemFontDirs() { @@ -391,6 +394,13 @@ func indexSystemFonts() []basefont.FileCandidate { return nil }) } + return files +} + +// indexSystemFonts 递归读取系统字体名称,保留集合索引 +// 返回: []basefont.FileCandidate 字体候选 +func indexSystemFonts() []basefont.FileCandidate { + files := FontSystemFiles() candidates := basefont.FileCandidates(files, filepath.Base) for i, candidate := range candidates { file, err := os.Open(candidate.Name)