本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:C#作为.NET框架下的主流编程语言,广泛应用于Windows桌面开发,尤其在文件操作与硬件交互方面具有强大支持。本文介绍如何使用C#实现对文本、图片、PDF等多种格式文件的打印功能,并支持同时选择文件与文件夹进行批量打印。通过PrintDocument、OpenFileDialog等核心类,结合System.Drawing、iTextSharp等库,系统可灵活处理不同文件类型的解析与图形绘制。项目涵盖文件遍历、格式识别、异常处理和打印输出全流程,具备良好的扩展性与实用性,适用于各类文档管理系统的集成需求。

C#打印系统深度解析:从基础到企业级架构设计

你有没有遇到过这样的场景?财务同事急匆匆跑来:“小王,这月的报表要马上打出来,300份!”结果点了打印按钮,程序卡住不动了——内存爆了。或者更糟,一份PDF文件怎么都打不出来,提示“字体缺失”,但人家明明在别的电脑上能正常打印……这些问题背后,往往不是代码写错了,而是整个打印系统的底层逻辑没吃透。

今天咱们就来聊点硬核的。C#里的打印看似简单,一行 Print() 调用搞定一切,可一旦进入真实业务环境,你会发现事情远比想象中复杂得多。从一个字符怎么画到纸上,到如何优雅地处理几百个不同格式的文件批量输出;从避免GDI资源泄漏导致内存暴涨,到让打印机离线时也能自动暂停任务队列并通知用户恢复——这些才是工业级打印系统的核心挑战。

而这一切的起点,正是那个被很多人忽视的类: PrintDocument


我们先来看一段最基础的代码:

var printDoc = new PrintDocument();
printDoc.DocumentName = "测试文档";
printDoc.Print(); // 启动打印作业

看起来是不是特别简洁?三行代码完成一次打印。但问题来了:内容呢?谁负责把文字、图像真正“画”到纸上?

答案是: 事件驱动模型

PrintDocument 本身并不直接绘制任何东西,它更像是一个“导演”。它的职责是协调整个打印流程,并在合适的时机通知“演员”(也就是开发者写的绘图逻辑)该上场了。这个关键的通知机制,就是 PrintPage 事件。

printDoc.PrintPage += (sender, e) =>
{
    using var font = new Font("微软雅黑", 12);
    using var brush = new SolidBrush(Color.Black);
    e.Graphics.DrawString("Hello World!", font, brush, new PointF(100, 100));
};

瞧,这才是真正的“演出开始”。每当系统需要生成一页时,就会触发 PrintPage 事件,把一个 PrintPageEventArgs 对象传进来。其中最重要的成员就是 e.Graphics —— 这是一个来自 GDI+ 的绘图表面,你可以把它理解为一块虚拟画布,所有的文本、线条、图片都要通过它来绘制。

但别高兴得太早!如果你以为只要在这里写完绘图逻辑就万事大吉,那迟早会踩坑。比如下面这段代码:

private void OnPrintPage(object sender, PrintPageEventArgs e)
{
    var lines = File.ReadAllLines("huge-log.txt"); // 危险!
    foreach (var line in lines)
    {
        e.Graphics.DrawString(line, font, brush, x, y);
        y += lineHeight;
        if (y > e.MarginBounds.Bottom) break;
    }
}

看着没问题对吧?可一旦日志文件有几十MB,这一句 ReadAllLines 就能把你的应用程序拖进内存深渊,甚至直接抛出 OutOfMemoryException 。这就是典型的“教学式代码”和“生产级代码”的差距。

真正的高手是怎么做的?他们知道 PrintPage 是按需分页的,意味着每一页的内容都是 动态生成 的。所以聪明的做法是只在当前页需要时才加载对应的数据块,而不是一次性全读进内存。

这就引出了 .NET 打印体系中的第一个核心设计理念: 延迟计算 + 状态维持

当调用 Print() 方法后,.NET 并不会立刻渲染所有页面,而是采用所谓的“按需分页”策略。第一次触发 PrintPage 时画第一页,如果此时设置了 e.HasMorePages = true ,系统就知道还有下一页,于是继续触发第二次 PrintPage ……如此循环,直到某次设置为 false 为止。

private int _currentPageIndex = 0;
private List<string> _allLines;

