mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 15:06:46 +08:00
This commit is contained in:
1 parent
87af84e4cd
commit
73472c5d4b
36 files changed
+2052
-609
No files matched your search
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 错误信息
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 允许图片及纯图片资源独立调整宽高,内联组合仍保持比例
|
||||
|
||||
@@ -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 矩阵对象
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user