mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
1 parent
799041171b
commit
0ae9ad6562
22 files changed
+2220
-21
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {}));
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user