diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 622d146..3cf2b07 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -133,12 +133,14 @@ const state = { pageCache: new Map(), mediaPlayers: new Map(), svgFonts: new Map(), + svgFontLoads: new Map(), svgImages: new Map(), selectedPages: new Set(), documentSelection: null, pageInFlight: new Map(), pageRenderQueue: [], pageRenderRunning: false, + pageRenderWorkers: 0, pageObserver: null, visiblePages: new Set(), scrollFrame: 0, @@ -8969,6 +8971,11 @@ 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 task of state.pageInFlight.values()) { task.resolve(null); } @@ -9104,6 +9111,9 @@ function loadPageData(index, options = {}) { function schedulePageRender() { if (state.pageRenderRunning) { + if (state.pageRenderWorkers) { + while (state.pageRenderQueue.length && state.pageRenderWorkers < (nativeHost?.parallelism || 1)) processPageRenderTasks(); + } return; } state.pageRenderRunning = true; @@ -9111,8 +9121,13 @@ function schedulePageRender() { } async function processPageRenderQueue() { + await nextFrame(); + await Promise.all(Array.from({ length: nativeHost?.parallelism || 1 }, () => processPageRenderTasks())); +} + +async function processPageRenderTasks() { + state.pageRenderWorkers++; try { - await nextFrame(); while (state.pageRenderQueue.length) { let next = 0; for (let index = 1; index < state.pageRenderQueue.length; index++) { @@ -9128,7 +9143,9 @@ async function processPageRenderQueue() { task.resolve(state.pageCache.get(task.index)); continue; } - const page = await callWASM("ofdgoRenderPage", task.index, state.renderBackend, state.renderDPI, displayMode() === "raster"); + const args = [task.index, state.renderBackend, state.renderDPI, displayMode() === "raster"]; + if (nativeHost) args.push([...state.svgFonts.keys()]); + const page = await callWASM("ofdgoRenderPage", ...args); if (state.pageInFlight.get(task.key) !== task) { task.resolve(null); continue; @@ -9139,6 +9156,7 @@ async function processPageRenderQueue() { if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects; } await loadSVGFonts(page.fonts, task.openSeq); + for (const font of page.fonts) delete font.data; if (task.openSeq === state.openSeq && state.pageInFlight.get(task.key) === task) { for (const image of page.images) { if (!state.svgImages.has(image.name)) { @@ -9172,8 +9190,11 @@ async function processPageRenderQueue() { } } } finally { - state.pageRenderRunning = false; - if (!state.pageInFlight.size) releasePageResources(); + if (--state.pageRenderWorkers === 0) { + state.pageRenderRunning = false; + if (!state.pageInFlight.size) releasePageResources(); + if (state.pageRenderQueue.length) schedulePageRender(); + } } } @@ -9196,8 +9217,9 @@ function trimPageCache() { for (const image of state.svgImages.values()) { bytes += image.size; } + const budget = nativeHost?.cacheBytes || PAGE_CACHE_BYTES; for (const [index, page] of state.pageCache) { - if (state.pageCache.size <= PAGE_CACHE_LIMIT && bytes <= PAGE_CACHE_BYTES) { + if (state.pageCache.size <= PAGE_CACHE_LIMIT * Math.max(1, budget / PAGE_CACHE_BYTES) && bytes <= budget) { break; } if (protectedPages.has(index)) { @@ -9227,20 +9249,31 @@ async function loadSVGFonts(fonts, openSeq) { if (state.svgFonts.has(font.name)) { continue; } - const data = await callWASM("ofdgoSVGFontData", font.name); - if (openSeq !== state.openSeq) { - return; + const key = `${openSeq}:${font.name}`; + let loading = state.svgFontLoads.get(key); + if (!loading) { + loading = loadSVGFont(font, openSeq); + state.svgFontLoads.set(key, loading); } - const face = new FontFace(font.name, data.bytes, { weight: String(font.weight), style: font.style }); - await face.load(); - if (openSeq !== state.openSeq) { - return; - } - document.fonts.add(face); - state.svgFonts.set(font.name, face); + try { await loading; } + finally { if (state.svgFontLoads.get(key) === loading) state.svgFontLoads.delete(key); } } } +async function loadSVGFont(font, openSeq) { + const data = font.data ? { bytes: font.data } : await callWASM("ofdgoSVGFontData", font.name); + if (openSeq !== state.openSeq) { + return; + } + const face = new FontFace(font.name, data.bytes, { weight: String(font.weight), style: font.style }); + await face.load(); + if (openSeq !== state.openSeq) { + return; + } + document.fonts.add(face); + state.svgFonts.set(font.name, face); +} + function comparePageRenderTask(a, b) { return a.priority - b.priority || Math.abs(a.index - state.pageIndex) - Math.abs(b.index - state.pageIndex); } @@ -9853,7 +9886,9 @@ function markFlowPageError(index, err) { function queueNearbyPages(index, openSeq = state.openSeq) { const start = pageGroupStart(index); - for (let i = Math.max(0, start - 1); i <= Math.min((state.doc?.pageCount || 1) - 1, start + state.pageLayout + 1); i += 1) { + const before = nativeHost ? state.pageLayout : 1; + const after = nativeHost ? Math.max(1, Math.min(nativeHost.parallelism, state.pageLayout * 2)) : 1; + for (let i = Math.max(0, start - before); i <= Math.min((state.doc?.pageCount || 1) - 1, start + state.pageLayout + after); i += 1) { renderFlowPage(i, { openSeq, priority: 2 }); } } @@ -12497,7 +12532,7 @@ async function callWASM(name, ...args) { el.cancelExportButton.disabled = false; } if (name === "ofdgoResources" && resourceElements.Panel.open) resourceManager.requestID = id; - wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq }); + wasmRequests.set(id, { name, resolve, reject, openSeq: state.openSeq }); if (name === "ofdgoExportMedia") wasmRequests.get(id).mediaID = args[0]; if (name === "ofdgoConvertPDF") { const request = wasmRequests.get(id); diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index 06219bd..ad15c57 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -87,6 +87,10 @@ class NativeHandle { class NativeHost { constructor(bridge) { this.bridge = bridge; + this.parallelism = 1; + this.cacheBytes = 32 * 1024 * 1024; + this.reading = 0; + this.readQueue = []; this.resources = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(file => { this.call("release", {file}).catch(() => {}); }) : null; @@ -96,25 +100,40 @@ class NativeHost { return this.bridge.call(`ofdgo.host.${name}`, value, {signal}); } - async decode(value, name = "", key = "", signal) { - if (value?.nativeFile) { - const file = new NativeFile(value); - if (key === "file" || name === "ofdgoConvertPDF" && key === "bytes") return file; - try { - const bytes = await file.arrayBuffer(signal); - return key === "blob" ? new Blob([bytes], {type:value.type}) : new Uint8Array(bytes); - } finally { - await this.call("release", {file:value.nativeFile}); + async readResource(file, signal) { + if (this.reading >= this.parallelism) await new Promise(resolve => this.readQueue.push(resolve)); + else this.reading++; + try { + signal?.throwIfAborted(); + return await file.arrayBuffer(signal); + } finally { + try { await this.call("release", {file:file.nativeFile}); } + finally { + const next = this.readQueue.shift(); + if (next) next(); + else this.reading--; } } - if (Array.isArray(value)) { - for (let i = 0; i < value.length; i++) value[i] = await this.decode(value[i], name, "", signal); - return value; - } - if (value && typeof value === "object") { - for (const [field, item] of Object.entries(value)) value[field] = await this.decode(item, name, field, signal); - return value; - } + } + + async decode(value, name = "", key = "", signal) { + const reads = []; + const visit = (item, field, replace) => { + if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return; + if (item.nativeFile) { + const file = new NativeFile(item); + if (field === "file" || name === "ofdgoConvertPDF" && field === "bytes") replace(file); + else reads.push(this.readResource(file, signal).then(bytes => replace(field === "blob" ? new Blob([bytes], {type:item.type}) : new Uint8Array(bytes)))); + return; + } + for (const [child, content] of Object.entries(item)) { + if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result); + } + }; + visit(value, key, result => value = result); + const results = await Promise.allSettled(reads); + const failed = results.find(result => result.status === "rejected"); + if (failed) throw failed.reason; return value; } @@ -134,14 +153,17 @@ class NativeHost { let closed = false; let unsubscribe; let pending = Promise.resolve(); + const readers = new Set(); const requests = new Map(); const completions = new Map(); const emit = data => { if (data.type === "result") { completions.get(data.id)?.(data.result); return; } if (!closed) worker.onmessage?.({data}); }; - const ready = host.call("engine.open", {}).then(({engine}) => { + const ready = host.call("engine.open", {}).then(({engine, parallelism, cacheBytes}) => { if (closed) return engine; + host.parallelism = Math.max(1, parallelism || 1); + host.cacheBytes = cacheBytes || host.cacheBytes; unsubscribe = host.bridge.on(`ofdgo.engine.${engine}`, emit); emit({type:"ready"}); return engine; @@ -158,7 +180,9 @@ class NativeHost { if (data.type === "font") return; const controller = new AbortController(); requests.set(data.id, controller); - const operation = pending.then(async () => { + const parallel = data.name === "ofdgoRenderPage"; + const barrier = parallel ? pending : Promise.all([pending, ...readers]); + const operation = barrier.then(async () => { const engine = await ready; if (closed) return; controller.signal.throwIfAborted(); @@ -189,7 +213,10 @@ class NativeHost { if (closed) { await host.release(result); return; } emit({id:data.id, ...decoded}); }).catch(error => emit({id:data.id, ok:false, error:error.message, code:error.code, canceled:error.name === "AbortError"})).finally(() => requests.delete(data.id)); - if (data.name !== "ofdgoSaveSnapshot") pending = operation; + if (parallel) { + readers.add(operation); + operation.finally(() => readers.delete(operation)); + } else if (data.name !== "ofdgoSaveSnapshot") pending = operation; }, terminate() { if (closed) return; diff --git a/internal/webui/webui.go b/internal/webui/webui.go index ba5272d..4779b69 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -1012,7 +1012,11 @@ func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) { return page, nil } clear(s.pageCache) - page, err := s.Reader.PageContentByIndex(index) + ctx := s.Renderer.OutputContext + if ctx == nil { + ctx = context.Background() + } + page, err := s.Reader.PageContentByIndexContext(ctx, index) if err != nil { return nil, err } diff --git a/internal/webui/webui_native.go b/internal/webui/webui_native.go index 7a70922..925cd7d 100644 --- a/internal/webui/webui_native.go +++ b/internal/webui/webui_native.go @@ -24,8 +24,10 @@ import ( "errors" "fmt" "io" + "maps" "math" "reflect" + "runtime" "strings" "sync" @@ -63,12 +65,15 @@ type NativeOutputTarget struct { Base, Name string } -// NativeEngine 保存独立会话,不同实例可并行,同一实例的回调不可重入Call或Close +// NativeEngine 保存独立会话,页面渲染可并行,修改等待已开始的只读任务完成 +// 同一实例的回调不可重入Call或Close // hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭 type NativeEngine struct { businessEngine // ConversionPool 协调不同会话的批量转换资源 ConversionPool *ofdgo.ConversionPool + // RenderParallelism 限制同时渲染的页面数,零值使用可用处理器数 + RenderParallelism int // Resolve 按授权标识提供资源读取源 Resolve NativeResolver // LoadFont 按名称读取系统字体数据 @@ -84,6 +89,11 @@ type NativeEngine struct { once sync.Once saves sync.WaitGroup closed bool + renderMu sync.Mutex + renderCount int + renderDone chan struct{} + renderSlots chan struct{} + renderIdle []*Session } // nativeReader 在会话当前操作上下文中分块读取,不捕获已结束请求的上下文 @@ -139,12 +149,21 @@ func NewNativeEngine(resolve NativeResolver) *NativeEngine { return &NativeEngin 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.renderSlots = make(chan struct{}, e.renderParallelism()) e.resolveInput = e.input e.conversionPool = e.ConversionPool }) if ctx == nil { ctx = context.Background() } + if name == "ofdgoRenderPage" { + select { + case e.renderSlots <- struct{}{}: + defer func() { <-e.renderSlots }() + case <-ctx.Done(): + return nativeFailure(ctx.Err()) + } + } select { case e.gate <- struct{}{}: case <-ctx.Done(): @@ -162,6 +181,84 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f if e.closed { return nativeFailure(fmt.Errorf("native engine is closed")) } + if name == "ofdgoRenderPage" && e.currentSession != nil { + defer func() { + if failure := recover(); failure != nil { + result = nativeFailure(fmt.Errorf("%v", failure)) + } + }() + worker, err := e.renderSession(ctx) + if err != nil { + return nativeFailure(err) + } + operation := &businessEngine{currentSession: worker, currentEditor: e.currentEditor, ctx: ctx} + values := make([]platformValue, len(args)) + for i, arg := range args { + values[i] = platformValueOf(arg) + } + if len(values) > 0 && operation.currentEditor != nil { + pageErr := e.prepareRender(operation, values[0].Int()) + if pageErr != nil { + return nativeFailure(pageErr) + } + } + operation.currentEditor = nil + e.renderMu.Lock() + if e.renderCount == 0 { + e.renderDone = make(chan struct{}) + } + e.renderCount++ + e.renderMu.Unlock() + <-e.gate + locked = false + defer func() { + worker.Renderer.OutputContext = context.Background() + e.renderMu.Lock() + e.currentSession.svgFonts = maps.Clone(worker.svgFonts) + e.renderIdle = append(e.renderIdle, worker) + e.renderCount-- + if e.renderCount == 0 { + close(e.renderDone) + } + e.renderMu.Unlock() + }() + data, err := operation.safeCall(operation.renderPage, values) + if err != nil { + return nativeFailure(err) + } + result = nativePlain(data).(map[string]any) + if result["ok"] != true { + return result + } + page := result["data"].(map[string]any) + known := make(map[string]bool) + if len(args) > 4 { + for i := 0; i < values[4].Length(); i++ { + known[values[4].Index(i).String()] = true + } + } + for _, value := range page["fonts"].([]any) { + font := value.(map[string]any) + if name := font["name"].(string); !known[name] { + font["data"] = worker.svgFonts[name] + } + } + if e.currentEditor != nil { + objects, textErr := e.renderObjects(operation, values[0].Int()) + if textErr != nil { + return nativeFailure(textErr) + } + page["objects"] = nativePlain(objects) + } + if err := ctx.Err(); err != nil { + return nativeFailure(err) + } + return result + } + if err := e.waitRenders(ctx); err != nil { + return nativeFailure(err) + } + e.renderIdle = nil if name == "ofdgoSaveSnapshot" { snapshot := e.saveSnapshot e.saveSnapshot = nil @@ -236,10 +333,16 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f // Close 释放会话,不关闭宿主持有的ReaderAt资源 func (e *NativeEngine) Close() error { - e.once.Do(func() { e.gate = make(chan struct{}, 1); e.resolveInput = e.input }) + e.once.Do(func() { + e.gate = make(chan struct{}, 1) + e.renderSlots = make(chan struct{}, e.renderParallelism()) + e.resolveInput = e.input + }) e.gate <- struct{}{} defer func() { <-e.gate }() e.closed = true + _ = e.waitRenders(context.Background()) + e.renderIdle = nil e.saveSnapshot = nil e.saves.Wait() var err error @@ -253,6 +356,85 @@ func (e *NativeEngine) Close() error { return err } +// waitRenders 等待已开始的页面任务,取消等待不修改当前会话 +// 入参: ctx 等待上下文 +// 返回: error 取消错误 +func (e *NativeEngine) waitRenders(ctx context.Context) error { + e.renderMu.Lock() + done, count := e.renderDone, e.renderCount + e.renderMu.Unlock() + if count == 0 { + return ctx.Err() + } + select { + case <-done: + return ctx.Err() + case <-ctx.Done(): + return ctx.Err() + } +} + +// renderParallelism 返回首次调用时采用的页面并行数 +// 返回: int 同时运行的页面任务数 +func (e *NativeEngine) renderParallelism() int { + if e.RenderParallelism > 0 { + return e.RenderParallelism + } + return runtime.GOMAXPROCS(0) +} + +// renderSession 复用独立只读会话,调用时已持有引擎门控 +// 入参: ctx 渲染上下文 +// 返回: *Session 渲染会话, error 准备错误 +func (e *NativeEngine) renderSession(ctx context.Context) (*Session, error) { + e.renderMu.Lock() + defer e.renderMu.Unlock() + if count := len(e.renderIdle); count > 0 { + worker := e.renderIdle[count-1] + e.renderIdle = e.renderIdle[:count-1] + worker.Renderer.OutputContext = ctx + return worker, nil + } + source := e.currentSession + renderer, err := source.Renderer.Fork(ctx) + if err != nil { + return nil, err + } + return &Session{Reader: renderer.Reader, Renderer: renderer, doc: source.doc, fontFS: source.fontFS, + pageCache: make(map[int]*ofdgo.PageContent), boxCache: append([]pageBoxInfo(nil), source.boxCache...), + textCache: make(map[int]*ofdgo.PageText), svgFonts: make(map[string][]byte), editing: source.editing}, nil +} + +// prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存 +// 入参: operation 只读任务, index 页面索引 +// 返回: error 页面或取消错误 +func (e *NativeEngine) prepareRender(operation *businessEngine, index int) error { + e.renderMu.Lock() + defer e.renderMu.Unlock() + page, err := operation.currentEditor.Page(index) + if err != nil { + return err + } + return operation.syncEditorPageIDs(index, page) +} + +// renderObjects 读取编辑图元信息,渲染状态由当前任务独立持有 +// 入参: operation 只读任务, index 页面索引 +// 返回: []any 图元信息, error 提取错误 +func (e *NativeEngine) renderObjects(operation *businessEngine, index int) ([]any, error) { + e.renderMu.Lock() + defer e.renderMu.Unlock() + operation.currentEditor = e.currentEditor + if !reflect.DeepEqual(e.currentEditor.Backends(), operation.currentSession.Renderer.Backends()) { + e.currentEditor.SetRenderBackends(operation.currentSession.Renderer.Backends()) + } + text, err := operation.currentSession.PageText(index) + if err != nil { + return nil, err + } + return operation.editorObjects(index, text) +} + // input 读取授权源并以宿主报告的大小为准 func (e *NativeEngine) input(ctx context.Context, value any) (io.ReaderAt, int64, error) { if err := ctx.Err(); err != nil { diff --git a/internal/webview/webview.go b/internal/webview/webview.go index c28835a..b380971 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -28,6 +28,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -267,6 +268,13 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) { switch name { case "engine.open": engine := webui.NewNativeEngine(h.files.resolve) + parallelism, cacheBytes := runtime.GOMAXPROCS(0), int64(128<<20) + if memory := conversionMemory; memory != nil { + available := memory() + parallelism = max(1, min(parallelism, int(available/(256<<20)))) + cacheBytes = max(32<<20, min(256<<20, available/16)) + } + engine.RenderParallelism = parallelism engine.ConversionPool = h.conversionPool engine.OpenWriter = h.openWriter engine.ReserveOutput = h.reserveOutput @@ -280,7 +288,7 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) { } h.engineStops[id] = context.AfterFunc(guigo.PageContext(ctx), func() { _ = h.closeEngine(id) }) h.mu.Unlock() - return guigo.Result{Value: map[string]any{"engine": id}, Discard: func() { _ = h.closeEngine(id) }}, nil + return guigo.Result{Value: map[string]any{"engine": id, "parallelism": parallelism, "cacheBytes": cacheBytes}, Discard: func() { _ = h.closeEngine(id) }}, nil case "batch": return map[string]any{"parallelism": h.conversionPool.Parallelism()}, nil case "engine.close": diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 381ce85..34d1760 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -95,6 +95,33 @@ func (r *Reader) Close() error { return nil } +// Fork 创建保留当前文档及历史版本的独立只读视图,不复制包内文件数据 +// 创建过程需与原实例串行,返回后可并行读取,公开结构及底层输入不得并发修改 +// 返回值不持有输入所有权,全部视图使用结束前不得关闭或替换原输入 +// 入参: ctx 取消上下文 +// 返回: *Reader 独立阅读器, error 准备或取消错误 +func (r *Reader) Fork(ctx context.Context) (*Reader, error) { + if _, err := r.DocContext(ctx); err != nil { + return nil, err + } + next := *r + next.Closer = nil + next.resourcesRead = maps.Clone(r.resourcesRead) + next.resourceFiles = maps.Clone(r.resourceFiles) + next.ResMap = maps.Clone(r.ResMap) + next.mediaCache = maps.Clone(r.mediaCache) + next.fontCache = maps.Clone(r.fontCache) + next.fontFaces = maps.Clone(r.fontFaces) + next.colorSpaceCache = maps.Clone(r.colorSpaceCache) + next.drawParamCache = maps.Clone(r.drawParamCache) + next.compositeGraphicUnitCache = maps.Clone(r.compositeGraphicUnitCache) + next.pageHeaderCache = maps.Clone(r.pageHeaderCache) + next.Annots = maps.Clone(r.Annots) + next.Stamps = maps.Clone(r.Stamps) + next.annotationFiles = maps.Clone(r.annotationFiles) + return &next, ctx.Err() +} + // DocumentCount 获取包内文档数量 // 返回: int 文档数量 func (r *Reader) DocumentCount() int { diff --git a/ofdgo_render_cache.go b/ofdgo_render_cache.go index c080257..a9ccf5b 100644 --- a/ofdgo_render_cache.go +++ b/ofdgo_render_cache.go @@ -18,12 +18,14 @@ import ( "container/list" "image" "reflect" + "sync" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" ) // renderCache 按估算字节数限制派生渲染数据,淘汰不修改源资源 type renderCache[K comparable, V any] struct { + mu sync.Mutex limit, used int entries map[K]*list.Element order list.List @@ -40,6 +42,8 @@ type renderCacheEntry[K comparable, V any] struct { // 入参: key 缓存键 // 返回: V 缓存值, bool 是否命中 func (c *renderCache[K, V]) get(key K) (V, bool) { + c.mu.Lock() + defer c.mu.Unlock() if item := c.entries[key]; item != nil { c.order.MoveToFront(item) return item.Value.(renderCacheEntry[K, V]).value, true @@ -51,6 +55,8 @@ func (c *renderCache[K, V]) get(key K) (V, bool) { // put 缓存预算内的派生数据,超大单项不进入缓存 // 入参: key 缓存键, value 缓存值, cost 字节成本 func (c *renderCache[K, V]) put(key K, value V, cost int) { + c.mu.Lock() + defer c.mu.Unlock() if item := c.entries[key]; item != nil { c.used -= item.Value.(renderCacheEntry[K, V]).cost c.order.Remove(item) diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index e88bee8..0351e21 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -18,10 +18,12 @@ import ( "context" "image" "io/fs" + "maps" ) // Renderer 渲染器实现 // 通过NewRenderer创建,实例及共享的Reader需串行使用 +// Fork创建独立任务实例,派生实例之间可并行渲染 // 外部字体缺失且无可用回退时不绘制对应文字,其余内容继续;字体状态可通过FontInfos查询 // OnPageText可选,接收页面绘制时同步提取的文字,不包含图案和签名外观 // OnExportProgress可选,同步回报文档导出的已处理页数及总页数,返回错误则停止,页数完成不代表写入成功 @@ -65,6 +67,31 @@ type Renderer struct { // RendererOption 渲染器配置选项 type RendererOption func(*Renderer) +// Fork 创建独立渲染实例,复用只读资源及线程安全的派生数据缓存 +// 创建过程需与原实例串行,输入生命周期遵循Reader.Fork,外部后端及字体文件系统需支持并行读取 +// 页面文字及导出进度回调不继承,取消上下文使用ctx +// 入参: ctx 取消上下文 +// 返回: *Renderer 独立渲染器, error 准备或取消错误 +func (r *Renderer) Fork(ctx context.Context) (*Renderer, error) { + reader, err := r.Reader.Fork(ctx) + if err != nil { + return nil, err + } + next := NewRenderer(reader, WithDPI(r.DPI), WithAnnotations(r.RenderAnnotations), WithRenderBackends(r.backends), WithFontDirs(r.fontDirs...), WithFontFS(r.fontFS...)) + next.Compression = r.Compression + next.DrawParams = maps.Clone(r.DrawParams) + next.CompositeGraphicUnits = maps.Clone(r.CompositeGraphicUnits) + next.OutputContext = ctx + next.TransparentBackground = r.TransparentBackground + next.decodeImages = r.decodeImages + next.imageCache = r.imageCache + next.fontDigests = r.fontDigests + next.fontPreparations = r.fontPreparations + next.fontMetrics = r.fontMetrics + next.glyphOutlines = r.glyphOutlines + return next, nil +} + // deviceLineWidth 按设备分辨率计算描边宽度,零线宽为一像素,正线宽至少为两像素 // 入参: width 页面空间线宽,单位为毫米 // 返回: float64 设备描边宽度,单位为毫米