feat: 初始功能

This commit is contained in:
Shiqvlizi Name
2026-05-23 18:16:50 +08:00
parent a5bfd8792e
commit 9a51259e9e
63 changed files with 5377 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Printing;
public interface IPrintJobExecutor
{
bool CanHandle(PrintJob job);
Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace CloudPrint.Client.Services.Printing;
public interface IPrinterStatusReader
{
PrinterStatusSnapshot Read(string printerName);
}

View File

@@ -0,0 +1,7 @@
namespace CloudPrint.Client.Services.Printing;
public interface IWindowsPrintQueueService
{
WindowsPrintQueueJob? FindLatestJob(string printerName, string documentName);
bool CancelJob(string printerName, int jobId);
}

View File

@@ -0,0 +1,88 @@
using CloudPrint.Client.Models;
using System.Drawing;
using System.Drawing.Printing;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
public sealed class ImagePrintJobExecutor : IPrintJobExecutor
{
private readonly IWindowsPrintQueueService _queueService;
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".bmp", ".gif"
};
public ImagePrintJobExecutor(IWindowsPrintQueueService queueService)
{
_queueService = queueService;
}
public bool CanHandle(PrintJob job)
{
return SupportedExtensions.Contains(Path.GetExtension(job.FilePath));
}
public Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
{
PrintFileGuards.EnsurePrintableFile(job);
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
using var image = Image.FromFile(job.FilePath);
using var document = new PrintDocument
{
DocumentName = string.IsNullOrWhiteSpace(job.FileName) ? job.JobNo : job.FileName,
PrinterSettings =
{
PrinterName = job.PrinterName,
Copies = (short)Math.Clamp(job.Copies, 1, short.MaxValue)
}
};
if (document.PrinterSettings.CanDuplex)
{
document.PrinterSettings.Duplex = job.Duplex ? Duplex.Vertical : Duplex.Simplex;
}
document.DefaultPageSettings.Color = job.Color;
document.PrintPage += (_, args) => DrawImageToPage(image, args);
progress?.Report(20);
document.Print();
CaptureWindowsJob(job, document.DocumentName);
progress?.Report(100);
}, cancellationToken);
}
private void CaptureWindowsJob(PrintJob job, string documentName)
{
var queueJob = _queueService.FindLatestJob(job.PrinterName, documentName);
if (queueJob is null)
{
return;
}
job.WindowsJobId = queueJob.JobId;
job.WindowsJobStatus = queueJob.Status;
}
private static void DrawImageToPage(Image image, PrintPageEventArgs args)
{
if (args.Graphics is null)
{
throw new InvalidOperationException("打印图形上下文不可用");
}
var bounds = args.MarginBounds;
var ratio = Math.Min(bounds.Width / (double)image.Width, bounds.Height / (double)image.Height);
var width = (int)(image.Width * ratio);
var height = (int)(image.Height * ratio);
var x = bounds.Left + (bounds.Width - width) / 2;
var y = bounds.Top + (bounds.Height - height) / 2;
args.Graphics.DrawImage(image, x, y, width, height);
args.HasMorePages = false;
}
}

View File

@@ -0,0 +1,100 @@
using CloudPrint.Client.Models;
using System.Diagnostics;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
public sealed class PdfPrintJobExecutor : IPrintJobExecutor
{
private readonly IWindowsPrintQueueService _queueService;
public PdfPrintJobExecutor(IWindowsPrintQueueService queueService)
{
_queueService = queueService;
}
public bool CanHandle(PrintJob job)
{
return string.Equals(Path.GetExtension(job.FilePath), ".pdf", StringComparison.OrdinalIgnoreCase);
}
public async Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
{
PrintFileGuards.EnsurePrintableFile(job);
progress?.Report(10);
var sumatraPath = FindSumatraPdfExecutable();
if (!string.IsNullOrWhiteSpace(sumatraPath))
{
await PrintWithSumatraAsync(sumatraPath, job, cancellationToken).ConfigureAwait(true);
}
else
{
await PrintWithShellAsync(job, cancellationToken).ConfigureAwait(true);
}
CaptureWindowsJob(job);
progress?.Report(100);
}
private void CaptureWindowsJob(PrintJob job)
{
var documentName = Path.GetFileName(job.FilePath);
var queueJob = _queueService.FindLatestJob(job.PrinterName, documentName);
if (queueJob is null)
{
return;
}
job.WindowsJobId = queueJob.JobId;
job.WindowsJobStatus = queueJob.Status;
}
private static async Task PrintWithSumatraAsync(string sumatraPath, PrintJob job, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = sumatraPath,
Arguments = $"-print-to \"{job.PrinterName}\" -silent -exit-when-done \"{job.FilePath}\"",
CreateNoWindow = true,
UseShellExecute = false
};
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("启动 SumatraPDF 失败");
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"SumatraPDF 打印失败,退出码:{process.ExitCode}");
}
}
private static async Task PrintWithShellAsync(PrintJob job, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = job.FilePath,
Verb = "printto",
Arguments = $"\"{job.PrinterName}\"",
UseShellExecute = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("无法调用系统默认 PDF 打印程序,请安装 SumatraPDF 或配置 PDF 默认打印程序");
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(true);
}
private static string? FindSumatraPdfExecutable()
{
var candidates = new[]
{
Environment.GetEnvironmentVariable("SUMATRA_PDF_PATH"),
@"C:\Program Files\SumatraPDF\SumatraPDF.exe",
@"C:\Program Files (x86)\SumatraPDF\SumatraPDF.exe"
};
return candidates.FirstOrDefault(path => !string.IsNullOrWhiteSpace(path) && File.Exists(path));
}
}

