diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 3cf2b07..c7b2570 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -4128,6 +4128,7 @@ async function refreshEditorPage(doc, index, openSeq, clearSelection = false) { } function releasePageResources() { + if (state.pageInFlight.size) return; const images = new Set(), fonts = new Set(); for (const page of state.pageCache.values()) { for (const name of page.imageNames) images.add(name); @@ -4568,7 +4569,7 @@ async function loadWASM() { state.ready = false; state.wasmExited = false; await new Promise((resolve, reject) => { - const worker = createEngineWorker(); + const worker = createEngineWorker({pages: true}); wasmWorker = worker; const fail = (err) => { markWASMExited(wasmSeq, err); @@ -5473,6 +5474,9 @@ async function openDocument(options = {}) { if (openSeq !== state.openSeq) { return; } + wasmWorker.configurePages?.(!state.editorInfo && !doc.encrypted && state.ofdBytes ? { + file: state.ofdBytes, fonts, annotations: state.renderAnnotations, document: doc.documentIndex, version: doc.documentVersion + } : null); if (options.keepPreview) { resetPageLoading(); state.pageCache.clear(); @@ -8724,7 +8728,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, updateControls(); } const result = readingSave - ? { label: "OFD", size: state.ofdBytes.byteLength, mime, blob: state.ofdBytes } + ? { label: "OFD", size: state.ofdBytes.size ?? state.ofdBytes.byteLength, mime, blob: state.ofdBytes } : saving ? await callWASM(background ? "ofdgoSaveSnapshot" : "ofdgoSaveDocument", indices, ...outputOptions, destination) : whole ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, ...outputOptions, file) : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, ...outputOptions, file); @@ -8971,10 +8975,8 @@ function resetPageFlow(keepCache = false) { function resetPageLoading() { releaseMediaPlayers(); - if (nativeHost) { - for (const [id, request] of wasmRequests) { - if (request.name === "ofdgoRenderPage") wasmWorker?.postMessage({ type: "cancel", id }); - } + for (const [id, request] of wasmRequests) { + if (request.name === "ofdgoRenderPage") wasmWorker?.postMessage({ type: "cancel", id }); } for (const task of state.pageInFlight.values()) { task.resolve(null); @@ -9088,9 +9090,10 @@ function loadPageData(index, options = {}) { const current = state.pageInFlight.get(key); if (current) { current.priority = Math.min(current.priority, priority); + if (current.preview && !options.preview) return current.promise.then(() => loadPageData(index, options)); return current.promise; } - if (!options.refresh && state.pageCache.has(index)) { + if (!options.refresh && state.pageCache.has(index) && (options.preview || !state.pageCache.get(index).previewOnly)) { const page = state.pageCache.get(index); state.pageCache.delete(index); state.pageCache.set(index, page); @@ -9102,7 +9105,7 @@ function loadPageData(index, options = {}) { resolve = done; reject = fail; }); - const task = { key, index, openSeq, priority, refresh: options.refresh, resolve, reject, promise }; + const task = { key, index, openSeq, priority, preview: !!options.preview, refresh: options.refresh, resolve, reject, promise }; state.pageInFlight.set(key, task); state.pageRenderQueue.push(task); schedulePageRender(); @@ -9112,7 +9115,7 @@ function loadPageData(index, options = {}) { function schedulePageRender() { if (state.pageRenderRunning) { if (state.pageRenderWorkers) { - while (state.pageRenderQueue.length && state.pageRenderWorkers < (nativeHost?.parallelism || 1)) processPageRenderTasks(); + while (state.pageRenderQueue.length && state.pageRenderWorkers < (nativeHost?.parallelism || wasmWorker?.parallelism || 1)) processPageRenderTasks(); } return; } @@ -9122,7 +9125,7 @@ function schedulePageRender() { async function processPageRenderQueue() { await nextFrame(); - await Promise.all(Array.from({ length: nativeHost?.parallelism || 1 }, () => processPageRenderTasks())); + await Promise.all(Array.from({ length: nativeHost?.parallelism || wasmWorker?.parallelism || 1 }, () => processPageRenderTasks())); } async function processPageRenderTasks() { @@ -9139,12 +9142,12 @@ async function processPageRenderTasks() { task.resolve(null); continue; } - if (!task.refresh && state.pageCache.has(task.index)) { + if (!task.refresh && state.pageCache.has(task.index) && (task.preview || !state.pageCache.get(task.index).previewOnly)) { task.resolve(state.pageCache.get(task.index)); continue; } const args = [task.index, state.renderBackend, state.renderDPI, displayMode() === "raster"]; - if (nativeHost) args.push([...state.svgFonts.keys()]); + args.push([...state.svgFonts.keys()], [...state.svgImages.keys()], task.preview); const page = await callWASM("ofdgoRenderPage", ...args); if (state.pageInFlight.get(task.key) !== task) { task.resolve(null); @@ -9230,7 +9233,7 @@ function trimPageCache() { for (const name of page.imageNames) { const count = references.get(name) - 1; references.set(name, count); - if (count === 0) { + if (count === 0 && !state.pageInFlight.size) { const image = state.svgImages.get(name); bytes -= image.size; URL.revokeObjectURL(image.url); @@ -11197,7 +11200,7 @@ async function renderThumbnail(index, openSeq = state.openSeq) { } state.thumbnailInFlight.add(key); try { - const page = await loadPageData(index, { openSeq, priority: 5 }); + const page = await loadPageData(index, { openSeq, priority: 5, preview: true }); if (openSeq !== state.openSeq) { return; } @@ -12547,11 +12550,11 @@ async function callWASM(name, ...args) { } try { const transfer = []; - if (name === "ofdgoOpen" && !nativeHost) { + if (name === "ofdgoOpen" && !nativeHost && ArrayBuffer.isView(args[0])) { args[0] = args[0].slice(); transfer.push(args[0].buffer); } - if (name === "ofdgoLoadImport" && args[0] && !nativeHost) transfer.push(args[0].buffer); + if (name === "ofdgoLoadImport" && ArrayBuffer.isView(args[0]) && !nativeHost) transfer.push(args[0].buffer); wasmWorker.postMessage({ id, name, args }, transfer); } catch (err) { wasmRequests.delete(id); diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index ad15c57..fef57ef 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -1,7 +1,8 @@ let engineModule = null; -export function createEngineWorker() { +export function createEngineWorker(options = {}) { if (nativeHost) return nativeHost.worker(); + if (options.pages) return new BrowserEngine(); const worker = new Worker("./ofdgo_wasm.js"); worker.addEventListener("message", event => { if (event.data.type !== "module") return; @@ -13,7 +14,156 @@ export function createEngineWorker() { } export async function documentBytes(file) { - return file?.nativeFile ? file : new Uint8Array(await file.arrayBuffer()); + if (file?.nativeFile) return file; + if (file instanceof Blob) return new Response(file.stream()).blob(); + return new Uint8Array(await file.arrayBuffer()); +} + +class BrowserEngine { + constructor() { + this.primary = createEngineWorker(); + this.primary.onmessage = event => { + const data = event.data; + if (data.memory) this.memory = data.memory; + if (data.id != null && !data.type) this.requests.delete(data.id); + this.onmessage?.(event); + }; + this.primary.onerror = event => this.onerror?.(event); + this.primary.onmessageerror = event => this.onmessageerror?.(event); + this.requests = new Map(); + this.slots = new Set(); + this.sequence = 0; + this.revision = 0; + this.memory = 0; + this.parallelism = 1; + this.budget = Math.max(128, Math.min(1024, (navigator.deviceMemory || 2) * 128)) * 1024 * 1024; + this.closed = false; + this.source = null; + } + + configurePages(source) { + this.invalidatePages(); + if (!source || this.closed) return; + this.source = {...source, file: source.file instanceof Blob ? source.file : new Blob([source.file])}; + this.parallelism = Math.max(1, Math.min(2, (navigator.hardwareConcurrency || 2) - 1, Math.floor(this.budget / (256 * 1024 * 1024)))); + } + + invalidatePages() { + this.revision++; + this.source = null; + this.parallelism = 1; + for (const slot of this.slots) this.closeSlot(slot); + } + + closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) { + if (!this.slots.delete(slot)) return; + slot.worker.terminate(); + slot.rejectReady(error); + for (const pending of slot.pending.values()) pending.reject(error); + slot.pending.clear(); + } + + createSlot(source) { + const worker = createEngineWorker(); + const slot = {worker, pending: new Map(), busy: false, memory: Math.max(128 << 20, this.memory)}; + const ready = new Promise((resolve, reject) => { + slot.rejectReady = reject; + worker.onmessage = ({data}) => { + if (data.memory) slot.memory = data.memory; + if (data.type === "ready") { resolve(); return; } + if (data.type === "exit") { this.closeSlot(slot, new Error("页面加载失败")); return; } + if (data.type) return; + const request = slot.pending.get(data.id); + if (!request) return; + slot.pending.delete(data.id); + if (data.ok) request.resolve(data); + else request.reject(Object.assign(new Error(data.error), {code: data.code})); + }; + worker.onerror = worker.onmessageerror = () => this.closeSlot(slot, new Error("页面加载失败")); + }); + this.slots.add(slot); + slot.ready = ready.then(async () => { + let opened = await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]); + if ((source.document || 0) !== (opened.data.documentIndex || 0)) opened = await this.callSlot(slot, "ofdgoSelectDocument", [source.document]); + if ((source.version || "") !== (opened.data.documentVersion || "")) await this.callSlot(slot, "ofdgoSelectVersion", [source.version || ""]); + }); + slot.ready.catch(() => {}); + return slot; + } + + callSlot(slot, name, args) { + return new Promise((resolve, reject) => { + if (!this.slots.has(slot)) { reject(new DOMException("页面加载已取消", "AbortError")); return; } + const id = --this.sequence; + slot.pending.set(id, {resolve, reject}); + try { slot.worker.postMessage({id, name, args}); } + catch (error) { slot.pending.delete(id); reject(error); } + }); + } + + postMessage(data, transfer) { + if (this.closed) return; + if (data.type === "cancel") { + const slot = this.requests.get(data.id); + if (slot) this.closeSlot(slot); + else this.primary.postMessage(data); + return; + } + if (!data.type && data.name !== "ofdgoRenderPage" && ![ + "ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData", + "ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets", + "ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles", + "ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures", + "ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice", + "ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal", + "ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument", + "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion" + ].includes(data.name)) this.invalidatePages(); + if (data.name !== "ofdgoRenderPage" || !this.source || ![...this.requests.values()].includes(null)) { + if (data.name === "ofdgoRenderPage") this.requests.set(data.id, null); + this.primary.postMessage(data, transfer); + return; + } + let slot = [...this.slots].find(slot => !slot.busy); + const memory = this.memory + [...this.slots].reduce((total, item) => total + item.memory, 0); + if (!slot && this.slots.size < this.parallelism - 1 && memory + Math.max(128 << 20, this.memory) <= this.budget) slot = this.createSlot(this.source); + if (!slot) { + this.requests.set(data.id, null); + this.primary.postMessage(data, transfer); + return; + } + slot.busy = true; + this.requests.set(data.id, slot); + const revision = this.revision; + let fallback = false; + slot.ready.then(() => this.callSlot(slot, data.name, data.args)).then(result => { + if (revision !== this.revision) throw new DOMException("页面加载已取消", "AbortError"); + if (!this.closed) this.onmessage?.({data: {...result, id: data.id}}); + }).catch(error => { + if (!this.closed && revision === this.revision && error.name !== "AbortError") { + this.closeSlot(slot, error); + this.parallelism = Math.max(1, this.slots.size + 1); + this.requests.set(data.id, null); + fallback = true; + this.primary.postMessage(data, transfer); + return; + } + if (!this.closed) this.onmessage?.({data: {id: data.id, ok: false, error: error.message, canceled: error.name === "AbortError"}}); + }).finally(() => { + if (!fallback) this.requests.delete(data.id); + slot.busy = false; + const memory = this.memory + [...this.slots].reduce((total, item) => total + item.memory, 0); + if (memory > this.budget) { this.closeSlot(slot); this.parallelism = Math.max(1, this.slots.size + 1); } + }); + } + + terminate() { + if (this.closed) return; + this.closed = true; + this.invalidatePages(); + this.primary.terminate(); + this.requests.clear(); + } } export function saveFilePicker(options) { diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index ab1c8df..c33f133 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -190,6 +190,10 @@ async function handleMessage({ id, name, args }) { const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null; try { signal?.throwIfAborted(); + if (["ofdgoOpen", "ofdgoLoadImport", "ofdgoConvertPDF"].includes(name) && args[0] instanceof Blob) { + const file = args[0], reader = new FileReaderSync(); + args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))}; + } if (name === "ofdgoDetectFormat") { const file = args[0], reader = new FileReaderSync(); channel = new MessageChannel(); @@ -299,8 +303,8 @@ async function handleMessage({ id, name, args }) { await output.abort(); } output = null; - const transfer = result.data?.bytes ? [result.data.bytes.buffer] : (result.data?.images || []).map((image) => image.bytes.buffer); - self.postMessage({ id, ...result }, transfer); + const transfer = [...new Set([result.data?.bytes, ...(result.data?.images || []).map(image => image.bytes), ...(result.data?.fonts || []).map(font => font.data)].filter(bytes => bytes?.byteLength).map(bytes => bytes.buffer))]; + self.postMessage({ id, ...result, memory: engineMemory.buffer.byteLength }, transfer); } catch (err) { await output?.abort().catch(() => {}); const result = { id, ok: false, error: err.message, errorName: err.name }; diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 4779b69..9a7372f 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -313,6 +313,7 @@ type exportWriter struct { // browserReaderAt 分块读取浏览器文件,只缓存最近的一个数据块 type browserReaderAt struct { + mu sync.Mutex read platformValue checkpoint platformValue size int64 @@ -461,14 +462,14 @@ func (s *Session) Close() error { return nil } reader := s.Reader - source := s.input + input := s.input *s = Session{} var err error if reader != nil { err = reader.Close() } - if source != nil { - err = errors.Join(err, source.Close()) + if input != nil { + err = errors.Join(err, input.Close()) } return err } @@ -744,6 +745,13 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { // 入参: index 页面索引, backend 后端组合,空值沿用会话配置, dpi 光栅分辨率, raster 是否光栅显示 // 返回: PageSVG 页面结果, error 错误信息 func (s *Session) RenderPage(index int, backend string, dpi float64, raster bool) (PageSVG, error) { + return s.renderPage(index, backend, dpi, raster, false, nil) +} + +// renderPage 按需生成预览及交互数据,省略调用方已持有的资源字节 +// 入参: index 页面索引, backend 后端组合, dpi 光栅分辨率, raster 光栅显示, preview 仅预览, known 已持有资源 +// 返回: PageSVG 页面结果, error 渲染错误 +func (s *Session) renderPage(index int, backend string, dpi float64, raster, preview bool, known map[string]bool) (PageSVG, error) { if backend != "" { if err := s.SetRenderBackend(backend); err != nil { return PageSVG{}, err @@ -759,7 +767,7 @@ func (s *Session) RenderPage(index int, backend string, dpi float64, raster bool } var buf bytes.Buffer renderer := *s.Renderer - if s.cachedPageText(index) == nil { + if !preview && s.cachedPageText(index) == nil { renderer.OnPageText = func(_ *ofdgo.PageContent, text *ofdgo.PageText) { s.cachePageText(index, text) } @@ -767,10 +775,18 @@ func (s *Session) RenderPage(index int, backend string, dpi float64, raster bool if raster { renderer.DPI = dpi } - resources, err := renderer.RenderPreview(page, &buf, ofdgo.PreviewOptions{Raster: raster, Objects: !raster && s.editing}) + resources, err := renderer.RenderPreview(page, &buf, ofdgo.PreviewOptions{Raster: raster, Objects: !raster && s.editing && !preview, KnownResources: known}) if err != nil { return PageSVG{}, err } + clear(s.svgFonts) + for i, font := range resources.Fonts { + s.svgFonts[font.Name] = font.Data + resources.Fonts[i].Data = nil + } + if preview { + return PageSVG{Index: index, Number: index + 1, ID: page.ID, Width: box.W, Height: box.H, SVG: buf.String(), Fonts: resources.Fonts, Images: resources.Images}, nil + } links, err := s.Renderer.PageLinks(page) if err != nil { return PageSVG{}, err @@ -790,11 +806,6 @@ func (s *Session) RenderPage(index int, backend string, dpi float64, raster bool annotations[i].Bounds, annotations[i].Contours = bounds, contours } } - clear(s.svgFonts) - for i, font := range resources.Fonts { - s.svgFonts[font.Name] = font.Data - resources.Fonts[i].Data = nil - } return PageSVG{Index: index, Number: index + 1, ID: page.ID, Width: box.W, Height: box.H, SVG: buf.String(), Links: links, Fonts: resources.Fonts, Images: resources.Images, Annotations: annotations}, nil } @@ -1282,26 +1293,21 @@ func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { if offset >= r.size { return n, io.EOF } - if time.Since(r.checked) >= 50*time.Millisecond { + r.mu.Lock() + check := r.checkpoint.Type() == platformTypeFunction && time.Since(r.checked) >= 50*time.Millisecond + if check { + r.checked = time.Now() + } + r.mu.Unlock() + if check { 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, err := r.readChunk(data, offset) + if err != nil { + return n, err } - copied := copy(data, r.data[offset-r.offset:]) n += copied offset += int64(copied) data = data[copied:] @@ -1309,6 +1315,31 @@ func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { return n, nil } +// readChunk 串行读取和复用缓存块,仅保留完整读取的内容 +// 入参: data 目标缓冲, offset 文件内偏移 +// 返回: int 复制字节数, error 读取错误 +func (r *browserReaderAt) readChunk(data []byte, offset int64) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.data) == 0 || offset < r.offset || offset-r.offset >= int64(len(r.data)) { + start := offset / (256 << 10) * (256 << 10) + size := int(min(int64(256<<10), r.size-start)) + buffer := r.data + r.data = nil + if cap(buffer) < size { + buffer = make([]byte, size) + } else { + buffer = buffer[:size] + } + value := r.read.Invoke(float64(start), size) + if platformCopyBytes(buffer, value) != size { + return 0, io.ErrUnexpectedEOF + } + r.offset, r.data = start, buffer + } + return copy(data, r.data[offset-r.offset:]), nil +} + // renderBackends 返回库提供的内置后端能力 // 入参: args 浏览器参数 // 返回: any 后端能力, error 错误信息 @@ -2440,7 +2471,7 @@ func (e *businessEngine) svgFontData(args []platformValue) (any, error) { } // renderPage 渲染OFD页面 -// 入参: args 页面索引、后端组合、DPI和光栅显示开关 +// 入参: args 页面索引、后端组合、DPI、光栅开关、已持有字体和图片、仅预览开关 // 返回: any 页面SVG结果, error 错误信息 func (e *businessEngine) renderPage(args []platformValue) (any, error) { if e.currentSession == nil { @@ -2457,6 +2488,13 @@ func (e *businessEngine) renderPage(args []platformValue) (any, error) { dpi = args[2].Float() } raster := len(args) > 3 && args[3].Bool() + preview := len(args) > 6 && args[6].Bool() + known := make(map[string]bool) + for i := 4; i < min(len(args), 6); i++ { + for j := 0; j < args[i].Length(); j++ { + known[args[i].Index(j).String()] = true + } + } if e.currentEditor != nil { page, err := e.currentEditor.Page(args[0].Int()) if err != nil { @@ -2466,24 +2504,30 @@ func (e *businessEngine) renderPage(args []platformValue) (any, error) { return nil, err } } - page, err := e.currentSession.RenderPage(args[0].Int(), backend, dpi, raster) + page, err := e.currentSession.renderPage(args[0].Int(), backend, dpi, raster, preview, known) 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 + text := &ofdgo.PageText{} + if !preview { + 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 + var objects []any + if !preview { + objects, err = e.editorObjects(args[0].Int(), text) + if err != nil { + return nil, err + } } links, err := e.linkResults(page.Links) if err != nil { @@ -2492,12 +2536,16 @@ func (e *businessEngine) renderPage(args []platformValue) (any, error) { 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} + if len(args) > 4 && !known[font.Name] { + fonts[i].(map[string]any)["data"] = bytesToJS(e.currentSession.svgFonts[font.Name]) + } } annotations, err := json.Marshal(page.Annotations) if err != nil { return nil, err } return successResult(map[string]any{ + "previewOnly": preview, "annotations": string(annotations), "index": page.Index, "number": page.Number, @@ -5502,7 +5550,7 @@ func (e *businessEngine) clearImport() { } // loadImport 加载待导入文件的索引,null参数清除待处理文件 -// 入参: args OFD字节数组或null +// 入参: args 文档输入或null及可选凭据 // 返回: any 文档列表、页数及签名提示, error 错误信息 func (e *businessEngine) loadImport(args []platformValue) (any, error) { e.clearImport() diff --git a/internal/webui/webui_native.go b/internal/webui/webui_native.go index a9479aa..5b0262d 100644 --- a/internal/webui/webui_native.go +++ b/internal/webui/webui_native.go @@ -249,7 +249,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f font["data"] = worker.svgFonts[name] } } - if e.currentEditor != nil { + if e.currentEditor != nil && !(len(values) > 6 && values[6].Bool()) { objects, textErr := e.renderObjects(operation, values[0].Int()) if textErr != nil { return nativeFailure(textErr) diff --git a/internal/webui/webui_wasm.go b/internal/webui/webui_wasm.go index e2b8038..a754e4d 100644 --- a/internal/webui/webui_wasm.go +++ b/internal/webui/webui_wasm.go @@ -221,8 +221,11 @@ func platformParseJSON(data []byte) map[string]any { return result } -// inputFromValue 读取浏览器二进制源 +// inputFromValue 读取浏览器随机输入或二进制数据 func (e *businessEngine) inputFromValue(value platformValue) (*ofdgo.Input, error) { + if value.Type() == platformTypeObject && !value.IsNull() && value.Get("read").Type() == platformTypeFunction { + return ofdgo.NewInput(&browserReaderAt{read: value.Get("read"), size: int64(value.Get("size").Float())}, int64(value.Get("size").Float())) + } data, err := e.bytesFromJS(value) if err != nil { return nil, err diff --git a/ofdgo_image.go b/ofdgo_image.go index ad20c5d..84d3e4d 100644 --- a/ofdgo_image.go +++ b/ofdgo_image.go @@ -320,16 +320,13 @@ func (r *Renderer) cachedImageResource(resPath string) (image.Image, error) { } } resPath = cleanPackagePath(r.Reader.ResPath(resPath)) - img, ok := r.imageCache.get(resPath) - if !ok { - var err error - img, err = r.readImageResource(resPath) + return r.imageCache.load(r.pageContext, resPath, func() (image.Image, int, error) { + img, err := r.readImageResource(resPath) if err != nil { - return nil, err + return nil, 0, err } - r.imageCache.put(resPath, img, baseimage.StorageCost(img, r.imageCache.limit)) - } - return img, nil + return img, baseimage.StorageCost(img, r.imageCache.limit), nil + }) } // decodeImageResource 读取或复用图片资源,按输出需要解码像素 diff --git a/ofdgo_render_cache.go b/ofdgo_render_cache.go index a9ccf5b..54a8c08 100644 --- a/ofdgo_render_cache.go +++ b/ofdgo_render_cache.go @@ -16,6 +16,8 @@ package ofdgo import ( "container/list" + "context" + "errors" "image" "reflect" "sync" @@ -29,6 +31,14 @@ type renderCache[K comparable, V any] struct { limit, used int entries map[K]*list.Element order list.List + loading map[K]*renderCacheLoad[V] +} + +// renderCacheLoad 合并同一资源的并发加载,不持有已完成任务 +type renderCacheLoad[V any] struct { + done chan struct{} + value V + err error } // renderCacheEntry 保存缓存值及内存成本 @@ -79,6 +89,61 @@ func (c *renderCache[K, V]) put(key K, value V, cost int) { c.used += cost } +// load 复用缓存并合并并发请求,等待取消不影响正在读取的任务 +// 入参: ctx 当前任务上下文, key 缓存键, read 加载资源及其内存成本 +// 返回: V 缓存值, error 读取或取消错误 +func (c *renderCache[K, V]) load(ctx context.Context, key K, read func() (V, int, error)) (V, error) { + if ctx == nil { + ctx = context.Background() + } + var zero V + for { + if err := ctx.Err(); err != nil { + return zero, err + } + c.mu.Lock() + if item := c.entries[key]; item != nil { + c.order.MoveToFront(item) + value := item.Value.(renderCacheEntry[K, V]).value + c.mu.Unlock() + return value, nil + } + if pending := c.loading[key]; pending != nil { + c.mu.Unlock() + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-pending.done: + if errors.Is(pending.err, context.Canceled) || errors.Is(pending.err, context.DeadlineExceeded) { + continue + } + return pending.value, pending.err + } + } + if c.loading == nil { + c.loading = make(map[K]*renderCacheLoad[V]) + } + pending := &renderCacheLoad[V]{done: make(chan struct{}), err: errors.New("resource loading interrupted")} + c.loading[key] = pending + c.mu.Unlock() + defer func() { + c.mu.Lock() + delete(c.loading, key) + close(pending.done) + c.mu.Unlock() + }() + value, cost, err := read() + if err == nil { + err = ctx.Err() + } + pending.value, pending.err = value, err + if err == nil { + c.put(key, value, cost) + } + return value, err + } +} + // rasterCommandCost 按保留容量估算中立指令成本,不含临时后端路径及借用图片 // 入参: command 绘制指令 // 返回: int 字节成本 diff --git a/ofdgo_render_svg.go b/ofdgo_render_svg.go index a87c641..1174860 100644 --- a/ofdgo_render_svg.go +++ b/ofdgo_render_svg.go @@ -50,6 +50,8 @@ type SVGResources struct { type PreviewOptions struct { Raster bool Objects bool + // KnownResources 标识调用方仍持有的资源,返回引用但不重复返回数据 + KnownResources map[string]bool } // RenderToSVGWithFonts 渲染为SVG并返回页面引用的字体资源,不在SVG中嵌入字体 @@ -92,7 +94,19 @@ func (r *Renderer) renderSVG(page *PageContent, writer io.Writer, mode SVGMode) // RenderPreview 生成统一SVG容器及独立资源,原生程序与WebUI使用相同绘制入口 // 入参: page 页面内容, writer 输出流, options 显示方式 // 返回: SVGResources 需由调用方映射地址的资源, error 错误信息 -func (r *Renderer) RenderPreview(page *PageContent, writer io.Writer, options PreviewOptions) (SVGResources, error) { +func (r *Renderer) RenderPreview(page *PageContent, writer io.Writer, options PreviewOptions) (resources SVGResources, err error) { + defer func() { + for i := range resources.Fonts { + if options.KnownResources[resources.Fonts[i].Name] { + resources.Fonts[i].Data = nil + } + } + for i := range resources.Images { + if options.KnownResources[resources.Images[i].Name] { + resources.Images[i].Data = nil + } + } + }() if !options.Raster { if options.Objects { return r.RenderToSVGWithObjects(page, writer) diff --git a/ofdgo_render_text.go b/ofdgo_render_text.go index 2a8daa4..aa72dbd 100644 --- a/ofdgo_render_text.go +++ b/ofdgo_render_text.go @@ -40,9 +40,10 @@ type PositionedText struct { // glyphOutlineKey 区分字体、字形与毫米字号 type glyphOutlineKey struct { - font *PreparedFont - glyph uint16 - size float64 + digest [32]byte + font *PreparedFont + glyph uint16 + size float64 } // PositionText 定位文字、字形与搜索范围,不依赖页面输出后端 @@ -279,21 +280,20 @@ func (r *Renderer) preparedMetrics(prepared *PreparedFont) (FontMetrics, error) prepared.digest = r.fontDigest(prepared.Data) } key := prepared.digest - if metrics, ok := r.fontMetrics.get(key); ok { - return metrics, nil - } - metrics, err := r.backends.Fonts.OpenFont(prepared.Data) - if err != nil { - return nil, err - } - r.fontMetrics.put(key, metrics, len(prepared.Data)*2+256) - return metrics, nil + return r.fontMetrics.load(r.pageContext, key, func() (FontMetrics, int, error) { + metrics, err := r.backends.Fonts.OpenFont(prepared.Data) + return metrics, len(prepared.Data)*2 + 256, err + }) } // preparedOutlines 批量提取缓存未命中的字形,保留原始编号顺序,不触发塑形 // 入参: prepared 字体, metrics 度量, glyphs 字形编号, size 毫米字号 // 返回: []GeometryPath 同序只读轮廓, error 能力或解析错误 func (r *Renderer) preparedOutlines(prepared *PreparedFont, metrics FontMetrics, glyphs []uint16, size float64) ([]GeometryPath, error) { + key := glyphOutlineKey{digest: prepared.digest, size: size} + if key.digest == ([32]byte{}) { + key.font = prepared + } result := make([]GeometryPath, len(glyphs)) var indices []int missing := make([]uint16, 0) @@ -304,7 +304,8 @@ func (r *Renderer) preparedOutlines(prepared *PreparedFont, metrics FontMetrics, return nil, err } } - if path, ok := r.glyphOutlines.get(glyphOutlineKey{prepared, glyph, size}); ok { + key.glyph = glyph + if path, ok := r.glyphOutlines.get(key); ok { result[i] = path continue } @@ -360,7 +361,8 @@ func (r *Renderer) preparedOutlines(prepared *PreparedFont, metrics FontMetrics, } } for i, path := range paths { - r.glyphOutlines.put(glyphOutlineKey{prepared, missing[i], size}, path, len(path)*96+128) + key.glyph = missing[i] + r.glyphOutlines.put(key, path, len(path)*96+128) } for i, index := range indices { if index >= 0 {