private void OnPrintPage(object sender, PrintPageEventArgs e)
{
    float yPos = e.MarginBounds.Top;

    // 每页最多打印25行
    int linesThisPage = 0;
    while (linesThisPage < 25 && _currentPageIndex < _allLines.Count)
    {
        e.Graphics.DrawString(
            _allLines[_currentPageIndex],
            _font,
            Brushes.Black,
            e.MarginBounds.Left,
            yPos);

        yPos += _lineHeight;
        linesThisPage++;
        _currentPageIndex++;
    }

    e.HasMorePages = _currentPageIndex < _allLines.Count;
}

注意 _currentPageIndex 是类级别的字段。因为每次 PrintPage 回调之间没有共享栈空间,局部变量无法跨页保留,所以我们必须自己维护状态。这也是为什么很多初学者会在多页打印时出现“重复打印第一页”或“漏掉最后几行”的原因——忘了用成员变量记录进度!

为了帮你更直观理解整个流程,这里有个 Mermaid 流程图 🌟:

graph TD
    A[调用 PrintDocument.Print()] --> B[触发 BeginPrint 事件]
    B --> C[准备打印资源]
    C --> D[触发第一次 PrintPage 事件]
    D --> E{HasMorePages == true?}
    E -- 是 --> F[继续触发下一次 PrintPage]
    F --> E
    E -- 否 --> G[触发 EndPrint 事件]
    G --> H[打印任务完成]

看到了吗? BeginPrint EndPrint 像两个守门人,分别负责初始化和清理工作。比如你可以在 BeginPrint 中打开数据库连接、加载配置、预解析文档结构;而在 EndPrint 中关闭资源、释放缓存、记录日志等。

那么 PrintPageEventArgs 到底提供了哪些关键信息呢?除了上面提到的 Graphics HasMorePages ,还有一个极其重要的属性: MarginBounds

属性名 类型 说明
Graphics System.Drawing.Graphics 绘图表面,用于绘制文本、图形、图像等内容
HasMorePages bool 是否还有更多页面需要打印;设置为 true 将触发下一轮事件
MarginBounds Rectangle 表示页面可用区域(扣除页边距后的矩形范围),单位为像素

举个例子,假设你想在一个A4纸上居中打印一个表格。如果不使用 MarginBounds ,而是直接用固定坐标,很容易导致内容超出可打印区域,被裁剪掉一部分。正确的做法是:

Rectangle margins = e.MarginBounds;
float tableWidth = 600;
float startX = margins.Left + (margins.Width - tableWidth) / 2; // 居中计算

另外一个小技巧:默认情况下 Graphics 使用的是“像素”作为单位,但它受打印机 DPI 影响极大。96 DPI 的屏幕和 600 DPI 的打印机,同样100像素代表的实际长度差了六倍!为了避免混乱,建议统一设置绘图单位:

e.Graphics.PageUnit = GraphicsUnit.Millimeter; // 或 Inches
e.Graphics.DrawString("标题", font, brush, new PointF(10, 15)); // 10mm, 15mm处绘制

这样一来,无论设备分辨率如何变化,输出效果都能保持一致。

说到分页,很多人觉得只要控制好 HasMorePages 就够了,但实际上复杂的排版需求会让这个问题变得异常棘手。比如表格跨页怎么办?表头要不要每页都显示?段落能不能断在中间?

来看一个实用的表格分页算法:

private int _currentRow = 0;

private void DrawTableHeader(PrintPageEventArgs e, ref float yPos)
{
    e.Graphics.FillRectangle(Brushes.Gray, new RectangleF(0, yPos, width, headerHeight));
    e.Graphics.DrawString("ID", font, Brushes.White, 10, yPos + 5);
    // ... 其他列标题
    yPos += headerHeight;
}

private void OnPrintPage(object sender, PrintPageEventArgs e)
{
    float yPos = e.MarginBounds.Top;

    DrawTableHeader(e, ref yPos); // 每页都打印表头 ✅

    for (; _currentRow < _rows.Count; _currentRow++)
    {
        float rowHeight = CalculateRowHeight(_rows[_currentRow]);
        if (yPos + rowHeight > e.MarginBounds.Bottom)
        {
            e.HasMorePages = true;
            return; // 提前退出,等待下一页
        }

        DrawTableRow(e, _rows[_currentRow], yPos);
        yPos += rowHeight;
    }

    e.HasMorePages = false;
}

重点在于这个判断:

if (yPos + rowHeight > e.MarginBounds.Bottom) { ... }

它确保不会把一行拆成两半打印。一旦发现放不下整行,立即停止并返回,靠 HasMorePages=true 触发下一页,在新页面重新绘制表头后再接着打剩下的行。

当然,不同的内容类型适合不同的分页策略。我总结了一个对比表供你参考:

