feat(后端架构): 接入GoGPU渲染,支持后端切换
build.yaml / build (push) Successful in 43s

This commit is contained in:
xiaoqidun committed 2026-09-23 13:42:55 +08:00
1 parent 87af84e4cd
commit 73472c5d4b
36 files changed
+2052 -609

No files matched your search

+20
View File
@@ -431,6 +431,26 @@
</div>
</div>
</section>
<section id="renderPanel" class="render-panel" aria-label="渲染">
<div class="panel-row">
<label class="panel-title compact" for="renderBackend">渲染</label>
<select id="renderBackend" title="渲染后端" disabled></select>
</div>
<div class="render-mode-row">
<span id="renderModeLabel">显示</span>
<div class="render-controls">
<div class="render-modes" role="group" aria-labelledby="renderModeLabel">
<button id="renderVectorButton" class="small-button" type="button" aria-pressed="true" title="矢量显示" disabled>矢量</button>
<button id="renderRasterButton" class="small-button" type="button" aria-pressed="false" title="位图显示" disabled>位图</button>
</div>
<select id="renderDPI" aria-label="精度" title="位图精度" disabled><option value="96">96 dpi</option><option value="150" selected>150 dpi</option><option value="300">300 dpi</option></select>
</div>
</div>
<details class="render-support">
<summary>详情</summary>
<dl><div><dt>显示</dt><dd id="renderDisplay">SVG</dd></div><div><dt>编辑</dt><dd>SVG</dd></div><div><dt>编译</dt><dd id="renderCompiler"></dd></div><div><dt>图片</dt><dd id="renderImages"></dd></div><div><dt>SVG</dt><dd id="renderSVG"></dd></div><div><dt>PDF</dt><dd id="renderPDF"></dd></div><div><dt>EPS</dt><dd id="renderEPS"></dd></div></dl>
</details>
</section>
<div class="panel-row">
<div class="panel-title compact">文档</div>
<button id="infoButton" class="small-button" type="button" aria-label="修改文档信息" aria-haspopup="dialog" aria-controls="infoPanel" hidden>修改</button>
+100 -1
View File
@@ -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;
+107 -5
View File
@@ -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;
+3 -1
View File
@@ -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 });
}
}
+3
View File
@@ -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
+14
View File
@@ -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=
+55 -8
View File
@@ -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 {
+30 -4
View File
@@ -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 错误信息
+1
View File
@@ -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 {
+201
View File
@@ -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)
}
+106
View File
@@ -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
}
+164
View File
@@ -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})
}
+312
View File
@@ -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)
}
+137
View File
@@ -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
}
@@ -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 {
+211
View File
@@ -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, `<g %s="%s">`, attribute, html.EscapeString(id))
return true
}
// endObject 结束对象分组
func (s *svgResourceRenderer) endObject() {
s.objectStack = s.objectStack[:len(s.objectStack)-1]
fmt.Fprint(s.writer, `</g>`)
}
// 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, `<g data-ofd-object="annotation:%s">`, 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, `<image transform="%s" width="%d" height="%d" xlink:href="%s"/>`, 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()
}
+21
View File
@@ -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{},
}
}
+1 -1
View File
@@ -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++ {
+5 -1
View File
@@ -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
+5 -7
View File
@@ -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))
+23 -12
View File
@@ -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)
})
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 允许图片及纯图片资源独立调整宽高,内联组合仍保持比例
+20
View File
@@ -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 矩阵对象
+42
View File
@@ -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
}
+101
View File
@@ -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}
}
+202
View File
@@ -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
}
+40
View File
@@ -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
}
+16 -10
View File
@@ -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
+13 -162
View File
@@ -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) {}
+20 -134
View File
@@ -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)
}
+57
View File
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><image width="%g" height="%g" preserveAspectRatio="none" xlink:href="%s"/></svg>`, 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
}
+10 -191
View File
@@ -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, `<g %s="%s">`, attribute, html.EscapeString(id))
return true
}
// endObject 结束对象分组
func (s *svgResourceRenderer) endObject() {
s.objectStack = s.objectStack[:len(s.objectStack)-1]
fmt.Fprint(s.writer, `</g>`)
}
// 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, `<g data-ofd-object="annotation:%s">`, 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, `<image transform="%s" width="%d" height="%d" xlink:href="%s"/>`, 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)
}
+4 -58
View File
@@ -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)
}
+2 -2
View File
@@ -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) {