View File

@@ -0,0 +1,22 @@
using CloudPrint.Client.Models;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
internal static class PrintFileGuards
{
public static void EnsurePrintableFile(PrintJob job)
{
ArgumentNullException.ThrowIfNull(job);
if (string.IsNullOrWhiteSpace(job.PrinterName))
{
throw new InvalidOperationException("打印机名称不能为空");
}
if (string.IsNullOrWhiteSpace(job.FilePath) || !File.Exists(job.FilePath))
{
throw new FileNotFoundException("待打印文件不存在", job.FilePath);
}
}
}

View File

@@ -0,0 +1,10 @@
using CloudPrint.Client.Models;
namespace CloudPrint.Client.Services.Printing;
public sealed class PrinterStatusSnapshot
{
public PrinterStatus Status { get; init; } = PrinterStatus.Unknown;
public int QueueCount { get; init; }
public string Message { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,94 @@
using CloudPrint.Client.Helpers;
using CloudPrint.Client.Models;
using System.IO;
namespace CloudPrint.Client.Services.Printing;
public sealed class RawPrintJobExecutor : IPrintJobExecutor
{
public bool CanHandle(PrintJob job) => true;
public async Task ExecuteAsync(PrintJob job, IProgress<int>? progress, CancellationToken cancellationToken = default)
{
PrintFileGuards.EnsurePrintableFile(job);
if (!WindowsPrintApi.OpenPrinter(job.PrinterName, out var printerHandle, IntPtr.Zero))
{
throw new InvalidOperationException($"打开打印机失败:{job.PrinterName}");
}
try
{
var docInfo = new WindowsPrintApi.DocInfo
{
DocumentName = string.IsNullOrWhiteSpace(job.FileName) ? job.JobNo : job.FileName,
DataType = "RAW"
};
var windowsJobId = WindowsPrintApi.StartDocPrinter(printerHandle, 1, docInfo);
if (windowsJobId == 0)
{
throw new InvalidOperationException("启动打印文档失败");
}
job.WindowsJobId = windowsJobId;
job.WindowsJobStatus = "已提交到 Windows 打印队列";
try
{
if (!WindowsPrintApi.StartPagePrinter(printerHandle))
{
throw new InvalidOperationException("启动打印页面失败");
}
try
{
await WriteFileToPrinterAsync(printerHandle, job.FilePath, progress, cancellationToken).ConfigureAwait(true);
}
finally
{
WindowsPrintApi.EndPagePrinter(printerHandle);
}
}
finally
{
WindowsPrintApi.EndDocPrinter(printerHandle);
}
}
finally
{
WindowsPrintApi.ClosePrinter(printerHandle);
}
}
private static async Task WriteFileToPrinterAsync(IntPtr printerHandle, string filePath, IProgress<int>? progress, CancellationToken cancellationToken)
{
const int bufferSize = 64 * 1024;
var buffer = new byte[bufferSize];
var fileInfo = new FileInfo(filePath);
long totalWritten = 0;
await using var stream = File.OpenRead(filePath);
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(true);
if (read == 0)
{
break;
}
var chunk = read == buffer.Length ? buffer : buffer[..read];
if (!WindowsPrintApi.WritePrinter(printerHandle, chunk, read, out var written) || written != read)
{
throw new InvalidOperationException("写入打印机失败");
}
totalWritten += written;
var percent = fileInfo.Length == 0 ? 100 : (int)Math.Min(99, totalWritten * 100 / fileInfo.Length);
progress?.Report(percent);
}
progress?.Report(100);
}
}

View File

@@ -0,0 +1,12 @@
namespace CloudPrint.Client.Services.Printing;
public sealed class WindowsPrintQueueJob
{
public int JobId { get; init; }
public string PrinterName { get; init; } = string.Empty;
public string DocumentName { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public int Position { get; init; }
public int TotalPages { get; init; }
public int PagesPrinted { get; init; }
}

View File

@@ -0,0 +1,164 @@
using System.Management;
using System.Runtime.InteropServices;
using CloudPrint.Client.Helpers;
namespace CloudPrint.Client.Services.Printing;
public sealed class WindowsPrintQueueService : IWindowsPrintQueueService
{
public WindowsPrintQueueJob? FindLatestJob(string printerName, string documentName)
{
if (string.IsNullOrWhiteSpace(printerName) || string.IsNullOrWhiteSpace(documentName))
{
return null;
}
try
{
return EnumerateJobs(printerName)
.Where(job => job.DocumentName.Contains(documentName, StringComparison.OrdinalIgnoreCase)
|| documentName.Contains(job.DocumentName, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(job => job.JobId)
.FirstOrDefault();
}
catch
{
return null;
}
}
public bool CancelJob(string printerName, int jobId)
{
if (string.IsNullOrWhiteSpace(printerName) || jobId <= 0)
{
return false;
}
if (CancelJobWithWinSpool(printerName, jobId))
{
return true;
}
foreach (var job in QueryJobObjects(printerName))
{
var currentJobId = Convert.ToInt32(job["JobId"] ?? 0);
if (currentJobId != jobId)
{
continue;
}
job.InvokeMethod("Delete", null);
return true;
}
return false;
}
private static IEnumerable<WindowsPrintQueueJob> EnumerateJobs(string printerName)
{
var spoolerJobs = EnumerateJobsWithWinSpool(printerName);
if (spoolerJobs.Count > 0)
{
return spoolerJobs;
}
return EnumerateJobsWithWmi(printerName);
}
private static IReadOnlyList<WindowsPrintQueueJob> EnumerateJobsWithWinSpool(string printerName)
{
if (!WindowsPrintApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero))
{
return Array.Empty<WindowsPrintQueueJob>();
}
try
{
const int maxJobs = 256;
_ = WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, IntPtr.Zero, 0, out var bytesNeeded, out _);
if (bytesNeeded <= 0)
{
return Array.Empty<WindowsPrintQueueJob>();
}
var buffer = Marshal.AllocHGlobal(bytesNeeded);
try
{
if (!WindowsPrintApi.EnumJobs(printerHandle, 0, maxJobs, 1, buffer, bytesNeeded, out _, out var jobsReturned))
{
return Array.Empty<WindowsPrintQueueJob>();
}
var jobs = new List<WindowsPrintQueueJob>(jobsReturned);
var itemSize = Marshal.SizeOf<WindowsPrintApi.JobInfo1>();
for (var index = 0; index < jobsReturned; index++)
{
var itemPointer = IntPtr.Add(buffer, index * itemSize);
var jobInfo = Marshal.PtrToStructure<WindowsPrintApi.JobInfo1>(itemPointer);
jobs.Add(new WindowsPrintQueueJob
{
JobId = jobInfo.JobId,
PrinterName = printerName,
DocumentName = jobInfo.DocumentName ?? string.Empty,
Status = string.IsNullOrWhiteSpace(jobInfo.StatusText) ? $"0x{jobInfo.Status:X}" : jobInfo.StatusText,
Position = jobInfo.Position,
TotalPages = jobInfo.TotalPages,
PagesPrinted = jobInfo.PagesPrinted
});
}
return jobs;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
finally
{
_ = WindowsPrintApi.ClosePrinter(printerHandle);
}
}
private static bool CancelJobWithWinSpool(string printerName, int jobId)
{
if (!WindowsPrintApi.OpenPrinter(printerName, out var printerHandle, IntPtr.Zero))
{
return false;
}
try
{
return WindowsPrintApi.SetJob(printerHandle, jobId, 0, IntPtr.Zero, WindowsPrintApi.JobControlDelete)
|| WindowsPrintApi.SetJob(printerHandle, jobId, 0, IntPtr.Zero, WindowsPrintApi.JobControlCancel);
}
finally
{
_ = WindowsPrintApi.ClosePrinter(printerHandle);
}
}
private static IEnumerable<WindowsPrintQueueJob> EnumerateJobsWithWmi(string printerName)
{
foreach (var job in QueryJobObjects(printerName))
{
yield return new WindowsPrintQueueJob
{
JobId = Convert.ToInt32(job["JobId"] ?? 0),
PrinterName = printerName,
DocumentName = Convert.ToString(job["Document"]) ?? string.Empty,
Status = Convert.ToString(job["JobStatus"]) ?? string.Empty
};
}
}
private static IEnumerable<ManagementObject> QueryJobObjects(string printerName)
{
var escapedName = printerName.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "''", StringComparison.Ordinal);
using var searcher = new ManagementObjectSearcher($"SELECT JobId, Document, JobStatus, Name FROM Win32_PrintJob WHERE Name LIKE '{escapedName},%'");
foreach (ManagementObject job in searcher.Get())
{
yield return job;
}
}
}

View File

@@ -0,0 +1,119 @@
using CloudPrint.Client.Models;
using System.Management;
namespace CloudPrint.Client.Services.Printing;
public sealed class WindowsPrinterStatusReader : IPrinterStatusReader
{
public PrinterStatusSnapshot Read(string printerName)
{
if (string.IsNullOrWhiteSpace(printerName))
{
return new PrinterStatusSnapshot { Status = PrinterStatus.Unknown, Message = "打印机名称为空" };
}
try
{
return ReadCore(printerName);
}
catch (Exception ex)
{
return new PrinterStatusSnapshot
{
Status = PrinterStatus.Unknown,
Message = $"状态读取失败:{ex.Message}"
};
}
}
private static PrinterStatusSnapshot ReadCore(string printerName)
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, WorkOffline, PrinterStatus, DetectedErrorState FROM Win32_Printer");
foreach (ManagementObject printer in searcher.Get())
{
var name = Convert.ToString(printer["Name"]);
if (!string.Equals(name, printerName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var workOffline = Convert.ToBoolean(printer["WorkOffline"] ?? false);
var printerStatus = Convert.ToUInt32(printer["PrinterStatus"] ?? 2u);
var errorState = Convert.ToUInt32(printer["DetectedErrorState"] ?? 0u);
var queueCount = CountQueueJobs(printerName);
var status = MapStatus(workOffline, printerStatus, errorState, queueCount);
return new PrinterStatusSnapshot
{
Status = status,
QueueCount = queueCount,
Message = BuildMessage(workOffline, printerStatus, errorState)
};
}
return new PrinterStatusSnapshot
{
Status = PrinterStatus.Offline,
Message = "未找到打印机"
};
}
private static int CountQueueJobs(string printerName)
{
var escapedName = printerName.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("'", "''", StringComparison.Ordinal);
using var searcher = new ManagementObjectSearcher($"SELECT Name FROM Win32_PrintJob WHERE Name LIKE '{escapedName},%'");
return searcher.Get().Count;
}
private static PrinterStatus MapStatus(bool workOffline, uint printerStatus, uint errorState, int queueCount)
{
if (workOffline || printerStatus == 7)
{
return PrinterStatus.Offline;
}
if (errorState is >= 3 and <= 11 || printerStatus == 6)
{
return PrinterStatus.Error;
}
if (printerStatus == 4 || queueCount > 0)
{
return PrinterStatus.Printing;
}
return printerStatus == 3 ? PrinterStatus.Idle : PrinterStatus.Unknown;
}
private static string BuildMessage(bool workOffline, uint printerStatus, uint errorState)
{
if (workOffline)
{
return "脱机";
}
return errorState switch
{
3 => "缺纸",
4 => "卡纸",
5 => "碳粉不足",
6 => "门打开",
7 => "需要人工干预",
8 => "离线",
9 => "维修中",
10 => "输出纸盘已满",
11 => "缺纸",
_ => printerStatus switch
{
3 => "就绪",
4 => "打印中",
5 => "预热中",
6 => "已停止",
7 => "离线",
_ => string.Empty
}
};
}
}