策略类型 适用场景 优点 缺点
固定行数分页 日志、列表打印 实现简单,性能高 忽略内容高度差异
动态测量分页 富文本、混合排版 精确控制,美观性强 计算开销大
预渲染分页 PDF导出、预览 可预先计算总页数 内存消耗高
流式分页 大文件、数据库流打印 内存友好,支持无限数据 无法预知总页数

大多数情况下,推荐组合使用“动态测量 + 流式分页”,既能保证布局精度,又不至于压垮内存。


讲到这里,我们还只是解决了“如何画一页”的问题。但在实际项目中,更大的挑战往往是: 用户扔过来一堆文件,什么格式都有,你怎么办?

想想看,一个医院信息系统,可能同时收到患者的检查报告(PDF)、X光片(DICOM/TIFF)、病历摘要(Word)、用药清单(Excel)……传统做法是一个个写硬编码分支:

if (path.EndsWith(".pdf")) PrintPdf(path);
else if (path.EndsWith(".jpg")) PrintImage(path);
// ... 数十个 else if ...

这种代码不仅丑陋,而且极难维护。新增一种格式就得改主逻辑,违反了开闭原则。更重要的是,扩展名真的可信吗?

⚠️ 注意!文件扩展名是可以伪造的。有人把 .exe 改成 .txt 就能绕过某些安全检查。真正的类型识别必须依赖“文件头签名”(Magic Number)。

比如:
- PDF 文件开头一定是 %PDF → 字节序列 25 50 44 46
- JPEG 是 FF D8 FF
- PNG 是 89 50 4E 47 0D 0A 1A 0A

我们可以封装一个通用的检测工具类:

public class FileTypeDetector
{
    private static readonly Dictionary<string, byte[]> Signatures = new()
    {
        ["pdf"] = new byte[] { 0x25, 0x50, 0x44, 0x46 },
        ["jpeg"] = new byte[] { 0xFF, 0xD8, 0xFF },
        ["png"] = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A },
        ["zip"] = new byte[] { 0x50, 0x4B, 0x03, 0x04 } // DOCX/XLSX本质是ZIP
    };

    public static string Detect(string filePath)
    {
        using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
        var header = new byte[8];
        fs.Read(header, 0, 8);

        foreach (var kv in Signatures)
        {
            if (header.Take(kv.Value.Length).SequenceEqual(kv.Value))
                return kv.Key;
        }

        return "unknown";
    }
}

但这还不够智能。我们应该结合多种方式做综合判断:先看扩展名快速过滤,再读文件头确认真实类型,最后回退到 MIME 类型推断。这样即使遇到无扩展名文件也能准确识别。

于是我们构建一个完整的 FileTypeInfo 结构:

public class FileTypeInfo
{
    public string FilePath { get; set; }
    public string ExtensionHint { get; set; } // .pdf
    public string MimeType { get; set; }      // application/pdf
    public string SignatureMatch { get; set; }// "pdf"
    public string PrimaryFormat { get; set; } // "pdf" or "image"
    public string Error { get; set; }
}

并通过 Mermaid 图展示完整识别流程 🎯:

graph TD
    A[开始识别文件] --> B{文件是否存在?}
    B -- 否 --> C[抛出 FileNotFoundException]
    B -- 是 --> D[提取扩展名]
    D --> E[查询MIME类型]
    E --> F[读取前8字节文件头]
    F --> G{匹配Magic Number?}
    G -- 是 --> H[采用签名识别结果]
    G -- 否 --> I[回退至MIME/扩展名推断]
    H --> J[合并信息生成FileTypeInfo]
    I --> J
    J --> K[返回最终格式类型]

这套机制就像侦探破案一样层层递进,失败降级,最大限度提高识别成功率。

有了准确的类型判断,下一步就是把不同格式交给对应的处理器去打印。这时候就不能再用 if-else 了,得上设计模式!

我们定义一个统一接口:

public interface IPrintableDocument
{
    bool CanHandle(string format);
    Task PrintAsync(PrintContext context, CancellationToken token = default);
    Task<bool> ValidateAsync(string filePath, CancellationToken token = default);
}

然后实现各种具体的处理器,比如图像打印器:

public class ImagePrintHandler : IPrintableDocument
{
    public bool CanHandle(string format) => format.Equals("image", StringComparison.OrdinalIgnoreCase);

    public async Task<bool> ValidateAsync(string filePath, CancellationToken token)
    {
        return await Task.Run(() =>
        {
            try
            {
                using var img = Image.FromFile(filePath);
                return img.Width > 0 && img.Height > 0;
            }
            catch { return false; }
        }, token);
    }

