572 lines
14 KiB
Go
572 lines
14 KiB
Go
//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, ",")
|
||
}
|