diff --git a/assets/webui/index.html b/assets/webui/index.html index e010b2c..43a7c4b 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -431,6 +431,26 @@ +
+
+ + +
+
+ 显示 +
+
+ + +
+ +
+
+
+ 详情 +
显示
SVG
编辑
SVG
编译
图片
SVG
PDF
EPS
+
+
文档
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 5952d93..73e78fa 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -607,7 +607,8 @@ button:not(:disabled):hover { } .format-select, -.order-select { +.order-select, +.render-panel select { width: 58px; height: var(--control-height); min-width: 0; @@ -983,6 +984,104 @@ body[data-hide-meta] .meta-panel { font-size: 12px; } +.render-panel { + position: relative; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--line); +} + +.render-panel select { + width: 180px; + max-width: calc(100% - 76px); + height: 28px; + font-size: 12px; +} + +.render-mode-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; +} + +.render-panel .panel-row { + flex-wrap: nowrap; + margin-bottom: 6px; +} + +.render-controls { + display: flex; + align-items: center; + width: 180px; + max-width: calc(100% - 76px); + gap: 8px; +} + +.render-modes { + display: flex; + flex: none; +} + +.render-modes .small-button { + width: 36px; + height: 28px; + padding: 0; +} + +.render-modes .small-button + .small-button { + margin-left: -1px; +} + +.render-modes .small-button[aria-pressed="true"] { + position: relative; + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-strong); +} + +.render-controls select { + flex: 1; + width: 0; + min-width: 0; + max-width: 100%; +} + +.render-support { + font-size: 12px; + color: var(--text-muted); +} + +.render-support summary { + position: absolute; + top: 0; + left: 32px; + width: fit-content; + line-height: 28px; + cursor: pointer; + color: var(--accent-strong); +} + +.render-support dl { + margin: 8px 0 0; +} + +.render-support dl > div { + display: flex; + justify-content: space-between; + gap: 16px; + margin-top: 6px; +} + +.render-support dd { + min-width: 0; + margin: 0; + text-align: right; + overflow-wrap: anywhere; + color: #1f2428; +} + .app-refresh { width: 26px; padding: 0; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index dcc7f29..90d6ce2 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -81,6 +81,10 @@ const state = { fitMode: "width", continuous: false, renderAnnotations: true, + renderBackend: "canvas", + renderMode: "svg", + renderBackends: {}, + renderDPI: 150, pageCache: new Map(), svgFonts: new Map(), svgImages: new Map(), @@ -318,6 +322,16 @@ const el = { searchResults: document.querySelector("#searchResults"), metaPanel: document.querySelector(".meta-panel"), appPanel: document.querySelector("#appPanel"), + renderBackend: document.querySelector("#renderBackend"), + renderDPI: document.querySelector("#renderDPI"), + renderVectorButton: document.querySelector("#renderVectorButton"), + renderRasterButton: document.querySelector("#renderRasterButton"), + renderDisplay: document.querySelector("#renderDisplay"), + renderImages: document.querySelector("#renderImages"), + renderSVG: document.querySelector("#renderSVG"), + renderPDF: document.querySelector("#renderPDF"), + renderEPS: document.querySelector("#renderEPS"), + renderCompiler: document.querySelector("#renderCompiler"), offlineStatus: document.querySelector("#offlineStatus"), refreshAppButton: document.querySelector("#refreshAppButton"), metaFile: document.querySelector("#metaFile"), @@ -948,6 +962,10 @@ el.ofdInput.addEventListener("change", () => openOFD(el.ofdInput.files[0])); el.fontInput.addEventListener("change", openSelectedFonts); el.fontDirectoryInput.addEventListener("change", openSelectedFonts); el.prevButton.addEventListener("click", () => renderPage(state.pageIndex - 1)); +el.renderBackend.addEventListener("change", () => changeRenderBackend()); +el.renderDPI.addEventListener("change", () => changeRenderBackend()); +el.renderVectorButton.addEventListener("click", () => changeRenderBackend("svg")); +el.renderRasterButton.addEventListener("click", () => changeRenderBackend("raster")); el.nextButton.addEventListener("click", () => renderPage(state.pageIndex + 1)); el.zoomOutButton.addEventListener("click", () => setScale(state.scale - 0.1)); el.zoomInButton.addEventListener("click", () => setScale(state.scale + 0.1)); @@ -1407,6 +1425,16 @@ async function toggleEditor() { } updateControls(); renderOutlines(false); + if (state.renderMode !== "svg") { + try { + await refreshRenderBackend(anchor); + } catch (err) { + state.editing = !state.editing; + updateControls(); + showError(err, false); + } + return; + } renderPageList(); applyFit(false); restoreScaleAnchor(anchor); @@ -2704,6 +2732,14 @@ async function loadWASM() { if (data.phase === "commit") el.importCancel.disabled = el.cancelExportButton.disabled = true; } } else if (data.type === "ready") { + state.renderBackends = data.backends; + el.renderBackend.replaceChildren(); + for (const name of Object.keys(data.backends)) { + const option = document.createElement("option"); + option.value = name; + option.textContent = renderBackendLabel(name); + el.renderBackend.append(option); + } state.ready = true; resolve(); } else if (data.type === "exit") { @@ -3091,6 +3127,69 @@ async function toggleAnnotations() { }); } +function displayMode() { + return state.editing ? "svg" : state.renderMode; +} + +function renderBackendLabel(name) { + return { svg: "SVG", canvas: "Canvas", gg: "GoGPU" }[name] || name || "无"; +} + +function updateRenderBackend() { + el.renderBackend.value = state.renderBackend; + el.renderDPI.value = String(state.renderDPI); + el.renderBackend.disabled = !state.ready || state.exporting || document.body.hasAttribute("aria-busy"); + el.renderDPI.disabled = el.renderBackend.disabled || displayMode() === "svg"; + el.renderVectorButton.disabled = el.renderRasterButton.disabled = el.renderBackend.disabled || state.editing; + el.renderVectorButton.setAttribute("aria-pressed", String(displayMode() === "svg")); + el.renderRasterButton.setAttribute("aria-pressed", String(displayMode() === "raster")); + el.renderVectorButton.title = state.editing ? "编辑使用矢量显示" : "矢量显示"; + el.renderRasterButton.title = state.editing ? "阅读时可用" : "位图显示"; + const backend = state.renderBackends[state.renderBackend]; + el.renderDisplay.textContent = displayMode() === "svg" ? "SVG" : renderBackendLabel(backend?.raster); + el.renderImages.textContent = renderBackendLabel(backend?.raster); + el.renderSVG.textContent = renderBackendLabel(backend?.svg); + el.renderPDF.textContent = renderBackendLabel(backend?.pdf); + el.renderEPS.textContent = renderBackendLabel(backend?.eps); + el.renderCompiler.textContent = renderBackendLabel(backend?.compiler); +} + +async function refreshRenderBackend(anchor = scaleAnchor(0)) { + const selection = state.editing ? { index: canvasEditor.selected?.index ?? state.pageIndex, ids: canvasEditor.items().map(item => item.id) } : null; + await openDocument({ + pageIndex: state.pageIndex, fitMode: state.fitMode, scale: state.scale, + skipAutoFonts: true, reuseSession: true, keepPreview: true, keepSearch: true, + previewAnchor: anchor, selection, throwError: true, + }); +} + +async function changeRenderBackend(mode = state.renderMode) { + if (document.body.hasAttribute("aria-busy")) return; + const backend = el.renderBackend.value, dpi = Number(el.renderDPI.value); + if (state.renderBackend === backend && state.renderDPI === dpi && state.renderMode === mode) return; + if (state.editing && (!await canvasEditor.commitText() || !await canvasEditor.commitCrop())) { + updateRenderBackend(); + return; + } + const previous = { backend: state.renderBackend, dpi: state.renderDPI, mode: state.renderMode }; + state.renderBackend = backend; + state.renderDPI = dpi; + state.renderMode = mode; + updateRenderBackend(); + if (!state.doc) return; + try { + await refreshRenderBackend(); + } catch (err) { + state.renderBackend = previous.backend; + state.renderDPI = previous.dpi; + state.renderMode = previous.mode; + resetPageLoading(); + state.pageCache.clear(); + updateRenderBackend(); + showError(err, false); + } +} + async function changeFont(font, changes) { if (document.body.hasAttribute("aria-busy")) { return; @@ -3215,7 +3314,7 @@ async function openDocument(options = {}) { return; } const resetLocalFonts = !options.skipAutoFonts; - resetSearch(); + if (!options.keepSearch) resetSearch(); if (resetLocalFonts) { fontManager.localFonts = []; updateFontSummary(); @@ -3334,6 +3433,7 @@ async function openDocument(options = {}) { if (openSeq !== state.openSeq) { return; } + if (options.throwError) throw err; showError(err, true); } finally { if (openSeq === state.openSeq) { @@ -3536,8 +3636,8 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) setBusy(true, `正在生成 ${label}`, null, status); } const result = saving ? await callWASM("ofdgoSaveDocument", indices, file) : whole - ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, file) - : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, file); + ? await callWASM("ofdgoExportDocument", format.value, dpi, indices, state.renderBackend, file) + : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, state.renderBackend, file); if (openSeq !== state.openSeq) { return; } @@ -3779,7 +3879,7 @@ async function processPageRenderQueue() { task.resolve(state.pageCache.get(task.index)); continue; } - const page = await callWASM("ofdgoRenderPage", task.index); + const page = await callWASM("ofdgoRenderPage", task.index, state.renderBackend, state.renderDPI, displayMode() === "raster"); const scope = state.composite; if (scope?.index === task.index) { const objects = await callWASM("ofdgoCompositeObjects", task.index, scope.key); @@ -3900,7 +4000,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { svg.classList.add("ofd-svg"); surface.replaceChildren(svg); surface.append(createTextLayer(page.text)); - if (state.editorInfo) { + if (state.editorInfo && displayMode() === "svg") { mountEditorObjects(index, page, surface); } mountAnnotationNotes(index, surface, page.annotations); @@ -5956,6 +6056,7 @@ function updateFitSpace() { function updateControls() { const hasDoc = Boolean(state.doc); + updateRenderBackend(); updateEditorTools(); const pageCount = state.doc ? state.doc.pageCount : 0; el.prevButton.disabled = !hasDoc || state.pageIndex <= 0; @@ -6147,6 +6248,7 @@ function showError(err, empty = !state.doc) { function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("aria-busy", busy); + updateRenderBackend(); el.editButton.disabled = busy || !state.doc || !state.ready || state.exporting; el.createForm.inert = busy; el.insertForm.inert = busy; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 14b5486..ec4aea0 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -114,7 +114,9 @@ async function loadWASM() { self.postMessage({ type: "progress", text: "正在启动引擎", percent: 58 }); go.run(instance).then(exitWASM, exitWASM); if (!go.exited) { - self.postMessage({ type: "ready" }); + const backends = JSON.parse(globalThis.ofdgoRenderBackends()); + if (!backends.ok) throw new Error(backends.error); + self.postMessage({ type: "ready", backends: backends.data }); } } diff --git a/go.mod b/go.mod index c00e34e..df9068f 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/go-text/typesetting v0.3.5 + github.com/gogpu/gg v0.52.5 github.com/tdewolff/canvas v0.0.0-20260913163248-dd4999d1c76a github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/xiaoqidun/jbig2 v1.1.1 @@ -22,6 +23,8 @@ require ( github.com/benoitkugler/textlayout v0.3.2 // indirect github.com/benoitkugler/textprocessing v0.0.6 // indirect github.com/go-fonts/latin-modern v0.3.3 // indirect + github.com/gogpu/gpucontext v0.28.0 // indirect + github.com/gogpu/gputypes v0.5.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 // indirect diff --git a/go.sum b/go.sum index e333320..bd18ee7 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,20 @@ github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLS github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68= github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY= github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= +github.com/go-webgpu/goffi v0.6.3 h1:p4gKGikHBAQ/8iUiew9MV4C5M1ZGIsk8QGFGuJjMj+A= +github.com/go-webgpu/goffi v0.6.3/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= +github.com/go-webgpu/webgpu v0.5.5 h1:pIrXzRg0LRlNjNmR+ZNo/ERN88YaObzbCY5oYhir5SA= +github.com/go-webgpu/webgpu v0.5.5/go.mod h1:vgIuNTa1UlZ4njCGY6Pmp/0c5T5o2Ml2fQ0znLc7B98= +github.com/gogpu/gg v0.52.5 h1:aDAxjwcUYWTaEcKiAVSX52FPtYKtMhuFYCQYK2SwJwE= +github.com/gogpu/gg v0.52.5/go.mod h1:mKiEGiR8lT7BCECfSoxRYBQ96HrDc0DHv/CNDxYQG5M= +github.com/gogpu/gpucontext v0.28.0 h1:W27+0PRnHLVRscDJ2L6r4B1JlaKjlV/EmL0Wxx8vGIc= +github.com/gogpu/gpucontext v0.28.0/go.mod h1:5rpKj+DpixgAFm8ArBXFXOZQRbeJSFncVMsqyyA9Rhc= +github.com/gogpu/gputypes v0.5.2 h1:3sbKI0+XW36Ekd3d4QhkbpdY7gbgMVgp8Q4+ZNdGhtE= +github.com/gogpu/gputypes v0.5.2/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= +github.com/gogpu/naga v0.18.0 h1:2y83HUcAnlwEZMuHOiYTMdNYM9J8rev40gkI4zJ96Uk= +github.com/gogpu/naga v0.18.0/go.mod h1:15sQaHKkbqXcwTN+hHYGLsA0WBBnkmYzne/eF5p5WEg= +github.com/gogpu/wgpu v0.31.6 h1:WIySgNZHQfUaNRCQ/AzaZpDkcZn5dggqXlRAgpWtVu4= +github.com/gogpu/wgpu v0.31.6/go.mod h1:r8z3uZOyjl95KXN1eyMxFnwwPMZ0yuBFrLFi8v37QGY= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 6d1b794..999d710 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -102,6 +102,7 @@ func RunWASM() { registerCallback("ofdgoConfigure", configureDocument) registerCallback("ofdgoDocumentInfo", documentInfo) registerCallback("ofdgoRenderPage", renderPage) + registerCallback("ofdgoRenderBackends", renderBackends) registerCallback("ofdgoSVGFontData", svgFontData) registerCallback("ofdgoSearchPage", searchPage) registerCallback("ofdgoPageTextString", pageTextString) @@ -181,6 +182,13 @@ func registerCallback(name string, fn func([]js.Value) (any, error)) { js.Global().Set(name, cb) } +// renderBackends 返回库提供的内置后端能力 +// 入参: args 浏览器参数 +// 返回: any 后端能力, error 错误信息 +func renderBackends(args []js.Value) (any, error) { + return ofdgo.RenderBackendInfos(), nil +} + // registerAsyncCallback 注册异步回调,为浏览器处理数据和取消请求让出事件循环 // 入参: name 回调名称, fn 回调函数 func registerAsyncCallback(name string, fn func([]js.Value) (any, error)) { @@ -345,7 +353,7 @@ func svgFontData(args []js.Value) (any, error) { } // renderPage 渲染OFD页面 -// 入参: args 浏览器参数 +// 入参: args 页面索引、后端组合、DPI和光栅显示开关 // 返回: any 页面SVG结果, error 错误信息 func renderPage(args []js.Value) (any, error) { if currentSession == nil { @@ -354,10 +362,21 @@ func renderPage(args []js.Value) (any, error) { if len(args) == 0 { return nil, fmt.Errorf("missing page index") } - page, err := currentSession.RenderPageSVG(args[0].Int()) + backend, dpi := "canvas", 150.0 + if len(args) > 1 { + backend = args[1].String() + } + if len(args) > 2 { + dpi = args[2].Float() + } + raster := len(args) > 3 && args[3].Bool() + page, err := currentSession.RenderPage(args[0].Int(), backend, dpi, raster) if err != nil { return nil, err } + if currentEditor != nil { + currentEditor.SetPageCompiler(currentSession.Renderer.Backends().Compiler) + } text, err := currentSession.PageText(args[0].Int()) if err != nil { return nil, err @@ -472,8 +491,12 @@ func exportPage(args []js.Value) (any, error) { if len(args) < 4 { return nil, fmt.Errorf("missing export page arguments") } - writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) - format, err := currentSession.ExportPage(args[0].Int(), args[1].String(), args[2].Float(), writer) + session, writerIndex, err := exportSession(args) + if err != nil { + return nil, err + } + writer := bufio.NewWriterSize(exportWriter{write: args[writerIndex]}, 1<<20) + format, err := session.ExportPage(args[0].Int(), args[1].String(), args[2].Float(), writer) if err != nil { return nil, err } @@ -493,9 +516,13 @@ func exportDocument(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } - renderer := currentSession.Renderer + session, writerIndex, err := exportSession(args) + if err != nil { + return nil, err + } + renderer := session.Renderer renderer.OnExportProgress = func(completed, total int) error { - return awaitExport(args[4], completed, total) + return awaitExport(args[writerIndex+1], completed, total) } defer func() { renderer.OnExportProgress = nil }() var indices []int @@ -505,8 +532,8 @@ func exportDocument(args []js.Value) (any, error) { indices[i] = args[2].Index(i).Int() } } - writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) - format, err := currentSession.ExportDocument(args[0].String(), args[1].Float(), writer, indices...) + writer := bufio.NewWriterSize(exportWriter{write: args[writerIndex]}, 1<<20) + format, err := session.ExportDocument(args[0].String(), args[1].Float(), writer, indices...) if err != nil { return nil, err } @@ -519,6 +546,23 @@ func exportDocument(args []js.Value) (any, error) { }), nil } +// exportSession 创建独立导出配置,写入回调前可指定光栅后端 +// 入参: args 浏览器参数 +// 返回: *Session 导出会话, int 写入回调位置, error 错误信息 +func exportSession(args []js.Value) (*Session, int, error) { + session := *currentSession + renderer := *session.Renderer + session.Renderer = &renderer + writerIndex := 3 + if args[3].Type() == js.TypeString { + if err := session.SetRenderBackend(args[3].String()); err != nil { + return nil, 0, err + } + writerIndex++ + } + return &session, writerIndex, nil +} + // successResult 创建成功接口结果 // 入参: data 返回数据 // 返回: js.Value 接口结果 @@ -2737,6 +2781,7 @@ func editDocument(args []js.Value) (any, error) { if currentSession.fontFS != nil { editor.SetFontFS(currentSession.fontFS) } + editor.SetPageCompiler(currentSession.Renderer.Backends().Compiler) currentEditor = editor currentSession.editing = true copiedObjects = nil @@ -2758,6 +2803,7 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { } editor.SetFontFS() if currentSession != nil { + ofdgo.WithRenderBackends(currentSession.Renderer.Backends())(session.Renderer) if currentSession.fontFS != nil { session.fontFS = currentSession.fontFS session.Renderer.SetFontFS(session.fontFS) @@ -2765,6 +2811,7 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { } _ = currentSession.Close() } + editor.SetPageCompiler(session.Renderer.Backends().Compiler) currentSession = session session.editing = true if currentEditor != editor { diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 886d602..9b2edb8 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -479,6 +479,18 @@ func (s *Session) Info() DocumentInfo { // 入参: index 页面索引 // 返回: PageSVG 页面SVG结果, error 错误信息 func (s *Session) RenderPageSVG(index int) (PageSVG, error) { + return s.RenderPage(index, "", 0, false) +} + +// RenderPage 按显示后端生成页面,光栅结果复用SVG容器和独立交互信息 +// 入参: index 页面索引, backend 后端组合,空值沿用会话配置, dpi 光栅分辨率, raster 是否光栅显示 +// 返回: PageSVG 页面结果, error 错误信息 +func (s *Session) RenderPage(index int, backend string, dpi float64, raster bool) (PageSVG, error) { + if backend != "" { + if err := s.SetRenderBackend(backend); err != nil { + return PageSVG{}, err + } + } page, err := s.pageContent(index) if err != nil { return PageSVG{}, err @@ -494,11 +506,10 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { s.textCache[index] = text } } - renderSVG := renderer.RenderToSVGWithResources - if s.editing { - renderSVG = renderer.RenderToSVGWithObjects + if raster { + renderer.DPI = dpi } - resources, err := renderSVG(page, &buf) + resources, err := renderer.RenderPreview(page, &buf, ofdgo.PreviewOptions{Raster: raster, Objects: !raster && s.editing}) if err != nil { return PageSVG{}, err } @@ -517,6 +528,21 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { 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, Annotations: annotations}, nil } +// SetRenderBackend 设置库提供的后端组合,不改变文档数据 +// 入参: name 后端名称 +// 返回: error 错误信息 +func (s *Session) SetRenderBackend(name string) error { + backends, err := ofdgo.NewRenderBackends(name) + if err != nil { + return err + } + if s.Renderer.Backends().Info().Compiler != backends.Info().Compiler { + clear(s.textCache) + } + ofdgo.WithRenderBackends(backends)(s.Renderer) + return nil +} + // SVGFontData 获取已渲染页面引用的字体数据 // 入参: name 字体资源标识 // 返回: []byte 字体数据, error 错误信息 diff --git a/ofdgo.go b/ofdgo.go index 847465a..cb30dcb 100644 --- a/ofdgo.go +++ b/ofdgo.go @@ -73,6 +73,7 @@ func NewRenderer(reader *Reader, opts ...RendererOption) *Renderer { CompositeGraphicUnits: reader.compositeGraphicUnitCache, templatePageCache: make(map[string]*PageContent), imageCache: make(map[string]image.Image), + backends: defaultRenderBackends(), } r.resetFontCache() for _, opt := range opts { diff --git a/ofdgo_backend.go b/ofdgo_backend.go new file mode 100644 index 0000000..51a2246 --- /dev/null +++ b/ofdgo_backend.go @@ -0,0 +1,201 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "errors" + "fmt" + "image" + "io" +) + +// ErrBackendUnavailable 表示未配置所需后端,不会自动切换其他实现 +var ErrBackendUnavailable = errors.New("render backend unavailable") + +// Backend 提供稳定的后端标识,不包含前端文案 +type Backend interface { + Name() string +} + +// RasterBackend 消费只读页面,返回独立图像,不得静默跳过指令或切换其他后端 +type RasterBackend interface { + Backend + Render(*RasterPage) (image.Image, error) +} + +// PageCompiler 统一页面编译、文字提取和对象度量,不得修改源数据 +// 三项能力需采用相同的字体和几何语义,避免绘制、搜索与选区不一致 +// renderer提供文档、字体配置和文字回调,实现中不得递归调用对应的Renderer方法 +// CompilePage在配置OnPageText时需同步回报文字,PageText和MeasureObject不触发回调 +type PageCompiler interface { + Backend + CompilePage(renderer *Renderer, page *PageContent) (*RasterPage, error) + PageText(renderer *Renderer, page *PageContent) (*PageText, error) + MeasureObject(renderer *Renderer, object GraphicObject, options MeasureOptions) (ObjectMeasurement, error) +} + +// MeasureOptions 描述对象度量上下文,坐标以页面左上角为原点,单位为毫米 +// Defaults为继承样式,Parent为父变换,BoundaryInCTM指定边界是否参与父变换 +// Clip为页面坐标中的非零填充裁剪,nil表示不裁剪,空路径表示全部裁去 +// Contours决定是否收集填充轮廓,关闭时文字采用逐字符范围 +type MeasureOptions struct { + Defaults *DrawParam + Parent *Matrix + BoundaryInCTM bool + Clip *GeometryPath + Contours bool +} + +// ObjectMeasurement 保存对象在页面坐标中的范围与可选轮廓 +type ObjectMeasurement struct { + Bounds Box + Contours []ObjectContour +} + +// SVGMode 指定SVG资源封装方式,Objects模式还需保留稳定的对象分组 +type SVGMode uint8 + +const ( + SVGEmbedded SVGMode = iota + SVGExternalFonts + SVGExternalResources + SVGObjects +) + +// SVGBackend 输出SVG及资源,不得修改源页面或省略不支持的内容 +// renderer提供文档和排版能力,输出时不得再次调用RenderToSVG系列方法 +type SVGBackend interface { + Backend + RenderSVG(renderer *Renderer, page *PageContent, writer io.Writer, mode SVGMode) (SVGResources, error) +} + +// RenderDocumentPage 保存已解析页面和物理区域,不得修改源页面内容 +type RenderDocumentPage struct { + Content *PageContent + Box Box +} + +// PDFBackend 输出完整PDF,保留文字、链接、目录和文档信息 +// pages交由后端消费,可逐页清空Content以释放引用,不得改变页面顺序 +// progress返回错误时应停止输出,调用方在失败后丢弃输出 +type PDFBackend interface { + Backend + RenderPDF(renderer *Renderer, pages []RenderDocumentPage, writer io.Writer, progress func(int, int) error) error +} + +// EPSBackend 输出单页EPS,不得修改源页面 +type EPSBackend interface { + Backend + RenderEPS(renderer *Renderer, page *PageContent, writer io.Writer) error +} + +// RenderBackends 按职责组合后端,nil表示明确禁用,不隐式回退 +// 实例及其Renderer需串行使用,第三方实现无需引用任何内置绘图库 +type RenderBackends struct { + Compiler PageCompiler + Raster RasterBackend + SVG SVGBackend + PDF PDFBackend + EPS EPSBackend +} + +// BackendInfo 描述各项能力实际使用的后端,空标识表示未配置 +type BackendInfo struct { + Compiler string `json:"compiler"` + Raster string `json:"raster"` + SVG string `json:"svg"` + PDF string `json:"pdf"` + EPS string `json:"eps"` +} + +// NewRenderBackends 创建内置后端组合,gg仅替换光栅绘制,其他能力通过Info明确报告 +// 入参: name canvas或gg +// 返回: RenderBackends 后端组合, error 未知后端错误 +func NewRenderBackends(name string) (RenderBackends, error) { + backends := defaultRenderBackends() + switch name { + case "canvas": + case "gg": + backends.Raster = GGBackend{} + default: + return RenderBackends{}, fmt.Errorf("unknown render backend %q", name) + } + return backends, nil +} + +// RenderBackendInfos 列出内置组合及其实际能力,供原生程序和WASM共用 +// 返回: map[string]BackendInfo 后端能力 +func RenderBackendInfos() map[string]BackendInfo { + result := make(map[string]BackendInfo, 2) + for _, name := range []string{"canvas", "gg"} { + backends, _ := NewRenderBackends(name) + result[name] = backends.Info() + } + return result +} + +// defaultRenderBackends 创建独立的默认配置 +// 返回: RenderBackends 默认后端组合 +func defaultRenderBackends() RenderBackends { + backend := CanvasBackend{} + return RenderBackends{Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend} +} + +// Info 返回配置中各能力的提供者,不依赖WebUI推断 +// 返回: BackendInfo 后端能力 +func (b RenderBackends) Info() BackendInfo { + return BackendInfo{Compiler: backendName(b.Compiler), Raster: backendName(b.Raster), SVG: backendName(b.SVG), PDF: backendName(b.PDF), EPS: backendName(b.EPS)} +} + +// backendName 获取已配置的后端标识 +// 入参: backend 后端实例 +// 返回: string 后端标识,未配置时为空 +func backendName(backend Backend) string { + if backend == nil { + return "" + } + return backend.Name() +} + +// WithRenderBackends 替换完整后端组合,未配置的能力不会沿用默认实现 +// 入参: backends 后端组合 +// 返回: RendererOption 渲染选项 +func WithRenderBackends(backends RenderBackends) RendererOption { + return func(r *Renderer) { r.backends = backends } +} + +// Backends 返回当前后端配置副本,可修改后通过WithRenderBackends应用 +// 返回: RenderBackends 后端组合 +func (r *Renderer) Backends() RenderBackends { + return r.backends +} + +// WithRasterBackend 选择当前渲染器的图像、PNG和JPEG后端,不改变SVG、PDF或OFD保存 +// 入参: backend 光栅后端,nil禁用图像输出 +// 返回: RendererOption 渲染选项 +func WithRasterBackend(backend RasterBackend) RendererOption { + return func(r *Renderer) { r.backends.Raster = backend } +} + +// CompilePage 编译与后端无关的光栅页面,可交给任意RasterBackend绘制 +// 字形、描边、裁剪和底纹沿用文档语义,不交由后端重新排版 +// 入参: page 页面内容 +// 返回: *RasterPage 只读绘制页面, error 编译错误 +func (r *Renderer) CompilePage(page *PageContent) (*RasterPage, error) { + if r.backends.Compiler == nil { + return nil, fmt.Errorf("page compiler: %w", ErrBackendUnavailable) + } + return r.backends.Compiler.CompilePage(r, page) +} diff --git a/ofdgo_canvas.go b/ofdgo_canvas.go new file mode 100644 index 0000000..51e0847 --- /dev/null +++ b/ofdgo_canvas.go @@ -0,0 +1,106 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image" + "image/color" + + "github.com/tdewolff/canvas" + "github.com/tdewolff/canvas/renderers/rasterizer" +) + +// CanvasBackend canvas页面编译与绘制适配器,ColorSpace仅影响光栅输出 +type CanvasBackend struct { + ColorSpace canvas.ColorSpace +} + +// Name 返回后端标识 +// 返回: string 后端标识 +func (CanvasBackend) Name() string { return "canvas" } + +// Render 绘制已编译页面,不修改输入数据 +// 入参: page 只读页面 +// 返回: image.Image 图像, error 绘制错误 +func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) { + w, h, err := page.PixelSize() + if err != nil { + return nil, err + } + img := image.NewRGBA(image.Rect(0, 0, w, h)) + space := b.ColorSpace + if space == nil { + space = canvas.DefaultColorSpace + } + r := rasterizer.FromImage(img, canvas.DPMM(page.DPI/25.4), space) + for i, cmd := range page.Commands { + if cmd.Image != nil { + source := cmd.Image + if _, linear := space.(canvas.LinearColorSpace); !linear { + converted := image.NewRGBA(source.Bounds()) + for y := source.Bounds().Min.Y; y < source.Bounds().Max.Y; y++ { + for x := source.Bounds().Min.X; x < source.Bounds().Max.X; x++ { + converted.SetRGBA(x, y, space.ToLinear(color.RGBAModel.Convert(source.At(x, y)).(color.RGBA))) + } + } + source = converted + } + drawRasterImage(img, source, page.PixelTransform(cmd.Transform, h)) + continue + } + p := &canvas.Path{} + for _, s := range cmd.Path { + switch s.Verb { + case RasterMove: + p.MoveTo(s.End.X, s.End.Y) + case RasterLine: + p.LineTo(s.End.X, s.End.Y) + case RasterQuad: + p.QuadTo(s.Control1.X, s.Control1.Y, s.End.X, s.End.Y) + case RasterCubic: + p.CubeTo(s.Control1.X, s.Control1.Y, s.Control2.X, s.Control2.Y, s.End.X, s.End.Y) + case RasterClose: + p.Close() + default: + return nil, fmt.Errorf("canvas command %d: unsupported path verb %d", i, s.Verb) + } + } + paint := canvas.Paint{Color: cmd.Paint.Color} + if g := cmd.Paint.Gradient; g != nil { + stops := make(canvas.Grad, len(g.Stops)) + for j, stop := range g.Stops { + stops[j] = canvas.Stop{Offset: stop.Offset, Color: stop.Color} + } + start, end := canvas.Point{X: g.Start.X, Y: g.Start.Y}, canvas.Point{X: g.End.X, Y: g.End.Y} + switch g.Kind { + case RasterLinear: + paint = canvas.Paint{Gradient: stops.ToLinear(start, end)} + case RasterRadial: + paint = canvas.Paint{Gradient: stops.ToRadial(start, g.R0, end, g.R1)} + default: + return nil, fmt.Errorf("canvas command %d: unsupported gradient %d", i, g.Kind) + } + } + style := canvas.Style{Fill: paint, FillRule: canvas.NonZero} + if cmd.EvenOdd { + style.FillRule = canvas.EvenOdd + } + m := cmd.Transform + r.RenderPath(p, style, canvas.Matrix{{m[0], m[2], m[4]}, {-m[1], -m[3], page.Height - m[5]}}) + } + r.Close() + return img, nil +} diff --git a/ofdgo_canvas_compile.go b/ofdgo_canvas_compile.go new file mode 100644 index 0000000..0e2fab0 --- /dev/null +++ b/ofdgo_canvas_compile.go @@ -0,0 +1,164 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image" + "image/draw" + + "github.com/tdewolff/canvas" +) + +// CompilePage 使用默认字体和几何解释器生成独立绘制数据 +// 入参: r 渲染器, page 页面内容 +// 返回: *RasterPage 绘制页面, error 编译错误 +func (CanvasBackend) CompilePage(r *Renderer, page *PageContent) (*RasterPage, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + result := &RasterPage{Width: box.W, Height: box.H, DPI: r.DPI} + if _, _, err := result.PixelSize(); err != nil { + return nil, err + } + compiler := &canvasPageCompiler{page: result} + if err := r.renderCanvasContext(canvas.NewContext(compiler), page); err != nil { + return nil, err + } + if compiler.err != nil { + return nil, compiler.err + } + return result, nil +} + +// canvasPageCompiler 将已解释的页面绘制转换为自有数据,不保存第三方绘图对象 +type canvasPageCompiler struct { + page *RasterPage + err error +} + +// Size 返回物理页面尺寸 +// 返回: float64 宽度, float64 高度 +func (c *canvasPageCompiler) Size() (float64, float64) { return c.page.Width, c.page.Height } + +// RenderPath 记录填充并按当前DPI展开描边轮廓 +// 入参: path 路径, style 绘制样式, m 变换矩阵 +func (c *canvasPageCompiler) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { + if c.err != nil { + return + } + if style.HasFill() { + c.fill(path, style.Fill, style.FillRule, m) + } + if style.HasStroke() { + stroke := path + if len(style.Dashes) > 0 { + offset, dashes := canvas.ScaleDash(style.StrokeWidth, style.DashOffset, style.Dashes) + stroke = stroke.Dash(offset, dashes...) + } + stroke = stroke.Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.PixelTolerance/(c.page.DPI/25.4)) + c.fill(stroke, style.Stroke, style.FillRule, m) + } +} + +// fill 转换路径和画刷,未知类型返回错误而非丢弃内容 +// 入参: path 路径, paint 画刷, rule 填充规则, m 变换矩阵 +func (c *canvasPageCompiler) fill(path *canvas.Path, paint canvas.Paint, rule canvas.FillRule, m canvas.Matrix) { + if c.err != nil || path.Empty() { + return + } + if paint.Pattern != nil { + c.err = fmt.Errorf("unsupported raster pattern %T", paint.Pattern) + return + } + cmd := RasterCommand{Paint: RasterPaint{Color: paint.Color}, EvenOdd: rule == canvas.EvenOdd, + Transform: RasterMatrix{m[0][0], -m[1][0], m[0][1], -m[1][1], m[0][2], c.page.Height - m[1][2]}} + if paint.Gradient != nil { + g := &RasterGradient{} + var stops canvas.Grad + switch source := paint.Gradient.(type) { + case *canvas.LinearGradient: + g.Kind, g.Start, g.End = RasterLinear, rasterPoint(source.Start), rasterPoint(source.End) + stops = source.Grad + case *canvas.RadialGradient: + g.Kind, g.Start, g.End = RasterRadial, rasterPoint(source.C0), rasterPoint(source.C1) + g.R0, g.R1, stops = source.R0, source.R1, source.Grad + default: + c.err = fmt.Errorf("unsupported raster gradient %T", paint.Gradient) + return + } + g.Stops = make([]RasterStop, len(stops)) + for i, stop := range stops { + g.Stops[i] = RasterStop{Offset: stop.Offset, Color: stop.Color} + } + cmd.Paint.Gradient = g + } + path = path.ReplaceArcs() + cmd.Path = make([]RasterSegment, 0, path.Len()) + scanner := path.Scanner() + for scanner.Scan() { + s := RasterSegment{End: rasterPoint(scanner.End())} + switch scanner.Cmd() { + case canvas.MoveToCmd: + s.Verb = RasterMove + case canvas.LineToCmd: + s.Verb = RasterLine + case canvas.QuadToCmd: + s.Verb, s.Control1 = RasterQuad, rasterPoint(scanner.CP1()) + case canvas.CubeToCmd: + s.Verb, s.Control1, s.Control2 = RasterCubic, rasterPoint(scanner.CP1()), rasterPoint(scanner.CP2()) + case canvas.CloseCmd: + s.Verb = RasterClose + default: + c.err = fmt.Errorf("unsupported raster path command %g", scanner.Cmd()) + return + } + cmd.Path = append(cmd.Path, s) + } + c.page.Commands = append(c.page.Commands, cmd) +} + +// rasterPoint 转换局部坐标点 +// 入参: p 默认引擎坐标点 +// 返回: RasterPoint 独立坐标点 +func rasterPoint(p canvas.Point) RasterPoint { return RasterPoint{X: p.X, Y: p.Y} } + +// RenderText 展开已定位字形,使用与目标DPI一致的字形精度 +// 入参: text 文字, m 变换矩阵 +func (c *canvasPageCompiler) RenderText(text *canvas.Text, m canvas.Matrix) { + if c.err == nil { + text.RenderTo(c, m, canvas.DPMM(c.page.DPI/25.4)) + } +} + +// RenderImage 记录按像素左上原点定位的图片,旋转或斜切时补充透明采样边距 +// 入参: img 图片, m 变换矩阵 +func (c *canvasPageCompiler) RenderImage(img image.Image, m canvas.Matrix) { + if c.err != nil { + return + } + img = imagePixelSource(img) + if (m[0][1] != 0 || m[1][0] != 0) && (m[0][0] != 0 || m[1][1] == 0) { + bounds := img.Bounds() + padded := image.NewRGBA(image.Rect(0, 0, bounds.Dx()+8, bounds.Dy()+8)) + draw.Draw(padded, image.Rect(4, 4, bounds.Dx()+4, bounds.Dy()+4), img, bounds.Min, draw.Src) + img = padded + m = m.Translate(-4, -4) + } + h := float64(img.Bounds().Dy()) + t := RasterMatrix{m[0][0], -m[1][0], -m[0][1], m[1][1], m[0][2] + m[0][1]*h, c.page.Height - m[1][2] - m[1][1]*h} + c.page.Commands = append(c.page.Commands, RasterCommand{Image: img, Transform: t}) +} diff --git a/ofdgo_canvas_measure.go b/ofdgo_canvas_measure.go new file mode 100644 index 0000000..c15acb4 --- /dev/null +++ b/ofdgo_canvas_measure.go @@ -0,0 +1,312 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image" + + "github.com/tdewolff/canvas" +) + +// canvasGeometryPath 将页面坐标路径转换为默认几何引擎的向上纵轴 +// 入参: path 独立几何路径,nil表示不裁剪 +// 返回: *canvas.Path 默认引擎路径, error 不支持的路径指令 +func canvasGeometryPath(path *GeometryPath) (*canvas.Path, error) { + if path == nil { + return nil, nil + } + p := &canvas.Path{} + for _, segment := range *path { + switch segment.Verb { + case GeometryMove: + p.MoveTo(segment.End.X, -segment.End.Y) + case GeometryLine: + p.LineTo(segment.End.X, -segment.End.Y) + case GeometryQuad: + p.QuadTo(segment.Control1.X, -segment.Control1.Y, segment.End.X, -segment.End.Y) + case GeometryCubic: + p.CubeTo(segment.Control1.X, -segment.Control1.Y, segment.Control2.X, -segment.Control2.Y, segment.End.X, -segment.End.Y) + case GeometryArc: + p.ArcTo(segment.RadiusX, segment.RadiusY, -segment.Rotation, segment.Large, !segment.Sweep, segment.End.X, -segment.End.Y) + case GeometryClose: + p.Close() + default: + return nil, fmt.Errorf("unsupported geometry command %d", segment.Verb) + } + } + return p, nil +} + +// geometryCanvasPath 将默认引擎路径转换为独立的页面坐标路径,保留曲线和椭圆弧 +// 入参: path 默认引擎路径 +// 返回: *GeometryPath 独立路径,nil表示不裁剪 +func geometryCanvasPath(path *canvas.Path) *GeometryPath { + if path == nil { + return nil + } + result := make(GeometryPath, 0, path.Len()) + scanner := path.Scanner() + for scanner.Scan() { + end := scanner.End() + segment := GeometrySegment{End: Point{X: end.X, Y: -end.Y}} + switch scanner.Cmd() { + case canvas.MoveToCmd: + segment.Verb = GeometryMove + case canvas.LineToCmd: + segment.Verb = GeometryLine + case canvas.QuadToCmd: + control := scanner.CP1() + segment.Verb, segment.Control1 = GeometryQuad, Point{X: control.X, Y: -control.Y} + case canvas.CubeToCmd: + c1, c2 := scanner.CP1(), scanner.CP2() + segment.Verb = GeometryCubic + segment.Control1, segment.Control2 = Point{X: c1.X, Y: -c1.Y}, Point{X: c2.X, Y: -c2.Y} + case canvas.ArcToCmd: + segment.Verb = GeometryArc + segment.RadiusX, segment.RadiusY, segment.Rotation, segment.Large, segment.Sweep = scanner.Arc() + segment.Rotation, segment.Sweep = -segment.Rotation, !segment.Sweep + case canvas.CloseCmd: + segment.Verb = GeometryClose + } + result = append(result, segment) + } + return &result +} + +// MeasureObject 使用默认解释器度量对象,不分配页面像素 +// 入参: r 渲染器, object 图形对象, options 度量上下文 +// 返回: ObjectMeasurement 度量结果, error 错误信息 +func (CanvasBackend) MeasureObject(r *Renderer, object GraphicObject, options MeasureOptions) (ObjectMeasurement, error) { + if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" && object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { + return ObjectMeasurement{}, fmt.Errorf("unsupported object type %q", object.Type) + } + boundary, ctm := editorGeometry(object) + if _, err := creationBox(boundary); err != nil { + return ObjectMeasurement{}, err + } + if ctm != "" { + if _, err := creationNumbers(ctm, 6); err != nil { + return ObjectMeasurement{}, err + } + } + clip, err := canvasGeometryPath(options.Clip) + if err != nil { + return ObjectMeasurement{}, err + } + bounds := &boundsRenderer{collect: options.Contours} + renderer := *r + renderer.pageText = nil + renderer.textOnly = false + defaults := options.Defaults + ctx := canvas.NewContext(bounds) + switch object.Type { + case "TextObject": + if options.Contours { + renderer.renderText(ctx, object.TextObject, 0, defaults, options.Parent, options.BoundaryInCTM, clip) + break + } + renderer.pageText = &PageText{} + renderer.textOnly = true + renderer.renderText(ctx, object.TextObject, 0, defaults, options.Parent, options.BoundaryInCTM, clip) + var box Box + for _, run := range renderer.pageText.Runs { + for _, glyph := range run.Boxes { + box = unionTextBox(box, glyph) + } + } + return ObjectMeasurement{Bounds: box}, nil + case "PathObject": + obj := object.PathObject + defaults = renderer.drawParamDefaults(obj.DrawParam, defaults) + obj.DrawParam = "" + if defaults != nil { + style := *defaults + style.FillColor = boundsColor(style.FillColor) + style.StrokeColor = (*StrokeColor)(boundsColor((*FillColor)(style.StrokeColor))) + defaults = &style + } + obj.FillColor = boundsColor(obj.FillColor) + obj.StrokeColor = (*StrokeColor)(boundsColor((*FillColor)(obj.StrokeColor))) + renderer.renderPath(ctx, obj, 0, defaults, options.Parent, options.BoundaryInCTM, clip) + case "ImageObject": + renderer.measureImage(ctx, object.ImageObject, 0, options.Parent, options.BoundaryInCTM, clip) + case "CompositeObject", "CompositeGraphicUnit": + renderer.renderCompositeGraphicUnit(ctx, object.CompositeGraphicUnit, 0, defaults, options.Parent, options.BoundaryInCTM, clip) + } + return ObjectMeasurement{Bounds: bounds.box, Contours: bounds.contours}, nil +} + +// measureImage 复用图片变换与裁剪语义度量范围,不读取像素数据 +// 入参: ctx 度量画布, obj 图片, pageH 页面高度, parentCTM 父变换, boundaryInCTM 边界是否参与变换, parentClip 父裁剪 +func (r *Renderer) measureImage(ctx *canvas.Context, obj ImageObject, pageH float64, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) { + if obj.Visible != nil && !*obj.Visible || obj.Alpha != nil && *obj.Alpha == 0 { + return + } + box, _ := ParseBox(obj.Boundary) + ctm := NewMatrix(obj.CTM) + if obj.CTM == "" { + ctm = Matrix{a: box.W, d: box.H} + } + m := TranslationMatrix(box.X, box.Y).Multiply(ctm) + if parentCTM != nil { + if boundaryInCTM { + m = parentCTM.Multiply(m) + } else { + m = TranslationMatrix(box.X, box.Y).Multiply(*parentCTM).Multiply(ctm) + } + } + p := canvas.Rectangle(1, 1).Transform(canvas.Matrix{{m.a, m.c, m.e}, {-m.b, -m.d, pageH - m.f}}) + clip := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, box.X, box.Y, ctm, parentCTM, boundaryInCTM)) + ctx.Renderer.(*boundsRenderer).add(applyClipPath(p, clip)) + if obj.Border != nil { + border := *obj.Border + border.BorderColor = (*StrokeColor)(boundsColor((*FillColor)(border.BorderColor))) + obj.Border = &border + r.renderImageBorder(ctx, obj, box, pageH, parentCTM, boundaryInCTM, clip) + } +} + +// boundsColor 以底纹的基础颜色和透明度度量轮廓,不修改原画刷 +// 入参: color 填充或描边颜色 +// 返回: *FillColor 用于度量的颜色 +func boundsColor(color *FillColor) *FillColor { + if color == nil || color.Pattern == nil { + return color + } + return &FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha} +} + +// boundsRenderer 收集绘制范围,不分配页面像素或合并独立图形轮廓 +type boundsRenderer struct { + box Box + collect bool + contours []ObjectContour +} + +// Size 返回不限定边界的度量画布尺寸 +// 返回: float64 宽度, float64 高度 +func (r *boundsRenderer) Size() (float64, float64) { + return 0, 0 +} + +// add 合并路径的精确曲线范围并转换为向下的纵轴 +// 入参: path 绘制路径 +func (r *boundsRenderer) add(path *canvas.Path) { + r.addContour(path, false) +} + +// addContour 合并范围并按需保留独立填充区域 +// 入参: path 绘制路径, evenOdd 是否使用奇偶规则 +func (r *boundsRenderer) addContour(path *canvas.Path, evenOdd bool) { + if path.Empty() { + return + } + rect := path.Bounds() + r.box = unionTextBox(r.box, Box{X: rect.X0, Y: -rect.Y1, W: rect.W(), H: rect.H()}) + if r.collect { + outline := &canvas.Path{} + for _, subpath := range path.Split() { + subpath = subpath.Copy() + subpath.Close() + outline = outline.Append(subpath) + } + r.contours = append(r.contours, ObjectContour{Path: outline.Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), EvenOdd: evenOdd}) + } +} + +// RenderPath 收集填充与描边范围,保持线帽、连接和虚线语义 +// 入参: path 路径, style 绘制样式, m 变换矩阵 +func (r *boundsRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { + if style.HasFill() { + 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) + r.add(p.Transform(m)) + } +} + +// RenderText 收集绘制文字范围 +// 入参: text 文字, m 变换矩阵 +func (r *boundsRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { + text.RenderTo(r, m, 0) +} + +// RenderImage 度量不展开底纹图片 +// 入参: img 图片, m 变换矩阵 +func (r *boundsRenderer) RenderImage(img image.Image, m canvas.Matrix) {} + +// PageText 使用默认解释器提取原文与字形位置 +// 入参: r 渲染器, page 页面内容 +// 返回: *PageText 页面文字, error 错误信息 +func (CanvasBackend) PageText(r *Renderer, page *PageContent) (*PageText, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + renderer := *r + renderer.OnPageText = nil + renderer.pageText = &PageText{} + renderer.textOnly = true + if err := renderer.renderPageToContext(canvas.NewContext(canvas.New(box.W, box.H)), page, false); err != nil { + return nil, err + } + return renderer.pageText, nil +} + +// textPathBox 将字形轮廓转换为页面区域 +// 入参: path 字形轮廓, pageH 页面高度 +// 返回: Box 字符区域 +func textPathBox(path *canvas.Path, pageH float64) Box { + if path.Empty() { + return Box{} + } + bounds := path.Bounds() + return Box{X: bounds.X0, Y: pageH - bounds.Y1, W: bounds.X1 - bounds.X0, H: bounds.Y1 - bounds.Y0} +} + +// addSpan 收集字形选择区域,同一原文区间的多个字形合并为一项 +// 入参: start 起始字符, end 结束字符, bounds 字形布局区域, m 字形变换, clip 裁剪路径, pageH 页面高度 +func (run *TextRun) addSpan(start, end int, bounds canvas.Rect, m canvas.Matrix, clip *canvas.Path, pageH float64) { + if m.Det() == 0 { + return + } + if clip != nil { + path := canvas.Rectangle(bounds.W(), bounds.H()).Translate(bounds.X0, bounds.Y0).Transform(m) + path = applyClipPath(path, clip) + if path.Empty() { + return + } + bounds = path.Transform(m.Inv()).Bounds() + } + if bounds.W() < 0 || bounds.H() <= 0 { + return + } + m = canvas.Matrix{{1, 0, 0}, {0, -1, pageH}}.Mul(m).Translate(bounds.X0, bounds.Y1).Scale(bounds.W(), -bounds.H()) + span := TextSpan{Start: start, End: end} + if n := len(run.Spans); n > 0 && run.Spans[n-1].Start == start && run.Spans[n-1].End == end { + span = run.Spans[n-1] + v := span.Matrix + previous := canvas.Matrix{{v[0], v[2], v[4]}, {v[1], v[3], v[5]}} + if previous.Det() != 0 { + bounds = canvas.Rect{X1: 1, Y1: 1}.Add(canvas.Rect{X1: 1, Y1: 1}.Transform(previous.Inv().Mul(m))) + m = previous.Translate(bounds.X0, bounds.Y0).Scale(bounds.W(), bounds.H()) + } + run.Spans = run.Spans[:n-1] + } + span.Matrix = [6]float64{m[0][0], m[1][0], m[0][1], m[1][1], m[0][2], m[1][2]} + run.Spans = append(run.Spans, span) +} diff --git a/ofdgo_canvas_output.go b/ofdgo_canvas_output.go new file mode 100644 index 0000000..f4a7dee --- /dev/null +++ b/ofdgo_canvas_output.go @@ -0,0 +1,137 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bufio" + "bytes" + "fmt" + "io" + + "github.com/tdewolff/canvas" + "github.com/tdewolff/canvas/renderers/pdf" + "github.com/tdewolff/canvas/renderers/ps" + "github.com/tdewolff/canvas/renderers/svg" +) + +// RenderSVG 使用canvas输出SVG,按模式保留资源和对象分组 +// 入参: r 渲染器, page 页面内容, writer 输出流, mode 资源模式 +// 返回: SVGResources 外部资源, error 错误信息 +func (CanvasBackend) RenderSVG(r *Renderer, page *PageContent, writer io.Writer, mode SVGMode) (SVGResources, error) { + switch mode { + case SVGExternalFonts: + return r.renderSVGResources(page, writer, false, false) + case SVGExternalResources: + return r.renderSVGResources(page, writer, true, false) + case SVGObjects: + return r.renderSVGResources(page, writer, true, true) + case SVGEmbedded: + c, err := r.renderPage(page) + if err != nil { + return SVGResources{}, err + } + buffer := bufio.NewWriter(writer) + renderer := svg.New(buffer, c.W, c.H, nil) + c.RenderTo(renderer) + if err := renderer.Close(); err != nil { + return SVGResources{}, err + } + return SVGResources{}, buffer.Flush() + default: + return SVGResources{}, fmt.Errorf("unsupported SVG mode %d", mode) + } +} + +// RenderEPS 使用canvas输出单页EPS +// 入参: r 渲染器, page 页面内容, writer 输出流 +// 返回: error 错误信息 +func (CanvasBackend) RenderEPS(r *Renderer, page *PageContent, writer io.Writer) error { + c, err := r.renderPage(page) + if err != nil { + return err + } + options := ps.DefaultOptions + options.Format = ps.EncapsulatedPostScript + buffer := bufio.NewWriter(writer) + renderer := ps.New(buffer, c.W, c.H, &options) + c.RenderTo(renderer) + if err := renderer.Close(); err != nil { + return err + } + return buffer.Flush() +} + +// RenderPDF 使用canvas输出PDF,保留导航与文字并复用输出缓冲 +// 入参: r 渲染器, pages 页面列表, writer 输出流, progress 页面处理进度 +// 返回: error 错误信息 +func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer io.Writer, progress func(int, int) error) error { + if len(pages) == 0 { + return fmt.Errorf("no pages found") + } + 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) + renderer := &pdfRenderer{PDF: p, glyphPaths: make(map[*canvas.Path]*canvas.Path)} + var info DocInfo + if docInfo, err := r.Reader.DocInfo(); err == nil { + info = *docInfo + } + p.SetInfo(info.Title, info.Subject, "", info.Author, "xiaoqidun/ofdgo") + for i, page := range pages { + if i > 0 { + p.NewPage(page.Box.W, page.Box.H) + } + navigation.apply(p, i) + if err := r.renderPageToContext(canvas.NewContext(renderer), page.Content, true); err != nil { + 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) + return err + } + } + } + if err := p.Close(); err != nil { + buf.Truncate(start) + return err + } + data := replacePDFProducer(buf.Bytes()[start:]) + if direct { + return nil + } + _, err := writer.Write(data) + return err +} + +// replacePDFProducer 替换PDF的Producer属性 +// 入参: data PDF字节数据 +// 返回: []byte 替换后的PDF字节数据 +func replacePDFProducer(data []byte) []byte { + old := []byte("/Producer(tdewolff/canvas)") + idx := bytes.LastIndex(data, old) + if idx < 0 { + return data + } + dst := []byte("/Producer(xiaoqidun/ofdgo)") + copy(data[idx:idx+len(old)], dst) + return data +} diff --git a/ofdgo_renderer_pdf.go b/ofdgo_canvas_pdf.go similarity index 97% rename from ofdgo_renderer_pdf.go rename to ofdgo_canvas_pdf.go index 63fe904..0a86c4c 100644 --- a/ofdgo_renderer_pdf.go +++ b/ofdgo_canvas_pdf.go @@ -42,12 +42,6 @@ func (r *pdfRenderer) glyphPath(path *canvas.Path, matrix canvas.Matrix) *canvas return converted } -// pdfPage PDF页面数据 -type pdfPage struct { - Content *PageContent - Box Box -} - // pdfNavigation PDF导航信息 type pdfNavigation struct { Anchor map[int][]pdfAnchor @@ -78,7 +72,7 @@ type pdfOutline struct { // newPDFNavigation 创建PDF导航信息 // 入参: renderer 渲染器, doc 文档结构, pages 页面数据 // 返回: *pdfNavigation PDF导航信息 -func newPDFNavigation(renderer *Renderer, doc *Document, pages []pdfPage) *pdfNavigation { +func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentPage) *pdfNavigation { navigation := &pdfNavigation{ Anchor: make(map[int][]pdfAnchor), Link: make(map[int][]pdfLink), @@ -121,7 +115,7 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []pdfPage) *pdfNa // addAction 添加PDF动作 // 入参: page 页面索引, rect 动作区域, action 动作, bookmarks 书签, pageIndex 页面索引表, pages 页面数据 -func (n *pdfNavigation) addAction(page int, rect canvas.Rect, action Action, bookmarks map[string]Dest, pageIndex map[string]int, pages []pdfPage) { +func (n *pdfNavigation) addAction(page int, rect canvas.Rect, action Action, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) { if action.Goto != nil { dest := gotoDest(action.Goto, bookmarks) if dest == nil { @@ -148,7 +142,7 @@ func (n *pdfNavigation) addAction(page int, rect canvas.Rect, action Action, boo // addOutlines 添加PDF大纲 // 入参: outlines 大纲节点, level 节点层级, bookmarks 书签, pageIndex 页面索引表, pages 页面数据 -func (n *pdfNavigation) addOutlines(outlines []OutlineElem, level int, bookmarks map[string]Dest, pageIndex map[string]int, pages []pdfPage) { +func (n *pdfNavigation) addOutlines(outlines []OutlineElem, level int, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) { for _, outline := range outlines { nextLevel := level if dest := outlineDest(outline, bookmarks); dest != nil { diff --git a/ofdgo_canvas_svg.go b/ofdgo_canvas_svg.go new file mode 100644 index 0000000..e2136dd --- /dev/null +++ b/ofdgo_canvas_svg.go @@ -0,0 +1,211 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bufio" + "bytes" + "crypto/sha256" + "fmt" + "html" + "image" + "image/png" + "io" + "strings" + + "github.com/tdewolff/canvas" + canvasimage "github.com/tdewolff/canvas/image" + "github.com/tdewolff/canvas/renderers/svg" +) + +// svgResourceRenderer 分离外部资源的SVG渲染器 +type svgResourceRenderer struct { + *svg.SVG + renderer *Renderer + writer io.Writer + fonts []SVGFont + images []SVGImage + imageNames map[image.Image]string + seen map[string]bool + styles strings.Builder + err error + objects map[*GraphicObject]string + objectStack []svgObjectGroup +} + +// svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员 +type svgObjectGroup struct { + path string + next int + composite bool +} + +// renderSVGResources 渲染SVG并收集外部资源 +// 入参: page 页面内容, writer 输出流, images 是否分离图片, objects 是否标识页面直接对象 +// 返回: SVGResources 外部资源, error 错误信息 +func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, images, objects bool) (SVGResources, error) { + box, err := r.GetPageBox(page) + if err != nil { + return SVGResources{}, err + } + options := svg.DefaultOptions + options.EmbedFonts = false + buffer := bufio.NewWriter(writer) + s := &svgResourceRenderer{ + SVG: svg.New(buffer, box.W, box.H, &options), + renderer: r, + writer: buffer, + seen: make(map[string]bool), + } + if images { + s.imageNames = make(map[image.Image]string) + } + if objects { + s.objects = make(map[*GraphicObject]string) + for i := range page.Content.Layer { + for j := range page.Content.Layer[i].Objects { + object := &page.Content.Layer[i].Objects[j] + s.objects[object] = editorObjectID(*object) + } + } + if err := r.renderPageToContext(canvas.NewContext(s), page, true); err != nil { + return SVGResources{}, err + } + } else { + c, err := r.renderPage(page) + if err != nil { + return SVGResources{}, err + } + c.RenderTo(s) + } + if s.err != nil { + return SVGResources{}, s.err + } + s.SetCustomStyle(s.styles.String()) + if err := s.Close(); err != nil { + return SVGResources{}, err + } + return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush() +} + +// beginObject 标记直接对象及复合或注解成员,模板图元不单独标记 +// 入参: object 图形对象 +// 返回: bool 是否写入分组 +func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool { + id := s.objects[object] + attribute := "data-ofd-object" + if id == "" { + if len(s.objectStack) == 0 || !s.objectStack[len(s.objectStack)-1].composite { + return false + } + parent := &s.objectStack[len(s.objectStack)-1] + id = fmt.Sprintf("%s/%d", parent.path, parent.next) + parent.next++ + attribute = "data-ofd-child" + } + s.objectStack = append(s.objectStack, svgObjectGroup{path: id, composite: object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit"}) + fmt.Fprintf(s.writer, ``, attribute, html.EscapeString(id)) + return true +} + +// endObject 结束对象分组 +func (s *svgResourceRenderer) endObject() { + s.objectStack = s.objectStack[:len(s.objectStack)-1] + fmt.Fprint(s.writer, ``) +} + +// beginAnnotation 标记完整注解外观,内部图元保持原有层级和绘制顺序 +// 入参: id 注解标识 +// 返回: bool 是否写入分组 +func (s *svgResourceRenderer) beginAnnotation(id string) bool { + if s.objects == nil { + return false + } + s.objectStack = append(s.objectStack, svgObjectGroup{path: "annotation:" + id, composite: true}) + fmt.Fprintf(s.writer, ``, html.EscapeString(id)) + return true +} + +// skipObjects 为不可见资源保留内部成员序号 +// 入参: count 直接成员数量 +func (s *svgResourceRenderer) skipObjects(count int) { + if len(s.objectStack) > 0 { + s.objectStack[len(s.objectStack)-1].next += count + } +} + +// 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 *svgResourceRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { + if text.Empty() { + return + } + font := text.MostCommonFontFace().Font + resource, ok := s.renderer.svgFontCache[font] + if !ok { + data := font.SFNT.Write() + resource = SVGFont{ + Name: fmt.Sprintf("ofdgo-%x-%d", sha256.Sum256(data), font.Style()), + Weight: font.Style().CSS(), + Style: "normal", + Data: data, + } + if font.Style().Italic() { + resource.Style = "italic" + } + s.renderer.svgFontCache[font] = resource + } + if !s.seen[resource.Name] { + s.seen[resource.Name] = true + s.fonts = append(s.fonts, resource) + fmt.Fprintf(&s.styles, ".%s{font-family:%s!important}", resource.Name, resource.Name) + } + s.SetClass(resource.Name) + s.SVG.RenderText(text, m) + s.SetClass() +} diff --git a/ofdgo_editor.go b/ofdgo_editor.go index c785a34..1c96107 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -48,6 +48,7 @@ type Editor struct { fonts map[string]*font.SFNT fontFS []fs.FS fontRenderer *Renderer + compiler PageCompiler images map[string]image.Point resourceID map[editorResourceKey]string maxID int @@ -68,6 +69,25 @@ func (e *Editor) SetFontFS(fsys ...fs.FS) { e.fontRenderer = nil } +// SetPageCompiler 设置编辑对象度量使用的页面编译器,不改变文档或撤销历史 +// 与Renderer使用相同实例可统一显示、搜索和选区,nil禁用度量,不隐式回退 +// 入参: compiler 页面编译器 +func (e *Editor) SetPageCompiler(compiler PageCompiler) { + e.compiler = compiler + if e.fontRenderer != nil { + e.fontRenderer.backends.Compiler = compiler + } +} + +// newRenderer 使用编辑器字体来源和页面编译器创建资源度量器 +// 入参: reader 当前文档快照 +// 返回: *Renderer 度量器 +func (e *Editor) newRenderer(reader *Reader) *Renderer { + backends := defaultRenderBackends() + backends.Compiler = e.compiler + return NewRenderer(reader, WithFontFS(e.fontFS...), WithRenderBackends(backends)) +} + // editorResource 文档内嵌资源 type editorResource struct { name string @@ -113,6 +133,7 @@ func NewEditor() *Editor { fonts: make(map[string]*font.SFNT), images: make(map[string]image.Point), resourceID: make(map[editorResourceKey]string), + compiler: CanvasBackend{}, } } diff --git a/ofdgo_editor_annot_content.go b/ofdgo_editor_annot_content.go index 30942ed..ad93250 100644 --- a/ofdgo_editor_annot_content.go +++ b/ofdgo_editor_annot_content.go @@ -400,7 +400,7 @@ func (e *Editor) annotationScope(page int, path ObjectPath) (*Reader, *Renderer, reader.Close() return nil, nil, nil, nil, err } - renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) + renderer := e.newRenderer(reader) root.parent, root.visible = IdentityMatrix, true scope := root for depth := 0; ; depth++ { diff --git a/ofdgo_editor_annot_ops.go b/ofdgo_editor_annot_ops.go index c90b9f3..090bbc5 100644 --- a/ofdgo_editor_annot_ops.go +++ b/ofdgo_editor_annot_ops.go @@ -249,7 +249,11 @@ func (e *Editor) ReplaceAnnotationText(page int, id, old, value string, style Te visiting := make(map[string]bool) var replace func([]*editorCompositeNode) error replace = func(nodes []*editorCompositeNode) error { - for i, member := range edit.measureCompositeMembers(renderer, nodes) { + members, err := edit.measureCompositeMembers(renderer, nodes) + if err != nil { + return err + } + for i, member := range members { if member.Object.Type == "TextObject" && member.Object.TextObject.Text() == old { if err := edit.updateCompositeText(nodes[i], member, &value, style, nil, nil); err != nil { return err diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 72ab5c0..293eab9 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -20,8 +20,6 @@ import ( "reflect" "slices" "strconv" - - "github.com/tdewolff/canvas" ) // editorObjectPosition 对象所属图层及层内索引 @@ -395,7 +393,7 @@ func editorDistribution(boxes []Box, indexes []editorObjectPosition, axis string gap := (positions[last] + sizes[last] - positions[first] - total) / float64(len(order)-1) for i, index := range order[:len(order)-1] { step := sizes[index] + gap - if canvas.Equal(step, 0) { + if geometryEqual(step, 0) { step = 0 } if step < 0 || step == 0 && compareEditorPosition(indexes[index], indexes[order[i+1]]) > 0 { @@ -406,7 +404,7 @@ func editorDistribution(boxes []Box, indexes []editorObjectPosition, axis string for _, index := range order[1 : len(order)-1] { position := next next += sizes[index] + gap - if canvas.Equal(position, positions[index]) { + if geometryEqual(position, positions[index]) { continue } dx, dy := position-positions[index], 0.0 @@ -633,10 +631,10 @@ func editorAlignment(box, target Box, alignment string) Matrix { case "bottom": matrix.f = target.Y + target.H - box.H - box.Y } - if canvas.Equal(matrix.e, 0) { + if geometryEqual(matrix.e, 0) { matrix.e = 0 } - if canvas.Equal(matrix.f, 0) { + if geometryEqual(matrix.f, 0) { matrix.f = 0 } return matrix @@ -737,7 +735,7 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) if _, err := reader.PageContentByIndex(page); err != nil { return nil, err } - renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) + renderer := e.newRenderer(reader) boxes := make([]Box, len(objects)) for i, object := range objects { layer, _, findErr := e.findObject(page, editorObjectID(object)) diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index e9a2cc5..8e317ec 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -157,7 +157,7 @@ func (e *Editor) CompositeObjects(page int, path ObjectPath) ([]CompositeMember, return nil, err } defer reader.Close() - return e.measureCompositeMembers(renderer, members), nil + return e.measureCompositeMembers(renderer, members) } // CompositeObject 读取指定内部成员,仅度量该成员并保留完整容器位置 @@ -200,7 +200,10 @@ func (e *Editor) CompositeObjectsAt(page int, path ObjectPath, indexes []int) ([ position.Count++ containers[node.span.parent] = position } - members := e.measureCompositeMembers(renderer, selected) + members, err := e.measureCompositeMembers(renderer, selected) + if err != nil { + return nil, err + } for i, index := range indexes { members[i].Position = positions[index] members[i].Position.Count = containers[nodes[index].span.parent].Count @@ -328,7 +331,7 @@ func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, reader.Close() return nil, nil, nil, nil, err } - renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) + renderer := e.newRenderer(reader) root.parent, root.visible, root.defaults = IdentityMatrix, true, renderer.drawParamDefaults(layer.DrawParam, nil) root.drawParams = []string{layer.DrawParam} scope := root @@ -501,8 +504,8 @@ func (e *Editor) compositeMembers(n *editorCompositeNode, reader *Reader, render // measureCompositeMembers 按父变换、裁剪和继承样式度量成员,透明对象保留选区,未着色路径按轮廓度量 // 入参: renderer 渲染器, nodes 成员 -// 返回: []CompositeMember 独立快照及操作能力 -func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorCompositeNode) []CompositeMember { +// 返回: []CompositeMember 独立快照及操作能力, error 度量错误 +func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorCompositeNode) ([]CompositeMember, error) { result := make([]CompositeMember, len(nodes)) positions := make(map[*editorXML]ObjectPosition) orderable := make(map[*editorXML]bool) @@ -517,7 +520,7 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp positions[parent] = position } for i, node := range nodes { - bounds := &boundsRenderer{collect: true} + var bounds ObjectMeasurement object := node.object object.TextObject.Alpha, object.PathObject.Alpha = nil, nil object.ImageObject.Alpha, object.CompositeGraphicUnit.Alpha = nil, nil @@ -525,10 +528,14 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp object.PathObject.Stroke = nil } if node.visible { - renderer.renderObject(canvas.NewContext(bounds), &object, 0, node.defaults, &node.parent, node.boundaryInCTM, node.clip) + var err error + bounds, err = renderer.MeasureObject(object, MeasureOptions{Defaults: node.defaults, Parent: &node.parent, BoundaryInCTM: node.boundaryInCTM, Clip: geometryCanvasPath(node.clip), Contours: true}) + if err != nil { + return nil, err + } } transform := node.transformable() - capability := ObjectCapabilities{Transform: transform, Arrange: transform && bounds.box.W > 0 && bounds.box.H > 0, + capability := ObjectCapabilities{Transform: transform, Arrange: transform && bounds.Bounds.W > 0 && bounds.Bounds.H > 0, Delete: true, Copy: transform && editorXMLCopyable(node.node), Order: orderable[node.span.parent], ReplaceImage: node.object.Type == "ImageObject"} capability.Ungroup = transform && capability.Order && e.compositeUngroupable(node.node) && (node.object.CompositeGraphicUnit.ResourceID != "" || len(node.object.CompositeGraphicUnit.Objects) != 0) capability.Stretch = transform && e.objectStretchable(node.object, make(map[string]bool)) @@ -557,7 +564,7 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp } } } - if transform && node.object.Type == "ImageObject" && bounds.box.W > 0 && bounds.box.H > 0 { + if transform && node.object.Type == "ImageObject" && bounds.Bounds.W > 0 && bounds.Bounds.H > 0 { clips := node.object.ImageObject.Clips _, capability.CropImage = node.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert() capability.FitImage = capability.CropImage && axisAlignedMatrix(NewMatrix(node.object.ImageObject.CTM)) @@ -566,11 +573,11 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp _, ctm := editorGeometry(node.object) matrix := node.parent.Multiply(NewMatrix(ctm)) position := positions[node.span.parent] - result[i] = CompositeMember{Object: style, Bounds: bounds.box, Contours: bounds.contours, Matrix: node.matrix(true), StrokeScale: math.Sqrt(math.Abs(matrix.a*matrix.d - matrix.b*matrix.c)), Capabilities: capability, Position: position, Transform: transform, Cropped: node.states[editorObjectID(node.object)].crop != nil} + result[i] = CompositeMember{Object: style, Bounds: bounds.Bounds, Contours: bounds.Contours, Matrix: node.matrix(true), StrokeScale: math.Sqrt(math.Abs(matrix.a*matrix.d - matrix.b*matrix.c)), Capabilities: capability, Position: position, Transform: transform, Cropped: node.states[editorObjectID(node.object)].crop != nil} position.Index++ positions[node.span.parent] = position } - return result + return result, nil } // transformable 校验内部对象能否保留原文进行几何操作,不执行绘制测量 @@ -741,7 +748,11 @@ func (e *Editor) editCompositeObjects(page int, path ObjectPath, indexes []int, if len(selected) == 0 { return nil } - return edit(renderer, selectedNodes, e.measureCompositeMembers(renderer, selectedNodes)) + members, err := e.measureCompositeMembers(renderer, selectedNodes) + if err != nil { + return err + } + return edit(renderer, selectedNodes, members) }) } diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index 207d375..09cf2aa 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -82,7 +82,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu return err } defer reader.Close() - renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) + renderer := e.newRenderer(reader) var objects []GraphicObject for _, source := range selection.nodes { copy, err := newEditorCompositeNode(source.data) diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 77cd88b..00add27 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -638,7 +638,7 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) { definition := e.source.reader.fontCache[id] if definition != nil && definition.FontFile == "" { if e.fontRenderer == nil { - e.fontRenderer = NewRenderer(e.source.reader, WithFontFS(e.fontFS...)) + e.fontRenderer = e.newRenderer(e.source.reader) } style := canvasFontStyle(definition) for _, source := range e.fontRenderer.fontSources(id, definition, style) { diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 13c650e..5d844c8 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -200,7 +200,7 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb return GraphicObject{}, err } defer reader.Close() - return e.transformBorderedImage(object, matrix, origin, NewRenderer(reader, WithFontFS(e.fontFS...))) + return e.transformBorderedImage(object, matrix, origin, e.newRenderer(reader)) } // objectStretchable 允许图片及纯图片资源独立调整宽高,内联组合仍保持比例 diff --git a/ofdgo_geom.go b/ofdgo_geom.go index 0ebbe48..c36b380 100644 --- a/ofdgo_geom.go +++ b/ofdgo_geom.go @@ -32,6 +32,13 @@ type Point struct { X, Y float64 } +// geometryEqual 比较毫米坐标,使用固定绝对误差,不受绘图库全局精度影响 +// 入参: a、b 待比较的坐标 +// 返回: bool 是否在容差内相等 +func geometryEqual(a, b float64) bool { + return math.Abs(a-b) <= 1e-10 +} + // ParseBox 解析Box字符串 // 入参: s 字符串 // 返回: Box 矩形对象, error 错误信息 @@ -63,6 +70,19 @@ type Matrix struct { // IdentityMatrix 单位矩阵 var IdentityMatrix = Matrix{1, 0, 0, 1, 0, 0} +// MatrixFromValues 从a、b、c、d、e、f构造矩阵,不经过文本格式化 +// 入参: values 矩阵分量 +// 返回: Matrix 变换矩阵 +func MatrixFromValues(values [6]float64) Matrix { + return Matrix{a: values[0], b: values[1], c: values[2], d: values[3], e: values[4], f: values[5]} +} + +// Values 返回a、b、c、d、e、f的独立副本,保留原始浮点精度 +// 返回: [6]float64 矩阵分量 +func (m Matrix) Values() [6]float64 { + return [6]float64{m.a, m.b, m.c, m.d, m.e, m.f} +} + // NewMatrix 解析CTM字符串 // 入参: s 字符串 // 返回: Matrix 矩阵对象 diff --git a/ofdgo_geom_path.go b/ofdgo_geom_path.go new file mode 100644 index 0000000..9bb36e5 --- /dev/null +++ b/ofdgo_geom_path.go @@ -0,0 +1,42 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +// GeometryPath 保存独立于绘图库的几何路径,不扁平化曲线或舍入坐标 +// 坐标以左上角为原点,单位为毫米,子路径从Move开始,Close回到当前子路径起点 +type GeometryPath []GeometrySegment + +// GeometryVerb 表示几何路径指令 +type GeometryVerb uint8 + +const ( + GeometryMove GeometryVerb = iota + GeometryLine + GeometryQuad + GeometryCubic + GeometryArc + GeometryClose +) + +// GeometrySegment 保存路径端点、贝塞尔控制点或端点式椭圆弧参数 +// 弧线Rotation单位为度,Sweep为true表示顺时针,Large表示大弧 +type GeometrySegment struct { + Verb GeometryVerb + End Point + Control1, Control2 Point + RadiusX, RadiusY float64 + Rotation float64 + Large, Sweep bool +} diff --git a/ofdgo_gg.go b/ofdgo_gg.go new file mode 100644 index 0000000..0e6c894 --- /dev/null +++ b/ofdgo_gg.go @@ -0,0 +1,101 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image" + "image/color" + + "github.com/gogpu/gg" +) + +// GGBackend 不注册全局GPU设备或回退到其他绘图库的CPU后端 +type GGBackend struct{} + +// Name 返回后端标识 +// 返回: string 后端标识 +func (GGBackend) Name() string { return "gg" } + +// Render 绘制已编译页面,使用自有双圆渐变语义保持原有定位 +// 入参: page 只读页面 +// 返回: image.Image 图像, error 绘制错误 +func (GGBackend) Render(page *RasterPage) (image.Image, error) { + w, h, err := page.PixelSize() + if err != nil { + return nil, err + } + pixmap := gg.NewPixmap(w, h) + img := pixmap.ImageView() + raster := gg.NewSoftwareRenderer(w, h) + for i, cmd := range page.Commands { + m := page.PixelTransform(cmd.Transform, h) + if cmd.Image != nil { + drawRasterImage(img, cmd.Image, m) + continue + } + path := gg.NewPath() + for _, s := range cmd.Path { + end, c1, c2 := m.Apply(s.End), m.Apply(s.Control1), m.Apply(s.Control2) + switch s.Verb { + case RasterMove: + path.MoveTo(end.X, end.Y) + case RasterLine: + path.LineTo(end.X, end.Y) + case RasterQuad: + path.QuadraticTo(c1.X, c1.Y, end.X, end.Y) + case RasterCubic: + path.CubicTo(c1.X, c1.Y, c2.X, c2.Y, end.X, end.Y) + case RasterClose: + path.Close() + default: + return nil, fmt.Errorf("gg command %d: unsupported path verb %d", i, s.Verb) + } + } + paint := gg.NewPaint() + paint.SetFillBrush(gg.SolidBrush{Color: ggRGBA(cmd.Paint.Color)}) + if g := cmd.Paint.Gradient; g != nil { + if g.Kind != RasterLinear && g.Kind != RasterRadial { + return nil, fmt.Errorf("gg command %d: unsupported gradient %d", i, g.Kind) + } + inverse, err := m.Inverse() + if err != nil { + return nil, fmt.Errorf("gg command %d: %w", i, err) + } + paint.SetFillBrush(gg.NewCustomBrush(func(x, y float64) gg.RGBA { + p := inverse.Apply(RasterPoint{X: x, Y: y}) + return ggRGBA(g.At(p.X, p.Y)) + })) + } + if cmd.EvenOdd { + paint.FillRule = gg.FillRuleEvenOdd + } + if err := raster.Fill(pixmap, path, paint); err != nil { + return nil, fmt.Errorf("gg command %d: %w", i, err) + } + } + return img, nil +} + +// ggRGBA 将标准库预乘颜色转换为gg的非预乘颜色 +// 入参: c 预乘颜色 +// 返回: gg.RGBA 非预乘颜色 +func ggRGBA(c color.RGBA) gg.RGBA { + if c.A == 0 { + return gg.Transparent + } + a := float64(c.A) + return gg.RGBA{R: float64(c.R) / a, G: float64(c.G) / a, B: float64(c.B) / a, A: a / 255} +} diff --git a/ofdgo_raster.go b/ofdgo_raster.go new file mode 100644 index 0000000..8cd6f63 --- /dev/null +++ b/ofdgo_raster.go @@ -0,0 +1,202 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image" + "image/color" + "math" +) + +// RasterPage 已解析的光栅绘制页面,单位为毫米,页面坐标原点在左上角 +// DPI决定像素尺寸和编译时的字形、描边精度,修改DPI后应重新编译 +// 页面及其引用资源在渲染期间只读,不用于重建或保存OFD +type RasterPage struct { + Width, Height float64 + DPI float64 + Commands []RasterCommand +} + +// RasterMatrix 按a、b、c、d、e、f排列的仿射矩阵,零值不是单位矩阵 +type RasterMatrix [6]float64 + +// RasterPoint 路径或渐变坐标 +type RasterPoint struct{ X, Y float64 } + +// RasterVerb 路径指令 +type RasterVerb uint8 + +const ( + RasterMove RasterVerb = iota + RasterLine + RasterQuad + RasterCubic + RasterClose +) + +// RasterSegment 路径片段,RasterMove和RasterLine使用End,RasterQuad使用Control1和End +type RasterSegment struct { + Verb RasterVerb + Control1, Control2 RasterPoint + End RasterPoint +} + +// RasterCommand 填充路径或绘制图像,Image非空时为图像指令 +// Transform将局部坐标映射到页面,图像局部坐标与image.Image的像素坐标一致 +// 字形和描边由页面编译器生成轮廓,裁剪与底纹在编译时解析 +type RasterCommand struct { + Path []RasterSegment + Paint RasterPaint + EvenOdd bool + Transform RasterMatrix + Image image.Image +} + +// RasterPaint 预乘RGBA纯色或渐变,Gradient非空时忽略Color +type RasterPaint struct { + Color color.RGBA + Gradient *RasterGradient +} + +// RasterGradientKind 渐变类型 +type RasterGradientKind uint8 + +const ( + RasterLinear RasterGradientKind = iota + RasterRadial +) + +// RasterStop 按Offset升序排列的预乘RGBA颜色节点 +type RasterStop struct { + Offset float64 + Color color.RGBA +} + +// RasterGradient 局部坐标下的线性或双圆径向渐变,区间外延续边界颜色 +type RasterGradient struct { + Kind RasterGradientKind + Start, End RasterPoint + R0, R1 float64 + Stops []RasterStop +} + +// PixelSize 检查页面尺寸并计算目标像素大小 +// 返回: int 宽度, int 高度, error 无效尺寸 +func (p *RasterPage) PixelSize() (int, int, error) { + if p == nil || !rasterPositive(p.Width) || !rasterPositive(p.Height) || !rasterPositive(p.DPI) { + return 0, 0, fmt.Errorf("invalid raster page size or DPI") + } + scale := p.DPI / 25.4 + w, h := math.Floor(p.Width*scale+0.5), math.Floor(p.Height*scale+0.5) + if !rasterPositive(w) || !rasterPositive(h) || w*h > math.MaxInt32/4 { + return 0, 0, fmt.Errorf("invalid raster pixel dimensions: %g x %g", w, h) + } + return int(w), int(h), nil +} + +// rasterPositive 判断正有限数 +// 入参: v 待检查数值 +// 返回: bool 是否为正有限数 +func rasterPositive(v float64) bool { return v > 0 && !math.IsInf(v, 0) && !math.IsNaN(v) } + +// Apply 将局部点映射到目标坐标 +// 入参: p 局部点 +// 返回: RasterPoint 目标点 +func (m RasterMatrix) Apply(p RasterPoint) RasterPoint { + return RasterPoint{m[0]*p.X + m[2]*p.Y + m[4], m[1]*p.X + m[3]*p.Y + m[5]} +} + +// Inverse 求逆矩阵,退化或行列式非有限时返回错误 +// 返回: RasterMatrix 逆矩阵, error 无效矩阵 +func (m RasterMatrix) Inverse() (RasterMatrix, error) { + det := m[0]*m[3] - m[1]*m[2] + if det == 0 || math.IsNaN(det) || math.IsInf(det, 0) { + return RasterMatrix{}, fmt.Errorf("invalid raster transform") + } + return RasterMatrix{m[3] / det, -m[1] / det, -m[2] / det, m[0] / det, + (m[2]*m[5] - m[3]*m[4]) / det, (m[1]*m[4] - m[0]*m[5]) / det}, nil +} + +// At 采样局部坐标下的渐变,使用预乘颜色插值 +// 入参: x 横坐标, y 纵坐标 +// 返回: color.RGBA 采样颜色 +func (g *RasterGradient) At(x, y float64) color.RGBA { + dx, dy := g.End.X-g.Start.X, g.End.Y-g.Start.Y + px, py := x-g.Start.X, y-g.Start.Y + t := 0.0 + if g.Kind == RasterRadial { + dr := g.R1 - g.R0 + a, b, c := dx*dx+dy*dy-dr*dr, -2*(px*dx+py*dy+g.R0*dr), px*px+py*py-g.R0*g.R0 + t = rasterRadialPosition(a, b, c, g.R0, dr) + } else if length := dx*dx + dy*dy; length > 0 { + t = (px*dx + py*dy) / length + } + return g.colorAt(t) +} + +// rasterRadialPosition 求双圆渐变中半径非负的有效参数 +// 入参: a 二次项系数, b 一次项系数, c 常数项, radius 起始半径, delta 半径增量 +// 返回: float64 渐变位置,范围为0至1 +func rasterRadialPosition(a, b, c, radius, delta float64) float64 { + x, y := math.NaN(), math.NaN() + if a == 0 { + if b != 0 { + x = -c / b + } + } else if d := b*b - 4*a*c; d >= 0 { + q := -0.5 * (b + math.Copysign(math.Sqrt(d), b)) + if q == 0 { + x = -b / (2 * a) + } else { + x, y = q/a, c/q + if x > y { + x, y = y, x + } + } + } + for _, t := range []float64{y, x} { + if t >= 0 && t <= 1 && radius+delta*t >= 0 { + return t + } + } + for _, t := range []float64{x, y} { + if t > 0 && radius+delta*t >= 0 { + return 1 + } + } + return 0 +} + +// colorAt 插值有序颜色节点 +// 入参: t 渐变位置 +// 返回: color.RGBA 预乘颜色 +func (g *RasterGradient) colorAt(t float64) color.RGBA { + if len(g.Stops) == 0 { + return color.RGBA{} + } + if t <= g.Stops[0].Offset { + return g.Stops[0].Color + } + for i := 1; i < len(g.Stops); i++ { + before, after := g.Stops[i-1], g.Stops[i] + if t < after.Offset { + u := uint32((t-before.Offset)/(after.Offset-before.Offset)*65535 + 0.5) + blend := func(a, b uint8) uint8 { return uint8(((65535-u)*uint32(a)*257 + u*uint32(b)*257) >> 24) } + return color.RGBA{blend(before.Color.R, after.Color.R), blend(before.Color.G, after.Color.G), blend(before.Color.B, after.Color.B), blend(before.Color.A, after.Color.A)} + } + } + return g.Stops[len(g.Stops)-1].Color +} diff --git a/ofdgo_raster_image.go b/ofdgo_raster_image.go new file mode 100644 index 0000000..ad6c9a7 --- /dev/null +++ b/ofdgo_raster_image.go @@ -0,0 +1,40 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "image" + + "golang.org/x/image/draw" + "golang.org/x/image/math/f64" +) + +// drawRasterImage 按页面变换采样图像,两后端共享相同的高质量图像插值 +// 入参: dst 目标图像, src 源图像, m 像素坐标变换 +func drawRasterImage(dst draw.Image, src image.Image, m RasterMatrix) { + draw.CatmullRom.Transform(dst, f64.Aff3{m[0], m[2], m[4], m[1], m[3], m[5]}, src, src.Bounds(), draw.Over, nil) +} + +// PixelTransform 将页面毫米变换转换为像素变换,保留尺寸取整后的原点位置 +// 入参: m 局部到页面变换, height 目标像素高度 +// 返回: RasterMatrix 局部到像素变换 +func (p *RasterPage) PixelTransform(m RasterMatrix, height int) RasterMatrix { + scale := p.DPI / 25.4 + for i := range m { + m[i] *= scale + } + m[5] += float64(height) - p.Height*scale + return m +} diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 2d8dc60..4f34da1 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -52,6 +52,7 @@ type Renderer struct { decodeImages bool pageText *PageText textOnly bool + backends RenderBackends } // RendererOption 渲染器配置选项 @@ -64,6 +65,13 @@ func (r *Renderer) SetFontFS(fsys ...fs.FS) { r.resetFontCache() } +// FontSources 返回外部字体配置副本,供自定义编译器和导出后端读取 +// 内嵌字体通过Reader.FontData读取,系统字体由各实现自行匹配 +// 返回: []string 字体目录, []fs.FS 字体文件系统 +func (r *Renderer) FontSources() ([]string, []fs.FS) { + return append([]string(nil), r.fontDirs...), append([]fs.FS(nil), r.fontFS...) +} + // resetFontCache 重置字体匹配、加载和字形缓存 func (r *Renderer) resetFontCache() { r.fontMap = make(map[string]*canvas.FontFamily) @@ -78,13 +86,11 @@ func (r *Renderer) resetFontCache() { r.textGlyphPathCache = make(map[textGlyphPathCacheKey]textGlyphPathCacheValue) } -// RenderPage 渲染特定页面内容 +// RenderPage 编译页面为与绘图库无关的只读绘制数据 // 入参: page 页面内容 -// 返回: *canvas.Canvas 画布实例, error 错误信息 -func (r *Renderer) RenderPage(page *PageContent) (*canvas.Canvas, error) { - renderer := *r - renderer.decodeImages = true - return renderer.renderPage(page) +// 返回: *RasterPage 绘制页面, error 错误信息 +func (r *Renderer) RenderPage(page *PageContent) (*RasterPage, error) { + return r.CompilePage(page) } // renderPage 渲染特定页面内容 @@ -125,10 +131,10 @@ func (r *Renderer) GetPageBox(page *PageContent) (Box, error) { return ParseBox(boxStr) } -// RenderPageToContext 渲染页面到指定上下文 +// renderCanvasContext 渲染页面到默认解释器上下文 // 入参: ctx 画布上下文, page 页面内容 // 返回: error 错误信息 -func (r *Renderer) RenderPageToContext(ctx *canvas.Context, page *PageContent) error { +func (r *Renderer) renderCanvasContext(ctx *canvas.Context, page *PageContent) error { renderer := *r renderer.decodeImages = true return renderer.renderPageToContext(ctx, page, true) @@ -240,8 +246,8 @@ func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { // RenderPageByIndex 按索引渲染页面 // 入参: index 页面索引,从0开始 -// 返回: *canvas.Canvas 画布实例, error 错误信息 -func (r *Renderer) RenderPageByIndex(index int) (*canvas.Canvas, error) { +// 返回: *RasterPage 绘制页面, error 错误信息 +func (r *Renderer) RenderPageByIndex(index int) (*RasterPage, error) { page, err := r.Reader.PageContentByIndex(index) if err != nil { return nil, err diff --git a/ofdgo_renderer_bounds.go b/ofdgo_renderer_bounds.go index dedff3f..98d277e 100644 --- a/ofdgo_renderer_bounds.go +++ b/ofdgo_renderer_bounds.go @@ -14,12 +14,7 @@ package ofdgo -import ( - "fmt" - "image" - - "github.com/tdewolff/canvas" -) +import "fmt" // ObjectBounds 获取文字、路径、图片或复合对象在页面坐标中的轴对齐范围,不修改对象 // 文字采用字形范围,路径包含描边与裁剪,图片采用裁剪后的几何范围,不解码像素或排除透明像素 @@ -28,7 +23,8 @@ import ( // 入参: object 图形对象, drawParam 图层绘制参数标识,无继承时为空 // 返回: Box 毫米坐标范围, error 错误信息 func (r *Renderer) ObjectBounds(object GraphicObject, drawParam string) (Box, error) { - return r.measureObject(object, drawParam, &boundsRenderer{}) + result, err := r.MeasureObject(object, MeasureOptions{Defaults: r.drawParamDefaults(drawParam, nil)}) + return result.Bounds, err } // ObjectContour 页面坐标中的填充轮廓,Path为SVG路径,EvenOdd表示奇偶填充 @@ -57,7 +53,7 @@ func (r *Renderer) AnnotationGeometry(annotation Annotation) (Box, []ObjectConto continue } if path == "" { - path = canvas.Rectangle(region.W, region.H).Translate(region.X, region.Y).ToSVG() + path = fmt.Sprintf("M%g %gH%gV%gH%gZ", region.X, region.Y, region.X+region.W, region.Y+region.H, region.X) } box = unionTextBox(box, region) contours = append(contours, ObjectContour{Path: path}) @@ -82,161 +78,16 @@ func (r *Renderer) ObjectGeometry(object GraphicObject, drawParam string) (Box, if object.Type != "PathObject" && object.Type != "ImageObject" && object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { return Box{}, nil, fmt.Errorf("contours require a path, image or composite object") } - bounds := &boundsRenderer{collect: true} - box, err := r.measureObject(object, drawParam, bounds) - return box, bounds.contours, err + result, err := r.MeasureObject(object, MeasureOptions{Defaults: r.drawParamDefaults(drawParam, nil), Contours: true}) + return result.Bounds, result.Contours, err } -// measureObject 复用渲染逻辑收集对象范围与可选轮廓 -// 入参: object 对象, drawParam 图层绘制参数标识, bounds 度量目标 -// 返回: Box 毫米范围, error 错误信息 -func (r *Renderer) measureObject(object GraphicObject, drawParam string, bounds *boundsRenderer) (Box, error) { - if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" && object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { - return Box{}, fmt.Errorf("unsupported object type %q", object.Type) +// MeasureObject 使用当前页面编译器度量对象,保持绘制与选区的字体和几何语义一致 +// 入参: object 图形对象, options 度量上下文 +// 返回: ObjectMeasurement 度量结果, error 错误信息 +func (r *Renderer) MeasureObject(object GraphicObject, options MeasureOptions) (ObjectMeasurement, error) { + if r.backends.Compiler == nil { + return ObjectMeasurement{}, fmt.Errorf("%w: object measurement", ErrBackendUnavailable) } - boundary, ctm := editorGeometry(object) - if _, err := creationBox(boundary); err != nil { - return Box{}, err - } - if ctm != "" { - if _, err := creationNumbers(ctm, 6); err != nil { - return Box{}, err - } - } - renderer := *r - renderer.pageText = nil - renderer.textOnly = false - defaults := renderer.drawParamDefaults(drawParam, nil) - ctx := canvas.NewContext(bounds) - switch object.Type { - case "TextObject": - renderer.pageText = &PageText{} - renderer.textOnly = true - renderer.renderText(ctx, object.TextObject, 0, defaults, nil, false, nil) - var box Box - for _, run := range renderer.pageText.Runs { - for _, glyph := range run.Boxes { - box = unionTextBox(box, glyph) - } - } - return box, nil - case "PathObject": - obj := object.PathObject - defaults = renderer.drawParamDefaults(obj.DrawParam, defaults) - obj.DrawParam = "" - if defaults != nil { - style := *defaults - style.FillColor = boundsColor(style.FillColor) - style.StrokeColor = (*StrokeColor)(boundsColor((*FillColor)(style.StrokeColor))) - defaults = &style - } - obj.FillColor = boundsColor(obj.FillColor) - obj.StrokeColor = (*StrokeColor)(boundsColor((*FillColor)(obj.StrokeColor))) - renderer.renderPath(ctx, obj, 0, defaults, nil, false, nil) - case "ImageObject": - renderer.measureImage(ctx, object.ImageObject, 0, nil, false, nil) - case "CompositeObject", "CompositeGraphicUnit": - renderer.renderCompositeGraphicUnit(ctx, object.CompositeGraphicUnit, 0, defaults, nil, false, nil) - } - return bounds.box, nil + return r.backends.Compiler.MeasureObject(r, object, options) } - -// measureImage 复用图片变换与裁剪语义度量范围,不读取像素数据 -// 入参: ctx 度量画布, obj 图片, pageH 页面高度, parentCTM 父变换, boundaryInCTM 边界是否参与变换, parentClip 父裁剪 -func (r *Renderer) measureImage(ctx *canvas.Context, obj ImageObject, pageH float64, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) { - if obj.Visible != nil && !*obj.Visible || obj.Alpha != nil && *obj.Alpha == 0 { - return - } - box, _ := ParseBox(obj.Boundary) - ctm := NewMatrix(obj.CTM) - if obj.CTM == "" { - ctm = Matrix{a: box.W, d: box.H} - } - m := TranslationMatrix(box.X, box.Y).Multiply(ctm) - if parentCTM != nil { - if boundaryInCTM { - m = parentCTM.Multiply(m) - } else { - m = TranslationMatrix(box.X, box.Y).Multiply(*parentCTM).Multiply(ctm) - } - } - p := canvas.Rectangle(1, 1).Transform(canvas.Matrix{{m.a, m.c, m.e}, {-m.b, -m.d, pageH - m.f}}) - clip := intersectClipPath(parentClip, r.buildObjectClipPath(obj.Clips, pageH, box.X, box.Y, ctm, parentCTM, boundaryInCTM)) - ctx.Renderer.(*boundsRenderer).add(applyClipPath(p, clip)) - if obj.Border != nil { - border := *obj.Border - border.BorderColor = (*StrokeColor)(boundsColor((*FillColor)(border.BorderColor))) - obj.Border = &border - r.renderImageBorder(ctx, obj, box, pageH, parentCTM, boundaryInCTM, clip) - } -} - -// boundsColor 以底纹的基础颜色和透明度度量轮廓,不修改原画刷 -// 入参: color 填充或描边颜色 -// 返回: *FillColor 用于度量的颜色 -func boundsColor(color *FillColor) *FillColor { - if color == nil || color.Pattern == nil { - return color - } - return &FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha} -} - -// boundsRenderer 收集绘制范围,不分配页面像素或合并独立图形轮廓 -type boundsRenderer struct { - box Box - collect bool - contours []ObjectContour -} - -// Size 返回不限定边界的度量画布尺寸 -// 返回: float64 宽度, float64 高度 -func (r *boundsRenderer) Size() (float64, float64) { - return 0, 0 -} - -// add 合并路径的精确曲线范围并转换为向下的纵轴 -// 入参: path 绘制路径 -func (r *boundsRenderer) add(path *canvas.Path) { - r.addContour(path, false) -} - -// addContour 合并范围并按需保留独立填充区域 -// 入参: path 绘制路径, evenOdd 是否使用奇偶规则 -func (r *boundsRenderer) addContour(path *canvas.Path, evenOdd bool) { - if path.Empty() { - return - } - rect := path.Bounds() - r.box = unionTextBox(r.box, Box{X: rect.X0, Y: -rect.Y1, W: rect.W(), H: rect.H()}) - if r.collect { - outline := &canvas.Path{} - for _, subpath := range path.Split() { - subpath = subpath.Copy() - subpath.Close() - outline = outline.Append(subpath) - } - r.contours = append(r.contours, ObjectContour{Path: outline.Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), EvenOdd: evenOdd}) - } -} - -// RenderPath 收集填充与描边范围,保持线帽、连接和虚线语义 -// 入参: path 路径, style 绘制样式, m 变换矩阵 -func (r *boundsRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { - if style.HasFill() { - 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) - r.add(p.Transform(m)) - } -} - -// RenderText 收集绘制文字范围 -// 入参: text 文字, m 变换矩阵 -func (r *boundsRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { - text.RenderTo(r, m, 0) -} - -// RenderImage 度量不展开底纹图片 -// 入参: img 图片, m 变换矩阵 -func (r *boundsRenderer) RenderImage(img image.Image, m canvas.Matrix) {} diff --git a/ofdgo_renderer_output.go b/ofdgo_renderer_output.go index cfbacb3..ca87bab 100644 --- a/ofdgo_renderer_output.go +++ b/ofdgo_renderer_output.go @@ -16,8 +16,6 @@ package ofdgo import ( "archive/zip" - "bufio" - "bytes" "fmt" "image" "image/jpeg" @@ -26,13 +24,7 @@ import ( "strconv" "strings" - "github.com/tdewolff/canvas" - "github.com/tdewolff/canvas/renderers/pdf" - "github.com/tdewolff/canvas/renderers/ps" - "github.com/tdewolff/canvas/renderers/rasterizer" - "github.com/tdewolff/canvas/renderers/svg" "golang.org/x/image/draw" - "golang.org/x/image/math/f64" ) // RenderTo 按格式渲染单页,JPEG使用95画质 @@ -178,56 +170,18 @@ func (r *Renderer) exportProgress(completed, total int) error { return nil } -// rasterRenderer 保留页面物理尺寸的光栅渲染器 -type rasterRenderer struct { - *rasterizer.Rasterizer - width, height float64 - dpmm float64 - linear bool -} - -// Size 返回页面物理尺寸 -// 返回: float64 宽度, float64 高度 -func (r *rasterRenderer) Size() (float64, float64) { - return r.width, r.height -} - -// RenderImage 使用底层图片完成平移和缩放 -// 入参: img 图片对象, m 图片变换矩阵 -func (r *rasterRenderer) RenderImage(img image.Image, m canvas.Matrix) { - if !r.linear || m[0][1] != 0 || m[1][0] != 0 { - r.Rasterizer.RenderImage(img, m) - return - } - origin := m.Dot(canvas.Point{Y: float64(img.Bounds().Dy())}).Mul(r.dpmm) - m = m.Scale(r.dpmm, r.dpmm) - transform := f64.Aff3{m[0][0], -m[0][1], origin.X, -m[1][0], m[1][1], float64(r.Bounds().Dy()) - origin.Y} - draw.CatmullRom.Transform(r.Image, transform, img, img.Bounds(), draw.Over, nil) -} - // RenderToImage 渲染为光栅图 // 入参: page 页面内容 // 返回: image.Image 图像对象, error 错误信息 func (r *Renderer) RenderToImage(page *PageContent) (image.Image, error) { - box, err := r.GetPageBox(page) + if r.backends.Raster == nil { + return nil, fmt.Errorf("raster: %w", ErrBackendUnavailable) + } + scene, err := r.CompilePage(page) if err != nil { return nil, err } - dpmm := r.DPI / 25.4 - colorSpace := canvas.DefaultColorSpace - _, linear := colorSpace.(canvas.LinearColorSpace) - raster := &rasterRenderer{ - Rasterizer: rasterizer.New(box.W, box.H, canvas.DPMM(dpmm), colorSpace), - width: box.W, - height: box.H, - dpmm: dpmm, - linear: linear, - } - if err := r.RenderPageToContext(canvas.NewContext(raster), page); err != nil { - return nil, err - } - raster.Close() - return raster.Image, nil + return r.backends.Raster.Render(scene) } // RenderToPNG 渲染为PNG,像素尺寸由DPI决定 @@ -281,31 +235,8 @@ func fillWhiteBackground(img image.Image) image.Image { // 入参: page 页面内容, writer 输出流 // 返回: error 错误信息 func (r *Renderer) RenderToSVG(page *PageContent, writer io.Writer) error { - c, err := r.renderPage(page) - if err != nil { - return err - } - buffer := bufio.NewWriter(writer) - renderer := svg.New(buffer, c.W, c.H, nil) - c.RenderTo(renderer) - if err := renderer.Close(); err != nil { - return err - } - return buffer.Flush() -} - -// replacePDFProducer 替换PDF的Producer属性 -// 入参: data PDF字节数据 -// 返回: []byte 替换后的PDF字节数据 -func replacePDFProducer(data []byte) []byte { - old := []byte("/Producer(tdewolff/canvas)") - idx := bytes.LastIndex(data, old) - if idx < 0 { - return data - } - dst := []byte("/Producer(xiaoqidun/ofdgo)") - copy(data[idx:idx+len(old)], dst) - return data + _, err := r.renderSVG(page, writer, SVGEmbedded) + return err } // RenderToPDF 渲染为PDF @@ -316,7 +247,7 @@ func (r *Renderer) RenderToPDF(page *PageContent, writer io.Writer) error { if err != nil { return err } - pages := []pdfPage{{Content: page, Box: box}} + pages := []RenderDocumentPage{{Content: page, Box: box}} return r.renderPDFPages(pages, writer, nil) } @@ -324,19 +255,10 @@ func (r *Renderer) RenderToPDF(page *PageContent, writer io.Writer) error { // 入参: page 页面内容, writer 输出流 // 返回: error 错误信息 func (r *Renderer) RenderToEPS(page *PageContent, writer io.Writer) error { - c, err := r.renderPage(page) - if err != nil { - return err + if r.backends.EPS == nil { + return fmt.Errorf("eps: %w", ErrBackendUnavailable) } - options := ps.DefaultOptions - options.Format = ps.EncapsulatedPostScript - buffer := bufio.NewWriter(writer) - renderer := ps.New(buffer, c.W, c.H, &options) - c.RenderTo(renderer) - if err := renderer.Close(); err != nil { - return err - } - return buffer.Flush() + return r.backends.EPS.RenderEPS(r, page, writer) } // RenderToMultiPagePDF 将文档页面导出为多页PDF @@ -354,7 +276,7 @@ func (r *Renderer) RenderToMultiPagePDF(writer io.Writer, indices ...int) error if err := r.exportProgress(0, len(indices)); err != nil { return err } - pages := make([]pdfPage, len(indices)) + pages := make([]RenderDocumentPage, len(indices)) for i, index := range indices { page, err := r.Reader.PageContent(doc.Pages.Page[index]) if err != nil { @@ -364,7 +286,7 @@ func (r *Renderer) RenderToMultiPagePDF(writer io.Writer, indices ...int) error if err != nil { return fmt.Errorf("failed to read page %d area: %w", index+1, err) } - pages[i] = pdfPage{Content: page, Box: box} + pages[i] = RenderDocumentPage{Content: page, Box: box} } return r.renderPDFPages(pages, writer, r.OnExportProgress) } @@ -379,59 +301,23 @@ func (r *Renderer) RenderPagesToPDF(contents []*PageContent, writer io.Writer) e if err := r.exportProgress(0, len(contents)); err != nil { return err } - pages := make([]pdfPage, len(contents)) + pages := make([]RenderDocumentPage, 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} + pages[i] = RenderDocumentPage{Content: page, Box: box} } return r.renderPDFPages(pages, writer, r.OnExportProgress) } -// renderPDFPages 渲染页面并复用调用方的字节缓冲 +// renderPDFPages 将已解析页面交给指定PDF后端 // 入参: pages 页面列表, writer 输出流, progress 页面处理进度 // 返回: error 错误信息 -func (r *Renderer) renderPDFPages(pages []pdfPage, writer io.Writer, progress func(int, int) error) error { - navigation := newPDFNavigation(r, r.Reader.doc, pages) - buf, direct := writer.(*bytes.Buffer) - if !direct { - buf = &bytes.Buffer{} +func (r *Renderer) renderPDFPages(pages []RenderDocumentPage, writer io.Writer, progress func(int, int) error) error { + if r.backends.PDF == nil { + return fmt.Errorf("pdf: %w", ErrBackendUnavailable) } - start := buf.Len() - p := pdf.New(buf, pages[0].Box.W, pages[0].Box.H, nil) - renderer := &pdfRenderer{PDF: p, glyphPaths: make(map[*canvas.Path]*canvas.Path)} - var info DocInfo - if docInfo, err := r.Reader.DocInfo(); err == nil { - info = *docInfo - } - p.SetInfo(info.Title, info.Subject, "", info.Author, "xiaoqidun/ofdgo") - for i, page := range pages { - if i > 0 { - p.NewPage(page.Box.W, page.Box.H) - } - navigation.apply(p, i) - if err := r.renderPageToContext(canvas.NewContext(renderer), page.Content, true); err != nil { - 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) - return err - } - } - } - if err := p.Close(); err != nil { - buf.Truncate(start) - return err - } - data := replacePDFProducer(buf.Bytes()[start:]) - if direct { - return nil - } - _, err := writer.Write(data) - return err + return r.backends.PDF.RenderPDF(r, pages, writer, progress) } diff --git a/ofdgo_renderer_preview.go b/ofdgo_renderer_preview.go new file mode 100644 index 0000000..b853074 --- /dev/null +++ b/ofdgo_renderer_preview.go @@ -0,0 +1,57 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "crypto/sha256" + "fmt" + "io" +) + +// PreviewOptions 指定页面显示方式,光栅预览不提供SVG对象分组 +type PreviewOptions struct { + Raster bool + Objects bool +} + +// RenderPreview 生成统一SVG容器及独立资源,原生程序与WebUI使用相同绘制入口 +// 入参: page 页面内容, writer 输出流, options 显示方式 +// 返回: SVGResources 需由调用方映射地址的资源, error 错误信息 +func (r *Renderer) RenderPreview(page *PageContent, writer io.Writer, options PreviewOptions) (SVGResources, error) { + if !options.Raster { + if options.Objects { + return r.RenderToSVGWithObjects(page, writer) + } + return r.RenderToSVGWithResources(page, writer) + } + if options.Objects { + return SVGResources{}, fmt.Errorf("raster preview does not provide SVG object groups") + } + box, err := r.GetPageBox(page) + if err != nil { + return SVGResources{}, err + } + var pixels bytes.Buffer + if err := r.RenderToPNG(page, &pixels); err != nil { + return SVGResources{}, err + } + name := fmt.Sprintf("ofdgo-image-%x", sha256.Sum256(pixels.Bytes())) + _, err = fmt.Fprintf(writer, ``, box.W, box.H, box.W, box.H, box.W, box.H, name) + if err != nil { + return SVGResources{}, err + } + return SVGResources{Images: []SVGImage{{Name: name, MIME: "image/png", Data: pixels.Bytes()}}}, nil +} diff --git a/ofdgo_renderer_svg.go b/ofdgo_renderer_svg.go index cd2267d..c4dbb50 100644 --- a/ofdgo_renderer_svg.go +++ b/ofdgo_renderer_svg.go @@ -15,19 +15,8 @@ package ofdgo import ( - "bufio" - "bytes" - "crypto/sha256" "fmt" - "html" - "image" - "image/png" "io" - "strings" - - "github.com/tdewolff/canvas" - canvasimage "github.com/tdewolff/canvas/image" - "github.com/tdewolff/canvas/renderers/svg" ) // SVGFont SVG引用的字体资源,Name为CSS字体族标识,Data为只读字体数据 @@ -51,33 +40,11 @@ type SVGResources struct { Images []SVGImage } -// svgResourceRenderer 分离外部资源的SVG渲染器 -type svgResourceRenderer struct { - *svg.SVG - renderer *Renderer - writer io.Writer - fonts []SVGFont - images []SVGImage - imageNames map[image.Image]string - seen map[string]bool - styles strings.Builder - err error - objects map[*GraphicObject]string - objectStack []svgObjectGroup -} - -// svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员 -type svgObjectGroup struct { - path string - next int - composite bool -} - // 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, false) + resources, err := r.renderSVG(page, writer, SVGExternalFonts) return resources.Fonts, err } @@ -85,170 +52,22 @@ func (r *Renderer) RenderToSVGWithFonts(page *PageContent, writer io.Writer) ([] // 入参: page 页面内容, writer 输出流 // 返回: SVGResources 可跨页面复用的资源, error 错误信息 func (r *Renderer) RenderToSVGWithResources(page *PageContent, writer io.Writer) (SVGResources, error) { - return r.renderSVGResources(page, writer, true, false) + return r.renderSVG(page, writer, SVGExternalResources) } // RenderToSVGWithObjects 渲染SVG并为页面对象和注解添加data-ofd-object分组,注解标识使用annotation:前缀 // 入参: page 页面内容, writer 输出流 // 返回: SVGResources 字体和图片资源, error 错误信息 func (r *Renderer) RenderToSVGWithObjects(page *PageContent, writer io.Writer) (SVGResources, error) { - return r.renderSVGResources(page, writer, true, true) + return r.renderSVG(page, writer, SVGObjects) } -// renderSVGResources 渲染SVG并收集外部资源 -// 入参: page 页面内容, writer 输出流, images 是否分离图片, objects 是否标识页面直接对象 -// 返回: SVGResources 外部资源, error 错误信息 -func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, images, objects bool) (SVGResources, error) { - box, err := r.GetPageBox(page) - if err != nil { - return SVGResources{}, err +// renderSVG 将SVG输出及对象分组交给指定后端 +// 入参: page 页面内容, writer 输出流, mode 资源封装方式 +// 返回: SVGResources 字体和图片资源, error 输出错误 +func (r *Renderer) renderSVG(page *PageContent, writer io.Writer, mode SVGMode) (SVGResources, error) { + if r.backends.SVG == nil { + return SVGResources{}, fmt.Errorf("svg: %w", ErrBackendUnavailable) } - options := svg.DefaultOptions - options.EmbedFonts = false - buffer := bufio.NewWriter(writer) - s := &svgResourceRenderer{ - SVG: svg.New(buffer, box.W, box.H, &options), - renderer: r, - writer: buffer, - seen: make(map[string]bool), - } - if images { - s.imageNames = make(map[image.Image]string) - } - if objects { - s.objects = make(map[*GraphicObject]string) - for i := range page.Content.Layer { - for j := range page.Content.Layer[i].Objects { - object := &page.Content.Layer[i].Objects[j] - s.objects[object] = editorObjectID(*object) - } - } - if err := r.renderPageToContext(canvas.NewContext(s), page, true); err != nil { - return SVGResources{}, err - } - } else { - c, err := r.renderPage(page) - if err != nil { - return SVGResources{}, err - } - c.RenderTo(s) - } - if s.err != nil { - return SVGResources{}, s.err - } - s.SetCustomStyle(s.styles.String()) - if err := s.Close(); err != nil { - return SVGResources{}, err - } - return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush() -} - -// beginObject 标记直接对象及复合或注解成员,模板图元不单独标记 -// 入参: object 图形对象 -// 返回: bool 是否写入分组 -func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool { - id := s.objects[object] - attribute := "data-ofd-object" - if id == "" { - if len(s.objectStack) == 0 || !s.objectStack[len(s.objectStack)-1].composite { - return false - } - parent := &s.objectStack[len(s.objectStack)-1] - id = fmt.Sprintf("%s/%d", parent.path, parent.next) - parent.next++ - attribute = "data-ofd-child" - } - s.objectStack = append(s.objectStack, svgObjectGroup{path: id, composite: object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit"}) - fmt.Fprintf(s.writer, ``, attribute, html.EscapeString(id)) - return true -} - -// endObject 结束对象分组 -func (s *svgResourceRenderer) endObject() { - s.objectStack = s.objectStack[:len(s.objectStack)-1] - fmt.Fprint(s.writer, ``) -} - -// beginAnnotation 标记完整注解外观,内部图元保持原有层级和绘制顺序 -// 入参: id 注解标识 -// 返回: bool 是否写入分组 -func (s *svgResourceRenderer) beginAnnotation(id string) bool { - if s.objects == nil { - return false - } - s.objectStack = append(s.objectStack, svgObjectGroup{path: "annotation:" + id, composite: true}) - fmt.Fprintf(s.writer, ``, html.EscapeString(id)) - return true -} - -// skipObjects 为不可见资源保留内部成员序号 -// 入参: count 直接成员数量 -func (s *svgResourceRenderer) skipObjects(count int) { - if len(s.objectStack) > 0 { - s.objectStack[len(s.objectStack)-1].next += count - } -} - -// 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 *svgResourceRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { - if text.Empty() { - return - } - font := text.MostCommonFontFace().Font - resource, ok := s.renderer.svgFontCache[font] - if !ok { - data := font.SFNT.Write() - resource = SVGFont{ - Name: fmt.Sprintf("ofdgo-%x-%d", sha256.Sum256(data), font.Style()), - Weight: font.Style().CSS(), - Style: "normal", - Data: data, - } - if font.Style().Italic() { - resource.Style = "italic" - } - s.renderer.svgFontCache[font] = resource - } - if !s.seen[resource.Name] { - s.seen[resource.Name] = true - s.fonts = append(s.fonts, resource) - fmt.Fprintf(&s.styles, ".%s{font-family:%s!important}", resource.Name, resource.Name) - } - s.SetClass(resource.Name) - s.SVG.RenderText(text, m) - s.SetClass() + return r.backends.SVG.RenderSVG(r, page, writer, mode) } diff --git a/ofdgo_search.go b/ofdgo_search.go index cbd243f..b41757f 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -21,8 +21,6 @@ import ( "strings" "unicode" "unicode/utf8" - - "github.com/tdewolff/canvas" ) // textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片 @@ -92,22 +90,14 @@ type TextMatch struct { Boxes []Box `json:"boxes"` } -// PageText 提取页面及启用注释的原文,复用当前字体配置和渲染位置计算 +// PageText 提取页面及启用注释的原文,复用当前编译器的字体和几何语义 // 入参: page 页面内容 // 返回: *PageText 页面文字, error 错误信息 func (r *Renderer) PageText(page *PageContent) (*PageText, error) { - box, err := r.GetPageBox(page) - if err != nil { - return nil, err + if r.backends.Compiler == nil { + return nil, fmt.Errorf("%w: page text", ErrBackendUnavailable) } - renderer := *r - renderer.OnPageText = nil - renderer.pageText = &PageText{} - renderer.textOnly = true - if err := renderer.renderPageToContext(canvas.NewContext(canvas.New(box.W, box.H)), page, false); err != nil { - return nil, err - } - return renderer.pageText, nil + return r.backends.Compiler.PageText(r, page) } // PageTextByIndex 按页面索引提取文字,避免解析独立路径和图片图元 @@ -230,17 +220,6 @@ func textGlyphSpans(runes []rune, transforms map[int]textGlyphTransform, offset return spans } -// textPathBox 将字形轮廓转换为页面区域 -// 入参: path 字形轮廓, pageH 页面高度 -// 返回: Box 字符区域 -func textPathBox(path *canvas.Path, pageH float64) Box { - if path.Empty() { - return Box{} - } - bounds := path.Bounds() - return Box{X: bounds.X0, Y: pageH - bounds.Y1, W: bounds.X1 - bounds.X0, H: bounds.Y1 - bounds.Y0} -} - // unionTextBox 合并同一字符的字形区域 // 入参: a 已有区域, b 新区域 // 返回: Box 合并区域 @@ -254,36 +233,3 @@ func unionTextBox(a, b Box) Box { x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y) return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y} } - -// addSpan 收集字形选择区域,同一原文区间的多个字形合并为一项 -// 入参: start 起始字符, end 结束字符, bounds 字形布局区域, m 字形变换, clip 裁剪路径, pageH 页面高度 -func (run *TextRun) addSpan(start, end int, bounds canvas.Rect, m canvas.Matrix, clip *canvas.Path, pageH float64) { - if m.Det() == 0 { - return - } - if clip != nil { - path := canvas.Rectangle(bounds.W(), bounds.H()).Translate(bounds.X0, bounds.Y0).Transform(m) - path = applyClipPath(path, clip) - if path.Empty() { - return - } - bounds = path.Transform(m.Inv()).Bounds() - } - if bounds.W() < 0 || bounds.H() <= 0 { - return - } - m = canvas.Matrix{{1, 0, 0}, {0, -1, pageH}}.Mul(m).Translate(bounds.X0, bounds.Y1).Scale(bounds.W(), -bounds.H()) - span := TextSpan{Start: start, End: end} - if n := len(run.Spans); n > 0 && run.Spans[n-1].Start == start && run.Spans[n-1].End == end { - span = run.Spans[n-1] - v := span.Matrix - previous := canvas.Matrix{{v[0], v[2], v[4]}, {v[1], v[3], v[5]}} - if previous.Det() != 0 { - bounds = canvas.Rect{X1: 1, Y1: 1}.Add(canvas.Rect{X1: 1, Y1: 1}.Transform(previous.Inv().Mul(m))) - m = previous.Translate(bounds.X0, bounds.Y0).Scale(bounds.W(), bounds.H()) - } - run.Spans = run.Spans[:n-1] - } - span.Matrix = [6]float64{m[0][0], m[1][0], m[0][1], m[1][1], m[0][2], m[1][2]} - run.Spans = append(run.Spans, span) -} diff --git a/ofdgo_shape.go b/ofdgo_shape.go index afe0c01..4d1802f 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -55,9 +55,9 @@ const ( ) // NewShape 创建使用标准紧缩路径的图形,默认黑色描边、不填充 -// 可调整返回对象的颜色、线宽等属性后通过 Editor.AddObject 写入 +// 可调整返回对象的颜色、线宽等属性后通过Editor.AddObject写入 // 水平、垂直直线使用正尺寸边界,路径端点不变 -// 入参: kind 图形类型, box 图形范围;直线从 (X,Y) 到 (X+W,Y+H),W、H 可为负或零 +// 入参: kind 图形类型, box 图形范围;直线从(X,Y)到(X+W,Y+H),W、H可为负或零 // 返回: PathObject 路径对象, error 错误信息 func NewShape(kind ShapeKind, box Box) (PathObject, error) { if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) {