diff --git a/assets/webui/assets.go b/assets/webui/assets.go index 8a87c88..5ab1a72 100644 --- a/assets/webui/assets.go +++ b/assets/webui/assets.go @@ -18,10 +18,10 @@ import "embed" // FS WebUI嵌入静态文件 // -//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_util.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js +//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_util.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js var FS embed.FS // NativeFS WebView嵌入静态文件 // -//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_util.js +//go:embed index.html ofdgo.css ofdgo.ico ofdgo.js ofdgo.json ofdgo.png ofdgo.svg ofdgo_edit.js ofdgo_font.js ofdgo_host.js ofdgo_print.js ofdgo_util.js var NativeFS embed.FS diff --git a/assets/webui/index.html b/assets/webui/index.html index 583348c..d340de3 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -31,6 +31,7 @@ + @@ -671,6 +672,42 @@
+ +
+

打印

+ +
+
+

导出文档

@@ -856,6 +893,7 @@ +
请先在OFDGo中打开打印预览,准备完整文档后打印。
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index d2b7b39..e4a50ca 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -26,6 +26,98 @@ --shadow-page: 0 14px 30px rgba(31, 36, 40, 0.16); } +.form-dialog.print-dialog { + width: 920px; +} + +.print-content { + display: grid; + grid-template-columns: 310px minmax(0, 1fr); + gap: 14px; +} + +.print-settings { + min-width: 0; + max-height: calc(100dvh - 230px); + margin: 0; + padding: 0 8px 0 0; + overflow: auto; + border: 0; +} + +.print-settings .form-row { + display: grid; + grid-template-columns: 4em minmax(0, 1fr); + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.print-settings [hidden] { display: none !important; } +.print-dialog :is(input, select, button):focus-visible { outline-offset: -2px; } +.print-field { display: flex; min-width: 0; align-items: center; gap: 6px; } +.print-dialog .print-settings input[type="number"] { min-width: 0; width: 100%; } +.print-dialog .print-field > :is(input, select) { flex: 1 1 0; min-width: 0; width: 0; } +.print-field > :is(.print-check, button) { flex: 0 0 80px; justify-content: center; } +.print-field:has(#printBackReverse) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.print-field:has(#printBackReverse) .print-check { justify-content: start; } +.print-field > span { flex: none; color: var(--text-muted); white-space: nowrap; } +.print-dialog .print-settings .print-margins input { padding: 0 3px; text-align: center; } +.print-check { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; } +.print-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: var(--control-height); align-items: center; gap: 8px; margin: 12px 0 0 calc(4em + 8px); } +.print-options label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; } +.print-check input, .print-options input { margin: 0; accent-color: var(--accent); } +.print-dialog .form-actions { grid-template-columns: minmax(0, 1fr) 80px 80px; margin-top: 12px; } +.print-dialog .form-actions > .form-status { overflow-wrap: anywhere; } +.print-dialog .form-actions > .form-status:empty { display: none; } +.print-dialog .form-actions > button:first-of-type { grid-column: 2; } + +.print-preview { + display: flex; + min-width: 0; + min-height: 300px; + flex-direction: column; + gap: 8px; +} + +.print-preview-page { + display: grid; + height: min(62dvh, 660px); + min-height: 240px; + justify-items: center; + align-items: start; + padding: 12px; + overflow: auto; + background: #e1e6e9; + border: 1px solid var(--line); +} + +.print-preview-page img { display: block; max-width: none; background: white; box-shadow: var(--shadow-soft); } +.print-preview-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 7px; } +.print-dialog .print-preview-tools input { width: 52px; text-align: center; } +.print-dialog .print-preview-tools select { width: 70px; } +#ofdgoPrintDocument { display: none; } + +@media (max-width: 680px) { + .print-content { grid-template-columns: minmax(0, 1fr); } + .print-settings { max-height: 34dvh; } + .print-preview-page { height: 32dvh; min-height: 160px; } + .print-preview { min-height: 210px; } + .print-dialog .form-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .print-dialog .form-actions > .form-status { grid-column: 1 / -1; } + .print-dialog .form-actions > button:first-of-type { grid-column: 1; } +} + +@media print { + html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; margin: 0 !important; padding: 0 !important; background: white !important; } + body > :not(#ofdgoPrintDocument) { display: none !important; } + dialog { display: none !important; } + #ofdgoPrintDocument { display: block !important; color: black; background: white; } + #ofdgoPrintDocument section { display: block; margin: 0; padding: 0; overflow: hidden; break-after: page; print-color-adjust: exact; -webkit-print-color-adjust: exact; } + #ofdgoPrintDocument section:last-child { break-after: auto; } + #ofdgoPrintDocument img { display: block; width: 100%; height: 100%; } +} + * { box-sizing: border-box; } @@ -654,6 +746,7 @@ button:not(:disabled):hover { .save-as-icon, .edit-icon, .convert-icon, +.print-icon, .pages-icon, .info-icon { width: 16px; @@ -663,7 +756,7 @@ button:not(:disabled):hover { } .open-icon { - mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V4h6l3 3h9v13H3Z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7V3h6l3 4h9v14H3Z'/%3E%3C/svg%3E") center / contain no-repeat; } .close-icon { @@ -694,6 +787,10 @@ button:not(:disabled):hover { mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 7h18l-4-4M21 17H3l4 4'/%3E%3C/svg%3E") center / contain no-repeat; } +.print-icon { + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9V3h12v6M6 17H3V9h18v8h-3M6 13h12v8H6Z'/%3E%3C/svg%3E") center / contain no-repeat; +} + .pages-icon { mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3h18M3 12h18M3 21h18'/%3E%3C/svg%3E") center / contain no-repeat; } @@ -1395,6 +1492,9 @@ button:not(:disabled):hover { .info-custom-row input, .gradient-stop-row input[type="number"], .structure-attribute > input, +.print-preview-tools input, +.print-preview-tools select, +.print-options select, .form-row input[type="text"], .form-row input[type="password"], .form-row input[type="number"], diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 071dbb4..35c7ab1 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,6 +1,7 @@ import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; import { nativeHost, createEngineWorker, engineLimits, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js"; +import { PrintDialog } from "./ofdgo_print.js"; import { DocumentDialogs } from "./ofdgo_util.js"; const MM_TO_PX = 96 / 25.4; @@ -187,6 +188,8 @@ function createDocumentState() { let state = createDocumentState(); const el = { + printButton: document.querySelector("#printButton"), + printPanel: document.querySelector("#printPanel"), documentTitleBar: document.querySelector("#documentTitleBar"), documentTabs: document.querySelector("#documentTabs"), documentNewTab: document.querySelector("#documentNewTab"), @@ -650,6 +653,30 @@ const el = { statusText: document.querySelector("#statusText"), }; +let printDialog = null; + +function createPrintDialog() { + return new PrintDialog({ + nativeHost, + show: panel => documentDialogs.showApplication(panel), + download: downloadBytes, + cancel: tab => { + for (const [id, request] of wasmRequests) if (request.tab === tab && ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF"].includes(request.name)) tab.worker?.postMessage({type:"cancel", id}); + }, + }); +} + +function openPrintPanel() { + const tab = documentTab(); + if (!tab.state.doc || !tab.state.ready || document.body.hasAttribute("data-busy") || formDialogOpen()) return; + printDialog ||= createPrintDialog(); + printDialog.open({tab, call:(name, ...args) => callTabWASM(tab, name, ...args), page:tab.state.pageIndex, + selection:[...tab.state.pageSelection].sort((a,b) => a-b), annotations:tab.state.renderAnnotations, + name:tab.state.fileName || tab.state.doc.title || "OFDGo"}); +} + +el.printButton.addEventListener("click", openPrintPanel); + const fontManager = new FontManager({ host: nativeHost, onChange: scheduleFontSync, @@ -1857,6 +1884,11 @@ function handleKeyDown(event) { } const key = event.key; const target = event.target; + if ((event.ctrlKey || event.metaKey) && !event.shiftKey && key.toLowerCase() === "p") { + event.preventDefault(); + openPrintPanel(); + return; + } if (nativeHost && (event.ctrlKey || event.metaKey) && (key === "Tab" || key.toLowerCase() === "w")) { event.preventDefault(); if (document.body.hasAttribute("data-busy") || formDialogOpen()) return; @@ -1976,6 +2008,7 @@ function handleKeyDown(event) { } function formDialogOpen() { + if (el.printPanel.open) return true; return Boolean(sealPreview || resourceManager.pick) || el.aboutPanel.open || el.closePanel.open || el.attachmentDialog.open || settingsElements.Panel.open || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open || resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; } @@ -13547,6 +13580,7 @@ function updateDocumentControls() { const count = state.doc?.documents?.length || 0, index = managedDocumentIndex(), manager = state.documentManagement; const busy = document.body.hasAttribute("data-busy"); const disabled = !state.doc || !state.ready || state.exporting || busy; + el.printButton.disabled = disabled; el.documentManageButton.hidden = !state.editing; el.documentManageButton.disabled = disabled || state.saving; resourceElements.ManageButton.disabled = disabled; @@ -13599,6 +13633,7 @@ function updateControls() { el.outputCompression.disabled = !hasDoc || state.exporting; el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length; el.exportButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length; + el.printButton.disabled = !hasDoc || !state.ready || state.exporting || document.body.hasAttribute("data-busy"); updateDPIControl(); } diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index a7785a3..109a62d 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -417,6 +417,7 @@ class BrowserEngine { "ofdgoAnnotationInfos", "ofdgoInvoices", "ofdgoInvoice", "ofdgoExportInvoice", "ofdgoValidateInvoice", "ofdgoActionList", "ofdgoPageSettings", "ofdgoReadAnnotation", "ofdgoPreviewSignatureSeal", "ofdgoExportFormats", "ofdgoOutputFormats", "ofdgoParsePageRange", "ofdgoExportPage", "ofdgoExportDocument", + "ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion" ].includes(data.name)) this.invalidatePages(); const preferred = data.name === "ofdgoRenderPage" ? [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0])) : null; diff --git a/assets/webui/ofdgo_print.js b/assets/webui/ofdgo_print.js new file mode 100644 index 0000000..0397d7d --- /dev/null +++ b/assets/webui/ofdgo_print.js @@ -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("", ``); + } + 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(() => {})); + } +} diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 025a78d..0c261db 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -235,11 +235,11 @@ async function handleMessage(engine, { id, name, args }) { let saveStamp; let channel; const signal = operations.get(id)?.signal; - const cancellable = ["ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name); + const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name); const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; const converting = name === "ofdgoConvertPDF"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned"; - const resources = name === "ofdgoResources" || name === "ofdgoPrepareSave"; + const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name); const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null; const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null; diff --git a/assets/webui/ofdgo_work.js b/assets/webui/ofdgo_work.js index fa1aa46..f5b4cb0 100644 --- a/assets/webui/ofdgo_work.js +++ b/assets/webui/ofdgo_work.js @@ -1,6 +1,6 @@ const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`; const META_CACHE = `${CACHE_PREFIX}meta`; -const ASSETS = ["./", "ofdgo.css", "ofdgo.ico", "ofdgo.js", "ofdgo.json", "ofdgo.png", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_util.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"] +const ASSETS = ["./", "ofdgo.css", "ofdgo.ico", "ofdgo.js", "ofdgo.json", "ofdgo.png", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_host.js", "ofdgo_print.js", "ofdgo_util.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"] .map((path) => new URL(path, self.registration.scope).href); const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href; diff --git a/go.mod b/go.mod index 277530e..bace7df 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,12 @@ module github.com/xiaoqidun/ofdgo go 1.26.0 require ( - github.com/andybalholm/brotli v1.2.6 + github.com/andybalholm/brotli v1.2.7 github.com/emmansun/gmsm v0.45.1 github.com/go-text/typesetting v0.3.5 github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20261010204837-88a7f6b56918 - github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62 + github.com/xiaoqidun/guigo v0.0.0-20261024125139-cafa62624ea9 github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/jbig2 v1.1.8 github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5 diff --git a/go.sum b/go.sum index 532c9d8..579c6cf 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= -github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= -github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A= +github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/benoitkugler/pstokenizer v1.0.0/go.mod h1:l1G2Voirz0q/jj0TQfabNxVsa8HZXh/VMxFSRALWTiE= github.com/benoitkugler/textlayout v0.3.2 h1:Y/LTkwpQ9G0Fia9yhPmZA9IR5AnE8Cq30j3C+Gx5/IE= github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7sBPgEFcuSgfvi/w= @@ -60,8 +60,8 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8 github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= -github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62 h1:Uefcwqu9kZ47FtIbrGBY4zue71VsQt048usDnXDLq0A= -github.com/xiaoqidun/guigo v0.0.0-20261022062419-599070546b62/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls= +github.com/xiaoqidun/guigo v0.0.0-20261024125139-cafa62624ea9 h1:5pB8LF+hXJeiY7it+c8xgjHco/ikjPScXsH7kbCTElM= +github.com/xiaoqidun/guigo v0.0.0-20261024125139-cafa62624ea9/go.mod h1:xJF8xE6Bjw+NcOZNvWJEVX/XXIkngs3ltc9F6vNcLls= github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw= github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= diff --git a/internal/codec/pdf/print.go b/internal/codec/pdf/print.go new file mode 100644 index 0000000..6ba64d5 --- /dev/null +++ b/internal/codec/pdf/print.go @@ -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<>\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 +} diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 0332483..6fc795f 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -423,6 +423,7 @@ type businessEngine struct { currentSession *Session currentEditor *ofdgo.Editor saveSnapshot *ofdgo.Editor + printDocument *ofdgo.PrintDocument pendingImport *ofdgo.Reader copiedObjects *editorClipboard copiedStyle *ofdgo.GraphicObject @@ -7445,6 +7446,11 @@ func (c *outputContext) Err() error { // businessCommands 共用业务命令表 func (e *businessEngine) businessCommands() map[string]func([]platformValue) (any, error) { return map[string]func([]platformValue) (any, error){ + "ofdgoPreparePrint": e.preparePrint, + "ofdgoLayoutPrint": e.layoutPrint, + "ofdgoPrintSide": e.printSide, + "ofdgoPrintPDF": e.printPDF, + "ofdgoDiscardPrint": e.discardPrint, "ofdgoDetectFormat": e.detectDocumentFormat, "ofdgoOpen": e.openDocument, "ofdgoSelectDocument": e.selectDocument, @@ -7594,6 +7600,8 @@ func (e *businessEngine) operationContext() context.Context { func failureResult(err error) apiResult { result := apiResult{Error: err.Error()} switch { + case errors.Is(err, ofdgo.ErrPrintDenied): + result.Code, result.Error = "printDenied", "文档禁止打印" case errors.Is(err, ofdgo.ErrPDFPassword): result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误" case errors.Is(err, ofdgo.ErrCredentialsRequired): diff --git a/internal/webui/webui_native.go b/internal/webui/webui_native.go index e451061..35cb5d5 100644 --- a/internal/webui/webui_native.go +++ b/internal/webui/webui_native.go @@ -357,6 +357,7 @@ func (e *NativeEngine) Close() error { e.gate <- struct{}{} defer func() { <-e.gate }() e.closed = true + _, _ = e.discardPrint(nil) _ = e.waitRenders(context.Background()) e.renderIdle = nil e.saveSnapshot = nil diff --git a/internal/webui/webui_print.go b/internal/webui/webui_print.go new file mode 100644 index 0000000..b9f3083 --- /dev/null +++ b/internal/webui/webui_print.go @@ -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 +} diff --git a/internal/webui/webui_wasm.go b/internal/webui/webui_wasm.go index a12f184..4b5cf20 100644 --- a/internal/webui/webui_wasm.go +++ b/internal/webui/webui_wasm.go @@ -99,6 +99,7 @@ func (w *wasmEngines) close(args []js.Value) (any, error) { return nil, nil } delete(w.engines, id) + _, _ = e.discardPrint(nil) e.clearImport() err := e.currentSession.Close() *e = businessEngine{} diff --git a/internal/webview/webview.go b/internal/webview/webview.go index ae9fe64..673e56e 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "crypto/rand" + "encoding/json" "errors" "fmt" "io" @@ -106,7 +107,7 @@ func Run(ctx context.Context, paths []string, dataDir string) error { h.conversionPool = h.resources.ConversionPool() defer h.close() bindings := make(map[string]guigo.HandlerFunc) - for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open"} { + for _, name := range []string{"engine.open", "engine.close", "engine.call", "engine.start", "engine.cancel", "engine.finish", "batch", "open", "save", "directory", "file", "release", "drop.release", "child", "remove", "write.begin", "write.data", "write.commit", "write.abort", "fonts", "ready", "title", "window.info", "window.drag", "window.close", "update.check", "update.open", "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print"} { bindings["ofdgo.host."+name] = func(ctx context.Context, args any) (any, error) { return h.call(ctx, name, args) } } static := http.FileServerFS(webuiassets.NativeFS) @@ -305,6 +306,44 @@ func (h *Host) call(ctx context.Context, name string, value any) (any, error) { text := func(key string) string { value, _ := args[key].(string); return value } flag := func(key string) bool { value, _ := args[key].(bool); return value } switch name { + case "print.capabilities", "print.dialog", "print.printers", "print.query", "print.properties", "print": + h.mu.Lock() + window := h.window + h.mu.Unlock() + if window == nil { + return nil, fmt.Errorf("窗口尚未就绪") + } + if name == "print.capabilities" { + return window.PrintCapabilities(ctx) + } + if name == "print.dialog" { + return nil, window.ShowPrintUI(ctx, text("kind")) + } + if name == "print.printers" { + return window.ListPrinters(ctx) + } + if name == "print.properties" { + return nil, window.ShowPrinterProperties(ctx, text("printer")) + } + data, err := json.Marshal(args) + if err != nil { + return nil, err + } + var options guigo.PrintOptions + if err := json.Unmarshal(data, &options); err != nil { + return nil, err + } + profile, err := window.QueryPrinter(ctx, options) + if err != nil { + return nil, err + } + if name == "print.query" { + return profile, nil + } + if text("fingerprint") == "" || text("fingerprint") != profile.Fingerprint { + return nil, fmt.Errorf("打印机设置已变化,请重新预览") + } + return window.Print(ctx, options) case "engine.open": engine := webui.NewNativeEngine(h.files.resolve) parallelism, cacheBytes := runtime.GOMAXPROCS(0), int64(128<<20) diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index 1469054..20d0d19 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -331,6 +331,12 @@ func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, a {Name: "PDF.Popup.Rect", Value: fmt.Sprintf("%g %g %g %g", popup.Rect.XMin, popup.Rect.YMin, popup.Rect.XMax, popup.Rect.YMax)}, }...) } + if annotation.Subtype != "Popup" { + if converted.Parameters == nil { + converted.Parameters = &AnnotationParameters{} + } + converted.Parameters.Parameter = append(converted.Parameters.Parameter, AnnotationParameter{Name: "PDF.Printable", Value: strconv.FormatBool(flags&4 != 0 && flags&2 == 0 && !invisible)}) + } if len(actions) != 0 { region, err := NewShape(ShapeRectangle, Box{X: playback.X - box.X, Y: playback.Y - box.Y, W: playback.W, H: playback.H}) if err != nil { diff --git a/ofdgo_print.go b/ofdgo_print.go new file mode 100644 index 0000000..a7473bf --- /dev/null +++ b/ofdgo_print.go @@ -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 +} diff --git a/ofdgo_print_output.go b/ofdgo_print_output.go new file mode 100644 index 0000000..3850487 --- /dev/null +++ b/ofdgo_print_output.go @@ -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, ``, 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, ``, 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, ``, 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, ``, line[0], line[1], line[2], line[3]) + } + if side.Label != "" { + fmt.Fprintf(&out, `%s`, side.LabelX, side.LabelY, html.EscapeString(side.Label)) + } + } + out.WriteString(``) + _, 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, "%s"); 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, `
`, i, base64.StdEncoding.EncodeToString(svg.Bytes())); err != nil { + return err + } + } + _, err := io.WriteString(out, "") + 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) +} diff --git a/ofdgo_render_walk.go b/ofdgo_render_walk.go index c8a1090..e3b80f1 100644 --- a/ofdgo_render_walk.go +++ b/ofdgo_render_walk.go @@ -77,6 +77,9 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error { } } for order := range 3 { + if r.printing && (r.printContents == "annotations" || r.printContents == "forms") { + break + } if r.Reader.doc != nil { for _, ref := range page.Template { kind := ref.ZOrder @@ -103,6 +106,18 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error { } if r.RenderAnnotations { for _, annotation := range r.Reader.Annots[page.ID] { + if r.printing && (r.printContents == "document" && annotation.Subtype != "Widget" || r.printContents == "forms" && annotation.Subtype != "Widget" || r.printContents == "annotations" && annotation.Subtype == "Widget") { + continue + } + if r.printing && annotation.Parameters != nil { + for _, parameter := range annotation.Parameters.Parameter { + if parameter.Name == "PDF.Printable" && (parameter.Value == "true" || parameter.Value == "false") { + visible := parameter.Value == "true" + annotation.Visible = &visible + break + } + } + } if annotation.Visible != nil && !*annotation.Visible && !pageIncludesHidden(visitor) { continue } @@ -111,6 +126,9 @@ func (r *Renderer) WalkPage(page *PageContent, visitor PageVisitor) error { } } } + if r.printing && (r.printContents == "annotations" || r.printContents == "forms") { + return nil + } for _, stamp := range r.Reader.Stamps[page.ID] { if r.pageContext != nil { if err := r.pageContext.Err(); err != nil { diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index b241c10..4ff4b6f 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -47,6 +47,8 @@ type Renderer struct { pageText *PageText pageContext context.Context textOnly bool + printing bool + printContents string textContentOnly bool backends RenderBackends backendStates map[any]any @@ -84,6 +86,8 @@ func (r *Renderer) Fork(ctx context.Context) (*Renderer, error) { next.OutputContext = ctx next.TransparentBackground = r.TransparentBackground next.decodeImages = r.decodeImages + next.printing = r.printing + next.printContents = r.printContents next.imageCache = r.imageCache next.fontDigests = r.fontDigests next.fontPreparations = r.fontPreparations @@ -150,6 +154,7 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer { opts = append(opts, WithFontFS(r.fontFS...)) } renderer := NewRenderer(reader, opts...) + renderer.printing = r.printing renderer.sharedBackendStates = r.sharedBackendStates renderer.fontPreparations = r.fontPreparations renderer.fontDigests = r.fontDigests diff --git a/ofdgo_renderer_output_canvas.go b/ofdgo_renderer_output_canvas.go index 5d8896b..c362e0c 100644 --- a/ofdgo_renderer_output_canvas.go +++ b/ofdgo_renderer_output_canvas.go @@ -138,9 +138,13 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i return fmt.Errorf("no pages found") } doc := r.Reader.doc - navigation, err := newPDFNavigation(r, doc, pages) - if err != nil { - return err + navigation := &pdfNavigation{} + if !r.printing { + var err error + navigation, err = newPDFNavigation(r, doc, pages) + if err != nil { + return err + } } buf, direct := writer.(*bytes.Buffer) if r.Compression.Mode != CompressionUnchanged { @@ -182,16 +186,20 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i buf.Truncate(start) return fmt.Errorf("failed to read page %d: %w", i+1, err) } - if err := navigation.addPage(r, i, content, pages); err != nil { - buf.Truncate(start) - return fmt.Errorf("failed to read page %d navigation: %w", i+1, err) + if !r.printing { + if err := navigation.addPage(r, i, content, pages); err != nil { + buf.Truncate(start) + return fmt.Errorf("failed to read page %d navigation: %w", i+1, err) + } } if i > 0 { p.NewPage(page.Box.W, page.Box.H) renderer.images = append(renderer.images, nil) renderer.text = append(renderer.text, codecpdf.TextPage{}) } - navigation.apply(p, i) + if !r.printing { + navigation.apply(p, i) + } if err := r.renderCanvasPageToContext(canvas.NewContext(renderer), content, !r.TransparentBackground); err != nil { buf.Truncate(start) return fmt.Errorf("failed to render page %d: %w", i+1, err) @@ -216,7 +224,7 @@ func (CanvasBackend) RenderPDF(r *Renderer, pages []RenderDocumentPage, writer i return err } } - if doc != nil { + if doc != nil && !r.printing { if err := navigation.addOutlines(doc.Outlines.OutlineElem, 0, navigation.bookmarks, navigation.pageIndex, pages); err != nil { buf.Truncate(start) return err