diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 1b8432b..5f354f9 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -30,6 +30,8 @@ const STATUS = { }; const WASM_CALLBACKS = [ "ofdgoOpen", + "ofdgoConfigure", + "ofdgoDocumentInfo", "ofdgoRenderPage", "ofdgoExportFormats", "ofdgoExportPage", @@ -66,10 +68,12 @@ const state = { pageRenderQueue: [], pageRenderRunning: false, pageObserver: null, + visiblePages: new Set(), scrollFrame: 0, thumbnailCache: new Map(), thumbnailInFlight: new Set(), thumbnailObserver: null, + visibleThumbnails: new Set(), exportFormats: [], showPages: !COMPACT_LAYOUT.matches, showMeta: !COMPACT_LAYOUT.matches, @@ -725,6 +729,8 @@ async function applyFontChange(options = {}) { pageIndex: state.pageIndex, fitMode: state.fitMode, skipAutoFonts: options.skipAutoFonts !== false, + reuseSession: true, + fontsChanged: true, }); } @@ -738,6 +744,7 @@ async function toggleAnnotations() { pageIndex: state.pageIndex, fitMode: state.fitMode, skipAutoFonts: true, + reuseSession: true, }); } @@ -884,7 +891,10 @@ async function openDocument(options = {}) { if (openSeq !== state.openSeq) { return; } - const doc = callWASM("ofdgoOpen", state.ofdBytes, resetLocalFonts ? uploadedFonts() : allFonts(), state.renderAnnotations); + const fonts = resetLocalFonts ? uploadedFonts() : allFonts(); + const doc = options.reuseSession + ? callWASM("ofdgoConfigure", options.fontsChanged ? fonts : null, state.renderAnnotations) + : callWASM("ofdgoOpen", state.ofdBytes, fonts, state.renderAnnotations); if (openSeq !== state.openSeq) { return; } @@ -904,6 +914,8 @@ async function openDocument(options = {}) { resetScroll: options.resetScroll, skipAutoFonts: true, openSeq, + reuseSession: true, + fontsChanged: true, }); return; } @@ -926,6 +938,7 @@ async function openDocument(options = {}) { await nextFrame(); await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq }); queueNearbyPages(pageIndex, openSeq); + loadDocumentDetails(openSeq); } catch (err) { if (openSeq !== state.openSeq) { return; @@ -938,6 +951,24 @@ async function openDocument(options = {}) { } } +async function loadDocumentDetails(openSeq) { + await waitForPaint(); + if (openSeq !== state.openSeq) { + return; + } + try { + const info = callWASM("ofdgoDocumentInfo"); + if (openSeq === state.openSeq) { + Object.assign(state.doc, info, { detailsPending: false }); + renderMeta(); + } + } catch (err) { + if (openSeq === state.openSeq) { + showError(err, false); + } + } +} + async function renderPage(index, options = {}) { if (!state.doc) { return; @@ -1100,6 +1131,7 @@ function renderPageFlow() { function resetPageFlow() { state.pageCache.clear(); + state.visiblePages.clear(); for (const task of state.pageInFlight.values()) { task.resolve(null); } @@ -1116,7 +1148,6 @@ function observeFlowPage(shell, index) { const openSeq = state.openSeq; if (index < 4 && index !== state.pageIndex) { renderFlowPage(index, { openSeq, priority: 3 }); - return; } const observer = flowPageObserver(); if (observer) { @@ -1131,12 +1162,20 @@ function flowPageObserver() { if (!state.pageObserver) { const openSeq = state.openSeq; state.pageObserver = new IntersectionObserver((entries) => { + if (openSeq !== state.openSeq) { + return; + } for (const entry of entries) { + const index = Number.parseInt(entry.target.dataset.pageIndex, 10); if (!entry.isIntersecting) { + state.visiblePages.delete(index); + if (entry.target.classList.contains("rendered")) { + entry.target.querySelector(".page-surface").replaceChildren(); + entry.target.classList.remove("rendered"); + } continue; } - const index = Number.parseInt(entry.target.dataset.pageIndex, 10); - state.pageObserver.unobserve(entry.target); + state.visiblePages.add(index); renderFlowPage(index, { openSeq, priority: 4 }); } }, { @@ -1153,13 +1192,13 @@ async function renderFlowPage(index, options = {}) { return null; } try { - const page = await loadPageData(index, { openSeq, priority: options.priority || 2 }); + const page = await loadPageData(index, { openSeq, priority: options.priority ?? 2 }); if (openSeq !== state.openSeq) { return null; } if (page) { const shell = pageShell(index); - if (!shell?.classList.contains("rendered")) { + if (!shell?.classList.contains("rendered") && (!state.pageObserver || state.visiblePages.has(index) || index === state.pageIndex)) { mountPageSVG(index, page, openSeq); } updateThumbnail(index, openSeq); @@ -1185,7 +1224,7 @@ function loadPageData(index, options = {}) { return Promise.resolve(state.pageCache.get(index)); } const key = `${openSeq}:${index}`; - const priority = options.priority || 3; + const priority = options.priority ?? 3; const current = state.pageInFlight.get(key); if (current) { current.priority = Math.min(current.priority, priority); @@ -1526,6 +1565,7 @@ function renderPageList() { function resetThumbnails() { state.thumbnailCache.clear(); + state.visibleThumbnails.clear(); state.thumbnailInFlight.clear(); if (state.thumbnailObserver) { state.thumbnailObserver.disconnect(); @@ -1540,6 +1580,11 @@ function cacheThumbnail(index, svgText) { } function setThumbnailContent(container, svgText, index, openSeq = state.openSeq) { + const renderKey = `${openSeq}:${index}`; + if (svgText && container.dataset.renderKey === renderKey) { + return; + } + delete container.dataset.renderKey; container.replaceChildren(); if (!svgText) { container.classList.add("pending"); @@ -1552,6 +1597,7 @@ function setThumbnailContent(container, svgText, index, openSeq = state.openSeq) svg.setAttribute("aria-hidden", "true"); container.classList.remove("pending", "error"); container.append(svg); + container.dataset.renderKey = renderKey; } catch { container.classList.add("error"); container.textContent = String(index + 1); @@ -1559,16 +1605,15 @@ function setThumbnailContent(container, svgText, index, openSeq = state.openSeq) } function observeThumbnail(button, index, openSeq = state.openSeq) { - if (state.thumbnailCache.has(index)) { - return; + const observer = thumbnailObserver(); + if (observer) { + observer.observe(button); } if (index === state.pageIndex || index < 6) { renderThumbnail(index, openSeq); return; } - const observer = thumbnailObserver(); if (observer) { - observer.observe(button); return; } if (index < 8) { @@ -1583,12 +1628,21 @@ function thumbnailObserver() { if (!state.thumbnailObserver) { const openSeq = state.openSeq; state.thumbnailObserver = new IntersectionObserver((entries) => { + if (openSeq !== state.openSeq) { + return; + } for (const entry of entries) { + const index = Number.parseInt(entry.target.dataset.pageIndex, 10); if (!entry.isIntersecting) { + state.visibleThumbnails.delete(index); + const thumb = entry.target.querySelector(".thumb-paper"); + if (thumb.dataset.renderKey) { + setThumbnailContent(thumb, "", index, openSeq); + } continue; } - state.thumbnailObserver.unobserve(entry.target); - renderThumbnail(Number.parseInt(entry.target.dataset.pageIndex, 10), openSeq); + state.visibleThumbnails.add(index); + renderThumbnail(index, openSeq); } }, { root: el.pageListPanel, @@ -1599,7 +1653,11 @@ function thumbnailObserver() { } async function renderThumbnail(index, openSeq = state.openSeq) { - if (openSeq !== state.openSeq || !state.doc || state.thumbnailCache.has(index)) { + if (openSeq !== state.openSeq || !state.doc) { + return; + } + if (state.thumbnailCache.has(index)) { + updateThumbnail(index, openSeq); return; } const key = `${openSeq}:${index}`; @@ -1626,6 +1684,9 @@ async function renderThumbnail(index, openSeq = state.openSeq) { } function updateThumbnail(index, openSeq = state.openSeq) { + if (openSeq !== state.openSeq || (state.thumbnailObserver && !state.visibleThumbnails.has(index) && index !== state.pageIndex)) { + return; + } const thumb = el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`); if (thumb) { setThumbnailContent(thumb, state.thumbnailCache.get(index), index, openSeq); @@ -1647,7 +1708,7 @@ function renderMeta() { el.metaVersion.textContent = doc.version || "-"; el.metaType.textContent = doc.docType || "-"; el.metaFonts.textContent = String(doc.fontCount || 0); - el.metaSignatures.textContent = String(doc.signatureCount || 0); + el.metaSignatures.textContent = doc.detailsPending ? "正在检查" : String(doc.signatureCount || 0); el.pageTotal.textContent = String(doc.pageCount || 0); renderSignatures(); renderDocumentFonts(); @@ -1948,6 +2009,8 @@ function pageStatus(index, pageCount) { function statusText(status) { switch (status) { + case "pending": + return "待检查"; case "embedded": return "内嵌"; case "matched": diff --git a/go.mod b/go.mod index e5871b3..e842bf9 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.0 require ( github.com/tdewolff/canvas v0.0.0-20260901160717-dae8cd8e19a7 github.com/tdewolff/font v0.0.0-20260822205238-d0d2f004b117 - github.com/xiaoqidun/jbig2 v0.0.0-20260823111534-2e67bcec7059 + github.com/xiaoqidun/jbig2 v0.0.0-20260907061036-a2fe11801f67 golang.org/x/image v0.45.0 golang.org/x/text v0.41.0 ) diff --git a/go.sum b/go.sum index db23ff2..4583fcd 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= -github.com/xiaoqidun/jbig2 v0.0.0-20260823111534-2e67bcec7059 h1:/JXxWbNTePSVfshS7BVRm75Kh1/5rixvLpgTRw7MMv8= -github.com/xiaoqidun/jbig2 v0.0.0-20260823111534-2e67bcec7059/go.mod h1:mdoUQnVPIA4yS0/+ZrtxJNiDdbphI0RMu+DPfxXfVhE= +github.com/xiaoqidun/jbig2 v0.0.0-20260907061036-a2fe11801f67 h1:tUpMiGYu6SQ8W1aHxAc/NGMmd0N0IqfvcaoE14q3WeQ= +github.com/xiaoqidun/jbig2 v0.0.0-20260907061036-a2fe11801f67/go.mod h1:mdoUQnVPIA4yS0/+ZrtxJNiDdbphI0RMu+DPfxXfVhE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 00cee99..fb1bc04 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -40,6 +40,8 @@ type apiResult struct { // RunWASM 注册浏览器WASM接口并阻塞运行 func RunWASM() { registerCallback("ofdgoOpen", openDocument) + registerCallback("ofdgoConfigure", configureDocument) + registerCallback("ofdgoDocumentInfo", documentInfo) registerCallback("ofdgoRenderPage", renderPage) registerCallback("ofdgoExportFormats", exportFormats) registerCallback("ofdgoExportPage", exportPage) @@ -102,6 +104,36 @@ func openDocument(args []js.Value) (any, error) { return nil, err } currentSession = session + return currentSession.Summary(), nil +} + +// configureDocument 更新当前会话的字体和注解设置 +// 入参: args 浏览器参数 +// 返回: any 文档信息, error 错误信息 +func configureDocument(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if !args[0].IsNull() && !args[0].IsUndefined() { + fonts, err := fontsFromJS(args[0]) + if err != nil { + return nil, err + } + if err := currentSession.SetFonts(fonts); err != nil { + return nil, err + } + } + currentSession.Renderer.RenderAnnotations = args[1].Bool() + return currentSession.Summary(), nil +} + +// documentInfo 获取首屏之后加载的字体统计和验签结果 +// 入参: args 浏览器参数 +// 返回: any 文档信息, error 错误信息 +func documentInfo(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } return currentSession.Info(), nil } diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 09206f1..addab44 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -94,11 +94,14 @@ type SignatureStampInfo struct { // Session WebUI文档会话 type Session struct { - Reader *ofdgo.Reader - Renderer *ofdgo.Renderer - doc *ofdgo.Document - pageCache map[int]*ofdgo.PageContent - boxCache map[int]ofdgo.Box + Reader *ofdgo.Reader + Renderer *ofdgo.Renderer + doc *ofdgo.Document + pageCache map[int]*ofdgo.PageContent + boxCache map[int]ofdgo.Box + signatures []SignatureInfo + signatureError error + signaturesRead bool } // DocumentInfo 文档信息 @@ -117,6 +120,7 @@ type DocumentInfo struct { Fonts []FontInfo `json:"fonts"` Signatures []SignatureInfo `json:"signatures"` Pages []PageInfo `json:"pages"` + DetailsPending bool `json:"detailsPending,omitempty"` } // PageInfo 页面信息 @@ -222,7 +226,69 @@ func (s *Session) Close() error { return s.Reader.Close() } -// Info 获取文档信息 +// SetFonts 更新字体配置并保留文档、页面和验签结果 +// 入参: fonts 字体文件列表 +// 返回: error 错误信息 +func (s *Session) SetFonts(fonts []FontFile) error { + opts := []ofdgo.RendererOption{ + ofdgo.WithDPI(s.Renderer.DPI), + ofdgo.WithAnnotations(s.Renderer.RenderAnnotations), + } + if len(fonts) > 0 { + fontFS := ofdgo.NewFontFS(fonts) + if fontFS.Len() == 0 { + return fmt.Errorf("invalid font file") + } + opts = append(opts, ofdgo.WithFontFS(fontFS)) + } + s.Renderer = ofdgo.NewRenderer(s.Reader, opts...) + return nil +} + +// Summary 获取首屏所需信息,不解析页面图元或执行验签 +// 返回: DocumentInfo 文档信息 +func (s *Session) Summary() DocumentInfo { + info := DocumentInfo{ + Version: s.Reader.Version(), + DocType: s.Reader.DocType(), + PageCount: len(s.doc.Pages.Page), + Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), + DetailsPending: true, + } + if docInfo, err := s.Reader.DocInfo(); err == nil && docInfo != nil { + info.Title = docInfo.Title + info.Author = docInfo.Author + info.Subject = docInfo.Subject + info.CreationDate = docInfo.CreationDate + info.ModDate = docInfo.ModDate + } + for index, page := range s.doc.Pages.Page { + box, ok := s.boxCache[index] + if !ok { + if area, err := s.Reader.PageArea(page); err == nil { + box, _ = s.pageBox(index, &ofdgo.PageContent{Area: area}) + } + } + info.Pages = append(info.Pages, PageInfo{Index: index, ID: page.ID, Width: box.W, Height: box.H}) + } + if fonts, err := s.Reader.Fonts(); err == nil { + for _, font := range fonts { + info.Fonts = append(info.Fonts, FontInfo{ + ID: font.ID, + FontName: font.FontName, + FamilyName: font.FamilyName, + Charset: font.Charset, + FontFile: font.FontFile, + Embedded: font.FontFile != "", + Status: "pending", + }) + } + } + info.FontCount = len(info.Fonts) + return info +} + +// Info 获取完整文档信息 // 返回: DocumentInfo 文档信息 func (s *Session) Info() DocumentInfo { info := DocumentInfo{ @@ -339,14 +405,20 @@ func (s *Session) renderPageImage(page *ofdgo.PageContent, dpi float64) (image.I // signatureInfos 获取签名验证信息 // 返回: []SignatureInfo 签名验证信息, error 错误信息 func (s *Session) signatureInfos() ([]SignatureInfo, error) { + if s.signaturesRead { + return s.signatures, s.signatureError + } + s.signaturesRead = true reports, err := s.Reader.VerifySignatures() if err != nil { + s.signatureError = err return nil, err } infos := make([]SignatureInfo, 0, len(reports)) for _, report := range reports { infos = append(infos, signatureInfo(report)) } + s.signatures = infos return infos, nil } @@ -464,7 +536,15 @@ func (s *Session) ExportPDF() ([]byte, error) { return nil, fmt.Errorf("ofd document is not opened") } var buf bytes.Buffer - if err := s.Renderer.RenderToMultiPagePDF(&buf); err != nil { + pages := make([]*ofdgo.PageContent, len(s.doc.Pages.Page)) + for i := range pages { + _, page, err := s.pageContent(i) + if err != nil { + return nil, err + } + pages[i] = page + } + if err := s.Renderer.RenderPagesToPDF(pages, &buf); err != nil { return nil, err } return buf.Bytes(), nil diff --git a/ofdgo.go b/ofdgo.go index 9d852c5..cad6546 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -17,6 +17,7 @@ package ofdgo import ( "archive/zip" + "image" "io" "io/fs" @@ -79,6 +80,7 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer { fontDirCandidates: make(map[string][]fontFileCandidate), textGlyphPathCache: make(map[textGlyphPathCacheKey]textGlyphPathCacheValue), templatePageCache: make(map[string]*PageContent), + imageCache: make(map[string]image.Image), } for _, opt := range opts { opt(r) diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 0f5014f..4794caf 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -248,6 +248,37 @@ func (r *Reader) PageContent(page Page) (*PageContent, error) { return &content, nil } +// PageArea 读取页面区域,不解析页面图元 +// 入参: page 页面对象 +// 返回: PageArea 页面区域, error 错误信息 +func (r *Reader) PageArea(page Page) (PageArea, error) { + f, err := r.openFile(r.ResPath(page.BaseLoc)) + if err != nil { + return PageArea{}, err + } + defer f.Close() + d := xml.NewDecoder(f) + for { + token, err := d.Token() + if err == io.EOF { + return PageArea{}, nil + } + if err != nil { + return PageArea{}, err + } + if start, ok := token.(xml.StartElement); ok { + switch start.Name.Local { + case "Area": + var area PageArea + err := d.DecodeElement(&area, &start) + return area, err + case "Content": + return PageArea{}, nil + } + } + } +} + // ResPath 获取资源的完整路径 // 入参: resLink 资源链接 // 返回: string 完整路径 diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index df42c22..1a12029 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + "image" "io/fs" "github.com/tdewolff/canvas" @@ -39,6 +40,7 @@ type Renderer struct { fontDirCandidates map[string][]fontFileCandidate textGlyphPathCache map[textGlyphPathCacheKey]textGlyphPathCacheValue templatePageCache map[string]*PageContent + imageCache map[string]image.Image fontDirs []string fontFS []fs.FS decodeImages bool diff --git a/ofdgo_renderer_image.go b/ofdgo_renderer_image.go index 4e6d0fd..e993d70 100644 --- a/ofdgo_renderer_image.go +++ b/ofdgo_renderer_image.go @@ -188,7 +188,32 @@ func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix) imag // 入参: resPath 图片资源路径 // 返回: image.Image 图片对象, error 错误信息 func (r *Renderer) decodeImageResource(resPath string) (image.Image, error) { - rc, err := r.Reader.openFile(r.Reader.ResPath(resPath)) + resPath = cleanPackagePath(r.Reader.ResPath(resPath)) + img, ok := r.imageCache[resPath] + if !ok { + var err error + img, err = r.readImageResource(resPath) + if err != nil { + return nil, err + } + if r.imageCache == nil { + r.imageCache = make(map[string]image.Image) + } + r.imageCache[resPath] = img + } + if r.decodeImages { + if source, ok := img.(*canvasimage.Image); ok { + return source.Image() + } + } + return img, nil +} + +// readImageResource 读取图片资源并保留可直接嵌入的原始编码 +// 入参: resPath 图片资源路径 +// 返回: image.Image 图片对象, error 错误信息 +func (r *Renderer) readImageResource(resPath string) (image.Image, error) { + rc, err := r.Reader.openFile(resPath) if err != nil { return nil, err } @@ -200,9 +225,6 @@ func (r *Renderer) decodeImageResource(resPath string) (image.Image, error) { if err != nil { return nil, err } - if r.decodeImages { - return img.Image() - } return img, nil } if isPNGData(header) { @@ -210,9 +232,6 @@ func (r *Renderer) decodeImageResource(resPath string) (image.Image, error) { if err != nil { return nil, err } - if r.decodeImages { - return img.Image() - } return img, nil } data, err := io.ReadAll(reader) diff --git a/ofdgo_renderer_layer.go b/ofdgo_renderer_layer.go index 88fefb4..4bad270 100644 --- a/ofdgo_renderer_layer.go +++ b/ofdgo_renderer_layer.go @@ -95,12 +95,20 @@ func (r *Renderer) renderCompositeGraphicUnit(ctx *canvas.Context, cgu Composite return } ctx.Push() - currentCTM := NewMatrix(cgu.CTM) - if parentCTM != nil { - currentCTM = parentCTM.Multiply(currentCTM) - } box, _ := ParseBox(cgu.Boundary) - clipPath := intersectClipPath(parentClip, r.buildClipPath(cgu.Clips, pageH, box.X, box.Y, currentCTM)) + boundaryCTM := TranslationMatrix(box.X, box.Y) + if parentCTM != nil { + boundaryCTM = parentCTM.Multiply(boundaryCTM) + } + currentCTM := boundaryCTM.Multiply(NewMatrix(cgu.CTM)) + clips, clipCTM := cgu.Clips, currentCTM + if clips != nil && clips.TransFlag != nil && !*clips.TransFlag { + // 不参与对象CTM的裁剪仍位于对象边界及父级坐标系中 + clipCopy := *clips + clipCopy.TransFlag = nil + clips, clipCTM = &clipCopy, boundaryCTM + } + clipPath := intersectClipPath(parentClip, r.buildClipPath(clips, pageH, 0, 0, clipCTM)) if cgu.ResourceID != "" { if ref, ok := r.CompositeGraphicUnits[cgu.ResourceID]; ok { refCopy := *ref diff --git a/ofdgo_renderer_output.go b/ofdgo_renderer_output.go index 5fc1f11..ee27d47 100644 --- a/ofdgo_renderer_output.go +++ b/ofdgo_renderer_output.go @@ -67,26 +67,12 @@ func replacePDFProducer(data []byte) []byte { // 入参: page 页面内容, writer 输出流 // 返回: error 错误信息 func (r *Renderer) RenderToPDF(page *PageContent, writer io.Writer) error { - c, err := r.renderPage(page) - if err != nil { - return err - } box, err := r.GetPageBox(page) if err != nil { return err } pages := []pdfPage{{Content: page, Box: box}} - navigation := newPDFNavigation(r, r.Reader.doc, pages) - var buf bytes.Buffer - p := pdf.New(&buf, c.W, c.H, nil) - p.SetInfo("", "", "", "", "xiaoqidun/ofdgo") - navigation.apply(p, 0) - c.RenderTo(p) - if err := p.Close(); err != nil { - return err - } - _, err = writer.Write(replacePDFProducer(buf.Bytes())) - return err + return r.renderPDFPages(pages, writer) } // RenderToEPS 渲染为EPS @@ -123,24 +109,57 @@ func (r *Renderer) RenderToMultiPagePDF(writer io.Writer) error { } pages[i] = pdfPage{Content: page, Box: box} } - navigation := newPDFNavigation(r, doc, pages) - var buf bytes.Buffer - p := pdf.New(&buf, pages[0].Box.W, pages[0].Box.H, nil) + return r.renderPDFPages(pages, writer) +} + +// RenderPagesToPDF 按指定顺序导出已解析的页面,复用调用方的页面缓存 +// 入参: contents 页面内容列表, writer 输出流 +// 返回: error 错误信息 +func (r *Renderer) RenderPagesToPDF(contents []*PageContent, writer io.Writer) error { + if len(contents) == 0 { + return fmt.Errorf("no pages found") + } + pages := make([]pdfPage, len(contents)) + for i, page := range contents { + box, err := r.GetPageBox(page) + if err != nil { + return fmt.Errorf("failed to read page %d area: %w", i+1, err) + } + pages[i] = pdfPage{Content: page, Box: box} + } + return r.renderPDFPages(pages, writer) +} + +// renderPDFPages 渲染页面并复用调用方的字节缓冲 +// 入参: pages 页面列表, writer 输出流 +// 返回: error 错误信息 +func (r *Renderer) renderPDFPages(pages []pdfPage, writer io.Writer) error { + navigation := newPDFNavigation(r, r.Reader.doc, pages) + buf, direct := writer.(*bytes.Buffer) + if !direct { + buf = &bytes.Buffer{} + } + start := buf.Len() + p := pdf.New(buf, pages[0].Box.W, pages[0].Box.H, nil) p.SetInfo("", "", "", "", "xiaoqidun/ofdgo") for i, page := range pages { - c, err := r.renderPage(page.Content) - if err != nil { - return fmt.Errorf("failed to render page %d: %w", i+1, err) - } if i > 0 { - p.NewPage(c.W, c.H) + p.NewPage(page.Box.W, page.Box.H) } navigation.apply(p, i) - c.RenderTo(p) + if err := r.renderPageToContext(canvas.NewContext(p), page.Content, true); err != nil { + buf.Truncate(start) + return fmt.Errorf("failed to render page %d: %w", i+1, err) + } } if err := p.Close(); err != nil { + buf.Truncate(start) return err } - _, err = writer.Write(replacePDFProducer(buf.Bytes())) + data := replacePDFProducer(buf.Bytes()[start:]) + if direct { + return nil + } + _, err := writer.Write(data) return err } diff --git a/ofdgo_renderer_text.go b/ofdgo_renderer_text.go index d04bf46..99bd9fd 100644 --- a/ofdgo_renderer_text.go +++ b/ofdgo_renderer_text.go @@ -29,6 +29,11 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if obj.Visible != nil && !*obj.Visible { return } + shouldFill := obj.Fill == nil || *obj.Fill + shouldStroke := obj.Stroke != nil && *obj.Stroke + if !shouldFill && !shouldStroke { + return + } ctx.Push() bx, by := 0.0, 0.0 if obj.Boundary != "" { @@ -84,6 +89,22 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if fillPaint == nil { fillPaint = fillColor } + strokeStyle := newPathStyle(nil, defaultStroke, 0, obj.Alpha) + if shouldStroke { + if dp != nil { + strokeStyle.applyDrawParam(dp, bx, by, pageH, obj.Alpha) + } + if obj.StrokeColor != nil { + strokeStyle.applyStrokeColor(obj.StrokeColor, bx, by, pageH, obj.Alpha) + } + if obj.LineWidth > 0 { + strokeStyle.lineWidth = obj.LineWidth + } + if strokeStyle.strokePaint == nil { + strokeStyle.strokePaint = colorWithAlpha(canvas.Black, obj.Alpha) + } + strokeStyle.scale(ctm) + } fontStyle := canvas.FontRegular weight := obj.Weight if weight == 0 && dp != nil && dp.Weight > 0 { @@ -136,8 +157,9 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 } dxs, dys := parseFloats(tc.DeltaX), parseFloats(tc.DeltaY) xs, ys := parseFloats(tc.X), parseFloats(tc.Y) - drawAsPath := embeddedFont || textCodePositioned(tc, xs, ys) || clipPath != nil + drawAsPath := embeddedFont || textCodePositioned(tc, xs, ys) || clipPath != nil || shouldStroke cx, cy := 0.0, 0.0 + previousWidth := 0.0 if len(xs) > 0 { cx = xs[0] } @@ -160,9 +182,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if dx, ok := textDelta(dxs, i-1); ok { cx += dx } else if len(dys) == 0 { - cx += glyphWidth * hScale + cx += previousWidth * hScale } } + previousWidth = glyphWidth if i < len(ys) { cy = ys[i] } else if i > 0 { @@ -185,6 +208,40 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 glyphFillPaint = parseFillPaint(fillColorNode, bx, by, pageH, canvasX, canvasY) } advanceLimit := textGlyphAdvanceLimit(dxs, dys, xs, i, len(glyphs), cx) + if shouldStroke { + scaleX := hScale + if advanceLimit > 0 && glyphWidth*scaleX > advanceLimit { + scaleX = advanceLimit / glyphWidth + } + transform := canvas.Identity.Translate(canvasX, canvasY) + if useTextMatrix { + transform = transform.Mul(textMatrix(ctm)) + } + path := glyphPath.Copy().Transform(transform.Scale(scaleX, 1)) + if shouldFill && glyphFillPaint != nil { + ctx.SetFill(glyphFillPaint) + ctx.SetStrokeColor(canvas.Transparent) + ctx.DrawPath(0, 0, applyClipPath(path.Copy(), clipPath)) + } + if len(strokeStyle.dashPattern) > 0 { + path = path.Dash(strokeStyle.dashOffset, strokeStyle.dashPattern...) + } + path = path.Stroke(strokeStyle.lineWidth, strokeStyle.lineCap, strokeStyle.lineJoin, canvas.Tolerance) + ctx.SetFill(strokeStyle.strokePaint) + ctx.SetStrokeColor(canvas.Transparent) + ctx.DrawPath(0, 0, applyClipPath(path, clipPath)) + if hasUnderline { + underline := &canvas.Path{} + underline.MoveTo(0, -sizeMM*0.1) + underline.LineTo(glyphWidth*scaleX, -sizeMM*0.1) + underline = underline.Stroke(sizeMM*0.05, canvas.ButtCap, canvas.MiterJoin, canvas.Tolerance) + if shouldFill { + ctx.SetFillColor(fillColor) + } + ctx.DrawPath(0, 0, applyClipPath(underline.Transform(transform), clipPath)) + } + continue + } if glyphFillPaint != nil { ctx.SetFill(glyphFillPaint) if clipPath != nil {