feat(打印能力): 新增打印并优化界面布局
build.yaml / build (push) Successful in 46s

This commit is contained in:
xiaoqidun committed 2026-10-24 20:53:46 +08:00
1 parent 799041171b
commit 0ae9ad6562
22 files changed
+2220 -21

No files matched your search

+2 -2
View File
@@ -18,10 +18,10 @@ import "embed"
// FS WebUI嵌入静态文件
//
//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_util.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_util.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
var FS embed.FS
// NativeFS WebView嵌入静态文件
//
//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_util.js
//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_util.js
var NativeFS embed.FS
+38
View File
@@ -31,6 +31,7 @@
<button id="saveAsButton" class="icon-button" type="button" title="另存" disabled><span class="save-as-icon"></span></button>
<button id="editButton" class="icon-button" type="button" title="编辑" data-pressed="false" disabled><span class="edit-icon"></span></button>
<button id="convertButton" class="icon-button" type="button" title="转换"><span class="convert-icon"></span></button>
<button id="printButton" class="icon-button" type="button" title="打印" disabled><span class="print-icon"></span></button>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" data-pressed="true"><span class="pages-icon"></span></button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" data-pressed="true"><span class="info-icon"></span></button>
</div>
@@ -671,6 +672,42 @@
<div class="form-actions"><div id="encryptionStatus" class="form-status"></div><button id="encryptionCancel" class="button" type="button">取消</button><button id="encryptionSubmit" class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="printPanel" class="form-dialog print-dialog">
<form id="printForm">
<h2 id="printTitle" class="form-title">打印</h2>
<div class="print-content">
<fieldset id="printSettings" class="print-settings">
<div class="form-row"><label for="printPrinter">打印设备</label><div class="print-field"><select id="printPrinter"><option value="browser">浏览器打印</option><option value="pdf">保存为PDF</option></select><button id="printProperties" class="small-button" type="button" hidden>设备属性</button></div></div>
<div class="form-row" data-print-row="device" hidden><label for="printCopies">打印份数</label><div class="print-field"><input id="printCopies" type="number" min="1" max="999" value="1"><label class="print-check"><input id="printCollation" type="checkbox" checked>逐份打印</label></div></div>
<div class="form-row"><label for="printScope">打印范围</label><select id="printScope"><option value="all">全部页面</option><option value="current">当前页面</option><option value="selected">所选页面</option><option value="range">指定页码</option></select></div>
<div class="form-row" data-print-row="range" hidden><label for="printRange">指定页码</label><input id="printRange" type="text" placeholder="1-3,5" autocomplete="off" spellcheck="false"></div>
<div class="form-row"><label for="printParity">奇偶筛选</label><div class="print-field"><select id="printParity"><option value="all">全部页面</option><option value="odd">奇数页面</option><option value="even">偶数页面</option></select><label class="print-check"><input id="printReverse" type="checkbox">逆序打印</label></div></div>
<div class="form-row"><label for="printMode">页面排版</label><select id="printMode"><option value="single">单页打印</option><option value="multiple">多页合印</option><option value="booklet">小册打印</option><option value="poster">海报拼接</option></select></div>
<div class="form-row"><label for="printPaper">纸张大小</label><select id="printPaper"><option value="210,297">A4</option><option value="297,420">A3</option><option value="148,210">A5</option><option value="176,250">B5</option><option value="215.9,279.4">Letter</option><option value="215.9,355.6">Legal</option><option value="source">原页面尺寸</option><option value="custom">自定义</option></select></div>
<div class="form-row" data-print-row="paper" hidden><label for="printWidth">纸张尺寸</label><div class="print-field"><input id="printWidth" type="number" value="210" min="1" max="5000" step="0.1" title="纸张宽度"><span>×</span><input id="printHeight" type="number" value="297" min="1" max="5000" step="0.1" title="纸张高度"><span>mm</span></div></div>
<div class="form-row"><label for="printOrientation">纸张方向</label><select id="printOrientation"><option value="portrait">纵向</option><option value="landscape">横向</option></select></div>
<div class="form-row"><label for="printScaling">页面缩放</label><select id="printScaling"><option value="shrink">缩小大页</option><option value="fit">适合纸张</option><option value="actual">实际大小</option><option value="custom">自定比例</option></select></div>
<div class="form-row" data-print-row="scale" hidden><label for="printScale">缩放比例</label><div class="print-field"><input id="printScale" type="number" min="1" max="10000" value="100" step="1"><span>%</span></div></div>
<div class="form-row"><span>额外边距</span><div class="print-field print-margins"><input id="printMarginTop" type="number" min="0" value="0" step="0.1" title="上边距"><input id="printMarginRight" type="number" min="0" value="0" step="0.1" title="右边距"><input id="printMarginBottom" type="number" min="0" value="0" step="0.1" title="下边距"><input id="printMarginLeft" type="number" min="0" value="0" step="0.1" title="左边距"><span>mm</span></div></div>
<div class="form-row" data-print-row="multiple" hidden><label for="printGrid">每面页数</label><select id="printGrid"><option value="1,2">2 页</option><option value="2,2">4 页</option><option value="2,3">6 页</option><option value="3,3">9 页</option><option value="4,4">16 页</option></select></div>
<div class="form-row" data-print-row="multiple" hidden><label for="printPageOrder">排列顺序</label><select id="printPageOrder"><option value="horizontal">横向顺序</option><option value="horizontalReverse">横向逆序</option><option value="vertical">纵向顺序</option><option value="verticalReverse">纵向逆序</option></select></div>
<div class="form-row" data-print-row="gap" hidden><label for="printGap">页面间距</label><div class="print-field"><input id="printGap" type="number" min="0" value="4" step="0.1"><span>mm</span></div></div>
<div class="form-row" data-print-row="booklet" hidden><label for="printBinding">装订方向</label><select id="printBinding"><option value="left">左侧装订</option><option value="right">右侧装订</option></select></div>
<div class="form-row" data-print-row="booklet" hidden><label for="printBookletSheets">每册纸数</label><input id="printBookletSheets" type="number" min="0" max="2500" placeholder="不分册" title="0表示不分册"></div>
<div class="form-row" data-print-row="booklet" hidden><label for="printSheetFrom">纸张范围</label><div class="print-field"><input id="printSheetFrom" type="number" min="1" placeholder="起始" title="起始纸张编号"><span>至</span><input id="printSheetTo" type="number" min="1" placeholder="结束" title="结束纸张编号"></div></div>
<div class="form-row" data-print-row="poster" hidden><label for="printOverlap">重叠宽度</label><div class="print-field"><input id="printOverlap" type="number" min="0" value="5" step="0.1"><span>mm</span></div></div>
<div class="form-row"><label for="printDuplex">双面方式</label><select id="printDuplex"><option value="simplex">单面打印</option><option value="longEdge">自动双面(长边)</option><option value="shortEdge">自动双面(短边)</option><option value="manualLongEdge">手动双面(长边)</option><option value="manualShortEdge">手动双面(短边)</option></select></div>
<div class="form-row" data-print-row="sides" hidden><label for="printSides">打印纸面</label><select id="printSides"><option value="all">正反两面</option><option value="front">只印正面</option><option value="back">只印背面</option></select></div>
<div class="form-row" data-print-row="manual" hidden><span>背面处理</span><div class="print-field"><label class="print-check"><input id="printBackReverse" type="checkbox">倒序送纸</label><label class="print-check" title="背面旋转180°"><input id="printBackRotate" type="checkbox">背面旋转</label></div></div>
<div class="form-row"><label for="printContents">打印内容</label><select id="printContents"><option value="documentAndAnnotations">文档与注解</option><option value="document">文档与表单</option><option value="annotations">仅打印注解</option><option value="forms">仅打印表单</option></select></div>
<div class="print-options"><label><input id="printRotate" type="checkbox" checked>自动旋转</label><label><input id="printGrayscale" type="checkbox">灰度打印</label><label><input id="printBorders" type="checkbox">页面边框</label><label><input id="printCropMarks" type="checkbox">裁切标记</label><label><input id="printRaster" type="checkbox">图像打印</label><select id="printDPI" title="图像精度"><option value="150">150 DPI</option><option value="300" selected>300 DPI</option><option value="600">600 DPI</option><option value="1200">1200 DPI</option></select></div>
<div class="print-options" data-print-row="poster" hidden><label title="仅拼接超出纸面的页面"><input id="printTileOnlyLarge" type="checkbox" checked>仅拼大页</label><label><input id="printLabels" type="checkbox" checked>拼接标签</label></div>
</fieldset>
<div class="print-preview"><div id="printPreview" class="print-preview-page"></div><div class="print-preview-tools"><button id="printPrevious" class="small-button" type="button">上一面</button><input id="printPreviewNumber" type="number" min="1" value="1" title="预览纸面"><span id="printPageCount">/ 0</span><button id="printNext" class="small-button" type="button">下一面</button><select id="printZoom" title="预览缩放"><option value="fit">适合</option><option value="0.5">50%</option><option value="1">100%</option><option value="2">200%</option></select></div></div>
</div>
<div class="form-actions"><div id="printStatus" class="form-status"></div><button id="printCancel" class="button" type="button">关闭</button><button id="printSubmit" class="button" type="submit" disabled>打印</button></div>
</form>
</dialog>
<dialog id="exportPanel" class="form-dialog">
<form id="exportForm">
<h2 id="exportTitle" class="form-title">导出文档</h2>
@@ -856,6 +893,7 @@
</footer>
</div>
<div id="ofdgoPrintDocument">请先在OFDGo中打开打印预览,准备完整文档后打印。</div>
<script type="module" src="./ofdgo.js"></script>
</body>
</html>
+101 -1
View File
@@ -26,6 +26,98 @@
--shadow-page: 0 14px 30px rgba(31, 36, 40, 0.16);
}
.form-dialog.print-dialog {
width: 920px;
}
.print-content {
display: grid;
grid-template-columns: 310px minmax(0, 1fr);
gap: 14px;
}
.print-settings {
min-width: 0;
max-height: calc(100dvh - 230px);
margin: 0;
padding: 0 8px 0 0;
overflow: auto;
border: 0;
}
.print-settings .form-row {
display: grid;
grid-template-columns: 4em minmax(0, 1fr);
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.print-settings [hidden] { display: none !important; }
.print-dialog :is(input, select, button):focus-visible { outline-offset: -2px; }
.print-field { display: flex; min-width: 0; align-items: center; gap: 6px; }
.print-dialog .print-settings input[type="number"] { min-width: 0; width: 100%; }
.print-dialog .print-field > :is(input, select) { flex: 1 1 0; min-width: 0; width: 0; }
.print-field > :is(.print-check, button) { flex: 0 0 80px; justify-content: center; }
.print-field:has(#printBackReverse) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.print-field:has(#printBackReverse) .print-check { justify-content: start; }
.print-field > span { flex: none; color: var(--text-muted); white-space: nowrap; }
.print-dialog .print-settings .print-margins input { padding: 0 3px; text-align: center; }
.print-check { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.print-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: var(--control-height); align-items: center; gap: 8px; margin: 12px 0 0 calc(4em + 8px); }
.print-options label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.print-check input, .print-options input { margin: 0; accent-color: var(--accent); }
.print-dialog .form-actions { grid-template-columns: minmax(0, 1fr) 80px 80px; margin-top: 12px; }
.print-dialog .form-actions > .form-status { overflow-wrap: anywhere; }
.print-dialog .form-actions > .form-status:empty { display: none; }
.print-dialog .form-actions > button:first-of-type { grid-column: 2; }
.print-preview {
display: flex;
min-width: 0;
min-height: 300px;
flex-direction: column;
gap: 8px;
}
.print-preview-page {
display: grid;
height: min(62dvh, 660px);
min-height: 240px;
justify-items: center;
align-items: start;
padding: 12px;
overflow: auto;
background: #e1e6e9;
border: 1px solid var(--line);
}
.print-preview-page img { display: block; max-width: none; background: white; box-shadow: var(--shadow-soft); }
.print-preview-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 7px; }
.print-dialog .print-preview-tools input { width: 52px; text-align: center; }
.print-dialog .print-preview-tools select { width: 70px; }
#ofdgoPrintDocument { display: none; }
@media (max-width: 680px) {
.print-content { grid-template-columns: minmax(0, 1fr); }
.print-settings { max-height: 34dvh; }
.print-preview-page { height: 32dvh; min-height: 160px; }
.print-preview { min-height: 210px; }
.print-dialog .form-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.print-dialog .form-actions > .form-status { grid-column: 1 / -1; }
.print-dialog .form-actions > button:first-of-type { grid-column: 1; }
}
@media print {
html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; margin: 0 !important; padding: 0 !important; background: white !important; }
body > :not(#ofdgoPrintDocument) { display: none !important; }
dialog { display: none !important; }
#ofdgoPrintDocument { display: block !important; color: black; background: white; }
#ofdgoPrintDocument section { display: block; margin: 0; padding: 0; overflow: hidden; break-after: page; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
#ofdgoPrintDocument section:last-child { break-after: auto; }
#ofdgoPrintDocument img { display: block; width: 100%; height: 100%; }
}
* {
box-sizing: border-box;
}
@@ -654,6 +746,7 @@ button:not(:disabled):hover {
.save-as-icon,
.edit-icon,
.convert-icon,
.print-icon,
.pages-icon,
.info-icon {
width: 16px;
@@ -663,7 +756,7 @@ button:not(:disabled):hover {
}
.open-icon {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V4h6l3 3h9v13H3Z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V3h6l3 4h9v14H3Z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.close-icon {
@@ -694,6 +787,10 @@ button:not(:disabled):hover {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7h18l-4-4M21 17H3l4 4'/%3E%3C/svg%3E") center / contain no-repeat;
}
.print-icon {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9V3h12v6M6 17H3V9h18v8h-3M6 13h12v8H6Z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.pages-icon {
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3h18M3 12h18M3 21h18'/%3E%3C/svg%3E") center / contain no-repeat;
}
@@ -1395,6 +1492,9 @@ button:not(:disabled):hover {
.info-custom-row input,
.gradient-stop-row input[type="number"],
.structure-attribute > input,
.print-preview-tools input,
.print-preview-tools select,
.print-options select,
.form-row input[type="text"],
.form-row input[type="password"],
.form-row input[type="number"],
+35
View File
@@ -1,6 +1,7 @@
import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js";
import { FontManager, FontPicker } from "./ofdgo_font.js";
import { nativeHost, createEngineWorker, engineLimits, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js";
import { PrintDialog } from "./ofdgo_print.js";
import { DocumentDialogs } from "./ofdgo_util.js";
const MM_TO_PX = 96 / 25.4;
@@ -187,6 +188,8 @@ function createDocumentState() {
let state = createDocumentState();
const el = {
printButton: document.querySelector("#printButton"),
printPanel: document.querySelector("#printPanel"),
documentTitleBar: document.querySelector("#documentTitleBar"),
documentTabs: document.querySelector("#documentTabs"),
documentNewTab: document.querySelector("#documentNewTab"),
@@ -650,6 +653,30 @@ const el = {
statusText: document.querySelector("#statusText"),
};
let printDialog = null;
function createPrintDialog() {
return new PrintDialog({
nativeHost,
show: panel => documentDialogs.showApplication(panel),
download: downloadBytes,
cancel: tab => {
for (const [id, request] of wasmRequests) if (request.tab === tab && ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF"].includes(request.name)) tab.worker?.postMessage({type:"cancel", id});
},
});
}
function openPrintPanel() {
const tab = documentTab();
if (!tab.state.doc || !tab.state.ready || document.body.hasAttribute("data-busy") || formDialogOpen()) return;
printDialog ||= createPrintDialog();
printDialog.open({tab, call:(name, ...args) => callTabWASM(tab, name, ...args), page:tab.state.pageIndex,
selection:[...tab.state.pageSelection].sort((a,b) => a-b), annotations:tab.state.renderAnnotations,
name:tab.state.fileName || tab.state.doc.title || "OFDGo"});
}
el.printButton.addEventListener("click", openPrintPanel);
const fontManager = new FontManager({
host: nativeHost,
onChange: scheduleFontSync,
@@ -1857,6 +1884,11 @@ function handleKeyDown(event) {
}
const key = event.key;
const target = event.target;
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "p") {
event.preventDefault();
openPrintPanel();
return;
}
if (nativeHost && (event.ctrlKey || event.metaKey) && (key === "Tab" || key.toLowerCase() === "w")) {
event.preventDefault();
if (document.body.hasAttribute("data-busy") || formDialogOpen()) return;
@@ -1976,6 +2008,7 @@ function handleKeyDown(event) {
}
function formDialogOpen() {
if (el.printPanel.open) return true;
return Boolean(sealPreview || resourceManager.pick) || el.aboutPanel.open || el.closePanel.open || el.attachmentDialog.open || settingsElements.Panel.open || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open
|| resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
}
@@ -13547,6 +13580,7 @@ function updateDocumentControls() {
const count = state.doc?.documents?.length || 0, index = managedDocumentIndex(), manager = state.documentManagement;
const busy = document.body.hasAttribute("data-busy");
const disabled = !state.doc || !state.ready || state.exporting || busy;
el.printButton.disabled = disabled;
el.documentManageButton.hidden = !state.editing;
el.documentManageButton.disabled = disabled || state.saving;
resourceElements.ManageButton.disabled = disabled;
@@ -13599,6 +13633,7 @@ function updateControls() {
el.outputCompression.disabled = !hasDoc || state.exporting;
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
el.printButton.disabled = !hasDoc || !state.ready || state.exporting || document.body.hasAttribute("data-busy");
updateDPIControl();
}
+1
View File
@@ -417,6 +417,7 @@ class BrowserEngine {
"ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice",
"ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal",
"ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument",
"ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint",
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
].includes(data.name)) this.invalidatePages();
const preferred = data.name === "ofdgoRenderPage" ? [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0])) : null;
+411
View File
@@ -0,0 +1,411 @@
export class PrintDialog {
constructor(options) {
Object.assign(this, options);
this.el = Object.fromEntries(["Panel", "Form", "Settings", "Printer", "Properties", "Copies", "Collation", "Duplex", "BackReverse", "BackRotate", "Contents", "Scope", "Range", "Parity", "Reverse", "Mode", "Paper", "Width", "Height", "Orientation", "Scaling", "Scale", "MarginTop", "MarginRight", "MarginBottom", "MarginLeft", "Grid", "PageOrder", "Gap", "Binding", "BookletSheets", "SheetFrom", "SheetTo", "Overlap", "Sides", "Rotate", "Grayscale", "Borders", "CropMarks", "Raster", "DPI", "TileOnlyLarge", "Labels", "Preview", "Previous", "Next", "PreviewNumber", "PageCount", "Zoom", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#print${name}`)]));
this.printerOptions = this.el.Printer.innerHTML;
this.paperOptions = this.el.Paper.innerHTML;
this.queue = Promise.resolve();
this.sequence = 0;
this.urls = [];
this.root = document.querySelector("#ofdgoPrintDocument");
this.el.Settings.addEventListener("input", event => { if (event.target === this.el.Printer) this.choosePrinter(); else this.schedule(); });
this.el.Properties.addEventListener("click", () => this.properties());
this.el.Form.addEventListener("submit", event => { event.preventDefault(); this.print(); });
this.el.Cancel.addEventListener("click", () => this.el.Panel.close());
this.el.Panel.addEventListener("close", () => this.close());
this.el.Panel.addEventListener("cancel", event => { if (this.waiting) event.preventDefault(); });
this.el.Previous.addEventListener("click", () => this.preview(this.side - 1));
this.el.Next.addEventListener("click", () => this.preview(this.side + 1));
this.el.PreviewNumber.addEventListener("change", () => this.preview(Number(this.el.PreviewNumber.value) - 1));
this.el.Zoom.addEventListener("change", () => this.resize());
new ResizeObserver(() => this.resize()).observe(this.el.Preview);
window.addEventListener("afterprint", () => { if (this.waiting === "browser") this.finishPrint(); });
window.addEventListener("beforeprint", () => {
if (!this.root?.children.length) {
this.root ||= this.createRoot();
this.root.textContent = "请先在OFDGo中打开打印预览,准备完整文档后打印。";
}
});
}
open(context) {
if (this.el.Panel.open) return;
this.context = context;
this.prepared = false;
this.layout = null;
this.job = this.profile = null;
this.side = 0;
this.sequence++;
this.el.Form.reset();
this.mode = null;
try {
const saved = JSON.parse(localStorage.getItem("ofdgo.print.v2") || "null");
for (const [name, value] of Object.entries(saved || {})) {
const node = this.el[name];
if (node?.type === "checkbox" && typeof value === "boolean") node.checked = value;
else if (node && typeof value === "string") node.value = value;
}
} catch {}
if (this.el.BookletSheets.value === "0") this.el.BookletSheets.value = "";
this.el.Scope.value = context.selection.length ? "selected" : "all";
this.el.Scope.querySelector('option[value="selected"]').disabled = !context.selection.length;
this.el.Contents.value = context.annotations ? "documentAndAnnotations" : "document";
this.el.Status.textContent = "";
this.el.Preview.replaceChildren();
this.show(this.el.Panel);
this.loadDevices();
}
isDevice() { return this.el.Printer.value.startsWith("device:"); }
manual() { return this.el.Duplex.value.startsWith("manual"); }
async loadDevices() {
const sequence = ++this.sequence;
this.loading = true;
this.controls();
this.el.Printer.innerHTML = this.printerOptions;
try {
if (this.nativeHost) {
this.el.Printer.querySelector('option[value="browser"]').textContent = "系统打印";
const capabilities = await this.nativeHost.call("print.capabilities", {});
if (!this.current(sequence)) return;
this.capabilities = capabilities;
const printers = capabilities.direct ? await this.nativeHost.call("print.printers", {}) : [];
if (!this.current(sequence)) return;
for (const printer of printers) this.el.Printer.add(new Option(printer.name, `device:${printer.name}`));
const printer = printers.find(printer => printer.default) || printers[0];
this.el.Printer.value = printer ? `device:${printer.name}` : "pdf";
}
} catch (error) { if (this.current(sequence)) this.el.Status.textContent = error.message; }
finally {
if (this.current(sequence)) { this.loading = false; await this.choosePrinter(); }
}
}
async choosePrinter() {
const sequence = ++this.sequence;
const paper = this.el.Paper.value;
this.layout = this.job = this.profile = null;
this.loading = true;
this.controls();
this.el.Paper.innerHTML = this.paperOptions;
if (!this.isDevice()) {
this.el.Paper.value = paper;
if (!this.el.Paper.value) {
const [width, height] = paper.split(",").map(Number);
if (width > 0 && height > 0 && Number.isFinite(width) && Number.isFinite(height)) {
this.el.Paper.value = "custom";
this.el.Width.value = width;
this.el.Height.value = height;
} else this.el.Paper.value = "210,297";
}
}
this.el.Properties.hidden = true;
try {
if (this.isDevice()) {
const profile = await this.nativeHost.call("print.query", {printer:this.el.Printer.value.slice(7), copies:1, duplex:"simplex"});
if (!this.current(sequence)) return;
this.profile = profile;
this.el.Paper.replaceChildren(...profile.papers.map(paper => new Option(paper.name, `${paper.width},${paper.height}`)));
if (profile.customPaper) this.el.Paper.add(new Option("自定义", "custom"));
this.el.Paper.value = `${profile.options.width},${profile.options.height}`;
if (!this.el.Paper.value && profile.customPaper) { this.el.Paper.value = "custom"; this.el.Width.value = profile.options.width; this.el.Height.value = profile.options.height; }
if (!this.el.Paper.value) throw new Error("设备默认纸张不在支持列表中");
this.el.Orientation.value = profile.options.orientation;
this.el.Copies.max = Math.min(999, profile.maxCopies || 999);
this.el.Copies.value = 1;
this.el.Grayscale.checked = !profile.color;
this.el.Grayscale.disabled = !profile.color;
this.el.Properties.hidden = !profile.properties;
} else this.el.Grayscale.disabled = false;
} catch (error) { if (this.current(sequence)) { this.el.Status.textContent = error.message; return; } }
finally { if (this.current(sequence)) { this.loading = false; this.controls(); } }
if (this.current(sequence)) this.schedule();
}
async properties() {
if (!this.isDevice() || this.running || this.loading) return;
this.loading = true;
this.controls();
try { await this.nativeHost.call("print.properties", {printer:this.el.Printer.value.slice(7)}); }
catch (error) { this.el.Status.textContent = error.message; }
finally { this.loading = false; if (this.current()) await this.choosePrinter(); }
}
deviceOptions() {
const [width, height] = this.el.Paper.value === "custom" ? [Number(this.el.Width.value), Number(this.el.Height.value)] : this.el.Paper.value.split(",").map(Number);
const manual = this.manual(), partial = this.el.Sides.value !== "all";
return {printer:this.el.Printer.value.slice(7), width, height, orientation:this.el.Orientation.value,
copies:Number(this.el.Copies.value), collation:this.el.Collation.checked ? "collated" : "uncollated",
duplex:manual || partial ? "simplex" : this.el.Duplex.value, color:this.el.Grayscale.checked ? "grayscale" : "color",
scale:1, pagesPerSide:1, margins:{top:0,right:0,bottom:0,left:0},
...(this.capabilities?.backgrounds ? {backgrounds:true} : {}), ...(this.capabilities?.headersFooters ? {headersFooters:false} : {})};
}
options(profile) {
const el = this.el;
const number = name => {
const value = Number(el[name].value);
if (!el[name].value.trim() || !Number.isFinite(value)) throw new Error("请输入有效数值");
return value;
};
let [paperWidth, paperHeight] = el.Paper.value === "source" ? [0, 0] : el.Paper.value === "custom" ? [number("Width"), number("Height")] : el.Paper.value.split(",").map(Number);
if (el.Orientation.value === "landscape" && paperWidth < paperHeight) [paperWidth, paperHeight] = [paperHeight, paperWidth];
if (profile) [paperWidth, paperHeight] = [profile.width, profile.height];
const scope = el.Scope.value;
const [rows, columns] = el.Grid.value.split(",").map(Number);
if (scope === "selected" && !this.context.selection.length) throw new Error("未选择页面");
if (scope === "range" && !el.Range.value.trim()) throw new Error("请输入页码范围");
const manual = this.manual(), duplex = el.Duplex.value.replace("manualL", "l").replace("manualS", "s");
if (manual && this.isDevice() && number("Copies") !== 1) throw new Error("手动双面每次仅支持1份");
return {
...(scope === "current" ? {pages:[this.context.page]} : scope === "selected" ? {pages:this.context.selection} : scope === "range" ? {pageRange:el.Range.value.trim()} : {}),
mode:el.Mode.value, scaling:el.Scaling.value, paperWidth, paperHeight,
margins:{top:number("MarginTop"), right:number("MarginRight"), bottom:number("MarginBottom"), left:number("MarginLeft")},
...(profile ? {printableArea:{x:profile.printableArea.x, y:profile.printableArea.y, w:profile.printableArea.width, h:profile.printableArea.height}} : {}),
copies:this.isDevice() ? number("Copies") : 1, collate:el.Collation.checked, duplex,
scale:number("Scale") / 100, rotate:el.Rotate.checked, reverse:el.Reverse.checked !== (manual && el.Sides.value === "back" && el.BackReverse.checked), parity:el.Parity.value, sides:el.Sides.value,
backRotate:manual && el.BackRotate.checked,
rows, columns, pageOrder:el.PageOrder.value, gap:number("Gap"), binding:el.Binding.value, bookletSheets:el.BookletSheets.value ? number("BookletSheets") : 0, overlap:number("Overlap"),
...(el.Mode.value === "booklet" ? {sheetFrom:el.SheetFrom.value ? number("SheetFrom") : 0, sheetTo:el.SheetTo.value ? number("SheetTo") : 0} : {}),
contents:el.Contents.value, grayscale:el.Grayscale.checked, borders:el.Borders.checked, cropMarks:el.CropMarks.checked, raster:el.Raster.checked, dpi:number("DPI"), tileOnlyLarge:el.TileOnlyLarge.checked, labels:el.Labels.checked
};
}
schedule() {
const el = this.el, mode = el.Mode.value;
const source = el.Paper.querySelector('option[value="source"]');
if (source) source.disabled = mode !== "single";
if (mode !== "single" && el.Paper.value === "source") el.Paper.value = "210,297";
if (mode === "booklet" && this.mode !== "booklet") {
this.orientation = el.Orientation.value;
this.duplexBeforeBooklet = el.Duplex.value;
this.scalingBeforeBooklet = el.Scaling.value;
el.Scaling.value = "shrink";
el.Duplex.value = this.isDevice() && !this.profile?.duplex ? "manualShortEdge" : "shortEdge";
} else if (mode !== "booklet" && this.mode === "booklet") {
el.Orientation.value = this.orientation;
el.Duplex.value = this.duplexBeforeBooklet;
el.Scaling.value = this.scalingBeforeBooklet;
}
if (mode === "booklet") el.Orientation.value = "landscape";
this.mode = mode;
if (this.isDevice() && !this.profile?.duplex && ["longEdge", "shortEdge"].includes(el.Duplex.value)) el.Duplex.value = el.Duplex.value === "longEdge" ? "manualLongEdge" : "manualShortEdge";
if (mode === "booklet" && el.Duplex.value === "simplex") el.Duplex.value = this.isDevice() && !this.profile?.duplex ? "manualShortEdge" : "shortEdge";
if (el.Duplex.value === "simplex") el.Sides.value = "all";
const isManual = this.manual();
el.Sides.querySelector('option[value="all"]').disabled = isManual;
if (isManual && el.Sides.value === "all") el.Sides.value = "front";
for (const option of el.Duplex.options) option.disabled = this.isDevice() && !this.profile?.duplex && ["longEdge", "shortEdge"].includes(option.value) || mode === "booklet" && ["simplex", "longEdge", "manualLongEdge"].includes(option.value);
el.Orientation.disabled = mode === "booklet" || el.Paper.value === "source";
const rows = {device:this.isDevice(), sides:el.Duplex.value !== "simplex", manual:isManual, range:el.Scope.value === "range", paper:el.Paper.value === "custom", scale:el.Scaling.value === "custom" || mode === "poster", multiple:mode === "multiple", gap:mode === "multiple" || mode === "booklet", booklet:mode === "booklet", poster:mode === "poster"};
for (const row of el.Panel.querySelectorAll("[data-print-row]")) {
row.hidden = !rows[row.dataset.printRow];
}
el.Scaling.disabled = mode === "poster" || mode === "booklet";
el.DPI.disabled = !el.Raster.checked && !el.Grayscale.checked;
const sequence = ++this.sequence;
this.layout = null;
this.job = null;
this.controls();
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.queue = this.queue.catch(() => {}).then(() => this.refresh(sequence));
}, 160);
}
async refresh(sequence) {
if (!this.current(sequence)) return;
const context = this.context;
try {
if (!this.el.Form.reportValidity()) return;
const deviceOptions = this.isDevice() ? this.deviceOptions() : null;
const profile = deviceOptions ? await this.nativeHost.call("print.query", deviceOptions) : null;
if (!this.current(sequence)) return;
this.profile = profile;
const options = this.options(profile);
this.el.Status.textContent = "正在准备打印";
const layout = await context.call(this.prepared ? "ofdgoLayoutPrint" : "ofdgoPreparePrint", options);
if (!this.current(sequence)) return;
this.prepared = true;
this.layout = layout;
if (this.isDevice()) this.el.Copies.max = Math.min(999, this.profile.maxCopies || 999, layout.copiesLimit > 0 ? layout.copiesLimit : 999);
this.job = {deviceOptions, fingerprint:profile?.fingerprint, manual:this.manual(), sides:options.sides, backContent:layout.backContent};
this.el.Status.textContent = "";
if (layout.sides.some(side => side.placements.some(placement => placement.clipped))) this.el.Status.textContent = "内容超出可打印区域";
if (this.manual() && options.sides === "back" && !layout.backContent) this.el.Status.textContent = "背面为空白";
this.remember();
await this.preview(Math.min(this.side, layout.sides.length - 1));
} catch (error) {
if (this.current(sequence)) this.el.Status.textContent = error.code === "printDenied" ? "文档禁止打印" : error.message;
} finally {
if (this.current(sequence)) this.controls();
}
}
current(sequence = this.sequence) { return this.el.Panel.open && sequence === this.sequence && this.context && !this.context.tab.closed; }
async preview(index) {
if (!this.layout || this.running || index < 0 || index >= this.layout.sides.length) return;
const sequence = this.sequence, preview = this.previewSequence = (this.previewSequence || 0) + 1;
this.side = index;
this.controls();
try {
let {svg} = await this.context.call("ofdgoPrintSide", index);
if (!this.current(sequence) || preview !== this.previewSequence) return;
if (this.profile) {
const area = this.profile.printableArea;
svg = svg.replace("</svg>", `<rect x="${area.x}" y="${area.y}" width="${area.width}" height="${area.height}" fill="none" stroke="#88949e" stroke-width="0.2" stroke-dasharray="1 1"/></svg>`);
}
const url = URL.createObjectURL(new Blob([svg], {type:"image/svg+xml"}));
const img = new Image();
img.src = url;
try { await img.decode(); } catch (error) { URL.revokeObjectURL(url); throw error; }
if (!this.current(sequence) || preview !== this.previewSequence) { URL.revokeObjectURL(url); return; }
if (this.previewURL) URL.revokeObjectURL(this.previewURL);
this.previewURL = url;
this.el.Preview.replaceChildren(img);
this.resize();
} catch (error) { if (this.current(sequence)) { this.el.Status.textContent = error.message; this.layout = null; this.controls(); } }
}
resize() {
const img = this.el.Preview.querySelector("img"), side = this.layout?.sides[this.side];
if (!img || !side) return;
const factor = this.el.Zoom.value === "fit" ? Math.min((this.el.Preview.clientWidth - 24) / side.width, (this.el.Preview.clientHeight - 24) / side.height) : Number(this.el.Zoom.value) * 96 / 25.4;
img.style.width = `${Math.max(1, side.width * factor)}px`;
img.style.height = `${Math.max(1, side.height * factor)}px`;
}
controls() {
const count = this.layout?.sides.length || 0, busy = !!this.running || !!this.loading;
this.el.Settings.disabled = busy;
this.el.Submit.disabled = !count || busy;
if (this.el.Printer.value !== "pdf" && this.manual() && this.el.Sides.value === "back" && !this.layout?.backContent) this.el.Submit.disabled = true;
this.el.Previous.disabled = busy || !count || this.side === 0;
this.el.Next.disabled = busy || !count || this.side + 1 === count;
this.el.PreviewNumber.disabled = busy || !count;
this.el.PreviewNumber.max = count || 1;
this.el.PreviewNumber.value = count ? this.side + 1 : 1;
this.el.PageCount.textContent = `/ ${count}`;
this.el.Cancel.textContent = busy && !this.waiting ? "取消" : "关闭";
this.el.Cancel.disabled = !!this.waiting;
}
remember() {
const saved = {};
for (const [name, node] of Object.entries(this.el)) if (node.closest("#printSettings") && !["Printer", "Copies", "Paper", "Scope", "Range", "Parity", "Reverse", "Sides", "Contents", "SheetFrom", "SheetTo"].includes(name) && node.matches("input, select")) saved[name] = node.type === "checkbox" ? node.checked : node.value;
try { localStorage.setItem("ofdgo.print.v2", JSON.stringify(saved)); } catch {}
}
createRoot() {
const root = document.createElement("div");
root.id = "ofdgoPrintDocument";
document.body.append(root);
return root;
}
async print() {
if (!this.layout || !this.job || this.running) return;
if (this.el.Printer.value === "pdf") return this.pdf();
const sequence = this.sequence, context = this.context;
const job = this.job;
this.running = true;
this.controls();
try {
this.root ||= this.createRoot();
this.root.replaceChildren();
const style = document.createElement("style");
this.root.append(style);
for (let i = 0; i < this.layout.sides.length; i++) {
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
this.el.Status.textContent = `正在准备纸面 ${i + 1}/${this.layout.sides.length}`;
const {svg} = await context.call("ofdgoPrintSide", i);
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
const url = URL.createObjectURL(new Blob([svg], {type:"image/svg+xml"}));
this.urls.push(url);
const img = new Image(), section = document.createElement("section"), side = this.layout.sides[i];
img.src = url;
section.append(img);
section.style.cssText = `page:ofdgoPrint${i};width:${side.width}mm;height:${side.height}mm`;
style.textContent += `@page ofdgoPrint${i}{size:${side.width}mm ${side.height}mm;margin:0}`;
this.root.append(section);
await img.decode();
}
await document.fonts.ready;
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
this.previousTitle = document.title;
document.title = context.name;
this.waiting = job.deviceOptions ? "native" : "browser";
this.controls();
this.el.Status.textContent = job.deviceOptions ? "正在提交打印作业" : "正在打开打印窗口";
if (job.deviceOptions) {
const result = await this.nativeHost.call("print", {...job.deviceOptions, fingerprint:job.fingerprint});
if (this.current(sequence)) {
this.finishPrint();
if (!result?.submitted) throw new Error("打印作业未被接受");
if (job.manual && job.sides === "front") {
if (job.backContent) { this.el.Sides.value = "back"; this.schedule(); }
else this.el.Status.textContent = "背面为空白";
}
}
} else if (this.nativeHost) {
const capabilities = await this.nativeHost.call("print.capabilities", {});
if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError");
if (!capabilities.systemDialog) throw new Error("当前运行时不支持系统打印");
await this.nativeHost.call("print.dialog", {kind:"system"});
if (this.current(sequence) && capabilities.dialogCompletion) this.finishPrint();
} else if (typeof window.print === "function") window.print();
else throw new Error("当前浏览器不支持打印");
} catch (error) {
if (this.current(sequence)) {
this.finishPrint();
this.el.Status.textContent = error.name === "AbortError" ? "打印已取消" : error.message;
}
} finally {
if (this.current(sequence)) {
if (!this.waiting) this.running = false;
this.controls();
}
}
}
finishPrint() {
if (this.previousTitle !== undefined) { document.title = this.previousTitle; delete this.previousTitle; }
this.root?.replaceChildren();
for (const url of this.urls) URL.revokeObjectURL(url);
this.urls = [];
this.el.Status.textContent = "";
this.waiting = this.running = false;
this.controls();
}
async pdf() {
if (!this.layout || this.running) return;
const sequence = this.sequence, context = this.context;
this.running = true;
this.controls();
this.el.Status.textContent = "正在生成打印PDF";
try {
const result = await context.call("ofdgoPrintPDF");
if (this.current(sequence)) {
await this.download(result.bytes, "application/pdf", `${context.name.replace(/\.[^.]+$/, "")}-打印.pdf`);
if (this.current(sequence)) this.el.Status.textContent = "";
}
} catch (error) { if (this.current(sequence)) this.el.Status.textContent = error.message; }
finally { if (this.current(sequence)) { this.running = false; this.controls(); } }
}
close() {
const context = this.context;
this.sequence++;
clearTimeout(this.timer);
if (context) this.cancel(context.tab);
this.finishPrint();
if (this.previewURL) URL.revokeObjectURL(this.previewURL);
this.previewURL = null;
this.el.Preview.replaceChildren();
this.context = null;
this.queue = this.queue.catch(() => {}).then(() => context?.call("ofdgoDiscardPrint").catch(() => {}));
}
}
+2 -2
View File
@@ -235,11 +235,11 @@ async function handleMessage(engine, { id, name, args }) {
let saveStamp;
let channel;
const signal = operations.get(id)?.signal;
const cancellable = ["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name);
const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name);
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
const converting = name === "ofdgoConvertPDF";
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
const resources = name === "ofdgoResources" || name === "ofdgoPrepareSave";
const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name);
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null;
const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null;
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
+1 -1
View File
@@ -1,6 +1,6 @@
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
const META_CACHE = `${CACHE_PREFIX}meta`;
const ASSETS = ["./", "ofdgo.css", "ofdgo.ico", "ofdgo.js", "ofdgo.json", "ofdgo.png", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_util.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
const ASSETS = ["./", "ofdgo.css", "ofdgo.ico", "ofdgo.js", "ofdgo.json", "ofdgo.png", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_print.js", "ofdgo_util.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
.map((path) => new URL(path, self.registration.scope).href);
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
+2 -2
View File
@@ -3,12 +3,12 @@ module github.com/xiaoqidun/ofdgo
go 1.26.0
require (
github.com/andybalholm/brotli v1.2.6
github.com/andybalholm/brotli v1.2.7
github.com/emmansun/gmsm v0.45.1
github.com/go-text/typesetting v0.3.5
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62
github.com/xiaoqidun/guigo v0.0.0-20261024125139-cafa62624ea9
github.com/xiaoqidun/j2kgo v1.0.2
github.com/xiaoqidun/jbig2 v1.1.8
github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5
+4 -4
View File
@@ -20,8 +20,8 @@ github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE=
github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
@@ -60,8 +60,8 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62 h1:Uefcwqu9kZ47FtIbrGBY4zue71VsQt048usDnXDLq0A=
github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls=
github.com/xiaoqidun/guigo v0.0.0-20261024125139-cafa62624ea9 h1:5pB8LF+hXJeiY7it+c8xgjHco/ikjPScXsH7kbCTElM=
github.com/xiaoqidun/guigo v0.0.0-20261024125139-cafa62624ea9/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls=
github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
+375
View File
@@ -0,0 +1,375 @@
// 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 pdf
import (
"bytes"
"compress/zlib"
"context"
"encoding/hex"
"fmt"
"image"
"io"
"maps"
"math"
"slices"
"strconv"
"strings"
"github.com/xiaoqidun/pdfgo"
)
// PrintPlacement 保存源PDF页索引及毫米纸面上的变换和裁剪
type PrintPlacement struct {
Page int
Matrix [6]float64
Clip [4]float64
}
// PrintSheet 保存一个纸面的物理尺寸、放置内容和辅助线
type PrintSheet struct {
Width, Height float64
Placements []PrintPlacement
Lines [][4]float64
Label string
LabelX, LabelY float64
}
// printPDFObjects 写出自有打印对象,原PDF资源仅复制实际引用的部分
type printPDFObjects struct {
ctx context.Context
objects []pdfgo.Object
copied map[pdfgo.Reference]pdfgo.Reference
source *pdfgo.Reader
}
// printPDFNumbers 编码内容流数值,不使用PDF语法不接受的科学记数法
// 入参: out 内容流缓冲区, values 待编码数值
func printPDFNumbers(out *bytes.Buffer, values ...float64) {
for _, value := range values {
out.WriteString(strconv.FormatFloat(value, 'f', -1, 64))
out.WriteByte(' ')
}
}
// add 登记一个独立输出对象
// 入参: object 待登记对象
// 返回: pdfgo.Reference 输出对象引用
func (p *printPDFObjects) add(object pdfgo.Object) pdfgo.Reference {
p.objects = append(p.objects, object)
return pdfgo.Reference{Number: int64(len(p.objects))}
}
// copy 递归复制资源并改写引用,保留流编码及资源共享
// 入参: object 待复制对象, depth 当前递归深度
// 返回: pdfgo.Object 改写引用后的对象, error 资源、嵌套或取消错误
func (p *printPDFObjects) copy(object pdfgo.Object, depth int) (pdfgo.Object, error) {
if err := p.ctx.Err(); err != nil {
return nil, err
}
if depth > 256 {
return nil, fmt.Errorf("print PDF resource nesting too deep")
}
switch value := object.(type) {
case pdfgo.Reference:
if next, ok := p.copied[value]; ok {
return next, nil
}
next := p.add(nil)
p.copied[value] = next
resolved, err := p.source.Object(value)
if err != nil {
return nil, err
}
copied, err := p.copy(resolved, depth+1)
if err != nil {
return nil, err
}
p.objects[next.Number-1] = copied
return next, nil
case pdfgo.Dictionary:
result := make(pdfgo.Dictionary, len(value))
for key, item := range value {
copied, err := p.copy(item, depth+1)
if err != nil {
return nil, err
}
result[key] = copied
}
return result, nil
case pdfgo.Array:
result := make(pdfgo.Array, len(value))
for i, item := range value {
copied, err := p.copy(item, depth+1)
if err != nil {
return nil, err
}
result[i] = copied
}
return result, nil
case *pdfgo.Stream:
dict := maps.Clone(value.Dictionary)
delete(dict, "Length")
copied, err := p.copy(dict, depth+1)
if err != nil {
return nil, err
}
return &pdfgo.Stream{Dictionary: copied.(pdfgo.Dictionary), Data: value.Data}, nil
default:
return object, nil
}
}
// WritePrintPDF 将源页面作为独立表单放置,保持矢量、文字、资源及透明组
// 入参: ctx 输出上下文, writer 输出流, source 源PDF数据, sheets 有序纸面
// 返回: error 资源、编码、写入或取消错误
func WritePrintPDF(ctx context.Context, writer io.Writer, source []byte, sheets []PrintSheet) error {
var reader *pdfgo.Reader
if len(source) > 0 {
var err error
reader, err = pdfgo.NewReader(bytes.NewReader(source), int64(len(source)))
if err != nil {
return err
}
defer reader.Close()
}
p := &printPDFObjects{ctx: ctx, objects: make([]pdfgo.Object, 2), copied: make(map[pdfgo.Reference]pdfgo.Reference), source: reader}
var pages []*pdfgo.Page
if reader != nil {
if err := reader.WalkPages(ctx, func(_ int, page *pdfgo.Page) error { pages = append(pages, page); return nil }); err != nil {
return err
}
}
forms := make(map[int]pdfgo.Reference)
var children pdfgo.Array
const pt = 72.0 / 25.4
for _, sheet := range sheets {
if err := ctx.Err(); err != nil {
return err
}
var content bytes.Buffer
objects := make(pdfgo.Dictionary)
for _, placement := range sheet.Placements {
if placement.Page < 0 || placement.Page >= len(pages) {
return fmt.Errorf("print PDF page outside source")
}
page := pages[placement.Page]
if page.Rotation != 0 || page.UserUnit != 1 {
return fmt.Errorf("print PDF backend must normalize page rotation and units")
}
form, ok := forms[placement.Page]
if !ok {
resources, err := p.copy(page.Resources, 0)
if err != nil {
return err
}
var data bytes.Buffer
if _, err := page.WriteContent(ctx, &data); err != nil {
return err
}
box := page.MediaBox
dict := pdfgo.Dictionary{"Type": pdfgo.Name("XObject"), "Subtype": pdfgo.Name("Form"), "FormType": pdfgo.Integer(1), "BBox": pdfgo.Array{pdfgo.Real(box.XMin), pdfgo.Real(box.YMin), pdfgo.Real(box.XMax), pdfgo.Real(box.YMax)}, "Resources": resources}
if group := page.Dictionary["Group"]; group != nil {
copied, err := p.copy(group, 0)
if err != nil {
return err
}
dict["Group"] = copied
}
form = p.add(&pdfgo.Stream{Dictionary: dict, Data: data.Bytes()})
forms[placement.Page] = form
}
name := pdfgo.Name(fmt.Sprintf("P%d", placement.Page))
objects[name] = form
c, m := placement.Clip, placement.Matrix
hs := (page.MediaBox.YMax - page.MediaBox.YMin) / pt
x0, y0 := page.MediaBox.XMin, page.MediaBox.YMin
content.WriteString("q ")
printPDFNumbers(&content, c[0]*pt, (sheet.Height-c[1]-c[3])*pt, c[2]*pt, c[3]*pt)
content.WriteString("re W n ")
printPDFNumbers(&content, m[0], -m[1], -m[2], m[3], (m[2]*hs+m[4])*pt-m[0]*x0+m[2]*y0, (sheet.Height-m[3]*hs-m[5])*pt+m[1]*x0-m[3]*y0)
fmt.Fprintf(&content, "cm /%s Do Q\n", name)
}
for _, line := range sheet.Lines {
content.WriteString("q 0 G ")
printPDFNumbers(&content, 0.15*pt)
content.WriteString("w ")
printPDFNumbers(&content, line[0]*pt, (sheet.Height-line[1])*pt)
content.WriteString("m ")
printPDFNumbers(&content, line[2]*pt, (sheet.Height-line[3])*pt)
content.WriteString("l S Q\n")
}
resources := pdfgo.Dictionary{"XObject": objects}
if sheet.Label != "" {
content.WriteString("q 0 g BT /Label 7.94 Tf 1 0 0 1 ")
printPDFNumbers(&content, sheet.LabelX*pt, (sheet.Height-sheet.LabelY)*pt)
content.WriteString("Tm ")
if err := printPDFObject(&content, pdfgo.String(sheet.Label), 0); err != nil {
return err
}
content.WriteString(" Tj ET Q\n")
resources["Font"] = pdfgo.Dictionary{"Label": pdfgo.Dictionary{"Type": pdfgo.Name("Font"), "Subtype": pdfgo.Name("Type1"), "BaseFont": pdfgo.Name("Helvetica")}}
}
stream := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{}, Data: content.Bytes()})
children = append(children, p.add(pdfgo.Dictionary{"Type": pdfgo.Name("Page"), "Parent": pdfgo.Reference{Number: 2}, "MediaBox": pdfgo.Array{pdfgo.Integer(0), pdfgo.Integer(0), pdfgo.Real(sheet.Width * pt), pdfgo.Real(sheet.Height * pt)}, "Resources": resources, "Contents": stream}))
}
return p.write(writer, children)
}
// WritePrintImages 逐纸面消费光栅结果,不在输出对象中保留未压缩像素
// 入参: ctx 输出上下文, writer 输出流, sheets 有序纸面, render 按索引渲染纸面
// 返回: error 渲染、编码、写入或取消错误
func WritePrintImages(ctx context.Context, writer io.Writer, sheets []PrintSheet, render func(int) (image.Image, error)) error {
p := &printPDFObjects{ctx: ctx, objects: make([]pdfgo.Object, 2)}
var children pdfgo.Array
const pt = 72.0 / 25.4
for i, sheet := range sheets {
if err := ctx.Err(); err != nil {
return err
}
img, err := render(i)
if err != nil {
return err
}
var data bytes.Buffer
compressed := zlib.NewWriter(&data)
b := img.Bounds()
row := make([]byte, b.Dx()*3)
for y := b.Min.Y; y < b.Max.Y; y++ {
if err := ctx.Err(); err != nil {
return err
}
for x := b.Min.X; x < b.Max.X; x++ {
r, g, blue, _ := img.At(x, y).RGBA()
offset := (x - b.Min.X) * 3
row[offset], row[offset+1], row[offset+2] = byte(r>>8), byte(g>>8), byte(blue>>8)
}
if _, err := compressed.Write(row); err != nil {
return err
}
}
if err := compressed.Close(); err != nil {
return err
}
picture := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{"Type": pdfgo.Name("XObject"), "Subtype": pdfgo.Name("Image"), "Width": pdfgo.Integer(b.Dx()), "Height": pdfgo.Integer(b.Dy()), "ColorSpace": pdfgo.Name("DeviceRGB"), "BitsPerComponent": pdfgo.Integer(8), "Filter": pdfgo.Name("FlateDecode")}, Data: data.Bytes()})
var content bytes.Buffer
content.WriteString("q ")
printPDFNumbers(&content, sheet.Width*pt, 0, 0, sheet.Height*pt, 0, 0)
content.WriteString("cm /I Do Q\n")
stream := p.add(&pdfgo.Stream{Dictionary: pdfgo.Dictionary{}, Data: content.Bytes()})
children = append(children, p.add(pdfgo.Dictionary{"Type": pdfgo.Name("Page"), "Parent": pdfgo.Reference{Number: 2}, "MediaBox": pdfgo.Array{pdfgo.Integer(0), pdfgo.Integer(0), pdfgo.Real(sheet.Width * pt), pdfgo.Real(sheet.Height * pt)}, "Resources": pdfgo.Dictionary{"XObject": pdfgo.Dictionary{"I": picture}}, "Contents": stream}))
}
return p.write(writer, children)
}
// write 写出完整对象表和交叉引用,失败时由调用方丢弃输出
// 入参: writer 输出流, children 页面对象引用
// 返回: error 编码、写入或取消错误
func (p *printPDFObjects) write(writer io.Writer, children pdfgo.Array) error {
p.objects[0] = pdfgo.Dictionary{"Type": pdfgo.Name("Catalog"), "Pages": pdfgo.Reference{Number: 2}}
p.objects[1] = pdfgo.Dictionary{"Type": pdfgo.Name("Pages"), "Count": pdfgo.Integer(len(children)), "Kids": children}
var out bytes.Buffer
out.WriteString("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
offsets := make([]int, len(p.objects))
for i, object := range p.objects {
if err := p.ctx.Err(); err != nil {
return err
}
offsets[i] = out.Len()
fmt.Fprintf(&out, "%d 0 obj\n", i+1)
if err := printPDFObject(&out, object, 0); err != nil {
return err
}
out.WriteString("\nendobj\n")
}
xref := out.Len()
fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
for _, offset := range offsets {
fmt.Fprintf(&out, "%010d 00000 n \n", offset)
}
fmt.Fprintf(&out, "trailer\n<</Size %d/Root 1 0 R>>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref)
return Write(p.ctx, out.Bytes(), writer, WriteOptions{})
}
// printPDFObject 编码已复制的标准PDF对象,名称和字符串按字节转义
// 入参: out 对象缓冲区, object 待编码对象, depth 当前递归深度
// 返回: error 类型或嵌套错误
func printPDFObject(out *bytes.Buffer, object pdfgo.Object, depth int) error {
if depth > 256 {
return fmt.Errorf("print PDF object nesting too deep")
}
switch value := object.(type) {
case nil:
out.WriteString("null")
case pdfgo.Boolean:
fmt.Fprintf(out, "%t", value)
case pdfgo.Integer:
fmt.Fprintf(out, "%d", value)
case pdfgo.Real:
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return fmt.Errorf("invalid print PDF number")
}
out.WriteString(strconv.FormatFloat(float64(value), 'f', -1, 64))
case pdfgo.Reference:
fmt.Fprintf(out, "%d %d R", value.Number, value.Generation)
case pdfgo.Name:
out.WriteByte('/')
for _, b := range []byte(value) {
if b <= 32 || b >= 127 || strings.ContainsRune("()<>[]{}/%#", rune(b)) {
fmt.Fprintf(out, "#%02X", b)
} else {
out.WriteByte(b)
}
}
case pdfgo.String:
out.WriteByte('<')
out.WriteString(hex.EncodeToString(value))
out.WriteByte('>')
case pdfgo.Array:
out.WriteByte('[')
for _, item := range value {
if err := printPDFObject(out, item, depth+1); err != nil {
return err
}
out.WriteByte(' ')
}
out.WriteByte(']')
case pdfgo.Dictionary:
out.WriteString("<<")
for _, key := range slices.Sorted(maps.Keys(value)) {
if err := printPDFObject(out, key, depth+1); err != nil {
return err
}
out.WriteByte(' ')
if err := printPDFObject(out, value[key], depth+1); err != nil {
return err
}
out.WriteByte('\n')
}
out.WriteString(">>")
case *pdfgo.Stream:
dict := maps.Clone(value.Dictionary)
dict["Length"] = pdfgo.Integer(len(value.Data))
if err := printPDFObject(out, dict, depth+1); err != nil {
return err
}
out.WriteString("\nstream\n")
out.Write(value.Data)
out.WriteString("\nendstream")
default:
return fmt.Errorf("unsupported print PDF object %T", object)
}
return nil
}
+8
View File
@@ -423,6 +423,7 @@ type businessEngine struct {
currentSession *Session
currentEditor *ofdgo.Editor
saveSnapshot *ofdgo.Editor
printDocument *ofdgo.PrintDocument
pendingImport *ofdgo.Reader
copiedObjects *editorClipboard
copiedStyle *ofdgo.GraphicObject
@@ -7445,6 +7446,11 @@ func (c *outputContext) Err() error {
// businessCommands 共用业务命令表
func (e *businessEngine) businessCommands() map[string]func([]platformValue) (any, error) {
return map[string]func([]platformValue) (any, error){
"ofdgoPreparePrint": e.preparePrint,
"ofdgoLayoutPrint": e.layoutPrint,
"ofdgoPrintSide": e.printSide,
"ofdgoPrintPDF": e.printPDF,
"ofdgoDiscardPrint": e.discardPrint,
"ofdgoDetectFormat": e.detectDocumentFormat,
"ofdgoOpen": e.openDocument,
"ofdgoSelectDocument": e.selectDocument,
@@ -7594,6 +7600,8 @@ func (e *businessEngine) operationContext() context.Context {
func failureResult(err error) apiResult {
result := apiResult{Error: err.Error()}
switch {
case errors.Is(err, ofdgo.ErrPrintDenied):
result.Code, result.Error = "printDenied", "文档禁止打印"
case errors.Is(err, ofdgo.ErrPDFPassword):
result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误"
case errors.Is(err, ofdgo.ErrCredentialsRequired):
+1
View File
@@ -357,6 +357,7 @@ func (e *NativeEngine) Close() error {
e.gate <- struct{}{}
defer func() { <-e.gate }()
e.closed = true
_, _ = e.discardPrint(nil)
_ = e.waitRenders(context.Background())
e.renderIdle = nil
e.saveSnapshot = nil
+141
View File
@@ -0,0 +1,141 @@
// 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 webui
import (
"bytes"
"encoding/json"
"fmt"
"github.com/xiaoqidun/ofdgo"
)
// preparePrint 从当前编辑入口创建独立打印快照,业务选项由公开库统一校验
// 入参: args 排版选项及可选输出任务标识
// 返回: any 纸面布局, error 快照、权限或排版错误
func (e *businessEngine) preparePrint(args []platformValue) (any, error) {
if e.currentSession == nil || len(args) == 0 {
return nil, fmt.Errorf("ofd document is not opened")
}
var options ofdgo.PrintOptions
if err := json.Unmarshal([]byte(platformJSON(args[0])), &options); err != nil {
return nil, err
}
ctx := e.operationContext()
if len(args) > 1 {
ctx = e.newOutputContext(args[1])
}
editor := e.currentEditor
if editor == nil {
var err error
editor, err = e.currentSession.Reader.EditorContext(ctx)
if err != nil {
return nil, err
}
}
source := e.currentSession.Renderer
dirs, fonts := source.FontSources()
document, err := editor.PreparePrint(ctx, options, ofdgo.WithRenderBackends(source.Backends()), ofdgo.WithFontDirs(dirs...), ofdgo.WithFontFS(fonts...))
if err != nil {
return nil, err
}
if e.printDocument != nil {
_ = e.printDocument.Close()
}
e.printDocument = document
data, err := json.Marshal(document.Layout())
if err != nil {
return nil, err
}
return successResult(platformParseJSON(data)), nil
}
// layoutPrint 在既有快照上调整排版,预览期间不重复复制源包
// 入参: args 新排版选项及可选输出任务标识
// 返回: any 新纸面布局, error 状态、权限或排版错误
func (e *businessEngine) layoutPrint(args []platformValue) (any, error) {
if e.printDocument == nil || len(args) == 0 {
return nil, fmt.Errorf("print document is not prepared")
}
var options ofdgo.PrintOptions
if err := json.Unmarshal([]byte(platformJSON(args[0])), &options); err != nil {
return nil, err
}
ctx := e.operationContext()
if len(args) > 1 {
ctx = e.newOutputContext(args[1])
}
next, err := e.printDocument.Reconfigure(ctx, options)
if err != nil {
return nil, err
}
_ = e.printDocument.Close()
e.printDocument = next
data, err := json.Marshal(next.Layout())
if err != nil {
return nil, err
}
return successResult(platformParseJSON(data)), nil
}
// printSide 按需输出一个纸面,不使用阅读区缓存或缩略图
// 入参: args 零基纸面索引及可选输出任务标识
// 返回: any 内嵌资源的SVG纸面, error 状态或渲染错误
func (e *businessEngine) printSide(args []platformValue) (any, error) {
if e.printDocument == nil || len(args) == 0 {
return nil, fmt.Errorf("print document is not prepared")
}
ctx := e.operationContext()
if len(args) > 1 {
ctx = e.newOutputContext(args[1])
}
var output bytes.Buffer
if err := e.printDocument.RenderSVG(ctx, args[0].Int(), &output); err != nil {
return nil, err
}
return successResult(map[string]any{"svg": output.String()}), nil
}
// printPDF 输出和预览相同的打印布局,便于另存或交给第三方打印系统
// 入参: args 可选输出任务标识
// 返回: any PDF数据及媒体类型, error 状态或输出错误
func (e *businessEngine) printPDF(args []platformValue) (any, error) {
if e.printDocument == nil {
return nil, fmt.Errorf("print document is not prepared")
}
ctx := e.operationContext()
if len(args) > 0 {
ctx = e.newOutputContext(args[0])
}
var output bytes.Buffer
if err := e.printDocument.WritePDF(ctx, &output); err != nil {
return nil, err
}
return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "mime": "application/pdf"}), nil
}
// discardPrint 释放当前打印快照,可重复调用
// 入参: _ 保留的调用参数
// 返回: any 空结果, error 资源释放错误
func (e *businessEngine) discardPrint(_ []platformValue) (any, error) {
if e.printDocument != nil {
err := e.printDocument.Close()
e.printDocument = nil
if err != nil {
return nil, err
}
}
return successResult(nil), nil
}
+1
View File
@@ -99,6 +99,7 @@ func (w *wasmEngines) close(args []js.Value) (any, error) {
return nil, nil
}
delete(w.engines, id)
_, _ = e.discardPrint(nil)
e.clearImport()
err := e.currentSession.Close()
*e = businessEngine{}
+40 -1
View File
@@ -20,6 +20,7 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
@@ -106,7 +107,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
h.conversionPool = h.resources.ConversionPool()
defer h.close()
bindings := make(map[string]guigo.HandlerFunc)
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open"} {
for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open", "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print"} {
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
}
static := http.FileServerFS(webuiassets.NativeFS)
@@ -305,6 +306,44 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) {
text := func(key string) string { value, _ := args[key].(string); return value }
flag := func(key string) bool { value, _ := args[key].(bool); return value }
switch name {
case "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print":
h.mu.Lock()
window := h.window
h.mu.Unlock()
if window == nil {
return nil, fmt.Errorf("窗口尚未就绪")
}
if name == "print.capabilities" {
return window.PrintCapabilities(ctx)
}
if name == "print.dialog" {
return nil, window.ShowPrintUI(ctx, text("kind"))
}
if name == "print.printers" {
return window.ListPrinters(ctx)
}
if name == "print.properties" {
return nil, window.ShowPrinterProperties(ctx, text("printer"))
}
data, err := json.Marshal(args)
if err != nil {
return nil, err
}
var options guigo.PrintOptions
if err := json.Unmarshal(data, &options); err != nil {
return nil, err
}
profile, err := window.QueryPrinter(ctx, options)
if err != nil {
return nil, err
}
if name == "print.query" {
return profile, nil
}
if text("fingerprint") == "" || text("fingerprint") != profile.Fingerprint {
return nil, fmt.Errorf("打印机设置已变化,请重新预览")
}
return window.Print(ctx, options)
case "engine.open":
engine := webui.NewNativeEngine(h.files.resolve)
parallelism, cacheBytes := runtime.GOMAXPROCS(0), int64(128<<20)
+6
View File
@@ -331,6 +331,12 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a
{Name: "PDF.Popup.Rect", Value: fmt.Sprintf("%g %g %g %g", popup.Rect.XMin, popup.Rect.YMin, popup.Rect.XMax, popup.Rect.YMax)},
}...)
}
if annotation.Subtype != "Popup" {
if converted.Parameters == nil {
converted.Parameters = &AnnotationParameters{}
}
converted.Parameters.Parameter = append(converted.Parameters.Parameter, AnnotationParameter{Name: "PDF.Printable", Value: strconv.FormatBool(flags&4 != 0 && flags&2 == 0 && !invisible)})
}
if len(actions) != 0 {
region, err := NewShape(ShapeRectangle, Box{X: playback.X - box.X, Y: playback.Y - box.Y, W: playback.W, H: playback.H})
if err != nil {
+655
View File
@@ -0,0 +1,655 @@
// 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 (
"context"
"errors"
"fmt"
"math"
"slices"
"sync"
)
// ErrPrintDenied 表示文档不允许打印或请求份数超出声明上限
var ErrPrintDenied = errors.New("document printing is not permitted")
// PrintOptions 设置内容排版,长度以毫米为单位,比例1表示100%
// Pages为零基有序页码,nil表示全部,非nil空列表表示不选择页面;与PageRange互斥
// Mode支持single、multiple、booklet、poster;Scaling支持fit、shrink、actual、custom
// PaperWidth和PaperHeight为实际纸面尺寸,显式提供时不自动交换方向
// 两者同时为零时,单页沿用源尺寸,其他模式采用A4,小册子默认横向
// Parity支持all、odd、even,按源页码过滤;Sides支持all、front、back,按输出纸面过滤
// BookletSheets为每册纸张数,0表示整份一册;Binding支持left、right
// Rows和Columns仅用于多页,Gap为单元间隔,Overlap为海报相邻纸面的重叠
// Duplex支持simplex、longEdge、shortEdge;小册子默认shortEdge,其他模式默认simplex
// PrintableArea为已选设备当前方向下的可打印区域,Margins为该区域内的额外留白
// Reverse反转纸张输出顺序,保留每张纸内部的正背面与拼版顺序
// Copies和Collate描述设备作业,不展开重复内容;SheetFrom/SheetTo为一基纸张范围
// Contents支持documentAndAnnotations、document、annotations、forms,document保留表单外观
// PageOrder支持horizontal、horizontalReverse、vertical、verticalReverse
// BackRotate将背面绕纸张中心旋转180度;海报Labels保留底部5毫米绘制拼接标签
// Scale用于自定义缩放及海报分块,零值采用1;Rotate允许自动旋转页面以适应单元
// Raster将纸面光栅化,Grayscale以光栅方式输出灰度;DPI默认300,上限2400
// Borders绘制单元边框,CropMarks绘制裁切标记,TileOnlyLarge仅分块超大页面
type PrintOptions struct {
Pages []int `json:"pages,omitempty"`
PageRange string `json:"pageRange,omitempty"`
Mode string `json:"mode,omitempty"`
Scaling string `json:"scaling,omitempty"`
PaperWidth float64 `json:"paperWidth,omitempty"`
PaperHeight float64 `json:"paperHeight,omitempty"`
Margins PrintMargins `json:"margins"`
PrintableArea *Box `json:"printableArea,omitempty"`
Duplex string `json:"duplex,omitempty"`
BackRotate bool `json:"backRotate,omitempty"`
Copies int `json:"copies,omitempty"`
Collate *bool `json:"collate,omitempty"`
SheetFrom int `json:"sheetFrom,omitempty"`
SheetTo int `json:"sheetTo,omitempty"`
Scale float64 `json:"scale,omitempty"`
Rotate bool `json:"rotate,omitempty"`
Reverse bool `json:"reverse,omitempty"`
Parity string `json:"parity,omitempty"`
Sides string `json:"sides,omitempty"`
Rows int `json:"rows,omitempty"`
Columns int `json:"columns,omitempty"`
// ColumnOrder 兼容纵向填充设置,仅在PageOrder为空时生效
ColumnOrder bool `json:"columnOrder,omitempty"`
PageOrder string `json:"pageOrder,omitempty"`
Gap float64 `json:"gap,omitempty"`
Overlap float64 `json:"overlap,omitempty"`
BookletSheets int `json:"bookletSheets,omitempty"`
Binding string `json:"binding,omitempty"`
// Annotations 兼容注解开关,仅在Contents为空时生效
Annotations *bool `json:"annotations,omitempty"`
Contents string `json:"contents,omitempty"`
Raster bool `json:"raster,omitempty"`
DPI float64 `json:"dpi,omitempty"`
Grayscale bool `json:"grayscale,omitempty"`
Borders bool `json:"borders,omitempty"`
CropMarks bool `json:"cropMarks,omitempty"`
TileOnlyLarge bool `json:"tileOnlyLarge,omitempty"`
Labels bool `json:"labels,omitempty"`
}
// PrintMargins 保存纸面四边的非负页边距
type PrintMargins struct {
Top float64 `json:"top"`
Right float64 `json:"right"`
Bottom float64 `json:"bottom"`
Left float64 `json:"left"`
}
// PrintPlacement 将归一化源页面坐标映射到纸面,Transform按a、b、c、d、e、f排列
// Clip为纸面坐标裁剪;Page从零开始,同一页面可以多次放置
// Width和Height为归一化源页尺寸;Clipped表示变换后的内容超出裁剪区域
type PrintPlacement struct {
Page int `json:"page"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Transform [6]float64 `json:"transform"`
Clip Box `json:"clip"`
Clipped bool `json:"clipped"`
}
// PrintSide 保存一个输出纸面,Number是过滤前的一基纸面序号,Sheet为一基纸张编号
// Back仅在双面作业中表示背面,TileRow/TileColumn为海报的一基拼接位置
type PrintSide struct {
Number int `json:"number"`
Sheet int `json:"sheet"`
Back bool `json:"back"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Placements []PrintPlacement `json:"placements"`
TileRow int `json:"tileRow,omitempty"`
TileColumn int `json:"tileColumn,omitempty"`
TileRows int `json:"tileRows,omitempty"`
TileColumns int `json:"tileColumns,omitempty"`
Label string `json:"label,omitempty"`
LabelX float64 `json:"labelX,omitempty"`
LabelY float64 `json:"labelY,omitempty"`
}
// PrintLayout 保存独立排版结果,SourcePages为选中源页数,CopiesLimit为文档声明的份数上限
// CopiesLimit为-1时无限制;纯布局计算不绑定文档,默认同样为-1
// Sheets为筛选后的纸张数;BackContent表示正背面筛选前存在含页面内容的背面
type PrintLayout struct {
SourcePages int `json:"sourcePages"`
CopiesLimit int `json:"copiesLimit"`
Copies int `json:"copies"`
Collate bool `json:"collate"`
Duplex string `json:"duplex"`
Sheets int `json:"sheets"`
BackContent bool `json:"backContent"`
Sides []PrintSide `json:"sides"`
}
// PrintDocument 固定排版和渲染配置,可串行输出多个纸面,Close后不可再渲染
// Renderer.PreparePrint借用输入,使用结束前不得关闭或修改源输入及外部字体
// Editor.PreparePrint创建独立编辑快照,不要求保留原编辑器;外部字体仍须可读
// 各方法以互斥锁串行执行,Layout返回独立副本;取消只中断当前输出,不销毁任务
type PrintDocument struct {
mu sync.Mutex
renderer *Renderer
layout PrintLayout
options PrintOptions
closed bool
}
// PlanPrint 仅根据源页面区域和选项计算打印布局,不读取文档或调用设备
// 入参: pages 按文档顺序排列的页面区域, options 打印内容选项
// 返回: PrintLayout 有序纸面, error 尺寸、页码或选项错误
func PlanPrint(pages []Box, options PrintOptions) (PrintLayout, error) {
o, err := normalizePrintOptions(options)
if err != nil {
return PrintLayout{CopiesLimit: -1}, err
}
return planPrint(pages, o)
}
// planPrint 按已校验的选项计算纸面,供独立布局和打印任务共用
// 入参: pages 按文档顺序排列的页面区域, o 规范排版选项
// 返回: PrintLayout 有序纸面, error 尺寸、页码或排版错误
func planPrint(pages []Box, o PrintOptions) (PrintLayout, error) {
result := PrintLayout{CopiesLimit: -1}
if len(pages) == 0 {
return result, fmt.Errorf("no pages to print")
}
for _, page := range pages {
if !printPositive(page.W) || !printPositive(page.H) {
return result, fmt.Errorf("invalid print page size")
}
}
indices := o.Pages
if o.PageRange != "" {
var err error
indices, err = ParsePageRange(o.PageRange, len(pages))
if err != nil {
return result, err
}
} else if o.Pages == nil {
indices = make([]int, len(pages))
for i := range indices {
indices[i] = i
}
}
selected := make([]int, 0, len(indices))
for _, index := range indices {
if index < 0 || index >= len(pages) {
return result, fmt.Errorf("print page index %d outside document", index)
}
if o.Parity == "odd" && index%2 != 0 || o.Parity == "even" && index%2 == 0 {
continue
}
selected = append(selected, index)
}
if len(selected) == 0 {
return result, fmt.Errorf("no pages selected for printing")
}
result.SourcePages = len(selected)
result.Copies, result.Collate, result.Duplex = o.Copies, *o.Collate, o.Duplex
addSide := func(width, height float64, order []int, rows, columns int) error {
area := printPageArea(width, height, o)
cw, ch := (area.W-float64(columns-1)*o.Gap)/float64(columns), (area.H-float64(rows-1)*o.Gap)/float64(rows)
if !printPositive(cw) || !printPositive(ch) {
return fmt.Errorf("print margins or gap exceed paper size")
}
side := PrintSide{Width: width, Height: height, Placements: []PrintPlacement{}}
for cell, index := range order {
if index < 0 {
continue
}
x, y := cell%columns, cell/columns
if o.PageOrder == "vertical" || o.PageOrder == "verticalReverse" {
x, y = cell/rows, cell%rows
}
if o.PageOrder == "horizontalReverse" || o.PageOrder == "verticalReverse" {
x = columns - 1 - x
}
box := Box{X: area.X + float64(x)*(cw+o.Gap), Y: area.Y + float64(y)*(ch+o.Gap), W: cw, H: ch}
placement := printPlacement(index, pages[index], box, o)
for _, value := range placement.Transform {
if math.IsNaN(value) || math.IsInf(value, 0) {
return fmt.Errorf("invalid print placement")
}
}
side.Placements = append(side.Placements, placement)
}
if o.Mode == "poster" && o.Labels && len(order) > 0 {
side.Label = fmt.Sprintf("Page %d Row 1/1 Column 1/1", order[0]+1)
side.LabelX, side.LabelY = area.X+1, area.Y+area.H+3.5
}
result.Sides = append(result.Sides, side)
return nil
}
w, h := o.PaperWidth, o.PaperHeight
if w == 0 && o.Mode != "single" {
w, h = 210, 297
if o.Mode == "booklet" {
w, h = h, w
}
}
switch o.Mode {
case "single":
for _, index := range selected {
pw, ph := w, h
if pw == 0 {
pw, ph = pages[index].W, pages[index].H
}
if err := addSide(pw, ph, []int{index}, 1, 1); err != nil {
return PrintLayout{}, err
}
}
case "multiple":
count := o.Rows * o.Columns
for start := 0; start < len(selected); start += count {
if err := addSide(w, h, selected[start:min(start+count, len(selected))], o.Rows, o.Columns); err != nil {
return PrintLayout{}, err
}
}
case "booklet":
chunk := ((len(selected) + 3) / 4) * 4
if o.BookletSheets > 0 {
chunk = o.BookletSheets * 4
}
for start := 0; start < len(selected); start += chunk {
end := min(start+chunk, len(selected))
order := make([]int, ((end-start+3)/4)*4)
for i := range order {
order[i] = -1
}
copy(order, selected[start:end])
for i := 0; i < len(order)/4; i++ {
for _, pair := range [][]int{{order[len(order)-1-i*2], order[i*2]}, {order[i*2+1], order[len(order)-2-i*2]}} {
if o.Binding == "right" {
slices.Reverse(pair)
}
if err := addSide(w, h, pair, 1, 2); err != nil {
return PrintLayout{}, err
}
}
}
}
case "poster":
area := printPageArea(w, h, o)
if area.W <= o.Overlap || area.H <= o.Overlap {
return result, fmt.Errorf("poster overlap or margins exceed paper size")
}
for _, index := range selected {
pw, ph := pages[index].W*o.Scale, pages[index].H*o.Scale
if o.TileOnlyLarge && pw <= area.W && ph <= area.H {
if err := addSide(w, h, []int{index}, 1, 1); err != nil {
return PrintLayout{}, err
}
continue
}
columns, rows := max(1, math.Ceil((pw-o.Overlap)/(area.W-o.Overlap))), max(1, math.Ceil((ph-o.Overlap)/(area.H-o.Overlap)))
if !printPositive(columns*rows) || columns*rows > 10000 || len(result.Sides)+int(columns*rows) > 10000 {
return result, fmt.Errorf("too many poster sides")
}
for row := 0; row < int(rows); row++ {
for column := 0; column < int(columns); column++ {
m := [6]float64{o.Scale, 0, 0, o.Scale, area.X - float64(column)*(area.W-o.Overlap), area.Y - float64(row)*(area.H-o.Overlap)}
side := PrintSide{Width: w, Height: h, TileRow: row + 1, TileColumn: column + 1, TileRows: int(rows), TileColumns: int(columns), Placements: []PrintPlacement{{Page: index, Width: pages[index].W, Height: pages[index].H, Transform: m, Clip: area}}}
if o.Labels {
side.Label = fmt.Sprintf("Page %d Row %d/%d Column %d/%d", index+1, row+1, int(rows), column+1, int(columns))
side.LabelX, side.LabelY = area.X+1, area.Y+area.H+3.5
}
result.Sides = append(result.Sides, side)
}
}
}
}
if o.Duplex != "simplex" {
paired := make([]PrintSide, 0, len(result.Sides)+1)
for _, side := range result.Sides {
if len(paired)%2 == 1 {
previous := paired[len(paired)-1]
if previous.Width != side.Width || previous.Height != side.Height {
paired = append(paired, PrintSide{Width: previous.Width, Height: previous.Height, Placements: []PrintPlacement{}})
}
}
paired = append(paired, side)
}
if len(paired)%2 == 1 {
previous := paired[len(paired)-1]
paired = append(paired, PrintSide{Width: previous.Width, Height: previous.Height, Placements: []PrintPlacement{}})
}
result.Sides = paired
}
for i := range result.Sides {
result.Sides[i].Number = i + 1
result.Sides[i].Sheet = i + 1
if o.Duplex != "simplex" {
result.Sides[i].Sheet = i/2 + 1
result.Sides[i].Back = i%2 == 1
}
if o.BackRotate && result.Sides[i].Back {
for j := range result.Sides[i].Placements {
placement := &result.Sides[i].Placements[j]
m := placement.Transform
placement.Transform = [6]float64{-m[0], -m[1], -m[2], -m[3], result.Sides[i].Width - m[4], result.Sides[i].Height - m[5]}
placement.Clip.X = result.Sides[i].Width - placement.Clip.X - placement.Clip.W
placement.Clip.Y = result.Sides[i].Height - placement.Clip.Y - placement.Clip.H
area := printPageArea(result.Sides[i].Width, result.Sides[i].Height, o)
x, y := math.Max(area.X, placement.Clip.X), math.Max(area.Y, placement.Clip.Y)
right, bottom := math.Min(area.X+area.W, placement.Clip.X+placement.Clip.W), math.Min(area.Y+area.H, placement.Clip.Y+placement.Clip.H)
if right-x < placement.Clip.W-1e-6 || bottom-y < placement.Clip.H-1e-6 {
placement.Clipped = true
}
placement.Clip = Box{X: x, Y: y, W: math.Max(0, right-x), H: math.Max(0, bottom-y)}
}
}
}
lastSheet := result.Sides[len(result.Sides)-1].Sheet
if o.SheetFrom > lastSheet || o.SheetTo > lastSheet {
return result, fmt.Errorf("print sheet outside layout")
}
for _, side := range result.Sides {
if side.Back && len(side.Placements) > 0 && (o.SheetFrom == 0 || side.Sheet >= o.SheetFrom) && (o.SheetTo == 0 || side.Sheet <= o.SheetTo) {
result.BackContent = true
break
}
}
if o.Sides != "all" || o.SheetFrom != 0 || o.SheetTo != 0 {
filtered := make([]PrintSide, 0, len(result.Sides))
for _, side := range result.Sides {
if o.SheetFrom > 0 && side.Sheet < o.SheetFrom || o.SheetTo > 0 && side.Sheet > o.SheetTo {
continue
}
if o.Sides == "all" || o.Sides == "front" && !side.Back || o.Sides == "back" && side.Back {
filtered = append(filtered, side)
}
}
result.Sides = filtered
}
if len(result.Sides) == 0 {
return result, fmt.Errorf("no print sides selected")
}
if o.Reverse {
slices.Reverse(result.Sides)
for i := 0; i+1 < len(result.Sides); i++ {
if result.Sides[i].Sheet == result.Sides[i+1].Sheet {
result.Sides[i], result.Sides[i+1] = result.Sides[i+1], result.Sides[i]
i++
}
}
}
previous := 0
for _, side := range result.Sides {
if side.Sheet != previous {
result.Sheets++
previous = side.Sheet
}
}
return result, nil
}
// normalizePrintOptions 校验有限尺寸并填充公共默认值
// 入参: o 原始排版选项
// 返回: PrintOptions 独立规范选项, error 配置错误
func normalizePrintOptions(o PrintOptions) (PrintOptions, error) {
if o.Mode == "" {
o.Mode = "single"
}
if o.Scaling == "" {
o.Scaling = "shrink"
}
if o.Parity == "" {
o.Parity = "all"
}
if o.Sides == "" {
o.Sides = "all"
}
if o.Binding == "" {
o.Binding = "left"
}
if o.Duplex == "" {
o.Duplex = "simplex"
if o.Mode == "booklet" {
o.Duplex = "shortEdge"
}
}
if o.Copies == 0 {
o.Copies = 1
}
if o.Collate == nil {
collate := true
o.Collate = &collate
} else {
collate := *o.Collate
o.Collate = &collate
}
if o.PageOrder == "" {
o.PageOrder = "horizontal"
if o.ColumnOrder {
o.PageOrder = "vertical"
}
}
if o.Contents == "" {
o.Contents = "documentAndAnnotations"
if o.Annotations != nil && !*o.Annotations {
o.Contents = "document"
}
}
o.ColumnOrder, o.Annotations = false, nil
if !slices.Contains([]string{"document", "documentAndAnnotations", "annotations", "forms"}, o.Contents) {
return o, fmt.Errorf("invalid print contents")
}
if o.Scale == 0 {
o.Scale = 1
}
if o.DPI == 0 {
o.DPI = 300
}
if o.Rows == 0 {
o.Rows = 1
}
if o.Columns == 0 {
o.Columns = 2
}
if !slices.Contains([]string{"single", "multiple", "booklet", "poster"}, o.Mode) || !slices.Contains([]string{"fit", "shrink", "actual", "custom"}, o.Scaling) || !slices.Contains([]string{"all", "odd", "even"}, o.Parity) || !slices.Contains([]string{"all", "front", "back"}, o.Sides) || !slices.Contains([]string{"left", "right"}, o.Binding) || !slices.Contains([]string{"simplex", "longEdge", "shortEdge"}, o.Duplex) || !slices.Contains([]string{"horizontal", "horizontalReverse", "vertical", "verticalReverse"}, o.PageOrder) {
return o, fmt.Errorf("invalid print mode")
}
if o.Copies < 1 || o.Copies > 999 || o.SheetFrom < 0 || o.SheetTo < 0 || o.SheetTo > 0 && o.SheetTo < o.SheetFrom || o.Duplex == "simplex" && (o.Mode == "booklet" || o.Sides != "all") {
return o, fmt.Errorf("invalid print job options")
}
if o.PrintableArea != nil {
area := *o.PrintableArea
if !printPositive(area.W) || !printPositive(area.H) || !printPositive(area.X+1) || !printPositive(area.Y+1) || area.X < 0 || area.Y < 0 || o.PaperWidth <= 0 || area.X+area.W > o.PaperWidth+1e-6 || area.Y+area.H > o.PaperHeight+1e-6 {
return o, fmt.Errorf("invalid printer printable area")
}
o.PrintableArea = &area
}
for _, v := range []float64{o.PaperWidth, o.PaperHeight, o.Margins.Top, o.Margins.Right, o.Margins.Bottom, o.Margins.Left, o.Gap, o.Overlap} {
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return o, fmt.Errorf("invalid print dimensions")
}
}
if (o.PaperWidth == 0) != (o.PaperHeight == 0) || !printPositive(o.Scale) || o.Scale > 100 || !printPositive(o.DPI) || o.DPI > 2400 || o.Rows < 1 || o.Columns < 1 || o.Rows > 16 || o.Columns > 16 || o.BookletSheets < 0 || o.BookletSheets > 2500 || o.Pages != nil && o.PageRange != "" {
return o, fmt.Errorf("invalid print options")
}
o.Pages = slices.Clone(o.Pages)
return o, nil
}
// printPositive 检查正有限数
// 入参: v 待校验数值
// 返回: bool 是否为正有限数
func printPositive(v float64) bool {
return v > 0 && !math.IsInf(v, 0) && !math.IsNaN(v)
}
// printPageArea 在设备可打印区域内应用额外留白,未知设备时使用整张纸
// 入参: w 纸面宽度, h 纸面高度, o 排版选项
// 返回: Box 应用额外边距后的毫米区域
func printPageArea(w, h float64, o PrintOptions) Box {
area := Box{W: w, H: h}
if o.PrintableArea != nil {
area = *o.PrintableArea
}
area.X += o.Margins.Left
area.Y += o.Margins.Top
area.W -= o.Margins.Left + o.Margins.Right
area.H -= o.Margins.Top + o.Margins.Bottom
if o.Mode == "poster" && o.Labels {
area.H -= 5
}
return area
}
// printPlacement 计算居中及自动旋转后的单元变换
// 入参: index 零基源页码, source 源页面区域, cell 纸面单元区域, o 排版选项
// 返回: PrintPlacement 页面放置及裁剪信息
func printPlacement(index int, source, cell Box, o PrintOptions) PrintPlacement {
w, h := source.W, source.H
rotated := o.Rotate && math.Min(cell.W/h, cell.H/w) > math.Min(cell.W/w, cell.H/h)
if rotated {
w, h = h, w
}
scale := 1.0
switch o.Scaling {
case "fit":
scale = math.Min(cell.W/w, cell.H/h)
case "shrink":
scale = math.Min(1, math.Min(cell.W/w, cell.H/h))
case "custom":
scale = o.Scale
}
x, y := cell.X+(cell.W-w*scale)/2, cell.Y+(cell.H-h*scale)/2
m := [6]float64{scale, 0, 0, scale, x, y}
if rotated {
m = [6]float64{0, scale, -scale, 0, x + source.H*scale, y}
}
clipped := w*scale > cell.W+1e-6 || h*scale > cell.H+1e-6
return PrintPlacement{Page: index, Width: source.W, Height: source.H, Transform: m, Clip: cell, Clipped: clipped}
}
// PreparePrint 固定当前阅读入口、版本及配置,底层输入由调用方保持存活
// 入参: ctx 准备上下文, options 内容排版选项
// 返回: *PrintDocument 独立打印任务, error 权限、布局或读取错误
func (r *Renderer) PreparePrint(ctx context.Context, options PrintOptions) (*PrintDocument, error) {
if ctx == nil {
return nil, fmt.Errorf("nil print context")
}
if r == nil || r.Reader == nil {
return nil, fmt.Errorf("missing print renderer")
}
o, err := normalizePrintOptions(options)
if err != nil {
return nil, err
}
view, err := r.Fork(ctx)
if err != nil {
return nil, err
}
doc, err := view.Reader.DocContext(ctx)
if err != nil {
return nil, err
}
if !doc.Permissions.Print || doc.Permissions.Copies == 0 {
return nil, ErrPrintDenied
}
if doc.Permissions.Copies > 0 && o.Copies > doc.Permissions.Copies {
return nil, ErrPrintDenied
}
boxes := make([]Box, len(doc.Pages.Page))
for i, page := range doc.Pages.Page {
if err := ctx.Err(); err != nil {
return nil, err
}
area, err := view.Reader.readPageHeaderContext(ctx, page)
if err != nil {
return nil, err
}
boxes[i], err = view.GetPageBox(&area)
if err != nil {
return nil, err
}
}
layout, err := planPrint(boxes, o)
if err != nil {
return nil, err
}
layout.CopiesLimit = doc.Permissions.Copies
view.printing = true
view.printContents = o.Contents
view.RenderAnnotations = true
return &PrintDocument{renderer: view, layout: layout, options: o}, nil
}
// PreparePrint 从独立编辑快照创建打印任务,不修改源编辑器或撤销记录
// 入参: ctx 准备上下文, options 排版选项, rendererOptions 字体及后端配置
// 返回: *PrintDocument 打印任务, error 快照或准备错误
func (e *Editor) PreparePrint(ctx context.Context, options PrintOptions, rendererOptions ...RendererOption) (*PrintDocument, error) {
if ctx == nil || e == nil {
return nil, fmt.Errorf("missing print editor or context")
}
snapshot, err := e.Snapshot(ctx)
if err != nil {
return nil, err
}
reader, err := snapshot.Reader(ctx)
if err != nil {
return nil, err
}
defer reader.Close()
renderer := NewRenderer(reader, rendererOptions...)
return renderer.PreparePrint(ctx, options)
}
// Layout 返回可供第三方预览和检查的独立布局副本
// 返回: PrintLayout 独立布局副本
func (p *PrintDocument) Layout() PrintLayout {
p.mu.Lock()
defer p.mu.Unlock()
result := p.layout
result.Sides = slices.Clone(result.Sides)
for i := range result.Sides {
result.Sides[i].Placements = slices.Clone(result.Sides[i].Placements)
}
return result
}
// Reconfigure 在相同文档快照上创建新的打印任务,不改变原任务的布局
// 入参: ctx 准备上下文, options 新排版选项
// 返回: *PrintDocument 独立打印任务, error 权限、布局或读取错误
func (p *PrintDocument) Reconfigure(ctx context.Context, options PrintOptions) (*PrintDocument, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil, fmt.Errorf("print document is closed")
}
return p.renderer.PreparePrint(ctx, options)
}
// Close 释放任务持有的派生缓存和快照,不关闭调用方借入的输入
// 返回: error 资源释放错误
func (p *PrintDocument) Close() error {
if p == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil
}
p.closed = true
p.renderer.ClearCache()
err := p.renderer.Reader.Close()
p.renderer = nil
return err
}
+357
View File
@@ -0,0 +1,357 @@
// 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"
"context"
"encoding/base64"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"math"
"slices"
codecpdf "github.com/xiaoqidun/ofdgo/internal/codec/pdf"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/f64"
"golang.org/x/image/math/fixed"
)
// printSide 校验任务及纸面索引,并设置当前输出上下文
// 入参: ctx 输出上下文, index 零基纸面索引
// 返回: PrintSide 当前纸面, error 状态、索引或取消错误
func (p *PrintDocument) printSide(ctx context.Context, index int) (PrintSide, error) {
if ctx == nil {
return PrintSide{}, fmt.Errorf("nil print context")
}
if err := ctx.Err(); err != nil {
return PrintSide{}, err
}
if p.closed {
return PrintSide{}, fmt.Errorf("print document is closed")
}
if index < 0 || index >= len(p.layout.Sides) {
return PrintSide{}, fmt.Errorf("print side outside layout")
}
p.renderer.OutputContext, p.renderer.pageContext = ctx, ctx
return p.layout.Sides[index], nil
}
// RenderSVG 输出一个完整纸面的独立SVG,字体和图片内嵌,不依赖界面资源
// 入参: ctx 输出上下文, side 零基纸面索引, writer 输出流
// 返回: error 读取、后端、写入或取消错误;失败时调用方丢弃部分输出
func (p *PrintDocument) RenderSVG(ctx context.Context, side int, writer io.Writer) error {
p.mu.Lock()
defer p.mu.Unlock()
return p.renderPrintSVG(ctx, side, writer)
}
// renderPrintSVG 按统一布局输出矢量或显式光栅纸面
// 入参: ctx 输出上下文, index 零基纸面索引, writer 输出流
// 返回: error 渲染、写入或取消错误
func (p *PrintDocument) renderPrintSVG(ctx context.Context, index int, writer io.Writer) error {
side, err := p.printSide(ctx, index)
if err != nil {
return err
}
if writer == nil {
return fmt.Errorf("missing print writer")
}
var out bytes.Buffer
fmt.Fprintf(&out, `<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"><rect width="100%%" height="100%%" fill="white"/>`, side.Width, side.Height, side.Width, side.Height)
if p.options.Raster || p.options.Grayscale {
img, err := p.renderPrintImage(ctx, index, p.options.DPI)
if err != nil {
return err
}
var data bytes.Buffer
if err := png.Encode(&data, img); err != nil {
return err
}
fmt.Fprintf(&out, `<image width="%g" height="%g" xlink:href="data:image/png;base64,%s"/>`, side.Width, side.Height, base64.StdEncoding.EncodeToString(data.Bytes()))
} else {
for i, placement := range side.Placements {
if err := ctx.Err(); err != nil {
return err
}
page, err := p.renderer.Reader.PageContentByIndex(placement.Page)
if err != nil {
return err
}
var data bytes.Buffer
if err := p.renderer.RenderToSVG(page, &data); err != nil {
return err
}
c, m := placement.Clip, placement.Transform
fmt.Fprintf(&out, `<defs><clipPath id="print%d"><rect x="%g" y="%g" width="%g" height="%g"/></clipPath></defs><g clip-path="url(#print%d)"><image width="%g" height="%g" transform="matrix(%g %g %g %g %g %g)" xlink:href="data:image/svg+xml;base64,%s"/></g>`, i, c.X, c.Y, c.W, c.H, i, placement.Width, placement.Height, m[0], m[1], m[2], m[3], m[4], m[5], base64.StdEncoding.EncodeToString(data.Bytes()))
}
for _, line := range p.printLines(side) {
fmt.Fprintf(&out, `<path d="M %g %g L %g %g" fill="none" stroke="black" stroke-width="0.15"/>`, line[0], line[1], line[2], line[3])
}
if side.Label != "" {
fmt.Fprintf(&out, `<text x="%g" y="%g" font-family="sans-serif" font-size="2.8" fill="black">%s</text>`, side.LabelX, side.LabelY, html.EscapeString(side.Label))
}
}
out.WriteString(`</svg>`)
_, err = io.Copy(convertWriter{ctx: ctx, writer: writer, count: new(int64)}, &out)
return err
}
// RenderImage 按指定DPI输出一个纸面的独立白底图像,dpi为0时采用任务配置
// 入参: ctx 输出上下文, side 零基纸面索引, dpi 输出分辨率
// 返回: image.Image 纸面图像, error 配置、读取、后端或取消错误
func (p *PrintDocument) RenderImage(ctx context.Context, side int, dpi float64) (image.Image, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.renderPrintImage(ctx, side, dpi)
}
// renderPrintImage 仅保留当前纸面及单个源页图像,不积累整份文档像素
// 入参: ctx 输出上下文, index 零基纸面索引, dpi 输出分辨率
// 返回: image.Image 白底纸面图像, error 渲染或取消错误
func (p *PrintDocument) renderPrintImage(ctx context.Context, index int, dpi float64) (image.Image, error) {
side, err := p.printSide(ctx, index)
if err != nil {
return nil, err
}
if dpi == 0 {
dpi = p.options.DPI
}
if !printPositive(dpi) || dpi > 2400 {
return nil, fmt.Errorf("invalid print DPI")
}
scale := dpi / 25.4
w, h, err := (&RasterPage{Width: side.Width, Height: side.Height, DPI: dpi}).PixelSize()
if err != nil {
return nil, err
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(dst, dst.Bounds(), image.White, image.Point{}, draw.Src)
for _, placement := range side.Placements {
if err := ctx.Err(); err != nil {
return nil, err
}
page, err := p.renderer.Reader.PageContentByIndex(placement.Page)
if err != nil {
return nil, err
}
view := *p.renderer
m := placement.Transform
view.DPI = dpi * math.Hypot(m[0], m[1])
img, err := view.RenderToImage(page)
if err != nil {
return nil, err
}
px, py := placement.Width/float64(img.Bounds().Dx()), placement.Height/float64(img.Bounds().Dy())
matrix := f64.Aff3{m[0] * px * scale, m[2] * py * scale, m[4] * scale, m[1] * px * scale, m[3] * py * scale, m[5] * scale}
clip := placement.Clip
rect := image.Rect(int(math.Ceil(clip.X*scale)), int(math.Ceil(clip.Y*scale)), int(math.Floor((clip.X+clip.W)*scale)), int(math.Floor((clip.Y+clip.H)*scale))).Intersect(dst.Bounds())
if !rect.Empty() {
for top := rect.Min.Y; top < rect.Max.Y; top += 128 {
if err := ctx.Err(); err != nil {
return nil, err
}
tile := image.Rect(rect.Min.X, top, rect.Max.X, min(top+128, rect.Max.Y))
xdraw.CatmullRom.Transform(dst.SubImage(tile).(*image.RGBA), matrix, img, img.Bounds(), draw.Over, nil)
}
}
}
for _, line := range p.printLines(side) {
x0, y0, x1, y1 := line[0]*scale, line[1]*scale, line[2]*scale, line[3]*scale
steps := max(1, int(math.Ceil(math.Max(math.Abs(x1-x0), math.Abs(y1-y0)))))
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
dst.Set(int(x0+(x1-x0)*t), int(y0+(y1-y0)*t), color.Black)
}
}
if side.Label != "" {
label := image.NewRGBA(image.Rect(0, 0, len(side.Label)*7, 13))
(&font.Drawer{Dst: label, Src: image.Black, Face: basicfont.Face7x13, Dot: fixed.P(0, 11)}).DrawString(side.Label)
x, y := int(side.LabelX*scale), int((side.LabelY-2.8)*scale)
width, height := int(math.Round(float64(label.Bounds().Dx())*2.8/13*scale)), max(1, int(math.Round(2.8*scale)))
xdraw.CatmullRom.Scale(dst, image.Rect(x, y, x+max(1, width), y+height), label, label.Bounds(), draw.Over, nil)
}
if p.options.Grayscale {
for y := 0; y < h; y++ {
if err := ctx.Err(); err != nil {
return nil, err
}
for x := 0; x < w; x++ {
i := dst.PixOffset(x, y)
v := uint8((uint32(dst.Pix[i])*19595 + uint32(dst.Pix[i+1])*38470 + uint32(dst.Pix[i+2])*7471 + 32768) >> 16)
dst.Pix[i], dst.Pix[i+1], dst.Pix[i+2] = v, v, v
}
}
}
return dst, ctx.Err()
}
// printLines 生成纸面边框和位于可打印区域内的裁切标记
// 入参: side 当前输出纸面
// 返回: [][4]float64 毫米坐标线段
func (p *PrintDocument) printLines(side PrintSide) [][4]float64 {
var result [][4]float64
for _, placement := range side.Placements {
b := placement.Clip
if p.options.Borders {
result = append(result, [4]float64{b.X, b.Y, b.X + b.W, b.Y}, [4]float64{b.X, b.Y, b.X, b.Y + b.H}, [4]float64{b.X + b.W, b.Y, b.X + b.W, b.Y + b.H}, [4]float64{b.X, b.Y + b.H, b.X + b.W, b.Y + b.H})
}
if p.options.CropMarks {
limits := b
m := placement.Transform
x0, y0, x1, y1 := math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
for _, point := range [][2]float64{{0, 0}, {placement.Width, 0}, {0, placement.Height}, {placement.Width, placement.Height}} {
x, y := m[0]*point[0]+m[2]*point[1]+m[4], m[1]*point[0]+m[3]*point[1]+m[5]
x0, y0, x1, y1 = math.Min(x0, x), math.Min(y0, y), math.Max(x1, x), math.Max(y1, y)
}
if side.TileColumns > 0 {
if side.TileColumn > 1 {
b.X += p.options.Overlap / 2
b.W -= p.options.Overlap / 2
}
if side.TileColumn < side.TileColumns {
b.W -= p.options.Overlap / 2
}
if side.TileRow > 1 {
b.Y += p.options.Overlap / 2
b.H -= p.options.Overlap / 2
}
if side.TileRow < side.TileRows {
b.H -= p.options.Overlap / 2
}
}
x0, y0, x1, y1 = math.Max(x0, b.X), math.Max(y0, b.Y), math.Min(x1, b.X+b.W), math.Min(y1, b.Y+b.H)
b = Box{X: x0, Y: y0, W: x1 - x0, H: y1 - y0}
if b.W <= 0 || b.H <= 0 {
continue
}
for _, x := range []float64{b.X, b.X + b.W} {
for _, y := range []float64{b.Y, b.Y + b.H} {
dx, dy, roomX, roomY := -1.0, -1.0, x-limits.X, y-limits.Y
if x > b.X {
dx = 1
roomX = limits.X + limits.W - x
}
if y > b.Y {
dy = 1
roomY = limits.Y + limits.H - y
}
if roomX > 0.7 {
result = append(result, [4]float64{x + dx*0.5, y, x + dx*math.Min(3.5, roomX-0.1), y})
}
if roomY > 0.7 {
result = append(result, [4]float64{x, y + dy*0.5, x, y + dy*math.Min(3.5, roomY-0.1)})
}
}
}
}
}
return result
}
// WriteHTML 输出不含应用脚本的独立打印文档,每个纸面按毫米尺寸分页
// 入参: ctx 输出上下文, writer 输出流, title 文档标题
// 返回: error 输出或取消错误;调用方在字体和图片加载后触发打印
func (p *PrintDocument) WriteHTML(ctx context.Context, writer io.Writer, title string) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, err := p.printSide(ctx, 0); err != nil {
return err
}
if writer == nil {
return fmt.Errorf("missing print writer")
}
out := convertWriter{ctx: ctx, writer: writer, count: new(int64)}
if _, err := fmt.Fprintf(out, "<!doctype html><html><head><meta charset=\"utf-8\"><title>%s</title><style>html,body{margin:0;padding:0;background:white}*{box-sizing:border-box}section{break-after:page;overflow:hidden;print-color-adjust:exact;-webkit-print-color-adjust:exact}section:last-child{break-after:auto}img{display:block;width:100%%;height:100%%}@media screen{section{margin:12px auto;box-shadow:0 0 4px #888}}</style><style>", html.EscapeString(title)); err != nil {
return err
}
for i, side := range p.layout.Sides {
if _, err := fmt.Fprintf(out, "@page s%d{size:%gmm %gmm;margin:0}.s%d{page:s%d;width:%gmm;height:%gmm}", i, side.Width, side.Height, i, i, side.Width, side.Height); err != nil {
return err
}
}
if _, err := io.WriteString(out, "</style></head><body>"); err != nil {
return err
}
for i := range p.layout.Sides {
var svg bytes.Buffer
if err := p.renderPrintSVG(ctx, i, &svg); err != nil {
return err
}
if _, err := fmt.Fprintf(out, `<section class="s%d"><img src="data:image/svg+xml;base64,%s"></section>`, i, base64.StdEncoding.EncodeToString(svg.Bytes())); err != nil {
return err
}
}
_, err := io.WriteString(out, "</body></html>")
return err
}
// WritePDF 将相同打印布局写成矢量PDF;显式图像或灰度模式按任务DPI输出
// 入参: ctx 输出上下文, writer 输出流
// 返回: error 后端、读取、写入或取消错误;失败时调用方丢弃部分输出
func (p *PrintDocument) WritePDF(ctx context.Context, writer io.Writer) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, err := p.printSide(ctx, 0); err != nil {
return err
}
if writer == nil {
return fmt.Errorf("missing print writer")
}
sheets := make([]codecpdf.PrintSheet, len(p.layout.Sides))
raster := p.options.Raster || p.options.Grayscale
for i, side := range p.layout.Sides {
sheets[i] = codecpdf.PrintSheet{Width: side.Width, Height: side.Height}
if raster {
continue
}
sheets[i].Lines = p.printLines(side)
sheets[i].Label, sheets[i].LabelX, sheets[i].LabelY = side.Label, side.LabelX, side.LabelY
for _, placement := range side.Placements {
sheets[i].Placements = append(sheets[i].Placements, codecpdf.PrintPlacement{Page: placement.Page, Matrix: placement.Transform, Clip: [4]float64{placement.Clip.X, placement.Clip.Y, placement.Clip.W, placement.Clip.H}})
}
}
if raster {
return codecpdf.WritePrintImages(ctx, writer, sheets, func(i int) (image.Image, error) { return p.renderPrintImage(ctx, i, p.options.DPI) })
}
var indices []int
for _, side := range p.layout.Sides {
for _, placement := range side.Placements {
indices = append(indices, placement.Page)
}
}
slices.Sort(indices)
indices = slices.Compact(indices)
if len(indices) == 0 {
return codecpdf.WritePrintPDF(ctx, writer, nil, sheets)
}
var source bytes.Buffer
if err := p.renderer.RenderToMultiPagePDF(&source, indices...); err != nil {
return err
}
for i := range sheets {
for j := range sheets[i].Placements {
sheets[i].Placements[j].Page, _ = slices.BinarySearch(indices, sheets[i].Placements[j].Page)
}
}
return codecpdf.WritePrintPDF(ctx, writer, source.Bytes(), sheets)
}
+18
View File
@@ -77,6 +77,9 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error {
}
}
for order := range 3 {
if r.printing && (r.printContents == "annotations" || r.printContents == "forms") {
break
}
if r.Reader.doc != nil {
for _, ref := range page.Template {
kind := ref.ZOrder
@@ -103,6 +106,18 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error {
}
if r.RenderAnnotations {
for _, annotation := range r.Reader.Annots[page.ID] {
if r.printing && (r.printContents == "document" && annotation.Subtype != "Widget" || r.printContents == "forms" && annotation.Subtype != "Widget" || r.printContents == "annotations" && annotation.Subtype == "Widget") {
continue
}
if r.printing && annotation.Parameters != nil {
for _, parameter := range annotation.Parameters.Parameter {
if parameter.Name == "PDF.Printable" && (parameter.Value == "true" || parameter.Value == "false") {
visible := parameter.Value == "true"
annotation.Visible = &visible
break
}
}
}
if annotation.Visible != nil && !*annotation.Visible && !pageIncludesHidden(visitor) {
continue
}
@@ -111,6 +126,9 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error {
}
}
}
if r.printing && (r.printContents == "annotations" || r.printContents == "forms") {
return nil
}
for _, stamp := range r.Reader.Stamps[page.ID] {
if r.pageContext != nil {
if err := r.pageContext.Err(); err != nil {
+5
View File
@@ -47,6 +47,8 @@ type Renderer struct {
pageText *PageText
pageContext context.Context
textOnly bool
printing bool
printContents string
textContentOnly bool
backends RenderBackends
backendStates map[any]any
@@ -84,6 +86,8 @@ func (r *Renderer) Fork(ctx context.Context) (*Renderer, error) {
next.OutputContext = ctx
next.TransparentBackground = r.TransparentBackground
next.decodeImages = r.decodeImages
next.printing = r.printing
next.printContents = r.printContents
next.imageCache = r.imageCache
next.fontDigests = r.fontDigests
next.fontPreparations = r.fontPreparations
@@ -150,6 +154,7 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer {
opts = append(opts, WithFontFS(r.fontFS...))
}
renderer := NewRenderer(reader, opts...)
renderer.printing = r.printing
renderer.sharedBackendStates = r.sharedBackendStates
renderer.fontPreparations = r.fontPreparations
renderer.fontDigests = r.fontDigests
+16 -8
View File
@@ -138,9 +138,13 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
return fmt.Errorf("no pages found")
}
doc := r.Reader.doc
navigation, err := newPDFNavigation(r, doc, pages)
if err != nil {
return err
navigation := &pdfNavigation{}
if !r.printing {
var err error
navigation, err = newPDFNavigation(r, doc, pages)
if err != nil {
return err
}
}
buf, direct := writer.(*bytes.Buffer)
if r.Compression.Mode != CompressionUnchanged {
@@ -182,16 +186,20 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
buf.Truncate(start)
return fmt.Errorf("failed to read page %d: %w", i+1, err)
}
if err := navigation.addPage(r, i, content, pages); err != nil {
buf.Truncate(start)
return fmt.Errorf("failed to read page %d navigation: %w", i+1, err)
if !r.printing {
if err := navigation.addPage(r, i, content, pages); err != nil {
buf.Truncate(start)
return fmt.Errorf("failed to read page %d navigation: %w", i+1, err)
}
}
if i > 0 {
p.NewPage(page.Box.W, page.Box.H)
renderer.images = append(renderer.images, nil)
renderer.text = append(renderer.text, codecpdf.TextPage{})
}
navigation.apply(p, i)
if !r.printing {
navigation.apply(p, i)
}
if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), content, !r.TransparentBackground); err != nil {
buf.Truncate(start)
return fmt.Errorf("failed to render page %d: %w", i+1, err)
@@ -216,7 +224,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
return err
}
}
if doc != nil {
if doc != nil && !r.printing {
if err := navigation.addOutlines(doc.Outlines.OutlineElem, 0, navigation.bookmarks, navigation.pageIndex, pages); err != nil {
buf.Truncate(start)
return err