diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 9a7372f..b0e6e8b 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -419,6 +419,7 @@ type outputContext struct { // businessEngine 独立文档、编辑、剪贴板及操作上下文 type businessEngine struct { + resources *ofdgo.Resources currentSession *Session currentEditor *ofdgo.Editor saveSnapshot *ofdgo.Editor @@ -1396,7 +1397,7 @@ func (e *businessEngine) convertPDFDocument(args []platformValue) (any, error) { return nil, err } var output bytes.Buffer - options := ofdgo.PDFImportOptions{OnProgress: e.editorOperationProgress(args[4]), PasswordUTF8: true} + options := ofdgo.PDFImportOptions{Resources: e.resources, OnProgress: e.editorOperationProgress(args[4]), PasswordUTF8: true} if !args[2].IsNull() && !args[2].IsUndefined() { options.Password, err = e.bytesFromJS(args[2]) if err != nil { @@ -1450,7 +1451,8 @@ func (e *businessEngine) convertFile(args []platformValue) (any, error) { return nil, err } options := ofdgo.ConvertOptions{ - Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(), + Resources: e.resources, + Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(), Pool: e.conversionPool, SkipUnchanged: true, Backends: &backends, @@ -1608,7 +1610,7 @@ func (e *businessEngine) openDocument(args []platformValue) (any, error) { return nil, err } renderAnnotations := args[2].Bool() - options := OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations} + options := OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations, ReaderOptions: []ofdgo.ReaderOption{ofdgo.WithResources(e.resources)}} if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { credentials, err := e.credentialsFromJS(args[3]) if err != nil { @@ -4047,6 +4049,7 @@ func (e *businessEngine) previewText(args []platformValue) (any, error) { return nil, err } editor = ofdgo.NewEditor() + editor.SetResources(e.resources) fontID, err = editor.AddFont(FontFile{Data: data}, 0) if err != nil { return nil, err @@ -5568,6 +5571,7 @@ func (e *businessEngine) loadImport(args []platformValue) (any, error) { } }() var options []ofdgo.ReaderOption + options = append(options, ofdgo.WithResources(e.resources)) if len(args) > 1 && !args[1].IsNull() && !args[1].IsUndefined() { credentials, err := e.credentialsFromJS(args[1]) if err != nil { @@ -5685,6 +5689,7 @@ func (e *businessEngine) importImages(args []platformValue) (any, error) { editor := e.currentEditor if at < 0 { editor = ofdgo.NewEditor() + editor.SetResources(e.resources) editor.Info.Title = args[3].String() } else if editor == nil { return nil, fmt.Errorf("document is not editable") @@ -5721,6 +5726,7 @@ func (e *businessEngine) importImages(args []platformValue) (any, error) { func (e *businessEngine) createDocument(args []platformValue) (any, error) { e.clearImport() editor := ofdgo.NewEditor() + editor.SetResources(e.resources) editor.Info.Title = args[0].String() if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil { return nil, err @@ -7124,6 +7130,7 @@ func (e *businessEngine) previewSignatureSeal(args []platformValue) (any, error) pages = append(pages, map[string]any{"index": index, "x": box.X, "y": box.Y, "width": box.W, "height": box.H}) } editor := ofdgo.NewEditor() + editor.SetResources(e.resources) if _, err := editor.AddPage(seal.Width, seal.Height); err != nil { return nil, err } diff --git a/internal/webui/webui_native.go b/internal/webui/webui_native.go index 5b0262d..e451061 100644 --- a/internal/webui/webui_native.go +++ b/internal/webui/webui_native.go @@ -70,6 +70,8 @@ type NativeOutputTarget struct { // hooks应在首次Call前配置,Resolve返回的资源需保持有效直到相应会话关闭 type NativeEngine struct { businessEngine + // Resources 协调会话的计算与缓存,需在首次Call前配置 + Resources *ofdgo.Resources // ConversionPool 协调不同会话的批量转换资源 ConversionPool *ofdgo.ConversionPool // RenderParallelism 限制同时渲染的页面数,零值使用可用处理器数 @@ -152,6 +154,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f e.renderSlots = make(chan struct{}, e.renderParallelism()) e.resolveInput = e.input e.conversionPool = e.ConversionPool + e.resources = e.Resources }) if ctx == nil { ctx = context.Background() @@ -163,6 +166,13 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f case <-ctx.Done(): return nativeFailure(ctx.Err()) } + if e.resources != nil { + release, err := e.resources.Acquire(ctx, 64<<20) + if err != nil { + return nativeFailure(err) + } + defer release() + } } select { case e.gate <- struct{}{}: diff --git a/internal/webui/webui_wasm.go b/internal/webui/webui_wasm.go index a754e4d..6359be9 100644 --- a/internal/webui/webui_wasm.go +++ b/internal/webui/webui_wasm.go @@ -39,7 +39,8 @@ type platformValue = js.Value // RunWASM 注册浏览器WASM接口并阻塞运行 func RunWASM() { - e := &businessEngine{} + resources, _ := ofdgo.NewResources(ofdgo.ResourceOptions{Parallelism: 1, MemoryLimit: 64 << 20, CacheBytes: 48 << 20, SnapshotBytes: 32 << 20}) + e := &businessEngine{resources: resources} asynchronous := map[string]bool{ "ofdgoDetectFormat": true, "ofdgoExportVersion": true, diff --git a/internal/webview/webview.go b/internal/webview/webview.go index b380971..4d3dff5 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -51,6 +51,7 @@ type Host struct { engines map[string]*webui.NativeEngine engineStops map[string]func() bool conversionPool *ofdgo.ConversionPool + resources *ofdgo.Resources cancels map[string]context.CancelFunc batchResults map[string]any fontOnce sync.Once @@ -79,11 +80,16 @@ func Run(ctx context.Context, paths []string, dataDir string) error { 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...)} - h.conversionPool, err = ofdgo.NewConversionPool(ofdgo.ConversionPoolOptions{AvailableMemory: conversionMemory}) + cacheBytes := int64(256 << 20) + if memory := conversionMemory; memory != nil { + cacheBytes = max(32<<20, memory()/8) + } + h.resources, err = ofdgo.NewResources(ofdgo.ResourceOptions{CacheBytes: cacheBytes, AvailableMemory: conversionMemory}) if err != nil { files.close() return err } + h.conversionPool = h.resources.ConversionPool() defer h.close() bindings := make(map[string]guigo.HandlerFunc) for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title"} { @@ -271,11 +277,11 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) { 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)) + cacheBytes = max(32<<20, available/32) } engine.RenderParallelism = parallelism engine.ConversionPool = h.conversionPool + engine.Resources = h.resources engine.OpenWriter = h.openWriter engine.ReserveOutput = h.reserveOutput engine.CleanupOutput = h.cleanupOutput @@ -798,6 +804,9 @@ func (h *Host) close() { for _, result := range results { h.discard(result) } + if h.resources != nil { + h.resources.Clear() + } _ = h.files.close() } diff --git a/ofdgo.go b/ofdgo.go index 6bd19d3..ce839d2 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -290,6 +290,9 @@ func NewReader(input *Input, options ...ReaderOption) (*Reader, error) { // 入参: reader 阅读器, opts 渲染选项 // 返回: *Renderer 渲染器实例 func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer { + if reader.resources == nil { + reader.resources, _ = NewResources(ResourceOptions{}) + } r := &Renderer{ Reader: reader, DPI: 300.0, @@ -297,7 +300,7 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer { DrawParams: reader.drawParamCache, CompositeGraphicUnits: reader.compositeGraphicUnitCache, templatePageCache: make(map[string]*PageContent), - imageCache: &renderCache[string, image.Image]{limit: imageCacheLimit}, + imageCache: resourceCache[string, image.Image](reader.resources, imageCacheLimit), backends: defaultRenderBackends(), backendStates: make(map[any]any), } diff --git a/ofdgo_compression.go b/ofdgo_compression.go index 4f78c45..60c3bd6 100644 --- a/ofdgo_compression.go +++ b/ofdgo_compression.go @@ -108,6 +108,7 @@ func (e *Editor) WriteToWithOptions(ctx context.Context, writer io.Writer, optio // 入参: ctx 取消上下文, writer 输出流, indexes 零基页码, options 输出配置 // 返回: int64 已写入字节数, error 错误信息 func (e *Editor) WritePagesTo(ctx context.Context, writer io.Writer, indexes []int, options WriteOptions) (int64, error) { + ctx = e.runtimeResources.context(ctx) if err := ctx.Err(); err != nil { return 0, err } @@ -125,6 +126,7 @@ func (e *Editor) WritePagesTo(ctx context.Context, writer io.Writer, indexes []i // 入参: ctx 取消上下文, options 输出配置 // 返回: *Editor 输出快照, error 配置或资源错误 func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Editor, error) { + ctx = e.runtimeResources.context(ctx) if err := ctx.Err(); err != nil { return nil, err } diff --git a/ofdgo_compression_parallel.go b/ofdgo_compression_parallel.go index bb3026f..902b462 100644 --- a/ofdgo_compression_parallel.go +++ b/ofdgo_compression_parallel.go @@ -20,16 +20,12 @@ import ( "crypto/sha256" "image" "io" - "strconv" "sync" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" ) -// outputImageBatchLimit 限制并行任务持有的原始编码,超限图片单独处理 -const outputImageBatchLimit = 16 << 20 - -// outputImageWorkingSetLimit 限制64位并行任务的像素工作量总估算,32位使用64MiB,超限图片单独处理 +// outputImageWorkingSetLimit 指定未提供任务预算时的图像成本估算上限 const outputImageWorkingSetLimit = 256 << 20 // outputImageJob 保存独立图片任务,不访问编辑文档或调用进度回调 @@ -82,20 +78,22 @@ func (e *Editor) outputImageOptions(key string) (CompressionOptions, image.Point // 入参: keys 资源路径, load 串行读取方法,空值表示跳过, results 候选缓存, sources 原始资源摘要,可为空 // 返回: error 读取、进度或取消错误 func (e *Editor) prepareOutputImages(keys []string, load func(string) ([]byte, error), results map[outputImageKey][]byte, sources map[string]outputImageSource) error { - workers := conversionWorkers(e.output.ctx, 4) + workers := conversionWorkers(e.output.ctx) if workers < 2 || len(keys) < 2 { return nil } - limit, encodedLimit := outputImageWorkingSetLimit, outputImageBatchLimit + limit := int(min(int64(workers)*(64<<20), int64(int(^uint(0)>>1)-1))) + encodedLimit := limit if pool, _ := e.output.ctx.Value(conversionPoolKey{}).(*ConversionPool); pool != nil { - if available := pool.memoryLimit(); available > 0 { + available := pool.memoryLimit() + if available == 0 { + return nil + } + if available > 0 { limit = int(min(available, int64(int(^uint(0)>>1)))) encodedLimit = limit } } - if strconv.IntSize == 32 { - limit = 64 << 20 - } base, cancel := context.WithCancel(e.output.ctx) ctx := outputImageContext{Context: base, parent: e.output.ctx} var group sync.WaitGroup diff --git a/ofdgo_convert.go b/ofdgo_convert.go index 54a102d..0d7ecc0 100644 --- a/ofdgo_convert.go +++ b/ofdgo_convert.go @@ -52,6 +52,7 @@ type ConvertProgress struct { // PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面 // SkipUnchanged仅在未压缩、源格式与目标相同且未选页时不写出数据 // Pool可由多个独立转换共享,nil时不限制文件间并发 +// Resources提供共享计算与缓存策略,显式Pool优先控制计算调度 // 整份PDF同格式压缩直接重写PDF对象,其余PDF先转换为OFD对象再输出 // RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用 // Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 @@ -60,6 +61,7 @@ type ConvertProgress struct { // PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil // 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换 type ConvertOptions struct { + Resources *Resources Input string Format string Pages []int @@ -145,6 +147,13 @@ func OutputFormats() []OutputFormat { // 入参: ctx 取消上下文, input 输入源, output 输出流, options 转换选项 // 返回: ConvertReport 转换报告, error 错误信息 func Convert(ctx context.Context, input *Input, output io.Writer, options ConvertOptions) (report ConvertReport, err error) { + if options.Resources != nil { + if options.Pool == nil { + options.Pool = options.Resources.pool + } + options.ReaderOptions = append(slices.Clone(options.ReaderOptions), WithResources(options.Resources)) + options.PDF.Resources = options.Resources + } if options.Pool != nil { if err = options.Pool.acquire(ctx, false, 0); err != nil { return report, err @@ -534,7 +543,7 @@ func (p *ConversionPool) acquire(ctx context.Context, image bool, size int64) er p.mu.Lock() allowed := p.active < p.options.Parallelism && (p.active == 0 || limit != 0) if image { - allowed = p.images < runtime.GOMAXPROCS(0) && (p.images == 0 || limit < 0 || size <= limit-p.memory) + allowed = p.images < p.options.Parallelism && (p.images == 0 || limit < 0 || size <= limit-p.memory) } if allowed { if image { @@ -584,15 +593,15 @@ func (p *ConversionPool) release(image bool, size int64) { } // conversionWorkers 按当前文件数量分配内部计算任务 -// 入参: ctx 转换上下文, fallback 非共享模式并行上限 +// 入参: ctx 转换上下文 // 返回: int 内部并行数 -func conversionWorkers(ctx context.Context, fallback int) int { +func conversionWorkers(ctx context.Context) int { p, _ := ctx.Value(conversionPoolKey{}).(*ConversionPool) if p == nil { - return min(fallback, runtime.GOMAXPROCS(0)) + return runtime.GOMAXPROCS(0) } p.mu.Lock() active := p.active p.mu.Unlock() - return max(1, runtime.GOMAXPROCS(0)/max(1, active)) + return max(1, p.options.Parallelism/max(1, active)) } diff --git a/ofdgo_crypto.go b/ofdgo_crypto.go index b838b24..e3d26d0 100644 --- a/ofdgo_crypto.go +++ b/ofdgo_crypto.go @@ -59,7 +59,7 @@ type CryptoProvider interface { EncryptKey(certificate, key []byte) ([]byte, error) } -// ReaderOption 阅读器的凭据与密码提供者选项 +// ReaderOption 阅读器的凭据、密码提供者与资源选项 type ReaderOption func(*readerOptions) // EncryptionRecipient 证书加密接收者,Certificate为SM2公钥证书的DER编码 @@ -92,6 +92,7 @@ type EncryptionInfo struct { // readerOptions 保存打开文档时的会话参数 type readerOptions struct { + resources *Resources credentials []Credentials provider CryptoProvider } diff --git a/ofdgo_crypto_package.go b/ofdgo_crypto_package.go index 17e5823..fead067 100644 --- a/ofdgo_crypto_package.go +++ b/ofdgo_crypto_package.go @@ -113,6 +113,7 @@ func (r *Reader) open(options []ReaderOption) error { for _, option := range options { option(&settings) } + r.resources = settings.resources if settings.provider == nil { settings.provider = GMCryptoProvider{} } diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 46dfc17..1892c66 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -50,6 +50,7 @@ type Editor struct { Info DocInfo pages []PageContent resources []editorResource + runtimeResources *Resources fonts map[string]*FontResource fontRegistration editorFontRegistration fontDirs []string @@ -154,11 +155,13 @@ func (e *Editor) Backends() RenderBackends { // NewEditor 创建空白文档,保存前至少添加一页 // 返回: *Editor 文档编辑器 func NewEditor() *Editor { + resources, _ := NewResources(ResourceOptions{}) var id [16]byte rand.Read(id[:]) id[6] = id[6]&0x0f | 0x40 id[8] = id[8]&0x3f | 0x80 return &Editor{ + runtimeResources: resources, Info: DocInfo{ DocID: hex.EncodeToString(id[:]), CreationDate: time.Now().Format("2006-01-02"), @@ -172,6 +175,30 @@ func NewEditor() *Editor { } } +// SetResources 替换后续操作的资源策略,需与编辑操作串行调用 +// 已创建的阅读器和快照继续使用原策略,不修改文档或撤销记录 +// 入参: resources 共享策略,nil使用默认策略 +func (e *Editor) SetResources(resources *Resources) { + if resources == nil { + resources, _ = NewResources(ResourceOptions{}) + } + if resources == e.runtimeResources { + return + } + e.runtimeResources = resources + e.snapshotArchive = nil + e.fontRenderer = nil + e.fontSourcesCache = newFontSourceCache(resources) + e.validatedPaths = nil + if e.source != nil { + source := *e.source + e.source = &source + e.source.reader = cloneEditorReader(e.source.reader) + e.source.reader.resources = resources + e.source.reader.pageContents = resourceCache[pageContentKey, PageContent](resources, 16<<20) + } +} + // PageCount 获取当前页数 // 返回: int 页数 func (e *Editor) PageCount() int { @@ -773,7 +800,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e // 返回: *Renderer 度量器 func (e *Editor) newRenderer(reader *Reader) *Renderer { if e.fontSourcesCache == nil { - e.fontSourcesCache = newFontSourceCache() + e.fontSourcesCache = newFontSourceCache(e.runtimeResources) } renderer := NewRenderer(reader, WithFontDirs(e.fontDirs...), WithFontFS(e.fontFS...), WithRenderBackends(e.backends)) renderer.fontSourcesCache = e.fontSourcesCache.child() @@ -1325,7 +1352,7 @@ func (v *editorValidation) creationPath(value string) error { // 返回: *renderCache[string, bool] 校验缓存 func (e *Editor) pathValidationCache() *renderCache[string, bool] { if e.validatedPaths == nil { - e.validatedPaths = &renderCache[string, bool]{limit: 8 << 20} + e.validatedPaths = resourceCache[string, bool](e.runtimeResources, 8<<20) } return e.validatedPaths } diff --git a/ofdgo_editor_documents.go b/ofdgo_editor_documents.go index f79c14d..3142fdc 100644 --- a/ofdgo_editor_documents.go +++ b/ofdgo_editor_documents.go @@ -453,6 +453,7 @@ func (e *Editor) adoptDocumentReader(ctx context.Context, reader *Reader) error next.encryption = e.encryption next.saveTarget = e.saveTarget next.backends, next.fontDirs, next.fontFS = e.backends, e.fontDirs, e.fontFS + next.SetResources(e.runtimeResources) next.validatedPaths = e.validatedPaths *e = *next } diff --git a/ofdgo_editor_font_binding.go b/ofdgo_editor_font_binding.go index 493231d..3abac63 100644 --- a/ofdgo_editor_font_binding.go +++ b/ofdgo_editor_font_binding.go @@ -907,6 +907,7 @@ func (e *Editor) restoreResourceSnapshot(state Editor) { state.clearFontResourceCache() } state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS + state.SetResources(e.runtimeResources) state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress state.saveTarget = e.saveTarget state.fontRenderer, state.fontSourcesCache = nil, nil diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index e1c3191..7167000 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -53,6 +53,7 @@ func (e *Editor) Transaction(edit func(*Editor) error) error { e.clearFontResourceCache() } e.backends, e.fontDirs, e.fontFS = before.backends, before.fontDirs, before.fontFS + e.SetResources(before.runtimeResources) e.fontSourcesCache = before.fontSourcesCache if e.sameResourceView(&before) { e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics @@ -168,6 +169,7 @@ func (e *Editor) restoreTransaction(state Editor) { state.clearFontResourceCache() } state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS + state.SetResources(e.runtimeResources) state.fontSourcesCache = e.fontSourcesCache state.validatedPaths = e.validatedPaths state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 6d026e5..3988bd0 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -151,6 +151,7 @@ func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) { } } e := NewEditor() + e.runtimeResources = r.resources e.encryption = r.encryption directory := cleanPackagePath(reader.RootDir) e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), fallbackDocID: e.Info.DocID, directory: directory, pages: make(map[string]*editorSourcePage, len(doc.Pages.Page)), origins: make(map[string]*editorObjectOrigin)} diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index d52fc1b..21e5c08 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -817,7 +817,7 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re files = make(map[string][]byte) } maps.Copy(files, parts) - reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption, documentIndex: e.source.reader.documentIndex, selectedVersion: e.source.reader.selectedVersion, pageContents: e.source.reader.pageContents} + reader := &Reader{Zip: e.source.reader.Zip, files: files, encryption: e.encryption, documentIndex: e.source.reader.documentIndex, selectedVersion: e.source.reader.selectedVersion, pageContents: e.source.reader.pageContents, resources: e.runtimeResources} if err := reader.initRootContext(ctx); err != nil { return nil, err } diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go index 0787cb4..5119f35 100644 --- a/ofdgo_font_backend.go +++ b/ofdgo_font_backend.go @@ -269,23 +269,35 @@ func (r *Renderer) fontDigest(data []byte) [32]byte { // resetFontCache 重置字体解析及字形缓存,保留图片和非字体后端状态 func (r *Renderer) resetFontCache() { - r.fontSourcesCache = newFontSourceCache() + if r.fontSourcesCache != nil { + r.fontSourcesCache.collections.clear() + } + r.fontSourcesCache = newFontSourceCache(r.Reader.resources) r.resetFontBackendCache() } // resetFontBackendCache 清理后端字体对象,保留字体文件和图片缓存 func (r *Renderer) resetFontBackendCache() { + r.fontDigests.clear() + r.fontPreparations.clear() + r.fontMetrics.clear() + r.glyphOutlines.clear() + for _, state := range r.sharedBackendStates { + if cache, ok := state.(interface{ clear() }); ok { + cache.clear() + } + } r.sharedBackendStates = make(map[any]any) r.preparedFonts = make(map[string]*PreparedFont) - r.fontDigests = &renderCache[fontDataKey, [32]byte]{limit: 4 << 20} - r.fontPreparations = &renderCache[[32]byte, PreparedFont]{limit: 16 << 20} + r.fontDigests = resourceCache[fontDataKey, [32]byte](r.Reader.resources, 4<<20) + r.fontPreparations = resourceCache[[32]byte, PreparedFont](r.Reader.resources, 16<<20) r.resolvedFonts = make(map[resolvedFontKey]resolvedFontResult) if r.fontSourcesCache != nil { r.fontSourcesCache.fallback = ResolvedFont{} r.fontSourcesCache.fallbackRead = false } - r.fontMetrics = &renderCache[[32]byte, FontMetrics]{limit: 32 << 20} - r.glyphOutlines = &renderCache[glyphOutlineKey, GeometryPath]{limit: 16 << 20} + r.fontMetrics = resourceCache[[32]byte, FontMetrics](r.Reader.resources, 32<<20) + r.glyphOutlines = resourceCache[glyphOutlineKey, GeometryPath](r.Reader.resources, 16<<20) for _, state := range r.backendStates { if fonts, ok := state.(interface{ resetFonts() }); ok { fonts.resetFonts() diff --git a/ofdgo_font_source.go b/ofdgo_font_source.go index aa6c6ef..70d227f 100644 --- a/ofdgo_font_source.go +++ b/ofdgo_font_source.go @@ -79,15 +79,16 @@ type fontSourceData struct { } // newFontSourceCache 创建独立字体来源缓存 +// 入参: resources 共享资源策略 // 返回: *fontSourceCache 字体来源缓存 -func newFontSourceCache() *fontSourceCache { - return &fontSourceCache{candidates: make(map[string][]fontSource), directories: make(map[string][]basefont.FileCandidate), filesystems: make(map[int][]basefont.FileCandidate), data: make(map[fontSourceKey]fontSourceData), collections: &renderCache[fontSourceKey, []byte]{limit: 32 << 20}} +func newFontSourceCache(resources *Resources) *fontSourceCache { + return &fontSourceCache{candidates: make(map[string][]fontSource), directories: make(map[string][]basefont.FileCandidate), filesystems: make(map[int][]basefont.FileCandidate), data: make(map[fontSourceKey]fontSourceData), collections: resourceCache[fontSourceKey, []byte](resources, 32<<20)} } // child 复用相同来源的文件索引与数据,匹配和回退状态保持独立 // 返回: *fontSourceCache 独立文档缓存 func (c *fontSourceCache) child() *fontSourceCache { - cache := newFontSourceCache() + cache := newFontSourceCache(nil) cache.directories, cache.filesystems, cache.data = c.directories, c.filesystems, c.data cache.collections = c.collections return cache diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 6b9293e..2d0cb1a 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -41,7 +41,9 @@ var ErrPDFPassword = pdfgo.ErrPassword // ResolveReference可提供已解密的引用页面,目标阅读器由调用方维护 // Halftones提供只读设备命名网屏,所属阅读器由调用方维护,连续色调输出不网屏化 // Compression配置转换输出,有损模式按图片实际尺寸降采样,原始及无损模式保持完整采样 +// Resources用于导入、局部合成与返回编辑器的共享资源预算 type PDFImportOptions struct { + Resources *Resources Password []byte PasswordUTF8 bool Backends *RenderBackends @@ -68,6 +70,7 @@ type PDFImportReport struct { // pdfImporter 保存单次转换的对象与资源状态 type pdfImporter struct { + resources *Resources ctx context.Context reader *pdfgo.Reader editor *Editor @@ -207,6 +210,10 @@ func (o pdfObjectOrigin) merge(other pdfObjectOrigin) pdfObjectOrigin { // 入参: ctx 取消上下文, input 输入源, options 转换选项 // 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息 func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Editor, PDFImportReport, error) { + if options.Resources == nil { + options.Resources, _ = NewResources(ResourceOptions{}) + } + ctx = options.Resources.context(ctx) if input == nil { return nil, PDFImportReport{}, fmt.Errorf("missing source") } @@ -219,7 +226,7 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed if !finite(options.RasterDPI) || options.RasterDPI < 0 { return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI") } - renderer := NewRenderer(&Reader{}, options.RendererOptions...) + renderer := NewRenderer(&Reader{resources: options.Resources}, options.RendererOptions...) if options.Backends != nil { WithRenderBackends(*options.Backends)(renderer) } @@ -234,10 +241,11 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed return nil, PDFImportReport{}, err } editor := NewEditor() + editor.runtimeResources = options.Resources editor.SetRenderBackends(renderer.Backends()) editor.fontDirs, editor.fontFS = renderer.FontSources() editor.fontSourcesCache = renderer.fontSourcesCache - importer := pdfImporter{ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}} + importer := pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}} readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText} var diagnostics []pdfgo.Diagnostic var readWarning func(pdfgo.Diagnostic) diff --git a/ofdgo_pdf_action.go b/ofdgo_pdf_action.go index 057d447..d0f2c1e 100644 --- a/ofdgo_pdf_action.go +++ b/ofdgo_pdf_action.go @@ -246,6 +246,7 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) { } matrix, width, height := codecpdf.PageMatrix(page) editor := NewEditor() + editor.SetResources(p.resources) editor.SetRenderBackends(p.editor.Backends()) editor.SetFontDirs(p.editor.fontDirs...) editor.SetFontFS(p.editor.fontFS...) @@ -255,7 +256,8 @@ func (p *pdfImporter) destinationBounds(page *pdfgo.Page) (Box, error) { return Box{}, err } local := pdfImporter{ - ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, + resources: p.resources, + ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: matrix, pageBox: page.CropBox, pageWidth: width, pageHeight: height, rasterDPI: p.rasterDPI, fontIDs: make(map[*pdfgo.Font]string), resolveFile: p.resolveFile, resolveReference: p.resolveReference, diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index 417992c..81c1064 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -126,6 +126,7 @@ type pdfCompositeImageColor struct { // pdfCompositeCache 在单页内复用几何、图像分量及蒙版内容 type pdfCompositeCache struct { + resources *Resources geometry *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] clips *renderCache[[32]byte, GeometryPath] bounds map[pdfCompositeKey]Box @@ -157,7 +158,7 @@ type pdfCompositeScratch struct { // 返回: *pdfCompositeCache 当前页面缓存 func (p *pdfImporter) compositingCache() *pdfCompositeCache { if p.compositeCache == nil { - p.compositeCache = &pdfCompositeCache{images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}, meshes: map[*pdfgo.MeshGradient][]codecpdf.MeshTriangle{}} + p.compositeCache = &pdfCompositeCache{resources: p.resources, images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}, meshes: map[*pdfgo.MeshGradient][]codecpdf.MeshTriangle{}} } return p.compositeCache } @@ -166,7 +167,7 @@ func (p *pdfImporter) compositingCache() *pdfCompositeCache { // 返回: *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] 单页场景缓存 func (c *pdfCompositeCache) geometryCache() *renderCache[pdfCompositeSceneKey, pdfCompositeGeometry] { if c.geometry == nil { - c.geometry = &renderCache[pdfCompositeSceneKey, pdfCompositeGeometry]{limit: 64 << 20} + c.geometry = resourceCache[pdfCompositeSceneKey, pdfCompositeGeometry](c.resources, 64<<20) } return c.geometry } @@ -175,7 +176,7 @@ func (c *pdfCompositeCache) geometryCache() *renderCache[pdfCompositeSceneKey, p // 返回: *renderCache[[32]byte, GeometryPath] 裁剪缓存 func (c *pdfCompositeCache) clipCache() *renderCache[[32]byte, GeometryPath] { if c.clips == nil { - c.clips = &renderCache[[32]byte, GeometryPath]{limit: 8 << 20} + c.clips = resourceCache[[32]byte, GeometryPath](c.resources, 8<<20) } return c.clips } diff --git a/ofdgo_pdf_form.go b/ofdgo_pdf_form.go index cc4418f..641afaa 100644 --- a/ofdgo_pdf_form.go +++ b/ofdgo_pdf_form.go @@ -108,7 +108,7 @@ func (p *pdfImporter) compositeForm(nodes []pdfCompositeNode) (bool, error) { return true, nil } if p.formResources == nil || p.formResources.editor != p.editor { - p.formResources = &pdfFormCache{editor: p.editor, entries: renderCache[[32]byte, pdfFormResource]{limit: 32 << 20}} + p.formResources = &pdfFormCache{editor: p.editor, entries: *resourceCache[[32]byte, pdfFormResource](p.resources, 32<<20)} } cache := p.formResources defer cache.reset() diff --git a/ofdgo_pdf_geometry.go b/ofdgo_pdf_geometry.go index 9f56be8..6321cd9 100644 --- a/ofdgo_pdf_geometry.go +++ b/ofdgo_pdf_geometry.go @@ -419,7 +419,7 @@ func (p *pdfImporter) clipPath(path pdfgo.Path) (pdfClipPath, error) { return pdfClipPath{}, err } if p.clipPaths == nil { - p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20} + p.clipPaths = resourceCache[[32]byte, pdfClipPath](p.resources, 8<<20) } if cached, ok := p.clipPaths.get(key); ok { return cached, nil diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index e7d5059..6913118 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -383,6 +383,7 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, return nil, box, err } editor := NewEditor() + editor.SetResources(p.resources) editor.SetRenderBackends(p.editor.Backends()) editor.SetFontDirs(p.editor.fontDirs...) editor.SetFontFS(p.editor.fontFS...) @@ -398,9 +399,9 @@ func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, p.symbolFonts = make(map[*pdfgo.Font]*pdfImportedFont) } if p.clipPaths == nil { - p.clipPaths = &renderCache[[32]byte, pdfClipPath]{limit: 8 << 20} + p.clipPaths = resourceCache[[32]byte, pdfClipPath](p.resources, 8<<20) } - local := pdfImporter{ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts, symbolFonts: p.symbolFonts, clipPaths: p.clipPaths, textOrigins: p.textOrigins} + local := pdfImporter{resources: p.resources, ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fonts: p.fonts, symbolFonts: p.symbolFonts, clipPaths: p.clipPaths, textOrigins: p.textOrigins} if err := build(&local); err != nil { return nil, box, err } diff --git a/ofdgo_pdf_image.go b/ofdgo_pdf_image.go index 9199f1f..c773802 100644 --- a/ofdgo_pdf_image.go +++ b/ofdgo_pdf_image.go @@ -136,7 +136,7 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { } if cacheable { if p.imageContents == nil { - p.imageContents = &renderCache[pdfImageContentKey, pdfImageContent]{limit: 8 << 20} + p.imageContents = resourceCache[pdfImageContentKey, pdfImageContent](p.resources, 8<<20) } if cached, found := p.imageContents.get(contentKey); found && cached.matches(source) { id = cached.id diff --git a/ofdgo_pdf_pattern.go b/ofdgo_pdf_pattern.go index 85bfea9..3f8219b 100644 --- a/ofdgo_pdf_pattern.go +++ b/ofdgo_pdf_pattern.go @@ -69,7 +69,7 @@ func (p *pdfImporter) tilingPattern(source *pdfgo.TilingPattern, base pdfgo.Pain return p.buildTilingPattern(source, base) } if p.patternCells == nil { - p.patternCells = &renderCache[pdfPatternCellKey, pdfPatternCell]{limit: pdfPatternCellLimit} + p.patternCells = resourceCache[pdfPatternCellKey, pdfPatternCell](p.resources, pdfPatternCellLimit) } key := pdfPatternCellKey{source: source, matrix: p.matrix, dpi: p.rasterDPI, editor: p.editor, space: p.compositeSpace} if source.PaintType == 2 { diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go index f64f51f..5097f78 100644 --- a/ofdgo_pdf_shading.go +++ b/ofdgo_pdf_shading.go @@ -887,7 +887,7 @@ func (p *pdfImporter) linearMeshTriangles(mesh *pdfgo.MeshGradient, index int) ( } cache := p.compositingCache() if cache.linearMeshes == nil { - cache.linearMeshes = &renderCache[pdfLinearMeshKey, []pdfgo.MeshTriangle]{limit: 8 << 20} + cache.linearMeshes = resourceCache[pdfLinearMeshKey, []pdfgo.MeshTriangle](p.resources, 8<<20) } key := pdfLinearMeshKey{mesh: mesh, index: index} if triangles, ok := cache.linearMeshes.get(key); ok { @@ -1047,7 +1047,7 @@ func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace, } c.cache.meshes[mesh] = triangles } - options := codecpdf.MeshRasterOptions{Matrix: c.importer.matrix, Origin: pdfgo.Point{X: c.box.X, Y: c.box.Y}, DPI: c.importer.rasterDPI, Width: c.width, Height: c.height, Workers: conversionWorkers(c.importer.ctx, 4), SoftMask: c.softMask} + options := codecpdf.MeshRasterOptions{Matrix: c.importer.matrix, Origin: pdfgo.Point{X: c.box.X, Y: c.box.Y}, DPI: c.importer.rasterDPI, Width: c.width, Height: c.height, Workers: conversionWorkers(c.importer.ctx), SoftMask: c.softMask} pixels, err := codecpdf.MeshPixels(c.importer.ctx, mesh, triangles, space, converter, options) if err != nil { return nil, err diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 4c37062..2aff2c8 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -37,6 +37,7 @@ type Reader struct { OFD *OFD RootDir string ResMap map[string]string + resources *Resources resourcesRead map[string]bool resourceFiles map[string]string imageCatalog []ImageInfo @@ -113,7 +114,7 @@ func (r *Reader) Fork(ctx context.Context) (*Reader, error) { return nil, err } if r.pageContents == nil { - r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20} + r.pageContents = resourceCache[pageContentKey, PageContent](r.resources, 16<<20) } next := *r next.Closer = nil @@ -210,7 +211,7 @@ func (r *Reader) DocumentContext(ctx context.Context, index int) (*Reader, error if index < 0 || index >= r.DocumentCount() { return nil, fmt.Errorf("document index %d out of range", index) } - next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index} + next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index, resources: r.resources} if err := next.initRootContext(ctx); err != nil { return nil, err } @@ -762,6 +763,9 @@ func (r *Reader) initRoot() error { // 入参: ctx 取消上下文 // 返回: error 索引、读取、解析或取消错误 func (r *Reader) initRootContext(ctx context.Context) error { + if r.resources == nil { + r.resources, _ = NewResources(ResourceOptions{}) + } view := *r if err := view.indexPackageContext(ctx); err != nil { return err @@ -780,7 +784,7 @@ func (r *Reader) initRootContext(ctx context.Context) error { } r.fileIndex, r.fileNames = view.fileIndex, view.fileNames if r.pageContents == nil { - r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20} + r.pageContents = resourceCache[pageContentKey, PageContent](r.resources, 16<<20) } r.OFD = &ofd r.doc, r.versionInfo = nil, nil @@ -1100,7 +1104,7 @@ func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, templ return nil, err } if r.pageContents == nil { - r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20} + r.pageContents = resourceCache[pageContentKey, PageContent](r.resources, 16<<20) } var content PageContent if len(data) > (r.pageContents.limit/4-4096)/16 { diff --git a/ofdgo_render_cache.go b/ofdgo_render_cache.go index 54a8c08..ce810ad 100644 --- a/ofdgo_render_cache.go +++ b/ofdgo_render_cache.go @@ -21,13 +21,49 @@ import ( "image" "reflect" "sync" + "time" + "weak" baseimage "github.com/xiaoqidun/ofdgo/internal/base/image" ) +// defaultResourceCacheBytes 定义默认缓存总预算及类别扩展基准 +const defaultResourceCacheBytes = 256 << 20 + +// ResourceOptions 设置共享计算、派生缓存与快照预算,字节预算不限制进程实际分配 +// Parallelism为0时使用GOMAXPROCS,MemoryLimit为0时不指定计算预算 +// CacheBytes为0时使用256MiB,SnapshotBytes为0时使用缓存预算的一半,负值禁用相应缓存 +// AvailableMemory返回可用于新增任务的字节数,同时作为缓存动态上限,需支持并发且不可重入Resources +type ResourceOptions struct { + Parallelism int + MemoryLimit int64 + CacheBytes int64 + SnapshotBytes int64 + AvailableMemory func() int64 +} + +// Resources 协调共享缓存与计算任务,通过NewResources创建,可跨阅读器使用,不可复制 +// 缓存仅持有存活实例的弱引用,Clear释放派生数据,不关闭输入或取消任务 +type Resources struct { + options ResourceOptions + pool *ConversionPool + mu sync.Mutex + used int64 + limit int64 + checked time.Time + order list.List +} + +// resourceEntry 保存共享缓存成本与弱引用淘汰操作 +type resourceEntry struct { + cost int64 + evict func() +} + // renderCache 按估算字节数限制派生渲染数据,淘汰不修改源资源 type renderCache[K comparable, V any] struct { mu sync.Mutex + resources *Resources limit, used int entries map[K]*list.Element order list.List @@ -43,19 +79,178 @@ type renderCacheLoad[V any] struct { // renderCacheEntry 保存缓存值及内存成本 type renderCacheEntry[K comparable, V any] struct { - key K - value V - cost int + key K + value V + cost int + shared *list.Element +} + +// NewResources 创建共享资源策略,不启动任务或修改进程全局配置 +// 入参: options 并行及内存选项 +// 返回: *Resources 共享策略, error 参数错误 +func NewResources(options ResourceOptions) (*Resources, error) { + pool, err := NewConversionPool(ConversionPoolOptions{Parallelism: options.Parallelism, MemoryLimit: options.MemoryLimit, AvailableMemory: options.AvailableMemory}) + if err != nil { + return nil, err + } + if options.CacheBytes == 0 { + options.CacheBytes = defaultResourceCacheBytes + } + options.CacheBytes = max(0, options.CacheBytes) + if options.SnapshotBytes == 0 { + options.SnapshotBytes = options.CacheBytes / 2 + } + options.SnapshotBytes = max(0, min(options.SnapshotBytes, options.CacheBytes)) + return &Resources{options: options, pool: pool, limit: options.CacheBytes}, nil +} + +// ConversionPool 返回资源策略共享的转换调度器 +// 返回: *ConversionPool 转换调度器 +func (r *Resources) ConversionPool() *ConversionPool { return r.pool } + +// Acquire 等待一个计算任务及其估算工作集额度,允许超预算任务独占执行 +// 调用方必须调用返回的释放函数,重复释放无效,不可持有额度再嵌套申请 +// 入参: ctx 等待上下文, memory 估算字节数,零值仅限制并行 +// 返回: func() 释放额度, error 参数或取消错误 +func (r *Resources) Acquire(ctx context.Context, memory int64) (func(), error) { + if memory < 0 { + return nil, errors.New("invalid task memory") + } + if err := r.pool.acquire(ctx, true, memory); err != nil { + return nil, err + } + var once sync.Once + return func() { once.Do(func() { r.pool.release(true, memory) }) }, nil +} + +// context 为未指定转换池的操作附加资源调度器 +// 入参: ctx 操作上下文 +// 返回: context.Context 计算上下文 +func (r *Resources) context(ctx context.Context) context.Context { + if r == nil || ctx.Value(conversionPoolKey{}) != nil { + return ctx + } + return context.WithValue(ctx, conversionPoolKey{}, r.pool) +} + +// WithResources 设置阅读器及派生编辑器共享的资源策略 +// 入参: resources 共享策略,nil使用默认策略 +// 返回: ReaderOption 阅读选项 +func WithResources(resources *Resources) ReaderOption { + return func(options *readerOptions) { options.resources = resources } +} + +// Clear 释放当前缓存,正在计算的任务完成后仍可写入新缓存 +func (r *Resources) Clear() { + r.mu.Lock() + defer r.mu.Unlock() + for r.order.Len() != 0 { + r.evict() + } +} + +// CacheUsage 返回当前登记的派生缓存估算字节数,不含任务临时内存 +// 返回: int64 缓存成本 +func (r *Resources) CacheUsage() int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.used +} + +// refresh 按当前可用内存收缩缓存,需持有资源锁 +func (r *Resources) refresh() { + if r.options.AvailableMemory == nil || time.Since(r.checked) < 250*time.Millisecond { + return + } + r.checked = time.Now() + r.limit = min(r.options.CacheBytes, max(0, r.options.AvailableMemory())) + for r.used > r.limit { + r.evict() + } +} + +// evict 淘汰最久未访问的共享条目,需持有资源锁 +func (r *Resources) evict() { + item := r.order.Back() + entry := item.Value.(resourceEntry) + r.order.Remove(item) + r.used -= entry.cost + entry.evict() +} + +// resourceCache 按类别基准分配共享预算,较大总预算同比扩展类别额度 +// 入参: resources 共享策略, baseline 类别基准预算 +// 返回: *renderCache 缓存实例 +func resourceCache[K comparable, V any](resources *Resources, baseline int) *renderCache[K, V] { + if resources == nil { + return &renderCache[K, V]{limit: baseline} + } + limit := min(resources.options.CacheBytes, int64(int(^uint(0)>>1)-1)) + if baseline > 0 && limit > int64(baseline) { + share := int64(baseline) + if limit > defaultResourceCacheBytes { + scaled := float64(baseline) * (float64(limit) / defaultResourceCacheBytes) + share = limit + if scaled < float64(limit) { + share = int64(scaled) + } + } + limit = min(limit, share) + } + return &renderCache[K, V]{resources: resources, limit: int(limit)} +} + +// mutex 返回缓存数据及共享账本使用的同一把锁 +// 返回: *sync.Mutex 缓存锁 +func (c *renderCache[K, V]) mutex() *sync.Mutex { + if c.resources != nil { + return &c.resources.mu + } + return &c.mu +} + +// touch 更新本地及共享条目的访问顺序,需持有缓存锁 +// 入参: item 缓存条目 +func (c *renderCache[K, V]) touch(item *list.Element) { + c.order.MoveToFront(item) + if shared := item.Value.(renderCacheEntry[K, V]).shared; shared != nil { + c.resources.order.MoveToFront(shared) + } +} + +// remove 删除本地条目并归还共享预算,需持有缓存锁 +// 入参: item 缓存条目 +func (c *renderCache[K, V]) remove(item *list.Element) { + entry := item.Value.(renderCacheEntry[K, V]) + c.used -= entry.cost + c.order.Remove(item) + delete(c.entries, entry.key) + if entry.shared != nil { + c.resources.used -= int64(entry.cost) + c.resources.order.Remove(entry.shared) + } +} + +// clear 释放已有缓存条目并归还共享预算,不取消正在加载的任务 +func (c *renderCache[K, V]) clear() { + if c == nil { + return + } + c.mutex().Lock() + defer c.mutex().Unlock() + for c.order.Len() != 0 { + c.remove(c.order.Back()) + } } // get 读取缓存并更新使用顺序 // 入参: key 缓存键 // 返回: V 缓存值, bool 是否命中 func (c *renderCache[K, V]) get(key K) (V, bool) { - c.mu.Lock() - defer c.mu.Unlock() + c.mutex().Lock() + defer c.mutex().Unlock() if item := c.entries[key]; item != nil { - c.order.MoveToFront(item) + c.touch(item) return item.Value.(renderCacheEntry[K, V]).value, true } var zero V @@ -65,12 +260,10 @@ 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() + c.mutex().Lock() + defer c.mutex().Unlock() if item := c.entries[key]; item != nil { - c.used -= item.Value.(renderCacheEntry[K, V]).cost - c.order.Remove(item) - delete(c.entries, key) + c.remove(item) } if cost <= 0 || cost > c.limit { return @@ -78,15 +271,43 @@ func (c *renderCache[K, V]) put(key K, value V, cost int) { if c.entries == nil { c.entries = make(map[K]*list.Element) } - for c.used+cost > c.limit { + for cost > c.limit-c.used { item := c.order.Back() - entry := item.Value.(renderCacheEntry[K, V]) - c.used -= entry.cost - delete(c.entries, entry.key) - c.order.Remove(item) + c.remove(item) } - c.entries[key] = c.order.PushFront(renderCacheEntry[K, V]{key, value, cost}) + entry := renderCacheEntry[K, V]{key: key, value: value, cost: cost} + if r := c.resources; r != nil { + r.refresh() + if int64(cost) > r.limit { + return + } + for int64(cost) > r.limit-r.used { + r.evict() + } + } + item := c.order.PushFront(entry) + c.entries[key] = item c.used += cost + if r := c.resources; r != nil { + ref, entryRef := weak.Make(c), weak.Make(item) + entry.shared = r.order.PushFront(resourceEntry{cost: int64(cost), evict: func() { + if cache := ref.Value(); cache != nil { + previous := entryRef.Value() + if previous == nil { + return + } + entry := previous.Value.(renderCacheEntry[K, V]) + if cache.entries[entry.key] != previous { + return + } + cache.used -= entry.cost + cache.order.Remove(previous) + delete(cache.entries, entry.key) + } + }}) + item.Value = entry + r.used += int64(cost) + } } // load 复用缓存并合并并发请求,等待取消不影响正在读取的任务 @@ -101,15 +322,15 @@ func (c *renderCache[K, V]) load(ctx context.Context, key K, read func() (V, int if err := ctx.Err(); err != nil { return zero, err } - c.mu.Lock() + c.mutex().Lock() if item := c.entries[key]; item != nil { - c.order.MoveToFront(item) + c.touch(item) value := item.Value.(renderCacheEntry[K, V]).value - c.mu.Unlock() + c.mutex().Unlock() return value, nil } if pending := c.loading[key]; pending != nil { - c.mu.Unlock() + c.mutex().Unlock() select { case <-ctx.Done(): return zero, ctx.Err() @@ -125,12 +346,12 @@ func (c *renderCache[K, V]) load(ctx context.Context, key K, read func() (V, int } pending := &renderCacheLoad[V]{done: make(chan struct{}), err: errors.New("resource loading interrupted")} c.loading[key] = pending - c.mu.Unlock() + c.mutex().Unlock() defer func() { - c.mu.Lock() + c.mutex().Lock() delete(c.loading, key) close(pending.done) - c.mu.Unlock() + c.mutex().Unlock() }() value, cost, err := read() if err == nil { diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go index 0bc3511..b46e427 100644 --- a/ofdgo_render_compile.go +++ b/ofdgo_render_compile.go @@ -77,7 +77,7 @@ func newSemanticCompiler(r *Renderer) (*semanticCompiler, error) { if err != nil { return nil, err } - return &semanticCompiler{renderer: r, geometry: geometry, patterns: &renderCache[patternCellKey, []RasterCommand]{limit: 8 << 20}}, nil + return &semanticCompiler{renderer: r, geometry: geometry, patterns: resourceCache[patternCellKey, []RasterCommand](r.Reader.resources, 8<<20)}, nil } // segments 转换光栅曲线,仅在输出边界展开椭圆弧 diff --git a/ofdgo_render_pattern.go b/ofdgo_render_pattern.go index 8ecbf77..4af1818 100644 --- a/ofdgo_render_pattern.go +++ b/ofdgo_render_pattern.go @@ -34,8 +34,9 @@ type stampContentKey struct { // stampContentState 保存有界的共享签章编译结果及配置 type stampContentState struct { - config RenderBackends - cache renderCache[stampContentKey, []RasterCommand] + resources *Resources + config RenderBackends + cache renderCache[stampContentKey, []RasterCommand] } // stampContentStateKey 隔离渲染器私有的共享签章数据 @@ -52,7 +53,8 @@ type patternCellKey struct { // resetFonts 使包含文字的签章编译结果随字体配置失效 func (s *stampContentState) resetFonts() { - s.cache = renderCache[stampContentKey, []RasterCommand]{limit: 16 << 20} + s.cache.clear() + s.cache = *resourceCache[stampContentKey, []RasterCommand](s.resources, 16<<20) } // sharedStamp 复用相同签章的解码、嵌套文档编译及局部裁剪,不缓存放置位置 @@ -74,7 +76,7 @@ func (c *semanticCompiler) sharedStamp(stamp Stamp, compile func(*semanticCompil config := r.backends if state == nil || !sameBackend(state.config.Fonts, config.Fonts) || !sameBackend(state.config.Geometry, config.Geometry) || !sameBackend(state.config.Compiler, config.Compiler) || !sameBackend(state.config.Raster, config.Raster) { - state = &stampContentState{config: config} + state = &stampContentState{config: config, resources: r.Reader.resources} state.resetFonts() r.backendStates[stampContentStateKey{}] = state } diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 0351e21..b241c10 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -89,6 +89,11 @@ func (r *Renderer) Fork(ctx context.Context) (*Renderer, error) { next.fontPreparations = r.fontPreparations next.fontMetrics = r.fontMetrics next.glyphOutlines = r.glyphOutlines + next.sharedBackendStates[canvasPathCacheKey{}] = r.canvasParsedPaths() + next.sharedBackendStates[canvasClipCacheKey{}] = r.canvasClipIntersections() + next.sharedBackendStates[canvasFillCacheKey{}] = r.canvasSettledPaths() + next.sharedBackendStates[canvasDrawClipCacheKey{}] = r.canvasDrawClips() + next.sharedBackendStates[svgResourceCacheKey{}] = r.svgImageResources() return next, nil } @@ -108,8 +113,9 @@ func (r *Renderer) deviceLineWidth(width float64) float64 { // ClearCache 释放派生字体、图片、模板与后端缓存,不改变文档和渲染配置 func (r *Renderer) ClearCache() { + r.imageCache.clear() r.backendStates = make(map[any]any) - r.imageCache = &renderCache[string, image.Image]{limit: imageCacheLimit} + r.imageCache = resourceCache[string, image.Image](r.Reader.resources, imageCacheLimit) r.templatePageCache = make(map[string]*PageContent) r.resetFontCache() } diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index c76f8df..b07004c 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -42,7 +42,7 @@ func (r *clipRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canva r.add(applyClipPath(path.Copy().Transform(m), r.clip)) } if style.HasStroke() { - p := path.Dash(style.DashOffset, style.Dashes...).Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) + p := strokeCanvasPath(path.Dash(style.DashOffset, style.Dashes...), style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) r.add(applyClipPath(p.Transform(m), r.clip)) } } @@ -164,7 +164,7 @@ func (r *Renderer) buildClipAreaPath(obj PathObject, drawParam string, parent Ma } result = paths.Merge() if obj.Rule == "Even-Odd" { - result = result.Settle(canvas.EvenOdd) + result = settleCanvasPath(result, canvas.EvenOdd) } } if stroke { @@ -255,12 +255,12 @@ func intersectClipPath(parent, current *canvas.Path) *canvas.Path { return result } if result, ok := intersectLinearCanvasContours(parent, current); ok { - return result.Settle(canvas.NonZero) + return settleCanvasPath(result, canvas.NonZero) } if result, ok := intersectLinearCanvasContours(current, parent); ok { - return result.Settle(canvas.NonZero) + return settleCanvasPath(result, canvas.NonZero) } - return parent.And(current) + return combineCanvasPaths(parent, current, "and") } // intersectConvexCanvasPaths 逐边裁剪单个凸多边形,避免仿射矩形共边进入曲线扫线 @@ -396,7 +396,7 @@ func unionClipPath(left, right *canvas.Path) *canvas.Path { return bounds.ToPath() } } - return left.Or(right) + return combineCanvasPaths(left, right, "or") } // applyClipPath 应用裁剪路径 @@ -432,12 +432,12 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path { return result } if result, ok := intersectLinearCanvasContours(path, clip); ok { - return result.Settle(canvas.NonZero) + return settleCanvasPath(result, canvas.NonZero) } if result, ok := intersectLinearCanvasContours(clip, path); ok { - return result.Settle(canvas.NonZero) + return settleCanvasPath(result, canvas.NonZero) } - return path.And(clip) + return combineCanvasPaths(path, clip, "and") } // pruneCanvasContours 排除与裁剪边界分离的完整轮廓,保留相交轮廓的曲线和绕向 diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 0cea166..096ca2c 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -20,10 +20,50 @@ import ( "math" "slices" "strconv" + "sync" "github.com/tdewolff/canvas" ) +// canvasGeometryMu 保护底层路径运算的共享扫描线对象池 +var canvasGeometryMu sync.Mutex + +// settleCanvasPath 在共享对象池保护下归整路径 +// 入参: path 路径, rule 填充规则 +// 返回: *canvas.Path 归整路径 +func settleCanvasPath(path *canvas.Path, rule canvas.FillRule) *canvas.Path { + canvasGeometryMu.Lock() + defer canvasGeometryMu.Unlock() + return path.Settle(rule) +} + +// combineCanvasPaths 在共享对象池保护下计算路径布尔运算 +// 入参: left 左路径, right 右路径, operation 运算名称 +// 返回: *canvas.Path 运算结果 +func combineCanvasPaths(left, right *canvas.Path, operation string) *canvas.Path { + canvasGeometryMu.Lock() + defer canvasGeometryMu.Unlock() + switch operation { + case "or": + return left.Or(right) + case "not": + return left.Not(right) + case "xor": + return left.Xor(right) + default: + return left.And(right) + } +} + +// strokeCanvasPath 在共享对象池保护下生成描边轮廓 +// 入参: path 路径, width 线宽, cap 端点, join 连接, tolerance 容差 +// 返回: *canvas.Path 描边轮廓 +func strokeCanvasPath(path *canvas.Path, width float64, cap canvas.Capper, join canvas.Joiner, tolerance float64) *canvas.Path { + canvasGeometryMu.Lock() + defer canvasGeometryMu.Unlock() + return path.Stroke(width, cap, join, tolerance) +} + // Curves 在输出边界转换椭圆弧,其他曲线保持原样 // 入参: path 页面路径 // 返回: GeometryPath 贝塞尔路径, error 路径错误 @@ -108,7 +148,7 @@ func (CanvasBackend) Normalize(path GeometryPath, evenOdd bool) (GeometryPath, e if evenOdd { rule = canvas.EvenOdd } - p = p.Settle(rule) + p = settleCanvasPath(p, rule) if p.Empty() { return nil, nil } @@ -132,11 +172,11 @@ func (CanvasBackend) Combine(left, right GeometryPath, operation GeometryOperati case GeometryIntersect: result = applyClipPath(a, b) case GeometryUnion: - result = a.Or(b) + result = combineCanvasPaths(a, b, "or") case GeometrySubtract: - result = a.Not(b) + result = combineCanvasPaths(a, b, "not") case GeometryXor: - result = a.Xor(b) + result = combineCanvasPaths(a, b, "xor") default: return nil, fmt.Errorf("unsupported geometry operation %d", operation) } @@ -178,7 +218,7 @@ func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (result Ge style.lineCap = pathLineCap(options.Cap, style.lineCap) p = p.Dash(options.DashOffset, options.Dashes...) p = flattenCanvasPath(p, tolerance) - p = p.Stroke(options.Width, style.lineCap, style.lineJoin, tolerance) + p = strokeCanvasPath(p, options.Width, style.lineCap, style.lineJoin, tolerance) return *geometryFromCanvasPath(p), nil } diff --git a/ofdgo_renderer_measure_canvas.go b/ofdgo_renderer_measure_canvas.go index 9dbbeab..4fc4389 100644 --- a/ofdgo_renderer_measure_canvas.go +++ b/ofdgo_renderer_measure_canvas.go @@ -106,7 +106,7 @@ func (r *boundsRenderer) RenderPath(path *canvas.Path, style canvas.Style, m can r.addContour(path.Copy().Transform(m), style.FillRule == canvas.EvenOdd) } if style.HasStroke() { - p := path.Dash(style.DashOffset, style.Dashes...).Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) + p := strokeCanvasPath(path.Dash(style.DashOffset, style.Dashes...), style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) r.add(p.Transform(m)) } } diff --git a/ofdgo_renderer_output_canvas.go b/ofdgo_renderer_output_canvas.go index c4f8f88..5d8896b 100644 --- a/ofdgo_renderer_output_canvas.go +++ b/ofdgo_renderer_output_canvas.go @@ -151,10 +151,10 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i } start := buf.Len() p := pdf.New(buf, pages[0].Box.W, pages[0].Box.H, nil) - renderer := &pdfRenderer{PDF: p, glyphPaths: renderCache[*canvas.Path, *canvas.Path]{limit: 16 << 20}, images: make([][]image.Image, 1), text: make([]codecpdf.TextPage, 1), navigation: navigation} + renderer := &pdfRenderer{PDF: p, glyphPaths: *resourceCache[*canvas.Path, *canvas.Path](r.Reader.resources, 16<<20), images: make([][]image.Image, 1), text: make([]codecpdf.TextPage, 1), navigation: navigation} defer func() { if renderer.imageError != nil { - r.canvasState().images = renderCache[*EncodedImage, image.Image]{limit: imageCacheLimit} + r.canvasState().images = *resourceCache[*EncodedImage, image.Image](r.Reader.resources, imageCacheLimit) } }() var info DocInfo diff --git a/ofdgo_renderer_shading_canvas.go b/ofdgo_renderer_shading_canvas.go index a854564..9d64dea 100644 --- a/ofdgo_renderer_shading_canvas.go +++ b/ofdgo_renderer_shading_canvas.go @@ -127,7 +127,7 @@ func resolveShdPaint(ctx *canvas.Context, paint any) (any, *canvas.Path, canvas. if g.gradient.Mesh != nil && g.gradient.Mesh.Background.A == 0 { outline := g.gradient.Mesh.outline() clip, _ := canvasObjectPath(outline) - return g, clip.Settle(canvas.NonZero).Transform(s.view), s.view + return g, settleCanvasPath(clip, canvas.NonZero).Transform(s.view), s.view } return g, nil, s.view case *canvas.LinearGradient: @@ -259,7 +259,7 @@ func (r *Renderer) drawShdPath(ctx *canvas.Context, path *canvas.Path, view canv func drawRepeatAxialPath(ctx *canvas.Context, path *canvas.Path, gradient *repeatAxialGradient, view canvas.Matrix) { p := path.Copy() if ctx.FillRule == canvas.EvenOdd { - p = p.Settle(canvas.EvenOdd) + p = settleCanvasPath(p, canvas.EvenOdd) } p = p.Transform(view.Inv()) d := gradient.End.Sub(gradient.Start).Mul(gradient.period) @@ -335,7 +335,7 @@ func radialShdClip(g *canvas.RadialGradient, extend int, bounds canvas.Rect) *ca clip = bounds.ToPath() } if extend&1 == 0 && r0 > 0 { - clip = clip.Not(canvas.Circle(r0).Translate(inner.X, inner.Y)) + clip = combineCanvasPaths(clip, canvas.Circle(r0).Translate(inner.X, inner.Y), "not") } return clip } diff --git a/ofdgo_renderer_state_canvas.go b/ofdgo_renderer_state_canvas.go index f9a1202..36f550c 100644 --- a/ofdgo_renderer_state_canvas.go +++ b/ofdgo_renderer_state_canvas.go @@ -40,6 +40,9 @@ type canvasPathCacheKey struct{} // canvasFillCacheKey 区分按填充规则规范化的共享路径缓存 type canvasFillCacheKey struct{} +// canvasDrawClipCacheKey 区分绘制轮廓的共享裁剪缓存 +type canvasDrawClipCacheKey struct{} + // canvasOutlineCacheKey 区分子渲染器共享的字体轮廓解析缓存 type canvasOutlineCacheKey struct{} @@ -57,6 +60,7 @@ type canvasPathKey struct { // canvasBackendState 保存Canvas字体、字形和编码图片适配缓存 type canvasBackendState struct { + resources *Resources images renderCache[*EncodedImage, image.Image] fontMap map[string]*canvas.FontFamily svgFontCache map[*canvas.Font]SVGFont @@ -74,7 +78,7 @@ func (r *Renderer) canvasClipIntersections() *renderCache[[32]byte, *canvas.Path if r.sharedBackendStates == nil { r.sharedBackendStates = make(map[any]any) } - cache := &renderCache[[32]byte, *canvas.Path]{limit: 8 << 20} + cache := resourceCache[[32]byte, *canvas.Path](r.Reader.resources, 8<<20) r.sharedBackendStates[key] = cache return cache } @@ -89,7 +93,7 @@ func (r *Renderer) canvasParsedPaths() *renderCache[canvasPathKey, *canvas.Path] if r.sharedBackendStates == nil { r.sharedBackendStates = make(map[any]any) } - cache := &renderCache[canvasPathKey, *canvas.Path]{limit: 16 << 20} + cache := resourceCache[canvasPathKey, *canvas.Path](r.Reader.resources, 16<<20) r.sharedBackendStates[key] = cache return cache } @@ -104,7 +108,7 @@ func (r *Renderer) canvasSettledPaths() *renderCache[canvasFillKey, *canvas.Path if r.sharedBackendStates == nil { r.sharedBackendStates = make(map[any]any) } - cache := &renderCache[canvasFillKey, *canvas.Path]{limit: 16 << 20} + cache := resourceCache[canvasFillKey, *canvas.Path](r.Reader.resources, 16<<20) r.sharedBackendStates[key] = cache return cache } @@ -116,11 +120,45 @@ func (r *Renderer) canvasFontFamilies() *renderCache[canvasFontKey, *canvas.Font if state, ok := r.sharedBackendStates[key]; ok { return state.(*renderCache[canvasFontKey, *canvas.FontFamily]) } - cache := &renderCache[canvasFontKey, *canvas.FontFamily]{limit: 32 << 20} + cache := resourceCache[canvasFontKey, *canvas.FontFamily](r.Reader.resources, 32<<20) r.sharedBackendStates[key] = cache return cache } +// canvasDrawClips 获取派生渲染器共享的绘制裁剪缓存 +// 返回: *renderCache 绘制裁剪缓存 +func (r *Renderer) canvasDrawClips() *renderCache[[32]byte, *canvas.Path] { + key := canvasDrawClipCacheKey{} + if state, ok := r.sharedBackendStates[key]; ok { + return state.(*renderCache[[32]byte, *canvas.Path]) + } + cache := resourceCache[[32]byte, *canvas.Path](r.Reader.resources, 16<<20) + r.sharedBackendStates[key] = cache + return cache +} + +// applyCachedClipPath 复用绘制轮廓的裁剪结果,返回可独立变换的副本 +// 入参: path 绘制轮廓, clip 裁剪轮廓 +// 返回: *canvas.Path 裁剪结果 +func (r *Renderer) applyCachedClipPath(path, clip *canvas.Path) *canvas.Path { + if path == nil || clip == nil { + return path + } + if rect, ok := rectangularPath(clip); ok && rect.Contains(path.Bounds()) { + return reduceRepeatedNonZeroPath(path) + } + key := canvasGeometryDigest(path, clip) + result, err := r.canvasDrawClips().load(r.pageContext, key, func() (*canvas.Path, int, error) { + result := applyClipPath(path, clip).Copy() + return result, len(result.Data())*8 + 256, nil + }) + if err != nil { + r.renderError = err + return &canvas.Path{} + } + return result.Copy() +} + // canvasFontOutlines 获取子渲染器共享的字体轮廓解析缓存,限制字体及解析数据保留量 // 返回: *renderCache[*canvas.Font, *basefont.Outliner] 字体轮廓缓存 func (r *Renderer) canvasFontOutlines() *renderCache[*canvas.Font, *basefont.Outliner] { @@ -128,7 +166,7 @@ func (r *Renderer) canvasFontOutlines() *renderCache[*canvas.Font, *basefont.Out if state, ok := r.sharedBackendStates[key]; ok { return state.(*renderCache[*canvas.Font, *basefont.Outliner]) } - cache := &renderCache[*canvas.Font, *basefont.Outliner]{limit: 32 << 20} + cache := resourceCache[*canvas.Font, *basefont.Outliner](r.Reader.resources, 32<<20) r.sharedBackendStates[key] = cache return cache } @@ -141,7 +179,8 @@ func (r *Renderer) canvasState() *canvasBackendState { return state.(*canvasBackendState) } state := &canvasBackendState{ - images: renderCache[*EncodedImage, image.Image]{limit: imageCacheLimit}, + resources: r.Reader.resources, + images: *resourceCache[*EncodedImage, image.Image](r.Reader.resources, imageCacheLimit), } state.resetFonts() r.backendStates[key] = state @@ -150,9 +189,10 @@ func (r *Renderer) canvasState() *canvasBackendState { // resetFonts 清除Canvas字体及字形缓存,保留编码图片适配对象 func (s *canvasBackendState) resetFonts() { + s.textGlyphPathCache.clear() s.fontMap = make(map[string]*canvas.FontFamily) s.svgFontCache = make(map[*canvas.Font]SVGFont) - s.textGlyphPathCache = renderCache[textGlyphPathCacheKey, textGlyphPathCacheValue]{limit: 16 << 20} + s.textGlyphPathCache = *resourceCache[textGlyphPathCacheKey, textGlyphPathCacheValue](s.resources, 16<<20) s.fontOutlines = make(map[*canvas.Font]*basefont.Outliner) } @@ -189,14 +229,14 @@ func canvasGeometryDigest(paths ...*canvas.Path) [32]byte { // 返回: *canvas.Path 规范化填充轮廓 func (r *Renderer) settleCanvasPath(path *canvas.Path, rule canvas.FillRule) *canvas.Path { if len(path.Data()) < 256 { - return path.Settle(rule) + return settleCanvasPath(path, rule) } key := canvasFillKey{digest: canvasGeometryDigest(path), rule: rule} cache := r.canvasSettledPaths() if result, ok := cache.get(key); ok { return result.Copy() } - result := path.Settle(rule) + result := settleCanvasPath(path, rule) if cost := len(result.Data())*8 + 256; cost <= cache.limit { cache.put(key, result.Copy(), cost) } diff --git a/ofdgo_renderer_svg_canvas.go b/ofdgo_renderer_svg_canvas.go index 6c180f9..331c5ba 100644 --- a/ofdgo_renderer_svg_canvas.go +++ b/ofdgo_renderer_svg_canvas.go @@ -20,6 +20,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "encoding/binary" "fmt" "html" "image" @@ -59,6 +60,16 @@ type svgImageKey struct { size image.Point } +// svgResourceCacheKey 区分共享的图片编码缓存 +type svgResourceCacheKey struct{} + +// svgImageResourceKey 按像素、压缩策略及目标尺寸区分编码结果 +type svgImageResourceKey struct { + digest [32]byte + options CompressionOptions + target image.Point +} + // svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员 type svgObjectGroup struct { path string @@ -66,6 +77,59 @@ type svgObjectGroup struct { composite bool } +// svgImageResources 获取派生渲染器共享的图片编码缓存 +// 返回: *renderCache 图片编码缓存 +func (r *Renderer) svgImageResources() *renderCache[svgImageResourceKey, SVGImage] { + key := svgResourceCacheKey{} + if state, ok := r.sharedBackendStates[key]; ok { + return state.(*renderCache[svgImageResourceKey, SVGImage]) + } + cache := resourceCache[svgImageResourceKey, SVGImage](r.Reader.resources, 4<<20) + r.sharedBackendStates[key] = cache + return cache +} + +// svgPixelDigest 对标准像素缓冲计算完整摘要,不展开延迟图像 +// 入参: ctx 取消上下文, img 原始图像, limit 可参与缓存的像素字节上限 +// 返回: [32]byte 像素摘要, bool 是否为支持的像素布局 +func svgPixelDigest(ctx context.Context, img image.Image, limit int) ([32]byte, bool) { + var pixels []byte + var stride, kind int + switch source := img.(type) { + case *image.NRGBA: + pixels, stride, kind = source.Pix, source.Stride, 1 + case *image.NRGBA64: + pixels, stride, kind = source.Pix, source.Stride, 2 + case *image.RGBA: + pixels, stride, kind = source.Pix, source.Stride, 3 + case *image.RGBA64: + pixels, stride, kind = source.Pix, source.Stride, 4 + default: + return [32]byte{}, false + } + if len(pixels) > limit { + return [32]byte{}, false + } + bounds := img.Bounds() + var header [48]byte + for i, value := range [...]int{kind, stride, bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y} { + binary.LittleEndian.PutUint64(header[i*8:], uint64(value)) + } + hash := sha256.New() + hash.Write(header[:]) + for len(pixels) > 0 { + if ctx.Err() != nil { + return [32]byte{}, false + } + count := min(len(pixels), 256<<10) + hash.Write(pixels[:count]) + pixels = pixels[count:] + } + var digest [32]byte + hash.Sum(digest[:0]) + return digest, true +} + // RenderImage 按输出策略编码图片,分离资源时保持尺寸和变换 // 入参: img 图片对象, m 变换矩阵 func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { @@ -134,11 +198,33 @@ func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { } } -// imageResource 保留原编码或压缩已解码像素,先降采样再编码并响应取消 -// 优化失败时回退原图PNG,取消时终止,不改变SVG中的图片尺寸和变换 +// imageResource 复用相同像素的编码结果,不持有源图像或改变输出策略 // 入参: ctx 取消上下文, img 原图, target 压缩像素需求 // 返回: SVGImage 图片资源, error 编码或取消错误 func (s *svgResourceRenderer) imageResource(ctx context.Context, img image.Image, target image.Point) (SVGImage, error) { + if err := ctx.Err(); err != nil { + return SVGImage{}, err + } + if s.renderer != nil { + cache := s.renderer.svgImageResources() + if digest, ok := svgPixelDigest(ctx, img, cache.limit); ok { + key := svgImageResourceKey{digest: digest, options: s.renderer.Compression, target: target} + result, err := cache.load(ctx, key, func() (SVGImage, int, error) { + result, err := s.encodeImageResource(ctx, img, target) + return result, cap(result.Data) + 256, err + }) + result.Data = bytes.Clone(result.Data) + return result, err + } + } + return s.encodeImageResource(ctx, img, target) +} + +// encodeImageResource 保留原编码或压缩已解码像素,先降采样再编码并响应取消 +// 优化失败时回退原图PNG,取消时终止,不改变SVG中的图片尺寸和变换 +// 入参: ctx 取消上下文, img 原图, target 压缩像素需求 +// 返回: SVGImage 图片资源, error 编码或取消错误 +func (s *svgResourceRenderer) encodeImageResource(ctx context.Context, img image.Image, target image.Point) (SVGImage, error) { options := CompressionOptions{} if s.renderer != nil { options = s.renderer.Compression diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index fe2b896..638a4c0 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -170,7 +170,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 } m := canvas.Matrix{{boundaryCTM.a, -boundaryCTM.c, boundaryCTM.e}, {-boundaryCTM.b, boundaryCTM.d, pageH - boundaryCTM.f}} path := canvas.Rectangle(box.W, box.H).Translate(0, -box.H).Transform(m) - box = textPathBox(applyClipPath(path, clipPath), pageH) + box = textPathBox(r.applyCachedClipPath(path, clipPath), pageH) for i := range textRun.Boxes { textRun.Boxes[i] = box } @@ -292,7 +292,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 m = m.Mul(glyphMatrix) } path := glyphPath.Copy().Transform(m.Scale(scaleX, 1)) - box := textPathBox(applyClipPath(path, clipPath), pageH) + box := textPathBox(r.applyCachedClipPath(path, clipPath), pageH) for j := spans[i][0]; j < spans[i][1]; j++ { textRun.Boxes[textPos+j] = unionTextBox(textRun.Boxes[textPos+j], box) } @@ -321,7 +321,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 r.drawShdPath(ctx, applyClipPath(path.Copy(), fillClip), fillView) } path = r.strokeDashedCanvasPath(path, strokeStyle.lineWidth, strokeStyle.lineCap, strokeStyle.lineJoin, strokeStyle.dashOffset, strokeStyle.dashPattern, false) - path = applyClipPath(path, strokeClip) + path = r.applyCachedClipPath(path, strokeClip) if strokeStyle.strokePattern != nil { r.renderPattern(ctx, strokeStyle.strokePattern, pageH, path, objectCTM) } else { @@ -378,7 +378,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if useTextMatrix { textTransform = textTransform.Mul(glyphMatrix) } - glyphPath = applyClipPath(glyphPath.Copy().Transform(textTransform.Scale(scaleX, 1)), glyphClip) + glyphPath = r.applyCachedClipPath(glyphPath.Copy().Transform(textTransform.Scale(scaleX, 1)), glyphClip) if fillPattern != nil { r.renderPattern(ctx, fillPattern, pageH, glyphPath, objectCTM) } else { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index fee6659..cdc2722 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -76,7 +76,7 @@ type fileOutput struct { type editorSnapshotArchive struct { files []*zip.File archive weak.Pointer[zip.Reader] - retained *zip.Reader + retained *renderCache[int, *zip.Reader] } // editorSnapshotBuffer 分块保存压缩数据,写入完成后支持并发随机读取 @@ -121,7 +121,7 @@ func (e *Editor) Snapshot(ctx context.Context) (*Editor, error) { return nil, err } defer reader.Close() - next := &Reader{files: make(map[string][]byte, len(reader.files)), encryption: reader.encryption, documentIndex: reader.documentIndex, selectedVersion: cloneEditorData(reader.selectedVersion)} + next := &Reader{files: make(map[string][]byte, len(reader.files)), encryption: reader.encryption, documentIndex: reader.documentIndex, selectedVersion: cloneEditorData(reader.selectedVersion), resources: e.runtimeResources} if reader.encryption != nil { next.encryption = &encryptionState{info: cloneEditorData(reader.encryption.info), options: cloneEncryptionOptions(reader.encryption.options)} } @@ -157,6 +157,11 @@ func (e *Editor) copySnapshotArchive(ctx context.Context, source *zip.Reader) (* return nil, err } if cached := e.snapshotArchive; cached != nil && slices.Equal(cached.files, source.File) { + if cached.retained != nil { + if archive, ok := cached.retained.get(0); ok { + return archive, nil + } + } if archive := cached.archive.Value(); archive != nil { return archive, nil } @@ -183,8 +188,14 @@ func (e *Editor) copySnapshotArchive(ctx context.Context, source *zip.Reader) (* return nil, err } cached := &editorSnapshotArchive{files: slices.Clone(source.File), archive: weak.Make(reader)} - if len(data.chunks) <= editorSnapshotArchiveLimit/editorSnapshotChunkSize { - cached.retained = reader + limit := int64(editorSnapshotArchiveLimit) + if e.runtimeResources != nil { + limit = e.runtimeResources.options.SnapshotBytes + } + if int64(len(data.chunks)) <= limit/editorSnapshotChunkSize { + cached.retained = resourceCache[int, *zip.Reader](e.runtimeResources, editorSnapshotArchiveLimit) + cached.retained.limit = int(min(limit, int64(int(^uint(0)>>1)-1))) + cached.retained.put(0, reader, len(data.chunks)*editorSnapshotChunkSize) } e.snapshotArchive = cached return reader, nil @@ -530,7 +541,7 @@ func (e *Editor) readerContext(ctx context.Context, progress editorProgress) (*R if e.source != nil { return e.sourceReader(ctx, progress) } - r := &Reader{files: make(map[string][]byte), encryption: e.encryption} + r := &Reader{files: make(map[string][]byte), encryption: e.encryption, resources: e.runtimeResources} if err := e.writeParts(ctx, func(name string, data []byte, _ bool) error { r.files[name] = data return nil @@ -562,7 +573,7 @@ func (e *Editor) resourceReader() (*Reader, error) { if err := e.validate(); err != nil { return nil, err } - r := &Reader{files: make(map[string][]byte)} + r := &Reader{files: make(map[string][]byte), resources: e.runtimeResources} if err := e.writePartsWithPages(ctx, func(name string, data []byte, _ bool) error { r.files[name] = data return nil