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编译图片SVGPDFEPS
+
+
文档
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) {