    public async Task PrintAsync(PrintContext context, CancellationToken token)
    {
        var doc = new PrintDocument();
        doc.DocumentName = Path.GetFileName(context.FilePath);

        doc.PrintPage += (sender, e) =>
        {
            using var img = Image.FromFile(context.FilePath);
            e.Graphics.DrawImage(img, e.MarginBounds);
            e.HasMorePages = false;
        };

        await Task.Run(() => doc.Print(), token);
    }
}

再配一个调度器来路由任务:

public class PrintHandlerDispatcher
{
    private readonly List<IPrintableDocument> _handlers = new();

    public void Register(IPrintableDocument handler) => _handlers.Add(handler);

    public IPrintableDocument GetHandlerForFormat(string format)
    {
        return _handlers.FirstOrDefault(h => h.CanHandle(format));
    }

    public async Task<RouteResult> RouteAndPrintAsync(PrintContext context, CancellationToken token)
    {
        var detector = FileTypeDetector.Instance;
        var fileInfo = detector.Detect(context.FilePath);

        if (fileInfo.Error != null)
            return new RouteResult { Success = false, Message = $"文件检测失败: {fileInfo.Error}" };

        var handler = GetHandlerForFormat(fileInfo.PrimaryFormat);
        if (handler == null)
            return new RouteResult { Success = false, Message = $"不支持的格式: {fileInfo.PrimaryFormat}" };

        if (!await handler.ValidateAsync(context.FilePath, token))
            return new RouteResult { Success = false, Message = "文件内容无效或已损坏" };

        await handler.PrintAsync(context, token);
        return new RouteResult { Success = true, Message = "打印提交成功" };
    }
}

看明白了吗?现在你要加一个新的格式支持,比如 Markdown,只需要写一个 MarkdownPrintHandler 实现接口,注册进去就行,完全不用动现有逻辑。👏

而且还能通过配置文件控制启用状态,实现灰度发布:

处理器类名 支持格式 是否启用
TextPrintHandler text
ImagePrintHandler image
PdfPrintHandler pdf
ArchivePrintHandler archive ❌(待开发)

整个过程就像搭积木一样灵活。

不过别忘了,每个处理器都需要一些公共信息,比如打印机设置、纸张方向、页边距等等。如果每个都单独传参,容易出错且难以统一管理。怎么办?

答案是引入 打印上下文(PrintContext)

public class PrintContext
{
    public string FilePath { get; set; }
    public PrinterSettings PrinterSettings { get; set; } = new();
    public PageSettings PageSettings { get; set; } = new();
    public PrintOptions Options { get; set; } = new();
    public IProgress<PrintProgress> ProgressReporter { get; set; }
    public CancellationTokenSource CancellationTokenSource { get; set; } = new();
    public IPrintLogger Logger { get; set; } = NullLogger.Instance;
    public object Tag { get; set; }
}

public class PrintOptions
{
    public int Copies { get; set; } = 1;
    public bool Collate { get; set; } = false;
    public bool PrintPreviewMode { get; set; } = false;
    public string OutputDeviceName { get; set; }
}

所有处理器共用同一个上下文实例,确保配置一致性。比如用户选择了“A4横向双面打印”,这个设置会自动同步给所有后续任务。

还可以把常用偏好保存下来,下次启动自动加载:

<UserPreferences>
  <DefaultPrinter>HP LaserJet Pro</DefaultPrinter>
  <PaperSize>A4</PaperSize>
  <Orientation>Landscape</Orientation>
  <Copies>1</Copies>
</UserPreferences>

当然,光功能强大还不够,系统还得稳。特别是在批量打印几百个文件时,万一中间某个坏了,难道整个任务都要中断?显然不行。

所以我们需要一套完善的 异常传播与日志追踪机制

try
{
    await handler.PrintAsync(context, token);
}
catch (Exception ex)
{
    context.Logger.LogError(ex, "打印文件 {FilePath} 时发生错误", context.FilePath);
    throw;
}

配合甘特图可视化异常时间线 💥:

gantt
    title 打印任务异常时间线
    dateFormat  YYYY-MM-DD HH:mm:ss
    section 文件处理
    检测格式           :done, t1, 2025-04-05 10:00:00, 1s
    加载图像           :active, t2, after t1, 2s
    发生GDI+异常       :crit, 2025-04-05 10:00:03, 1s
    记录日志并通知UI   : 2025-04-05 10:00:04, 1s

让用户清楚知道哪里出了问题,而不是一脸懵地看着程序崩溃。

