diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index c7b2570..21afe66 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -7,6 +7,7 @@ const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)"); const DEFAULT_IMAGE_DPI = 300; const PAGE_CACHE_LIMIT = 16; const PAGE_CACHE_BYTES = 32 * 1024 * 1024; +const svgFontBytes = new WeakMap(); const PAGE_LAYOUT_GAP = 16; const SCROLL_SEGMENT_SIZE = 1000000; const OBJECT_CLIPBOARD_TYPE = "application/x-ofdgo-objects"; @@ -139,6 +140,8 @@ const state = { documentSelection: null, pageInFlight: new Map(), pageRenderQueue: [], + pagePrefetchIndex: null, + pagePrefetchDirection: 1, pageRenderRunning: false, pageRenderWorkers: 0, pageObserver: null, @@ -8982,6 +8985,8 @@ function resetPageLoading() { task.resolve(null); } state.pageRenderQueue = []; + state.pagePrefetchIndex = null; + state.pagePrefetchDirection = 1; state.pageInFlight.clear(); resetThumbnails(); if (state.pageObserver) { @@ -9209,17 +9214,21 @@ function trimPageCache() { for (const task of state.pageInFlight.values()) { protectedPages.add(task.index); } - const references = new Map(); + const references = new Map(), fontReferences = new Map(); let bytes = 0; for (const page of state.pageCache.values()) { bytes += page.cacheBytes; for (const name of page.imageNames) { references.set(name, (references.get(name) || 0) + 1); } + for (const font of page.fonts || []) { + fontReferences.set(font.name, (fontReferences.get(font.name) || 0) + 1); + } } for (const image of state.svgImages.values()) { bytes += image.size; } + for (const face of state.svgFonts.values()) bytes += svgFontBytes.get(face) || 0; const budget = nativeHost?.cacheBytes || PAGE_CACHE_BYTES; for (const [index, page] of state.pageCache) { if (state.pageCache.size <= PAGE_CACHE_LIMIT * Math.max(1, budget / PAGE_CACHE_BYTES) && bytes <= budget) { @@ -9235,9 +9244,23 @@ function trimPageCache() { references.set(name, count); if (count === 0 && !state.pageInFlight.size) { const image = state.svgImages.get(name); - bytes -= image.size; - URL.revokeObjectURL(image.url); - state.svgImages.delete(name); + if (image) { + bytes -= image.size; + URL.revokeObjectURL(image.url); + state.svgImages.delete(name); + } + } + } + for (const font of page.fonts || []) { + const count = fontReferences.get(font.name) - 1; + fontReferences.set(font.name, count); + if (count === 0 && !state.pageInFlight.size) { + const face = state.svgFonts.get(font.name); + if (face) { + bytes -= svgFontBytes.get(face) || 0; + document.fonts.delete(face); + state.svgFonts.delete(font.name); + } } } } @@ -9274,6 +9297,7 @@ async function loadSVGFont(font, openSeq) { return; } document.fonts.add(face); + svgFontBytes.set(face, data.bytes.byteLength); state.svgFonts.set(font.name, face); } @@ -9889,10 +9913,22 @@ function markFlowPageError(index, err) { function queueNearbyPages(index, openSeq = state.openSeq) { const start = pageGroupStart(index); - 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 }); + if (state.pagePrefetchIndex !== null && start !== state.pagePrefetchIndex) state.pagePrefetchDirection = Math.sign(start - state.pagePrefetchIndex); + const direction = state.pagePrefetchDirection; + state.pagePrefetchIndex = start; + let bytes = 0; + for (const page of state.pageCache.values()) bytes += page.cacheBytes || 0; + for (const image of state.svgImages.values()) bytes += image.size; + for (const face of state.svgFonts.values()) bytes += svgFontBytes.get(face) || 0; + const budget = nativeHost?.cacheBytes || PAGE_CACHE_BYTES; + const cost = Math.max(1 << 20, bytes / Math.max(1, state.pageCache.size)); + const count = Math.max(0, Math.min(nativeHost?.parallelism || 1, state.pageLayout * 2, Math.floor((budget - bytes) / cost))); + for (let i = start; i < Math.min(state.doc?.pageCount || 1, start + state.pageLayout); i += 1) { + renderFlowPage(i, { openSeq, priority: 1 }); + } + for (let offset = 1; offset <= count; offset += 1) { + const next = direction > 0 ? start + state.pageLayout + offset - 1 : start - offset; + if (next >= 0 && next < (state.doc?.pageCount || 1)) renderFlowPage(next, { openSeq, priority: 6 }); } } diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 99bce64..46dfc17 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -74,6 +74,7 @@ type Editor struct { encryption *encryptionState output *outputOptimization saveTarget Output + snapshotArchive *editorSnapshotArchive validatedPaths *renderCache[string, bool] richIndex *richTextIndex } diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 6ee1b47..d52fc1b 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} + 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} if err := reader.initRootContext(ctx); err != nil { return nil, err } diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 34d1760..4c37062 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -18,6 +18,7 @@ import ( "archive/zip" "bytes" "context" + "crypto/sha256" "encoding/xml" "fmt" "io" @@ -48,6 +49,7 @@ type Reader struct { drawParamCache map[string]*DrawParam compositeGraphicUnitCache map[string]*CompositeGraphicUnit pageHeaderCache map[string]PageContent + pageContents *renderCache[pageContentKey, PageContent] doc *Document relatedPending bool documentIndex int @@ -63,6 +65,12 @@ type Reader struct { encryption *encryptionState } +// pageContentKey 按页面原文及解析模式区分缓存,不包含文档资源和区域继承 +type pageContentKey struct { + digest [32]byte + textOnly bool +} + // documentPageList 仅读取文档中的页面索引,统计页数时不构建其他结构 type documentPageList struct { Pages Pages @@ -104,6 +112,9 @@ func (r *Reader) Fork(ctx context.Context) (*Reader, error) { if _, err := r.DocContext(ctx); err != nil { return nil, err } + if r.pageContents == nil { + r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20} + } next := *r next.Closer = nil next.resourcesRead = maps.Clone(r.resourcesRead) @@ -768,6 +779,9 @@ func (r *Reader) initRootContext(ctx context.Context) error { return err } r.fileIndex, r.fileNames = view.fileIndex, view.fileNames + if r.pageContents == nil { + r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20} + } r.OFD = &ofd r.doc, r.versionInfo = nil, nil r.documentRoot, r.RootDir = "", "" @@ -1085,12 +1099,21 @@ func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, templ if err != nil { return nil, err } + if r.pageContents == nil { + r.pageContents = &renderCache[pageContentKey, PageContent]{limit: 16 << 20} + } var content PageContent - decoder := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx}) - if textOnly { - err = xml.NewTokenDecoder(&textPageTokens{Decoder: decoder}).Decode(&content) + if len(data) > (r.pageContents.limit/4-4096)/16 { + content, err = decodePageContent(ctx, data, textOnly) } else { - err = decoder.Decode(&content) + var parsed PageContent + parsed, err = r.pageContents.load(ctx, pageContentKey{sha256.Sum256(data), textOnly}, func() (PageContent, int, error) { + content, err := decodePageContent(ctx, data, textOnly) + return content, len(data)*16 + 4096, err + }) + if err == nil { + content = cloneEditorData(parsed) + } } if err != nil { return nil, fmt.Errorf("failed to unmarshal page content: %w", err) @@ -1114,6 +1137,21 @@ func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, templ return &content, nil } +// decodePageContent 解析未应用页面资源和区域继承的原始内容 +// 入参: ctx 取消上下文, data 页面XML, textOnly 是否仅读取文字 +// 返回: PageContent 页面内容, error 解析错误 +func decodePageContent(ctx context.Context, data []byte, textOnly bool) (PageContent, error) { + var content PageContent + decoder := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx}) + var err error + if textOnly { + err = xml.NewTokenDecoder(&textPageTokens{Decoder: decoder}).Decode(&content) + } else { + err = decoder.Decode(&content) + } + return content, err +} + // loadPageResources 加载页面资源,不解析图元 // 入参: page 页面引用 func (r *Reader) loadPageResources(page Page) { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 069d6ed..bcb9ad6 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -30,10 +30,14 @@ import ( "strconv" "strings" "time" + "weak" basefile "github.com/xiaoqidun/ofdgo/internal/base/file" ) +// editorSnapshotArchiveLimit 限制编辑器常驻的快照压缩包字节数 +const editorSnapshotArchiveLimit = 32 << 20 + // ErrSaveTarget 表示编辑器尚未绑定保存目标 var ErrSaveTarget = errors.New("save target is not set") @@ -65,6 +69,13 @@ type fileOutput struct { target *basefile.Target } +// editorSnapshotArchive 复用独立压缩包,超预算时仅在已有快照存活期间共享 +type editorSnapshotArchive struct { + files []*zip.File + archive weak.Pointer[zip.Reader] + retained *zip.Reader +} + // editorProgress 编辑与保存阶段的进度回调,nil表示不回报进度 type editorProgress func(stage string, completed, total int) error @@ -91,7 +102,7 @@ type ofdAttrs []xml.Attr // Snapshot 创建独立编辑快照,完成后可与原编辑器并行使用 // 创建过程需与原实例串行,保留修订及保存目标,不复制撤销记录和进度回调 // 保存目标与原实例共享,由输出实现协调并发事务及提交版本 -// 原包压缩数据直接复制,快照不依赖原阅读器及文件句柄,内存随快照释放 +// 原包压缩数据独立复制并在快照间复用,超预算数据随最后一个快照释放 // 自定义后端及字体文件系统仍由调用方提供,需支持并行读取 // 入参: ctx 取消上下文 // 返回: *Editor 独立快照, error 准备或取消错误 @@ -106,21 +117,7 @@ func (e *Editor) Snapshot(ctx context.Context) (*Editor, error) { next.encryption = &encryptionState{info: cloneEditorData(reader.encryption.info), options: cloneEncryptionOptions(reader.encryption.options)} } if reader.Zip != nil { - var data bytes.Buffer - var count int64 - archive := zip.NewWriter(convertWriter{ctx: ctx, writer: &data, count: &count}) - for _, file := range reader.Zip.File { - if err := ctx.Err(); err != nil { - return nil, err - } - if err := archive.Copy(file); err != nil { - return nil, err - } - } - if err := archive.Close(); err != nil { - return nil, err - } - next.Zip, err = zip.NewReader(bytes.NewReader(data.Bytes()), int64(data.Len())) + next.Zip, err = e.copySnapshotArchive(ctx, reader.Zip) if err != nil { return nil, err } @@ -143,6 +140,47 @@ func (e *Editor) Snapshot(ctx context.Context) (*Editor, error) { return snapshot, nil } +// copySnapshotArchive 复用条目未变的独立压缩包,取消或失败不发布缓存 +// 入参: ctx 取消上下文, source 原压缩包 +// 返回: *zip.Reader 独立压缩包, error 复制错误 +func (e *Editor) copySnapshotArchive(ctx context.Context, source *zip.Reader) (*zip.Reader, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if cached := e.snapshotArchive; cached != nil && slices.Equal(cached.files, source.File) { + if archive := cached.archive.Value(); archive != nil { + return archive, nil + } + } + var data bytes.Buffer + var count int64 + archive := zip.NewWriter(convertWriter{ctx: ctx, writer: &data, count: &count}) + for _, file := range source.File { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := archive.Copy(file); err != nil { + return nil, err + } + } + if err := archive.Close(); err != nil { + return nil, err + } + reader, err := zip.NewReader(bytes.NewReader(data.Bytes()), int64(data.Len())) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + cached := &editorSnapshotArchive{files: slices.Clone(source.File), archive: weak.Make(reader)} + if data.Cap() <= editorSnapshotArchiveLimit { + cached.retained = reader + } + e.snapshotArchive = cached + return reader, nil +} + // NewFileOutput 创建本地保存目标并记录当前文件状态,不创建或修改文件 // 同一目标应复用返回值,以便后续保存检测外部修改 // 替换前比较文件标识、大小和修改时间,不提供跨进程排他锁