From aacb83c50f4093619d6f7e17e3eaf97e9a4b8a2b Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Mon, 14 Sep 2026 19:54:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=86=85=E5=AD=98=E5=8D=A0=E7=94=A8):=20?= =?UTF-8?q?=E9=99=8D=E4=BD=8E=E5=A4=8D=E6=9D=82=E6=96=87=E6=A1=A3=E9=98=85?= =?UTF-8?q?=E8=AF=BB=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 | 10 +++- internal/webui/wasm.go | 6 +++ internal/webui/webui.go | 17 ++++--- ofdgo_font_info.go | 99 ++++++++++++++++++++++++++++++++++++-- ofdgo_renderer_svg.go | 103 +++++++++++++++++++++++++++++++++++----- 5 files changed, 211 insertions(+), 24 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 982d47c..c2b3dd4 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -56,6 +56,7 @@ const state = { renderAnnotations: true, pageCache: new Map(), svgFonts: new Map(), + svgImages: new Map(), selectedPages: new Set(), documentSelection: null, pageInFlight: new Map(), @@ -1710,6 +1711,7 @@ function resetPageFlow() { document.fonts.delete(font); } state.svgFonts.clear(); + state.svgImages.clear(); state.pageCache.clear(); state.selectedPages.clear(); state.documentSelection = null; @@ -1862,6 +1864,12 @@ async function processPageRenderQueue() { const page = await callWASM("ofdgoRenderPage", task.index); await loadSVGFonts(page.fonts, task.openSeq); if (task.openSeq === state.openSeq) { + for (const image of page.images) { + if (!state.svgImages.has(image.name)) { + state.svgImages.set(image.name, image.url); + } + } + delete page.images; page.text = JSON.parse(page.text); state.pageCache.set(task.index, page); } @@ -2229,7 +2237,7 @@ function layoutPageShell(shell, page) { function parseSVG(svgText, prefix = "") { const template = document.createElement("template"); - template.innerHTML = svgText; + template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${state.svgImages.get(name)}"`); const svg = document.adoptNode(template.content.firstElementChild); prefixSVGIds(svg, prefix); return svg; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index b901a3d..f27dbc9 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -19,6 +19,7 @@ package webui import ( "bufio" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -282,6 +283,10 @@ func renderPage(args []js.Value) (any, error) { for i, font := range page.Fonts { fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style} } + images := make([]any, len(page.Images)) + for i, image := range page.Images { + images[i] = map[string]any{"name": image.Name, "url": "data:" + image.MIME + ";base64," + base64.StdEncoding.EncodeToString(image.Data)} + } return successResult(map[string]any{ "index": page.Index, "number": page.Number, @@ -291,6 +296,7 @@ func renderPage(args []js.Value) (any, error) { "svg": page.SVG, "links": links, "fonts": fonts, + "images": images, "text": string(textData), }), nil } diff --git a/internal/webui/webui.go b/internal/webui/webui.go index b011ec6..789d39e 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -163,6 +163,7 @@ type PageSVG struct { SVG string `json:"svg"` Links []ofdgo.PageLink `json:"-"` Fonts []ofdgo.SVGFont `json:"-"` + Images []ofdgo.SVGImage `json:"-"` } // ExportFormat 导出格式 @@ -402,19 +403,17 @@ func (s *Session) Info() DocumentInfo { info.CreationDate = docInfo.CreationDate info.ModDate = docInfo.ModDate } - pages := make([]*ofdgo.PageContent, 0, len(s.doc.Pages.Page)) for index, pageRef := range s.doc.Pages.Page { pageInfo := PageInfo{Index: index, ID: pageRef.ID} - if page, err := s.pageContent(index); err == nil { - pages = append(pages, page) - if box, err := s.pageBox(index, page); err == nil { + if area, err := s.Reader.PageArea(pageRef); err == nil { + if box, err := s.pageBox(index, &ofdgo.PageContent{Area: area}); err == nil { pageInfo.Width = box.W pageInfo.Height = box.H } } info.Pages = append(info.Pages, pageInfo) } - if fonts, err := s.Renderer.FontInfosFromPages(pages); err == nil { + if fonts, err := s.Renderer.FontInfos(); err == nil { info.Fonts = fonts } info.FontCount = len(info.Fonts) @@ -440,7 +439,7 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { s.textCache[index] = text } } - fonts, err := renderer.RenderToSVGWithFonts(page, &buf) + resources, err := renderer.RenderToSVGWithResources(page, &buf) if err != nil { return PageSVG{}, err } @@ -448,11 +447,11 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { if err != nil { return PageSVG{}, err } - for i, font := range fonts { + for i, font := range resources.Fonts { s.svgFonts[font.Name] = font.Data - fonts[i].Data = nil + resources.Fonts[i].Data = nil } - return PageSVG{Index: index, Number: index + 1, ID: page.ID, Width: box.W, Height: box.H, SVG: buf.String(), Links: links, Fonts: fonts}, nil + return PageSVG{Index: index, Number: index + 1, ID: page.ID, Width: box.W, Height: box.H, SVG: buf.String(), Links: links, Fonts: resources.Fonts, Images: resources.Images}, nil } // SVGFontData 获取已渲染页面引用的字体数据 diff --git a/ofdgo_font_info.go b/ofdgo_font_info.go index 77e7027..3c4d483 100644 --- a/ofdgo_font_info.go +++ b/ofdgo_font_info.go @@ -15,6 +15,7 @@ package ofdgo import ( + "encoding/xml" "fmt" "io" "path" @@ -214,8 +215,10 @@ func (r *Renderer) fontUsage(doc *Document, pages []*PageContent) map[string]int usage := make(map[string]int) if pages == nil { for _, pageRef := range doc.Pages.Page { - if page, err := r.Reader.PageContent(pageRef); err == nil { - r.countPageFonts(page, usage) + if counts, err := r.readPageFontUsage(pageRef); err == nil { + for id, count := range counts { + usage[id] += count + } } } } else { @@ -233,14 +236,104 @@ func (r *Renderer) fontUsage(doc *Document, pages []*PageContent) map[string]int return usage } +// fontUsagePage 逐图元统计字体的页面解码器 +type fontUsagePage struct { + renderer *Renderer + usage map[string]int +} + +// readPageFontUsage 读取页面字体用量,不保留页面图元 +// 入参: page 页面引用 +// 返回: map[string]int 字体使用次数, error 错误信息 +func (r *Renderer) readPageFontUsage(page Page) (map[string]int, error) { + r.Reader.loadPageResources(page) + f, err := r.Reader.openFile(r.Reader.ResPath(page.BaseLoc)) + if err != nil { + return nil, err + } + defer f.Close() + content := fontUsagePage{renderer: r, usage: make(map[string]int)} + if err := xml.NewDecoder(f).Decode(&content); err != nil { + return nil, err + } + if _, err := io.Copy(io.Discard, f); err != nil { + return nil, err + } + r.countAnnotationFonts(page.ID, content.usage) + return content.usage, nil +} + +// UnmarshalXML 解析页面内容中的图层字体 +// 入参: d XML解码器, start 页面起始节点 +// 返回: error 错误信息 +func (p *fontUsagePage) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + if start.Name.Local != "Page" { + return fmt.Errorf("expected element type but have <%s>", start.Name.Local) + } + return decodeObjectContainer(d, start, func(d *xml.Decoder, node xml.StartElement) error { + if node.Name.Local != "Content" { + return d.Skip() + } + return decodeObjectContainer(d, node, func(d *xml.Decoder, layer xml.StartElement) error { + if layer.Name.Local != "Layer" { + return d.Skip() + } + return p.decodeObjects(d, layer, p.renderer.drawParamDefaults(attrValue(layer, "DrawParam"), nil)) + }) + }) +} + +// decodeObjects 逐个读取图元并复用字体统计规则 +// 入参: d XML解码器, start 容器起始节点, defaults 默认绘制参数 +// 返回: error 错误信息 +func (p *fontUsagePage) decodeObjects(d *xml.Decoder, start xml.StartElement, defaults *DrawParam) error { + return decodeObjectContainer(d, start, func(d *xml.Decoder, node xml.StartElement) error { + switch node.Name.Local { + case "PageBlock": + return p.decodeObjects(d, node, defaults) + case "PathObject": + var obj struct { + PathObject + AbbreviatedData struct{} `xml:"AbbreviatedData"` + } + if err := d.DecodeElement(&obj, &node); err != nil { + return err + } + p.renderer.countObjectFonts(GraphicObject{Type: "PathObject", PathObject: obj.PathObject}, p.usage, defaults, nil) + return nil + case "ImageObject": + var obj ImageObject + if err := d.DecodeElement(&obj, &node); err != nil { + return err + } + p.renderer.countObjectFonts(GraphicObject{Type: "ImageObject", ImageObject: obj}, p.usage, defaults, nil) + return nil + } + var layer Layer + if err := layer.decodeObject(d, node); err != nil { + return err + } + for _, obj := range layer.Objects { + p.renderer.countObjectFonts(obj, p.usage, defaults, nil) + } + return nil + }) +} + // countPageFonts 统计页面字体使用次数 // 入参: page 页面内容, usage 字体使用次数 func (r *Renderer) countPageFonts(page *PageContent, usage map[string]int) { for _, layer := range page.Content.Layer { r.countLayerFonts(layer, usage) } + r.countAnnotationFonts(page.ID, usage) +} + +// countAnnotationFonts 统计页面注释中的字体使用次数 +// 入参: pageID 页面标识, usage 字体使用次数 +func (r *Renderer) countAnnotationFonts(pageID string, usage map[string]int) { if r.RenderAnnotations { - for _, annot := range r.Reader.Annots[page.ID] { + for _, annot := range r.Reader.Annots[pageID] { if annot.Visible != nil && !*annot.Visible { continue } diff --git a/ofdgo_renderer_svg.go b/ofdgo_renderer_svg.go index b428e7c..d23d388 100644 --- a/ofdgo_renderer_svg.go +++ b/ofdgo_renderer_svg.go @@ -15,12 +15,17 @@ package ofdgo import ( + "bufio" + "bytes" "crypto/sha256" "fmt" + "image" + "image/png" "io" "strings" "github.com/tdewolff/canvas" + canvasimage "github.com/tdewolff/canvas/image" "github.com/tdewolff/canvas/renderers/svg" ) @@ -32,38 +37,114 @@ type SVGFont struct { Data []byte } -// svgFontRenderer 分离字体资源的SVG渲染器 -type svgFontRenderer struct { +// SVGImage SVG引用的图片资源,Name为图片地址,Data为只读图片数据 +type SVGImage struct { + Name string + MIME string + Data []byte +} + +// SVGResources SVG引用的字体和图片资源 +type SVGResources struct { + Fonts []SVGFont + Images []SVGImage +} + +// svgResourceRenderer 分离外部资源的SVG渲染器 +type svgResourceRenderer struct { *svg.SVG - renderer *Renderer - fonts []SVGFont - seen map[string]bool - styles strings.Builder + renderer *Renderer + writer io.Writer + fonts []SVGFont + images []SVGImage + imageNames map[image.Image]string + seen map[string]bool + styles strings.Builder + err error } // RenderToSVGWithFonts 渲染为SVG并返回页面引用的字体资源,不在SVG中嵌入字体 // 入参: page 页面内容, writer 输出流 // 返回: []SVGFont 字体资源,可按Name复用并按Weight、Style注册,error 错误信息 func (r *Renderer) RenderToSVGWithFonts(page *PageContent, writer io.Writer) ([]SVGFont, error) { + resources, err := r.renderSVGResources(page, writer, false) + return resources.Fonts, err +} + +// RenderToSVGWithResources 渲染为SVG并分离字体和图片,调用方需注册字体并将图片Name映射为可访问地址 +// 入参: page 页面内容, writer 输出流 +// 返回: SVGResources 可跨页面复用的资源, error 错误信息 +func (r *Renderer) RenderToSVGWithResources(page *PageContent, writer io.Writer) (SVGResources, error) { + return r.renderSVGResources(page, writer, true) +} + +// renderSVGResources 渲染SVG并收集外部资源 +// 入参: page 页面内容, writer 输出流, images 是否分离图片 +// 返回: SVGResources 外部资源, error 错误信息 +func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, images bool) (SVGResources, error) { c, err := r.renderPage(page) if err != nil { - return nil, err + return SVGResources{}, err } options := svg.DefaultOptions options.EmbedFonts = false - s := &svgFontRenderer{ - SVG: svg.New(writer, c.W, c.H, &options), + buffer := bufio.NewWriter(writer) + s := &svgResourceRenderer{ + SVG: svg.New(buffer, c.W, c.H, &options), renderer: r, + writer: buffer, seen: make(map[string]bool), } + if images { + s.imageNames = make(map[image.Image]string) + } c.RenderTo(s) + if s.err != nil { + return SVGResources{}, s.err + } s.SetCustomStyle(s.styles.String()) - return s.fonts, s.Close() + if err := s.Close(); err != nil { + return SVGResources{}, err + } + return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush() +} + +// RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换 +// 入参: img 图片对象, m 变换矩阵 +func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { + if s.imageNames == nil { + s.SVG.RenderImage(img, m) + return + } + name, ok := s.imageNames[img] + if !ok { + resource := SVGImage{MIME: "image/png"} + if encoded, ok := img.(*canvasimage.Image); ok { + resource.MIME, resource.Data = encoded.Mimetype, encoded.Bytes + } else { + var buffer bytes.Buffer + if err := png.Encode(&buffer, img); err != nil { + s.err = err + return + } + resource.Data = buffer.Bytes() + } + name = fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(resource.Data)) + resource.Name = name + s.imageNames[img] = name + if !s.seen[name] { + s.seen[name] = true + s.images = append(s.images, resource) + } + } + _, height := s.Size() + size := img.Bounds().Size() + fmt.Fprintf(s.writer, ``, m.Translate(0, float64(size.Y)).ToSVG(height), size.X, size.Y, name) } // RenderText 绘制文字并记录实际使用的字体资源 // 入参: text 文字对象, m 变换矩阵 -func (s *svgFontRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { +func (s *svgResourceRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { if text.Empty() { return }