接下来聊聊具体格式的处理细节。

对于文本文件,小文件可以用 File.ReadAllText ,但大文件必须流式读取:

await foreach (var line in File.ReadLinesAsync(path))
{
    // 逐行处理,避免内存爆炸
}

并且要实现自动换行算法:

private List<string> WrapText(Graphics g, string text, Font font, float maxWidth)
{
    var words = text.Split(' ');
    var lines = new List<string>();
    var currentLine = "";

    foreach (var word in words)
    {
        string testLine = currentLine + (currentLine.Length > 0 ? " " : "") + word;
        SizeF size = g.MeasureString(testLine, font);

        if (size.Width <= maxWidth)
        {
            currentLine = testLine;
        }
        else
        {
            if (currentLine != "")
                lines.Add(currentLine);
            currentLine = word;
        }
    }

    if (currentLine != "")
        lines.Add(currentLine);

    return lines;
}

如果是代码文件还想加语法高亮?也不是不行!简单做个关键字着色:

string[] keywords = { "public", "class", "void" };
foreach (var token in Regex.Split(line, @"(\b\w+\b)"))
{
    bool isKeyword = keywords.Contains(token);
    Brush brush = isKeyword ? Brushes.Blue : Brushes.Black;
    Font font = isKeyword ? boldFont : normalFont;

    SizeF size = g.MeasureString(token, font);
    g.DrawString(token, font, brush, x, y);
    x += size.Width;
}

图像打印也有讲究。千万别用 Image.FromFile 直接加载,否则文件会被锁住,删都删不掉!正确姿势是复制一份:

using (var temp = Image.FromFile(path))
{
    image = new Bitmap(temp); // 数据已独立
}

而且要适配打印机 DPI,不能照搬屏幕坐标:

float dpiScaleX = g.DpiX / 96f;
float dpiScaleY = g.DpiY / 96f;
RectangleF destRect = new RectangleF(x, y, img.Width * dpiScaleX, img.Height * dpiScaleY);

至于 PDF,.NET 原生不支持渲染,得靠第三方库。推荐 PDFsharp(MIT 许可)或 iTextSharp(AGPL)。中文乱码怎么办?手动注册字体:

GlobalFontSettings.FontResolver = new CustomFontResolver();

class CustomFontResolver : IFontResolver
{
    public byte[] GetFont(string fontFamilyName) => File.ReadAllBytes(@"SimSun.ttf");
    public string DefaultFontName => "SimSun";
}

Office 文档呢?Interop 虽然方便,但依赖 Office 安装,不适合服务器端。稳妥做法是先转 PDF 再打印。

说到这里,你可能会问:这么多组件,怎么组织才不乱?

答案是分层架构 🏗️:

模块 职责 技术要点
Core Printing Engine 打印流程调度 PrintDocument封装、事件驱动
Format Router 格式识别与分发 策略模式 + 接口抽象
Plugin Host 外部扩展加载 AssemblyLoadContext隔离
UI Interaction Layer 用户交互 WinForms/WPF双向绑定
Logging & Diagnostics 故障追踪 Serilog + TraceSource

每一层各司其职,互不影响。未来想加远程打印API?插个Web模块就行。想集成AI自动排版?换个LayoutEngine注入进去即可。

最后提醒几个性能优化要点 ⚠️:

  1. GDI资源必须用 using 包裹 ,否则必然内存泄漏;
  2. 大文件禁止一次性加载 ,要用流式或分块读取;
  3. 长时间任务走后台线程 ,配合 CancellationToken 支持取消;
  4. UI反馈要及时 ,进度条、状态栏都不能少;
  5. 打印机异常要有兜底方案 ,比如暂停队列、弹窗提示重连。

整个系统的设计哲学其实很简单: 把复杂留给自己,把简单交给用户 。让他们只需点一下“打印”,剩下的事由我们默默搞定。

毕竟,真正的技术实力,从来不在炫技,而在无声处见真章。✨

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:C#作为.NET框架下的主流编程语言,广泛应用于Windows桌面开发,尤其在文件操作与硬件交互方面具有强大支持。本文介绍如何使用C#实现对文本、图片、PDF等多种格式文件的打印功能,并支持同时选择文件与文件夹进行批量打印。通过PrintDocument、OpenFileDialog等核心类,结合System.Drawing、iTextSharp等库,系统可灵活处理不同文件类型的解析与图形绘制。项目涵盖文件遍历、格式识别、异常处理和打印输出全流程,具备良好的扩展性与实用性,适用于各类文档管理系统的集成需求。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