From f472ebd931e8d524559d4f3612e0f10c0f2ccdc8 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Mon, 14 Sep 2026 22:15:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=98=85=E8=AF=BB=E7=BC=93=E5=AD=98=E3=80=81?= =?UTF-8?q?=E5=85=A8=E6=96=87=E6=90=9C=E7=B4=A2=E5=8F=8APDF=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E5=86=85=E5=AD=98=E5=8D=A0=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 50 ++++++++++++++++++++++++++++++++++++- internal/webui/webui.go | 42 ++++++++++++------------------- ofdgo_reader.go | 16 +++++++++++- ofdgo_renderer_output.go | 7 ++---- ofdgo_search.go | 53 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 33 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index c2b3dd4..fb1f1c1 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -3,6 +3,8 @@ const FONT_DATABASE = "ofdgo"; const fontChannel = typeof BroadcastChannel === "function" ? new BroadcastChannel(FONT_DATABASE) : null; 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 STATUS = { ready: "选择 OFD 文件", opening: "正在打开文档", @@ -1760,6 +1762,7 @@ function flowPageObserver() { state.selectedPages.delete(index); renderFlowPage(index, { openSeq, priority: 4 }); } + trimPageCache(); }, { root: el.viewerPanel, rootMargin: "600px 0px", @@ -1798,6 +1801,7 @@ async function renderFlowPage(index, options = {}) { } updateThumbnail(index, openSeq); } + trimPageCache(); return page; } catch (err) { if (openSeq === state.openSeq) { @@ -1816,7 +1820,10 @@ function loadPageData(index, options = {}) { return Promise.resolve(null); } if (state.pageCache.has(index)) { - return Promise.resolve(state.pageCache.get(index)); + const page = state.pageCache.get(index); + state.pageCache.delete(index); + state.pageCache.set(index, page); + return Promise.resolve(page); } const key = `${openSeq}:${index}`; const priority = options.priority ?? 3; @@ -1869,7 +1876,9 @@ async function processPageRenderQueue() { state.svgImages.set(image.name, image.url); } } + page.imageNames = page.images.map((image) => image.name); delete page.images; + page.cacheBytes = (page.svg.length + page.text.length) * 2; page.text = JSON.parse(page.text); state.pageCache.set(task.index, page); } @@ -1885,6 +1894,42 @@ async function processPageRenderQueue() { } } +function trimPageCache() { + const protectedPages = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails, ...state.selectedPages]); + for (const task of state.pageInFlight.values()) { + protectedPages.add(task.index); + } + const references = 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 url of state.svgImages.values()) { + bytes += url.length * 2; + } + for (const [index, page] of state.pageCache) { + if (state.pageCache.size <= PAGE_CACHE_LIMIT && bytes <= PAGE_CACHE_BYTES) { + break; + } + if (protectedPages.has(index)) { + continue; + } + state.pageCache.delete(index); + bytes -= page.cacheBytes; + for (const name of page.imageNames) { + const count = references.get(name) - 1; + references.set(name, count); + if (count === 0) { + bytes -= state.svgImages.get(name).length * 2; + state.svgImages.delete(name); + } + } + } +} + async function loadSVGFonts(fonts, openSeq) { for (const font of fonts) { if (openSeq !== state.openSeq) { @@ -1992,6 +2037,7 @@ function syncSelection() { for (const index of state.selectedPages) { unmountPage(index); } + trimPageCache(); } function isDocumentSelection(range) { @@ -2652,6 +2698,7 @@ function thumbnailObserver() { state.visibleThumbnails.add(index); renderThumbnail(index, openSeq); } + trimPageCache(); }, { root: el.pageListPanel, rootMargin: "180px 0px", @@ -2682,6 +2729,7 @@ async function renderThumbnail(index, openSeq = state.openSeq) { return; } updateThumbnail(index, openSeq); + trimPageCache(); } catch { if (openSeq === state.openSeq) { markThumbnailError(index); diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 50389e0..1726c53 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -278,15 +278,8 @@ func (s *Session) SetFonts(fonts []FontFile) error { func (s *Session) PageText(index int) (*ofdgo.PageText, error) { text := s.textCache[index] if text == nil { - page := s.pageCache[index] var err error - if page == nil { - page, err = s.Reader.PageContentByIndex(index) - if err != nil { - return nil, err - } - } - text, err = s.Renderer.PageText(page) + text, err = s.readPageText(index) if err != nil { return nil, err } @@ -301,11 +294,8 @@ func (s *Session) PageText(index int) (*ofdgo.PageText, error) { func (s *Session) PageTextString(index int) (string, error) { text := s.textCache[index] if text == nil { - page, err := s.Reader.PageContentByIndex(index) - if err != nil { - return "", err - } - text, err = s.Renderer.PageText(page) + var err error + text, err = s.readPageText(index) if err != nil { return "", err } @@ -313,6 +303,16 @@ func (s *Session) PageTextString(index int) (string, error) { return text.String(), nil } +// readPageText 复用已加载页面,否则只解析文字所需图元 +// 入参: index 页面索引 +// 返回: *ofdgo.PageText 页面文字, error 错误信息 +func (s *Session) readPageText(index int) (*ofdgo.PageText, error) { + if page := s.pageCache[index]; page != nil { + return s.Renderer.PageText(page) + } + return s.Renderer.PageTextByIndex(index) +} + // SearchPage 搜索指定页面,复用当前会话的文字索引 // 入参: index 页面索引, query 搜索文字 // 返回: []ofdgo.TextMatch 匹配结果, error 错误信息 @@ -667,21 +667,10 @@ func (s *Session) ExportPDF(writer io.Writer, indices ...int) error { if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { return fmt.Errorf("ofd document is not opened") } - if len(indices) > 0 { - return s.Renderer.RenderToMultiPagePDF(writer, indices...) - } - pages := make([]*ofdgo.PageContent, len(s.doc.Pages.Page)) - for i := range pages { - page, err := s.pageContent(i) - if err != nil { - return err - } - pages[i] = page - } - return s.Renderer.RenderPagesToPDF(pages, writer) + return s.Renderer.RenderToMultiPagePDF(writer, indices...) } -// pageContent 获取页面内容 +// pageContent 读取页面,复用最近一次解析结果 // 入参: index 页面索引 // 返回: *ofdgo.PageContent 页面内容, error 错误信息 func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) { @@ -691,6 +680,7 @@ func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) { if page, ok := s.pageCache[index]; ok { return page, nil } + clear(s.pageCache) page, err := s.Reader.PageContentByIndex(index) if err != nil { return nil, err diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 13a3b2d..64e3d39 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -16,6 +16,7 @@ package ofdgo import ( "archive/zip" + "bytes" "encoding/xml" "fmt" "io" @@ -254,13 +255,26 @@ func resolveResourcePath(resPath, baseLoc, filePath string) string { // 入参: page 页面对象 // 返回: *PageContent 页面内容, error 错误信息 func (r *Reader) PageContent(page Page) (*PageContent, error) { + return r.readPageContent(page, false) +} + +// readPageContent 读取页面,可跳过文字提取不使用的独立路径和图片 +// 入参: page 页面对象, textOnly 是否仅用于文字提取 +// 返回: *PageContent 页面内容, error 错误信息 +func (r *Reader) readPageContent(page Page, textOnly bool) (*PageContent, error) { fullPath := r.ResPath(page.BaseLoc) data, err := r.readFile(fullPath) if err != nil { return nil, err } var content PageContent - if err := xml.Unmarshal(data, &content); err != nil { + if textOnly { + decoder := &textPageTokens{Decoder: xml.NewDecoder(bytes.NewReader(data))} + err = xml.NewTokenDecoder(decoder).Decode(&content) + } else { + err = xml.Unmarshal(data, &content) + } + if err != nil { return nil, fmt.Errorf("failed to unmarshal page content: %w", err) } content.ID = page.ID diff --git a/ofdgo_renderer_output.go b/ofdgo_renderer_output.go index 547f297..cfbacb3 100644 --- a/ofdgo_renderer_output.go +++ b/ofdgo_renderer_output.go @@ -146,11 +146,7 @@ func (r *Renderer) RenderToMultiPageText(writer io.Writer, indices ...int) error } written := false for i, index := range indices { - page, err := r.Reader.PageContentByIndex(index) - if err != nil { - return fmt.Errorf("failed to read page %d: %w", index+1, err) - } - text, err := r.PageText(page) + text, err := r.PageTextByIndex(index) if err != nil { return fmt.Errorf("failed to extract page %d text: %w", index+1, err) } @@ -420,6 +416,7 @@ func (r *Renderer) renderPDFPages(pages []pdfPage, writer io.Writer, progress fu buf.Truncate(start) return fmt.Errorf("failed to render page %d: %w", i+1, err) } + pages[i].Content = nil if progress != nil { if err := progress(i+1, len(pages)); err != nil { buf.Truncate(start) diff --git a/ofdgo_search.go b/ofdgo_search.go index 49ca122..80a0569 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -15,6 +15,8 @@ package ofdgo import ( + "encoding/xml" + "fmt" "math" "strings" "unicode" @@ -23,6 +25,39 @@ import ( "github.com/tdewolff/canvas" ) +// textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片 +type textPageTokens struct { + *xml.Decoder + parents []string +} + +// Token 读取文字页面所需的XML节点 +// 返回: xml.Token XML节点, error 错误信息 +func (d *textPageTokens) Token() (xml.Token, error) { + for { + token, err := d.Decoder.Token() + if err != nil { + return nil, err + } + switch node := token.(type) { + case xml.StartElement: + if len(d.parents) > 0 && (node.Name.Local == "PathObject" || node.Name.Local == "ImageObject") { + switch d.parents[len(d.parents)-1] { + case "Layer", "PageBlock", "CompositeGraphicUnit", "CompositeObject": + if err := d.Decoder.Skip(); err != nil { + return nil, err + } + continue + } + } + d.parents = append(d.parents, node.Name.Local) + case xml.EndElement: + d.parents = d.parents[:len(d.parents)-1] + } + return token, nil + } +} + // PageText 页面文字,按绘制顺序保留文本对象,不包含图像和签名外观 type PageText struct { Runs []TextRun `json:"runs"` @@ -74,6 +109,24 @@ func (r *Renderer) PageText(page *PageContent) (*PageText, error) { return renderer.pageText, nil } +// PageTextByIndex 按页面索引提取文字,避免解析独立路径和图片图元 +// 入参: index 页面索引,从0开始 +// 返回: *PageText 页面文字, error 错误信息 +func (r *Renderer) PageTextByIndex(index int) (*PageText, error) { + doc, err := r.Reader.Doc() + if err != nil { + return nil, err + } + if index < 0 || index >= len(doc.Pages.Page) { + return nil, fmt.Errorf("page index %d out of range", index) + } + page, err := r.Reader.readPageContent(doc.Pages.Page[index], true) + if err != nil { + return nil, err + } + return r.PageText(page) +} + // String 按文本对象顺序合并原文,以换行分隔非空对象 // 返回: string 页面原文 func (p *PageText) String() string {