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", "PageControl", "PageCount", "Status", "Spinner", "ProgressTrack", "ProgressBar", "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.PreviewNumber.addEventListener("keydown", event => { if (event.key !== "Enter") return; event.preventDefault(); if (!event.repeat) this.el.PreviewNumber.dispatchEvent(new Event("change")); }); new ResizeObserver(([entry]) => { this.previewSize = entry.contentRect; 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.orientation = this.el.Orientation.value; this.duplexBeforeBooklet = this.el.Duplex.value; this.scalingBeforeBooklet = this.el.Scaling.value; try { const saved = JSON.parse(localStorage.getItem("ofdgo.print") || "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.options || [...node.options].some(option => option.value === value))) node.value = value; } } catch {} this.mode = this.el.Mode.value; 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.status(); 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; const browser = this.el.Printer.querySelector('option[value="browser"]'); browser.disabled = !!this.nativeHost || typeof window.print !== "function"; if (browser.disabled) this.el.Printer.value = "pdf"; try { if (this.nativeHost) { this.status("正在读取设备", true); const capabilities = await this.nativeHost.call("print.capabilities", {}); if (!this.current(sequence)) return; this.capabilities = capabilities; browser.disabled = !capabilities.systemDialog; 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.status(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.status(); 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()) { this.status("正在读取纸张", true); 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.options.color === "grayscale"; 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.status(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; const sequence = this.sequence; this.loading = true; this.status("正在打开属性", true); this.controls(); try { await this.nativeHost.call("print.properties", {printer:this.el.Printer.value.slice(7)}); } catch (error) { if (this.current(sequence)) this.status(error.message); } finally { if (this.current(sequence)) { this.loading = false; 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] = [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:el.Scale.disabled ? 1 : 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:el.Gap.disabled ? 0 : number("Gap"), binding:el.Binding.value, bookletSheets:!el.BookletSheets.disabled && el.BookletSheets.value ? number("BookletSheets") : 0, overlap:el.Overlap.disabled ? 0 : 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]; for (const input of row.querySelectorAll("input, select")) input.disabled = row.hidden; } 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.status(); 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; this.status("正在准备预览", true); 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); 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.remember(); await this.preview(Math.min(this.side, layout.sides.length - 1)); if (!this.current(sequence) || this.layout !== layout) return; this.status(); if (layout.sides.some(side => side.placements.some(placement => placement.clipped))) this.status("内容超出可打印区域"); if (this.manual() && options.sides === "back" && !layout.backContent) this.status("背面为空白"); } catch (error) { if (this.current(sequence)) this.status(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) return; if (!Number.isInteger(index) || index < 0 || index >= this.layout.sides.length) { this.controls(); 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) && preview === this.previewSequence) { this.status(error.message); this.layout = null; this.controls(); } } } resize() { const img = this.el.Preview.querySelector("img"), side = this.layout?.sides[this.side]; if (!img || !side || !this.previewSize) return; const factor = Math.min(this.previewSize.width / side.width, this.previewSize.height / side.height); img.style.width = `${Math.max(1, side.width * factor)}px`; img.style.height = `${Math.max(1, side.height * factor)}px`; } status(text = "", busy = false, completed = 0, total = 0) { this.el.Status.textContent = total ? `${text} · ${completed} / ${total}` : text; this.el.Spinner.hidden = !busy; this.el.ProgressTrack.hidden = !busy || !total; this.el.ProgressBar.style.width = `${total ? completed / total * 100 : 0}%`; } 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 = String(count); this.el.PageControl.style.setProperty("--page-width", `${String(count || 1).length + 0.5}ch`); 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", 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); this.status("正在准备纸面", true, 0, this.layout.sides.length); for (let i = 0; i < this.layout.sides.length; i++) { if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError"); 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(); if (this.current(sequence)) this.status("正在准备纸面", true, i + 1, this.layout.sides.length); } await document.fonts.ready; if (!this.current(sequence)) throw new DOMException("打印已取消", "AbortError"); this.previousTitle = document.title; document.title = context.name; this.waiting = this.nativeHost ? "native" : "browser"; this.controls(); this.status(job.deviceOptions ? "正在提交打印" : "正在打开打印", true); 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.status("背面为空白"); } } } 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("当前运行时不支持系统打印"); if (!capabilities.dialogCompletion) this.waiting = "browser"; 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.status(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.status(); 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.status("正在生成文件", true); 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.status(); } } catch (error) { if (this.current(sequence)) this.status(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(() => {})); } }