mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 14:53:44 +08:00
1 parent
799041171b
commit
0ae9ad6562
22 files changed
+2214
-15
No files matched your search
@@ -18,10 +18,10 @@ import "embed"
|
|||||||
|
|
||||||
// FS WebUI嵌入静态文件
|
// 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
|
var FS embed.FS
|
||||||
|
|
||||||
// NativeFS WebView嵌入静态文件
|
// 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
|
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="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="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="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="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>
|
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" data-pressed="true"><span class="info-icon"></span></button>
|
||||||
</div>
|
</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>
|
<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>
|
</form>
|
||||||
</dialog>
|
</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">
|
<dialog id="exportPanel" class="form-dialog">
|
||||||
<form id="exportForm">
|
<form id="exportForm">
|
||||||
<h2 id="exportTitle" class="form-title">导出文档</h2>
|
<h2 id="exportTitle" class="form-title">导出文档</h2>
|
||||||
@@ -856,6 +893,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="ofdgoPrintDocument">请先在OFDGo中打开打印预览,准备完整文档后打印。</div>
|
||||||
<script type="module" src="./ofdgo.js"></script>
|
<script type="module" src="./ofdgo.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+101
-1
@@ -26,6 +26,98 @@
|
|||||||
--shadow-page: 0 14px 30px rgba(31, 36, 40, 0.16);
|
--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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -654,6 +746,7 @@ button:not(:disabled):hover {
|
|||||||
.save-as-icon,
|
.save-as-icon,
|
||||||
.edit-icon,
|
.edit-icon,
|
||||||
.convert-icon,
|
.convert-icon,
|
||||||
|
.print-icon,
|
||||||
.pages-icon,
|
.pages-icon,
|
||||||
.info-icon {
|
.info-icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -663,7 +756,7 @@ button:not(:disabled):hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.open-icon {
|
.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 {
|
.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;
|
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 {
|
.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;
|
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,
|
.info-custom-row input,
|
||||||
.gradient-stop-row input[type="number"],
|
.gradient-stop-row input[type="number"],
|
||||||
.structure-attribute > input,
|
.structure-attribute > input,
|
||||||
|
.print-preview-tools input,
|
||||||
|
.print-preview-tools select,
|
||||||
|
.print-options select,
|
||||||
.form-row input[type="text"],
|
.form-row input[type="text"],
|
||||||
.form-row input[type="password"],
|
.form-row input[type="password"],
|
||||||
.form-row input[type="number"],
|
.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 { 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 { FontManager, FontPicker } from "./ofdgo_font.js";
|
||||||
import { nativeHost, createEngineWorker, engineLimits, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js";
|
import { nativeHost, createEngineWorker, engineLimits, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js";
|
||||||
|
import { PrintDialog } from "./ofdgo_print.js";
|
||||||
import { DocumentDialogs } from "./ofdgo_util.js";
|
import { DocumentDialogs } from "./ofdgo_util.js";
|
||||||
|
|
||||||
const MM_TO_PX = 96 / 25.4;
|
const MM_TO_PX = 96 / 25.4;
|
||||||
@@ -187,6 +188,8 @@ function createDocumentState() {
|
|||||||
let state = createDocumentState();
|
let state = createDocumentState();
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
|
printButton: document.querySelector("#printButton"),
|
||||||
|
printPanel: document.querySelector("#printPanel"),
|
||||||
documentTitleBar: document.querySelector("#documentTitleBar"),
|
documentTitleBar: document.querySelector("#documentTitleBar"),
|
||||||
documentTabs: document.querySelector("#documentTabs"),
|
documentTabs: document.querySelector("#documentTabs"),
|
||||||
documentNewTab: document.querySelector("#documentNewTab"),
|
documentNewTab: document.querySelector("#documentNewTab"),
|
||||||
@@ -650,6 +653,30 @@ const el = {
|
|||||||
statusText: document.querySelector("#statusText"),
|
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({
|
const fontManager = new FontManager({
|
||||||
host: nativeHost,
|
host: nativeHost,
|
||||||
onChange: scheduleFontSync,
|
onChange: scheduleFontSync,
|
||||||
@@ -1857,6 +1884,11 @@ function handleKeyDown(event) {
|
|||||||
}
|
}
|
||||||
const key = event.key;
|
const key = event.key;
|
||||||
const target = event.target;
|
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")) {
|
if (nativeHost && (event.ctrlKey || event.metaKey) && (key === "Tab" || key.toLowerCase() === "w")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (document.body.hasAttribute("data-busy") || formDialogOpen()) return;
|
if (document.body.hasAttribute("data-busy") || formDialogOpen()) return;
|
||||||
@@ -1976,6 +2008,7 @@ function handleKeyDown(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formDialogOpen() {
|
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
|
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;
|
|| 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 count = state.doc?.documents?.length || 0, index = managedDocumentIndex(), manager = state.documentManagement;
|
||||||
const busy = document.body.hasAttribute("data-busy");
|
const busy = document.body.hasAttribute("data-busy");
|
||||||
const disabled = !state.doc || !state.ready || state.exporting || busy;
|
const disabled = !state.doc || !state.ready || state.exporting || busy;
|
||||||
|
el.printButton.disabled = disabled;
|
||||||
el.documentManageButton.hidden = !state.editing;
|
el.documentManageButton.hidden = !state.editing;
|
||||||
el.documentManageButton.disabled = disabled || state.saving;
|
el.documentManageButton.disabled = disabled || state.saving;
|
||||||
resourceElements.ManageButton.disabled = disabled;
|
resourceElements.ManageButton.disabled = disabled;
|
||||||
@@ -13599,6 +13633,7 @@ function updateControls() {
|
|||||||
el.outputCompression.disabled = !hasDoc || state.exporting;
|
el.outputCompression.disabled = !hasDoc || state.exporting;
|
||||||
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length;
|
||||||
el.exportButton.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();
|
updateDPIControl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ class BrowserEngine {
|
|||||||
"ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice",
|
"ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice",
|
||||||
"ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal",
|
"ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal",
|
||||||
"ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument",
|
"ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument",
|
||||||
|
"ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint",
|
||||||
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
|
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
|
||||||
].includes(data.name)) this.invalidatePages();
|
].includes(data.name)) this.invalidatePages();
|
||||||
const preferred = data.name === "ofdgoRenderPage" ? [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0])) : null;
|
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 saveStamp;
|
||||||
let channel;
|
let channel;
|
||||||
const signal = operations.get(id)?.signal;
|
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 importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
|
||||||
const converting = name === "ofdgoConvertPDF";
|
const converting = name === "ofdgoConvertPDF";
|
||||||
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
|
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 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 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;
|
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 CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
|
||||||
const META_CACHE = `${CACHE_PREFIX}meta`;
|
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);
|
.map((path) => new URL(path, self.registration.scope).href);
|
||||||
const CLIENT_PREFIX = new URL("__client/", 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
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.6
|
github.com/andybalholm/brotli v1.2.7
|
||||||
github.com/emmansun/gmsm v0.45.1
|
github.com/emmansun/gmsm v0.45.1
|
||||||
github.com/go-text/typesetting v0.3.5
|
github.com/go-text/typesetting v0.3.5
|
||||||
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
|
||||||
github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918
|
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/j2kgo v1.0.2
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8
|
github.com/xiaoqidun/jbig2 v1.1.8
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5
|
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/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 h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||||
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
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.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
|
||||||
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
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/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 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE=
|
||||||
github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w=
|
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/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 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
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-20261024125139-cafa62624ea9 h1:5pB8LF+hXJeiY7it+c8xgjHco/ikjPScXsH7kbCTElM=
|
||||||
github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls=
|
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 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw=
|
||||||
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
|
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
|
currentSession *Session
|
||||||
currentEditor *ofdgo.Editor
|
currentEditor *ofdgo.Editor
|
||||||
saveSnapshot *ofdgo.Editor
|
saveSnapshot *ofdgo.Editor
|
||||||
|
printDocument *ofdgo.PrintDocument
|
||||||
pendingImport *ofdgo.Reader
|
pendingImport *ofdgo.Reader
|
||||||
copiedObjects *editorClipboard
|
copiedObjects *editorClipboard
|
||||||
copiedStyle *ofdgo.GraphicObject
|
copiedStyle *ofdgo.GraphicObject
|
||||||
@@ -7445,6 +7446,11 @@ func (c *outputContext) Err() error {
|
|||||||
// businessCommands 共用业务命令表
|
// businessCommands 共用业务命令表
|
||||||
func (e *businessEngine) businessCommands() map[string]func([]platformValue) (any, error) {
|
func (e *businessEngine) businessCommands() map[string]func([]platformValue) (any, error) {
|
||||||
return 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,
|
"ofdgoDetectFormat": e.detectDocumentFormat,
|
||||||
"ofdgoOpen": e.openDocument,
|
"ofdgoOpen": e.openDocument,
|
||||||
"ofdgoSelectDocument": e.selectDocument,
|
"ofdgoSelectDocument": e.selectDocument,
|
||||||
@@ -7594,6 +7600,8 @@ func (e *businessEngine) operationContext() context.Context {
|
|||||||
func failureResult(err error) apiResult {
|
func failureResult(err error) apiResult {
|
||||||
result := apiResult{Error: err.Error()}
|
result := apiResult{Error: err.Error()}
|
||||||
switch {
|
switch {
|
||||||
|
case errors.Is(err, ofdgo.ErrPrintDenied):
|
||||||
|
result.Code, result.Error = "printDenied", "文档禁止打印"
|
||||||
case errors.Is(err, ofdgo.ErrPDFPassword):
|
case errors.Is(err, ofdgo.ErrPDFPassword):
|
||||||
result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误"
|
result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误"
|
||||||
case errors.Is(err, ofdgo.ErrCredentialsRequired):
|
case errors.Is(err, ofdgo.ErrCredentialsRequired):
|
||||||
|
|||||||
@@ -357,6 +357,7 @@ func (e *NativeEngine) Close() error {
|
|||||||
e.gate <- struct{}{}
|
e.gate <- struct{}{}
|
||||||
defer func() { <-e.gate }()
|
defer func() { <-e.gate }()
|
||||||
e.closed = true
|
e.closed = true
|
||||||
|
_, _ = e.discardPrint(nil)
|
||||||
_ = e.waitRenders(context.Background())
|
_ = e.waitRenders(context.Background())
|
||||||
e.renderIdle = nil
|
e.renderIdle = nil
|
||||||
e.saveSnapshot = 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
|
return nil, nil
|
||||||
}
|
}
|
||||||
delete(w.engines, id)
|
delete(w.engines, id)
|
||||||
|
_, _ = e.discardPrint(nil)
|
||||||
e.clearImport()
|
e.clearImport()
|
||||||
err := e.currentSession.Close()
|
err := e.currentSession.Close()
|
||||||
*e = businessEngine{}
|
*e = businessEngine{}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -106,7 +107,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error {
|
|||||||
h.conversionPool = h.resources.ConversionPool()
|
h.conversionPool = h.resources.ConversionPool()
|
||||||
defer h.close()
|
defer h.close()
|
||||||
bindings := make(map[string]guigo.HandlerFunc)
|
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) }
|
bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) }
|
||||||
}
|
}
|
||||||
static := http.FileServerFS(webuiassets.NativeFS)
|
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 }
|
text := func(key string) string { value, _ := args[key].(string); return value }
|
||||||
flag := func(key string) bool { value, _ := args[key].(bool); return value }
|
flag := func(key string) bool { value, _ := args[key].(bool); return value }
|
||||||
switch name {
|
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":
|
case "engine.open":
|
||||||
engine := webui.NewNativeEngine(h.files.resolve)
|
engine := webui.NewNativeEngine(h.files.resolve)
|
||||||
parallelism, cacheBytes := runtime.GOMAXPROCS(0), int64(128<<20)
|
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)},
|
{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 {
|
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})
|
region, err := NewShape(ShapeRectangle, Box{X: playback.X - box.X, Y: playback.Y - box.Y, W: playback.W, H: playback.H})
|
||||||
if err != nil {
|
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 {
|
for order := range 3 {
|
||||||
|
if r.printing && (r.printContents == "annotations" || r.printContents == "forms") {
|
||||||
|
break
|
||||||
|
}
|
||||||
if r.Reader.doc != nil {
|
if r.Reader.doc != nil {
|
||||||
for _, ref := range page.Template {
|
for _, ref := range page.Template {
|
||||||
kind := ref.ZOrder
|
kind := ref.ZOrder
|
||||||
@@ -103,6 +106,18 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error {
|
|||||||
}
|
}
|
||||||
if r.RenderAnnotations {
|
if r.RenderAnnotations {
|
||||||
for _, annotation := range r.Reader.Annots[page.ID] {
|
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) {
|
if annotation.Visible != nil && !*annotation.Visible && !pageIncludesHidden(visitor) {
|
||||||
continue
|
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] {
|
for _, stamp := range r.Reader.Stamps[page.ID] {
|
||||||
if r.pageContext != nil {
|
if r.pageContext != nil {
|
||||||
if err := r.pageContext.Err(); err != nil {
|
if err := r.pageContext.Err(); err != nil {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ type Renderer struct {
|
|||||||
pageText *PageText
|
pageText *PageText
|
||||||
pageContext context.Context
|
pageContext context.Context
|
||||||
textOnly bool
|
textOnly bool
|
||||||
|
printing bool
|
||||||
|
printContents string
|
||||||
textContentOnly bool
|
textContentOnly bool
|
||||||
backends RenderBackends
|
backends RenderBackends
|
||||||
backendStates map[any]any
|
backendStates map[any]any
|
||||||
@@ -84,6 +86,8 @@ func (r *Renderer) Fork(ctx context.Context) (*Renderer, error) {
|
|||||||
next.OutputContext = ctx
|
next.OutputContext = ctx
|
||||||
next.TransparentBackground = r.TransparentBackground
|
next.TransparentBackground = r.TransparentBackground
|
||||||
next.decodeImages = r.decodeImages
|
next.decodeImages = r.decodeImages
|
||||||
|
next.printing = r.printing
|
||||||
|
next.printContents = r.printContents
|
||||||
next.imageCache = r.imageCache
|
next.imageCache = r.imageCache
|
||||||
next.fontDigests = r.fontDigests
|
next.fontDigests = r.fontDigests
|
||||||
next.fontPreparations = r.fontPreparations
|
next.fontPreparations = r.fontPreparations
|
||||||
@@ -150,6 +154,7 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer {
|
|||||||
opts = append(opts, WithFontFS(r.fontFS...))
|
opts = append(opts, WithFontFS(r.fontFS...))
|
||||||
}
|
}
|
||||||
renderer := NewRenderer(reader, opts...)
|
renderer := NewRenderer(reader, opts...)
|
||||||
|
renderer.printing = r.printing
|
||||||
renderer.sharedBackendStates = r.sharedBackendStates
|
renderer.sharedBackendStates = r.sharedBackendStates
|
||||||
renderer.fontPreparations = r.fontPreparations
|
renderer.fontPreparations = r.fontPreparations
|
||||||
renderer.fontDigests = r.fontDigests
|
renderer.fontDigests = r.fontDigests
|
||||||
|
|||||||
@@ -138,10 +138,14 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
|
|||||||
return fmt.Errorf("no pages found")
|
return fmt.Errorf("no pages found")
|
||||||
}
|
}
|
||||||
doc := r.Reader.doc
|
doc := r.Reader.doc
|
||||||
navigation, err := newPDFNavigation(r, doc, pages)
|
navigation := &pdfNavigation{}
|
||||||
|
if !r.printing {
|
||||||
|
var err error
|
||||||
|
navigation, err = newPDFNavigation(r, doc, pages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
buf, direct := writer.(*bytes.Buffer)
|
buf, direct := writer.(*bytes.Buffer)
|
||||||
if r.Compression.Mode != CompressionUnchanged {
|
if r.Compression.Mode != CompressionUnchanged {
|
||||||
direct = false
|
direct = false
|
||||||
@@ -182,16 +186,20 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i
|
|||||||
buf.Truncate(start)
|
buf.Truncate(start)
|
||||||
return fmt.Errorf("failed to read page %d: %w", i+1, err)
|
return fmt.Errorf("failed to read page %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
|
if !r.printing {
|
||||||
if err := navigation.addPage(r, i, content, pages); err != nil {
|
if err := navigation.addPage(r, i, content, pages); err != nil {
|
||||||
buf.Truncate(start)
|
buf.Truncate(start)
|
||||||
return fmt.Errorf("failed to read page %d navigation: %w", i+1, err)
|
return fmt.Errorf("failed to read page %d navigation: %w", i+1, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
p.NewPage(page.Box.W, page.Box.H)
|
p.NewPage(page.Box.W, page.Box.H)
|
||||||
renderer.images = append(renderer.images, nil)
|
renderer.images = append(renderer.images, nil)
|
||||||
renderer.text = append(renderer.text, codecpdf.TextPage{})
|
renderer.text = append(renderer.text, codecpdf.TextPage{})
|
||||||
}
|
}
|
||||||
|
if !r.printing {
|
||||||
navigation.apply(p, i)
|
navigation.apply(p, i)
|
||||||
|
}
|
||||||
if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), content, !r.TransparentBackground); err != nil {
|
if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), content, !r.TransparentBackground); err != nil {
|
||||||
buf.Truncate(start)
|
buf.Truncate(start)
|
||||||
return fmt.Errorf("failed to render page %d: %w", i+1, err)
|
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
|
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 {
|
if err := navigation.addOutlines(doc.Outlines.OutlineElem, 0, navigation.bookmarks, navigation.pageIndex, pages); err != nil {
|
||||||
buf.Truncate(start)
|
buf.Truncate(start)
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in new issue
Block a user