Files
qhmes/XSLPrintDot/backend_windows.go

572 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build windows
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"unsafe"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procMultiByteToWideChar = kernel32.NewProc("MultiByteToWideChar")
)
// ansiToUtf8 converts ANSI (CP_ACP) bytes to UTF-8 string
func ansiToUtf8(b []byte) (string, error) {
if len(b) == 0 {
return "", nil
}
// CP_ACP = 0
// 1. Get required length
ret, _, _ := procMultiByteToWideChar.Call(
0, // CP_ACP
0,
uintptr(unsafe.Pointer(&b[0])),
uintptr(len(b)),
0,
0,
)
if ret == 0 {
return "", fmt.Errorf("MultiByteToWideChar failed")
}
// 2. Allocate buffer
utf16buf := make([]uint16, ret)
// 3. Convert
ret, _, _ = procMultiByteToWideChar.Call(
0,
0,
uintptr(unsafe.Pointer(&b[0])),
uintptr(len(b)),
uintptr(unsafe.Pointer(&utf16buf[0])),
ret,
)
if ret == 0 {
return "", fmt.Errorf("MultiByteToWideChar failed")
}
return syscall.UTF16ToString(utf16buf), nil
}
// decodeCmdOutput handles WMIC encoding quirks (UTF-16LE BOM or ANSI)
func decodeCmdOutput(output []byte) (string, error) {
if len(output) >= 2 && output[0] == 0xFF && output[1] == 0xFE {
// UTF-16LE BOM detected
// Skip BOM
raw16 := output[2:]
// Make sure even number of bytes
if len(raw16)%2 != 0 {
raw16 = append(raw16, 0)
}
u16s := make([]uint16, len(raw16)/2)
for i := 0; i < len(u16s); i++ {
u16s[i] = uint16(raw16[i*2]) | uint16(raw16[i*2+1])<<8
}
return syscall.UTF16ToString(u16s), nil
}
// Assume ANSI (e.g. GBK on Chinese Windows)
return ansiToUtf8(output)
}
func (b *Bridge) getPrintersPlatform() ([]PrinterInfo, error) {
// Use WMIC to get printer names and default flags in CSV format
cmd := exec.Command("wmic", "printer", "get", "name,default", "/format:csv")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
output, err := cmd.Output()
if err != nil {
return getPrintersViaPowerShell()
}
decodedStr, err := decodeCmdOutput(output)
if err != nil {
// Fallback
decodedStr = string(output)
}
reader := csv.NewReader(strings.NewReader(decodedStr))
reader.FieldsPerRecord = -1
reader.LazyQuotes = true
records, err := reader.ReadAll()
if err != nil {
return getPrintersViaPowerShell()
}
var printers []PrinterInfo
for _, record := range records {
if len(record) < 3 {
continue
}
if strings.EqualFold(record[1], "Default") && strings.EqualFold(record[2], "Name") {
continue
}
name := strings.TrimSpace(record[len(record)-1])
if name == "" {
continue
}
if strings.EqualFold(name, "Name") {
continue
}
isDefault := strings.EqualFold(strings.TrimSpace(record[1]), "TRUE")
printers = append(printers, PrinterInfo{Name: name, IsDefault: isDefault})
}
if len(printers) == 0 {
return getPrintersViaPowerShell()
}
return printers, nil
}
func getPrintersViaPowerShell() ([]PrinterInfo, error) {
ps := `Get-Printer | Select-Object Name,Default | ConvertTo-Json -Depth 3`
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
output, err := cmd.Output()
if err != nil {
return nil, err
}
decoded, err := decodeCmdOutput(output)
if err != nil {
decoded = string(output)
}
var list []struct {
Name string `json:"Name"`
Default bool `json:"Default"`
}
if err := json.Unmarshal([]byte(decoded), &list); err == nil {
printers := make([]PrinterInfo, 0, len(list))
for _, item := range list {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
printers = append(printers, PrinterInfo{Name: name, IsDefault: item.Default})
}
return printers, nil
}
var single struct {
Name string `json:"Name"`
Default bool `json:"Default"`
}
if err := json.Unmarshal([]byte(decoded), &single); err != nil {
return nil, err
}
name := strings.TrimSpace(single.Name)
if name == "" {
return nil, fmt.Errorf("no printers found")
}
return []PrinterInfo{{Name: name, IsDefault: single.Default}}, nil
}
func (b *Bridge) getPrinterCapabilitiesPlatform(printerName string) (map[string]interface{}, error) {
printerName = strings.TrimSpace(printerName)
if printerName == "" {
return nil, fmt.Errorf("printer name is empty")
}
escaped := strings.ReplaceAll(printerName, "'", "''")
ps := fmt.Sprintf(`$p = Get-CimInstance Win32_Printer -Filter "Name='%s'"; `+
`$c = Get-CimInstance Win32_PrinterConfiguration -Filter "Name='%s'"; `+
`if ($null -eq $p) { throw "Printer not found" }; `+
`[pscustomobject]@{ `+
`name = $p.Name; `+
`defaultPaperSize = $p.DefaultPaperSize; `+
`printerPaperNames = $p.PrinterPaperNames; `+
`paperSizes = $p.PaperSizes; `+
`colorSupported = $p.ColorSupported; `+
`duplexSupported = $p.DuplexSupported; `+
`driverName = $p.DriverName; `+
`portName = $p.PortName; `+
`printProcessor = $p.PrintProcessor; `+
`deviceId = $p.DeviceID; `+
`config = @{ `+
`paperSize = $c.PaperSize; `+
`orientation = $c.Orientation; `+
`color = $c.Color; `+
`duplex = $c.Duplex; `+
`copies = $c.Copies `+
`} `+
`} | ConvertTo-Json -Depth 4`, escaped, escaped)
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("get printer capabilities failed: %v", err)
}
decoded, err := decodeCmdOutput(output)
if err != nil {
decoded = string(output)
}
var caps map[string]interface{}
if err := json.Unmarshal([]byte(decoded), &caps); err != nil {
return nil, fmt.Errorf("parse printer capabilities failed: %v", err)
}
return caps, nil
}
func (b *Bridge) printPDFPlatform(printerName, filePath string, options PrintOptions) error {
sumatraPath, err := findSumatraPDF()
if err != nil {
return err
}
settings := buildSumatraPrintSettings(options)
args := []string{"-print-to", printerName}
if settings != "" {
args = append(args, "-print-settings", settings)
}
args = append(args, filePath)
cmd := exec.Command(sumatraPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
queueSnapshot, _ := getPrintJobIDs(printerName)
if err := cmd.Start(); err != nil {
return fmt.Errorf("sumatra print failed to start: %v", err)
}
cmdDone := make(chan error, 1)
go func() {
cmdDone <- cmd.Wait()
}()
if err := waitForWindowsPrintCompletion(printerName, queueSnapshot, cmdDone); err != nil {
return err
}
return nil
}
type windowsPrintJob struct {
Id int `json:"Id"`
JobStatus interface{} `json:"JobStatus"`
DocumentName string `json:"DocumentName"`
}
const (
printQueuePollInterval = 500 * time.Millisecond
printQueueAppearTimeout = 120 * time.Second
printQueueCompleteTimeout = 5 * time.Minute
// 虚拟打印机Print to PDF 等)常弹「另存为」对话框,且不一定进入 Get-PrintJob 队列
virtualPrinterSumatraTimeout = 10 * time.Minute
)
// isVirtualWindowsPrinter 判断是否为虚拟/文件类打印机(队列跟踪不可靠)。
func isVirtualWindowsPrinter(printerName string) bool {
n := strings.ToLower(strings.TrimSpace(printerName))
if n == "" {
return false
}
keywords := []string{
"microsoft print to pdf",
"microsoft xps document writer",
"onenote",
"fax",
"adobe pdf",
"pdf24",
"foxit pdf",
"foxit reader pdf",
"bullzip",
"dopdf",
"cutepdf",
"novapdf",
"pdfcreator",
"print to file",
"print to pdf",
"pdf",
"xps",
}
for _, k := range keywords {
if strings.Contains(n, k) {
return true
}
}
return false
}
func getPrintJobs(printerName string) ([]windowsPrintJob, error) {
printerName = strings.TrimSpace(printerName)
if printerName == "" {
return nil, fmt.Errorf("printer name is empty")
}
escaped := strings.ReplaceAll(printerName, "'", "''")
ps := fmt.Sprintf(`Get-PrintJob -PrinterName '%s' | Select-Object Id,JobStatus,DocumentName | ConvertTo-Json -Depth 3`, escaped)
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
output, err := cmd.Output()
if err != nil {
return nil, err
}
decoded, err := decodeCmdOutput(output)
if err != nil {
decoded = string(output)
}
decoded = strings.TrimSpace(decoded)
if decoded == "" || decoded == "null" {
return nil, nil
}
var list []windowsPrintJob
if err := json.Unmarshal([]byte(decoded), &list); err == nil {
return list, nil
}
var single windowsPrintJob
if err := json.Unmarshal([]byte(decoded), &single); err != nil {
return nil, err
}
return []windowsPrintJob{single}, nil
}
func getPrintJobIDs(printerName string) (map[int]bool, error) {
jobs, err := getPrintJobs(printerName)
if err != nil {
return nil, err
}
ids := make(map[int]bool, len(jobs))
for _, job := range jobs {
if job.Id > 0 {
ids[job.Id] = true
}
}
return ids, nil
}
func waitForWindowsPrintCompletion(printerName string, existingIDs map[int]bool, cmdDone <-chan error) error {
// 虚拟打印机:不要求进入 spooler 队列,只等 Sumatra 退出(用户完成另存为)
if isVirtualWindowsPrinter(printerName) {
return waitForVirtualPrinterPrint(cmdDone)
}
appearDeadline := time.Now().Add(printQueueAppearTimeout)
completeDeadline := time.Now().Add(printQueueCompleteTimeout)
queued := false
jobID := 0
sumatraDone := false
for {
select {
case err := <-cmdDone:
sumatraDone = true
if err != nil && !queued {
return fmt.Errorf("sumatra print failed: %v", err)
}
// Sumatra 已正常退出且 spooler 未出现新任务:部分驱动/打印机直接出纸,不经过队列
if err == nil && !queued {
return nil
}
default:
}
now := time.Now()
if !queued && sumatraDone {
return nil
}
if !queued && now.After(appearDeadline) {
// 超时仍未入队:虚拟打印机另存为、或部分驱动不经队列;不再硬失败(避免误报 Printed 0/1
return nil
}
if queued && now.After(completeDeadline) {
return fmt.Errorf("print job not completed within %s", printQueueCompleteTimeout)
}
jobs, err := getPrintJobs(printerName)
if err == nil {
if !queued {
for _, job := range jobs {
if !existingIDs[job.Id] {
jobID = job.Id
queued = true
break
}
}
} else {
found := false
var statusList []string
for _, job := range jobs {
if job.Id == jobID {
found = true
statusList = normalizeJobStatus(job.JobStatus)
break
}
}
if !found {
return nil
}
if isWindowsJobFailed(statusList) {
return fmt.Errorf("print job failed: %s", strings.Join(statusList, ", "))
}
}
}
time.Sleep(printQueuePollInterval)
}
}
// waitForVirtualPrinterPrint 虚拟打印机专用:跳过 Get-PrintJob 队列检测。
// Microsoft Print to PDF / XPS 等会弹对话框,任务常不进队列,原逻辑会误报 2 分钟超时。
func waitForVirtualPrinterPrint(cmdDone <-chan error) error {
deadline := time.Now().Add(virtualPrinterSumatraTimeout)
ticker := time.NewTicker(printQueuePollInterval)
defer ticker.Stop()
for {
select {
case err := <-cmdDone:
if err != nil {
return fmt.Errorf("sumatra print failed: %v", err)
}
return nil
case <-ticker.C:
if time.Now().After(deadline) {
// 对话框可能仍打开或进程未退出;任务多半已交给系统,按成功返回避免业务误报失败
return nil
}
}
}
}
func normalizeJobStatus(status interface{}) []string {
switch v := status.(type) {
case string:
if strings.TrimSpace(v) == "" {
return nil
}
return []string{strings.TrimSpace(v)}
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
if strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
}
return out
default:
return nil
}
}
func isWindowsJobFailed(statuses []string) bool {
for _, status := range statuses {
value := strings.ToLower(status)
if strings.Contains(value, "error") || strings.Contains(value, "offline") || strings.Contains(value, "paused") {
return true
}
}
return false
}
func findSumatraPDF() (string, error) {
if envPath := strings.TrimSpace(os.Getenv("SUMATRAPDF_PATH")); envPath != "" {
if fileExists(envPath) {
return envPath, nil
}
}
if exe, err := os.Executable(); err == nil {
candidate := filepath.Join(filepath.Dir(exe), "SumatraPDF.exe")
if fileExists(candidate) {
return candidate, nil
}
}
if path, err := exec.LookPath("SumatraPDF.exe"); err == nil {
return path, nil
}
if path, err := exec.LookPath("SumatraPDF"); err == nil {
return path, nil
}
return "", fmt.Errorf("SumatraPDF.exe not found. Place it next to the app, add it to PATH, or set SUMATRAPDF_PATH")
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func buildSumatraPrintSettings(options PrintOptions) string {
var settings []string
if options.PageRange != "" {
settings = append(settings, options.PageRange)
}
switch strings.ToLower(strings.TrimSpace(options.PageSet)) {
case "even":
settings = append(settings, "even")
case "odd":
settings = append(settings, "odd")
}
switch strings.ToLower(strings.TrimSpace(options.Scale)) {
case "fit":
settings = append(settings, "fit")
case "shrink":
settings = append(settings, "shrink")
case "none", "actual", "noscale":
settings = append(settings, "noscale")
}
switch strings.ToLower(strings.TrimSpace(options.Orientation)) {
case "portrait":
settings = append(settings, "portrait")
case "landscape":
settings = append(settings, "landscape")
}
switch strings.ToLower(strings.TrimSpace(options.Duplex)) {
case "simplex", "one-sided":
settings = append(settings, "simplex")
case "long-edge", "long", "duplex", "duplexlong":
settings = append(settings, "duplex")
case "short-edge", "short", "duplexshort":
settings = append(settings, "duplexshort")
}
switch strings.ToLower(strings.TrimSpace(options.ColorMode)) {
case "color":
settings = append(settings, "color")
case "mono", "monochrome", "grayscale", "gray":
settings = append(settings, "monochrome")
}
if options.Paper != "" {
settings = append(settings, fmt.Sprintf("paper=%s", options.Paper))
}
if options.TrayBin != "" {
settings = append(settings, fmt.Sprintf("bin=%s", options.TrayBin))
}
if options.Copies > 1 {
settings = append(settings, fmt.Sprintf("%dx", options.Copies))
}
return strings.Join(settings, ",")
}