Print FixedDocument/XPS to PDF without showing file save dialog
Asked Answered
E

1

17

I have a FixedDocument that I allow the user to preview in a WPF GUI and then print to paper without showing any Windows printing dialogue, like so:

private void Print()
{
    PrintQueueCollection printQueues;
    using (var printServer = new PrintServer())
    {
        var flags = new[] { EnumeratedPrintQueueTypes.Local };
        printQueues = printServer.GetPrintQueues(flags);
    }

    //SelectedPrinter.FullName can be something like "Microsoft Print to PDF"
    var selectedQueue = printQueues.SingleOrDefault(pq => pq.FullName == SelectedPrinter.FullName);

    if (selectedQueue != null)
    {
        var myTicket = new PrintTicket
        {
            CopyCount = 1,
            PageOrientation = PageOrientation.Portrait,
            OutputColor = OutputColor.Color,
            PageMediaSize = new PageMediaSize(PageMediaSizeName.ISOA4)
        };

        var mergeTicketResult = selectedQueue.MergeAndValidatePrintTicket(selectedQueue.DefaultPrintTicket, myTicket);
        var printTicket = mergeTicketResult.ValidatedPrintTicket;

        // TODO: Make sure merge was OK

        // Calling GetPrintCapabilities with our ticket allows us to use
        // the OrientedPageMediaHeight/OrientedPageMediaWidth properties
        // and the PageImageableArea property to calculate the minimum
        // document margins supported by the printer. Very important!
        var printCapabilities = queue.GetPrintCapabilities(myTicket);
        var fixedDocument = GenerateFixedDocument(printCapabilities);

        var dlg = new PrintDialog
        {
            PrintTicket = printTicket,
            PrintQueue = selectedQueue
        };

        dlg.PrintDocument(fixedDocument.DocumentPaginator, "test document");
    }
}

The problem is that I want to also support virtual/file printers, namely PDF printing, by giving the file destination path and not showing any Windows dialogues, but that doesn't seem to work with the PrintDialog.

I would really like to avoid 3rd party libraries as much as possible, so at least for now, using something like PdfSharp to convert an XPS to PDF is not something I want to do. Correction: It seems like XPS conversion support was removed from the latest version of PdfSharp.

After doing some research, it seems the only way to print straight to a file is to use a PrintDocument where it's possible to set PrintFileName and PrintToFile in the PrinterSettings object, but there is no way to give the actual document contents, rather we need to subscribe to the PrintPage event and do some System.Drawing.Graphics manipulation where the document is created.

Here's the code I tried:

var printDoc = new PrintDocument
{
    PrinterSettings =
    {
        PrinterName = SelectedPrinter.FullName,
        PrintFileName = destinationFilePath,
        PrintToFile = true
    },
    PrintController = new StandardPrintController()
};

printDoc.PrintPage += OnPrintPage; // Without this line, we get a blank PDF
printDoc.Print();

Then the handler for PrintPage where we need to build the document:

private void OnPrintPage(object sender, PrintPageEventArgs e)
{
    // What to do here? 
}

Other things that I thought could work are using the System.Windows.Forms.PrintDialog class instead, but that also expects a PrintDocument. I was able to create an XPS file easily like so:

var pkg = Package.Open(destinationFilePath, FileMode.Create);
var doc = new XpsDocument(pkg);
var writer = XpsDocument.CreateXpsDocumentWriter(doc);
writer.Write(PreviewDocument.DocumentPaginator);
pkg.Flush();
pkg.Close();

But it's not a PDF, and there seems to be no way to convert it to PDF without a 3rd party library.

Is it possible to maybe do a hack that automatically fills the filename then clicks save on the PrintDialog?

Thank you!

EDIT: It's possible to print directly to PDF from Word documents using Microsoft.Office.Interop.Word, but there seems to be no easy way of converting from XPS/FixedDocument to Word.

EDIT: It seems so far the best way is to grab the old XPS to PDF conversion code that was present in PdfSharp 1.31. I grabbed the source code and built it, imported the DLL's, and it works. Credit goes to Nathan Jones, check out his blog post about this here.

Erlin answered 23/10, 2019 at 7:15 Comment(2)
As I remember the Microsoft Print to PDF does not give that option. A workaround for me was to create a XPS or PNG file and use GhostScript to convert to PDF.Aundrea
@NawedNabiZada I'd rather avoid installing a 3rd party tool and calling the exe from my code, but I appreciate you sharing the knowledge.Erlin
E
18

Solved! After googling around I was inspired by the P/Invoke method of directly calling Windows printers.

So the solution is to use the Print Spooler API functions to directly call the Microsoft Print to PDF printer available in Windows (make sure the feature is installed though!) and giving the WritePrinter function the bytes of an XPS file.

I believe this works because the Microsoft PDF printer driver understands the XPS page description language. This can be checked by inspecting the IsXpsDevice property of the print queue.

The "Microsoft Print to PDF" feature must be installed in Windows for this to work!

Here's the code:

using System;
using System.Linq;
using System.Printing;
using System.Runtime.InteropServices;

public static class PdfFilePrinter
{
    private const string PdfPrinterDriveName = "Microsoft Print To PDF";

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
    private class DOCINFOA
    {
        [MarshalAs(UnmanagedType.LPStr)] 
        public string pDocName;
        [MarshalAs(UnmanagedType.LPStr)] 
        public string pOutputFile;
        [MarshalAs(UnmanagedType.LPStr)] 
        public string pDataType;
    }

    [DllImport("winspool.drv", EntryPoint = "OpenPrinterA", SetLastError = true, CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
    private static extern bool OpenPrinter([MarshalAs(UnmanagedType.LPStr)] string szPrinter, out IntPtr hPrinter, IntPtr pd);

    [DllImport("winspool.drv", EntryPoint = "ClosePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
    private static extern bool ClosePrinter(IntPtr hPrinter);

    [DllImport("winspool.drv", EntryPoint = "StartDocPrinterA", SetLastError = true, CharSet = CharSet.Ansi, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
    private static extern int StartDocPrinter(IntPtr hPrinter, int level, [In, MarshalAs(UnmanagedType.LPStruct)] DOCINFOA di);

    [DllImport("winspool.drv", EntryPoint = "EndDocPrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
    private static extern bool EndDocPrinter(IntPtr hPrinter);

    [DllImport("winspool.drv", EntryPoint = "StartPagePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
    private static extern bool StartPagePrinter(IntPtr hPrinter);

    [DllImport("winspool.drv", EntryPoint = "EndPagePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
    private static extern bool EndPagePrinter(IntPtr hPrinter);

    [DllImport("winspool.drv", EntryPoint = "WritePrinter", SetLastError = true, ExactSpelling = true, CallingConvention = CallingConvention.StdCall)]
    private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);

    public static void PrintXpsToPdf(byte[] bytes, string outputFilePath, string documentTitle)
    {
        // Get Microsoft Print to PDF print queue
        var pdfPrintQueue = GetMicrosoftPdfPrintQueue();

        // Copy byte array to unmanaged pointer
        var ptrUnmanagedBytes = Marshal.AllocCoTaskMem(bytes.Length);
        Marshal.Copy(bytes, 0, ptrUnmanagedBytes, bytes.Length);

        // Prepare document info
        var di = new DOCINFOA
        {
            pDocName = documentTitle, 
            pOutputFile = outputFilePath, 
            pDataType = "RAW"
        };

        // Print to PDF
        var errorCode = SendBytesToPrinter(pdfPrintQueue.Name, ptrUnmanagedBytes, bytes.Length, di, out var jobId);

        // Free unmanaged memory
        Marshal.FreeCoTaskMem(ptrUnmanagedBytes);

        // Check if job in error state (for example not enough disk space)
        var jobFailed = false;
        try
        {
            var pdfPrintJob = pdfPrintQueue.GetJob(jobId);
            if (pdfPrintJob.IsInError)
            {
                jobFailed = true;
                pdfPrintJob.Cancel();
            }
        }
        catch
        {
            // If job succeeds, GetJob will throw an exception. Ignore it. 
        }
        finally
        {
            pdfPrintQueue.Dispose();
        }

        if (errorCode > 0 || jobFailed)
        {
            try
            {
                if (File.Exists(outputFilePath))
                {
                    File.Delete(outputFilePath);
                }
            }
            catch
            {
                // ignored
            }
        }

        if (errorCode > 0)
        {
            throw new Exception($"Printing to PDF failed. Error code: {errorCode}.");
        }

        if (jobFailed)
        {
            throw new Exception("PDF Print job failed.");
        }
    }

    private static int SendBytesToPrinter(string szPrinterName, IntPtr pBytes, int dwCount, DOCINFOA documentInfo, out int jobId)
    {
        jobId = 0;
        var dwWritten = 0;
        var success = false;

        if (OpenPrinter(szPrinterName.Normalize(), out var hPrinter, IntPtr.Zero))
        {
            jobId = StartDocPrinter(hPrinter, 1, documentInfo);
            if (jobId > 0)
            {
                if (StartPagePrinter(hPrinter))
                {
                    success = WritePrinter(hPrinter, pBytes, dwCount, out dwWritten);
                    EndPagePrinter(hPrinter);
                }

                EndDocPrinter(hPrinter);
            }

            ClosePrinter(hPrinter);
        }

        // TODO: The other methods such as OpenPrinter also have return values. Check those?

        if (success == false)
        {
            return Marshal.GetLastWin32Error();
        }

        return 0;
    }

    private static PrintQueue GetMicrosoftPdfPrintQueue()
    {
        PrintQueue pdfPrintQueue = null;

        try
        {
            using (var printServer = new PrintServer())
            {
                var flags = new[] { EnumeratedPrintQueueTypes.Local };
                // FirstOrDefault because it's possible for there to be multiple PDF printers with the same driver name (though unusual)
                // To get a specific printer, search by FullName property instead (note that in Windows, queue name can be changed)
                pdfPrintQueue = printServer.GetPrintQueues(flags).FirstOrDefault(lq => lq.QueueDriver.Name == PdfPrinterDriveName);
            }

            if (pdfPrintQueue == null)
            {
                throw new Exception($"Could not find printer with driver name: {PdfPrinterDriveName}");
            }

            if (!pdfPrintQueue.IsXpsDevice)
            {
                throw new Exception($"PrintQueue '{pdfPrintQueue.Name}' does not understand XPS page description language.");
            }

            return pdfPrintQueue;
        }
        catch
        {
            pdfPrintQueue?.Dispose();
            throw;
        }
    }
}

Usage:

public static void FixedDocument2Pdf(FixedDocument fd)
{
    // Convert FixedDocument to XPS file in memory
    var ms = new MemoryStream();
    var package = Package.Open(ms, FileMode.Create);
    var doc = new XpsDocument(package);
    var writer = XpsDocument.CreateXpsDocumentWriter(doc);
    writer.Write(fd.DocumentPaginator);
    doc.Close();
    package.Close();

    // Get XPS file bytes
    var bytes = ms.ToArray();
    ms.Dispose();

    // Print to PDF
    var outputFilePath = @"C:\tmp\test.pdf";
    PdfFilePrinter.PrintXpsToPdf(bytes, outputFilePath, "Document Title");
}

In the code above, instead of directly giving the printer name, I get the name by finding the print queue using the driver name because I believe it's constant while the printer name can actually be changed in Windows, also I don't know if it's affected by localization so this way is safer.

Note: It's a good idea to check available disk space size before starting the printing operation, because I couldn't find a way to reliably find out if the error was insufficient disk space. One idea is to multiply the XPS byte array length by a magic number like 3 and then check if we have that much space on disk. Also, giving an empty byte array or one with bogus data does not fail anywhere, but produces a corrupt PDF file.

Note from comments: Simply reading an XPS file using FileStream will not work. We have to create an XpsDocument from a Package in memory, then read the bytes from the MemomryStream like this:

public static void PrintFile(string xpsSourcePath, string pdfOutputPath)
{
    // Write XPS file to memory stream
    var ms = new MemoryStream();
    var package = Package.Open(ms, FileMode.Create);
    var doc = new XpsDocument(package);
    var writer = XpsDocument.CreateXpsDocumentWriter(doc);
    writer.Write(xpsSourcePath);
    doc.Close();
    package.Close();

    // Get XPS file bytes
    var bytes = ms.ToArray();
    ms.Dispose();

    // Print to PDF
    PdfPrinter.PrintXpsToPdf(bytes, pdfOutputPath, "Document title");
}
Erlin answered 25/10, 2019 at 22:38 Comment(17)
Please @shahin Dohan, Have you missed something? I've tried it many times, I only an empty pdfThruway
@Thruway Did you make sure that "Microsoft Print to PDF" feature is installed? If yes, maybe post your code somewhere and I can try to debug the code myself when I have the time. Corrupt/blank PDF's may appear due to filesystem errors also, for example not having enough disk space or maybe even permission issues.Erlin
Thank you. I have posted it pastebin.com/embed_js/4iwMPSssThruway
I have Microsoft print To PDF installed and Memory. I launched the application as administrator but nothing changes (empty PDF file). In my case, I have a XPS file I want to convert to PDF.Thruway
@Thruway The problem seems to be that simply reading the bytes of the XPS file doesn't work and actually corrupts the XPS file, you need to read it into an XpsDocument that is inside a Package in memory, then get the bytes from the memory stream. Here's a working example: pastebin.com/ABXx8biD (I have updated the answer with an example)Erlin
Sorry. Thanks. I have deleted it. It was a mistake.Thruway
Bug: There were two PDF printers installed on the production system for some reason. SingleOrDefault doesn't like that.Push
@LorenPechtel Thanks Loren, changed it to FirstOrDefault and added a comment. I think it's a pretty uncommon situation though.Erlin
@ShahinDohan That's the tactic I took, also--sent it to them and it's working. They had slightly different names, I suspect one might have been a relic from an earlier version of Windows. (I know the box was upgraded to 10, but I'm not sure from what.)Push
This solution also work for FlowDocument <!-- language: c# --> writer.Write(((IDocumentPaginatorSource)myFlowDocumentObject).DocumentPaginator); <!-- end snippet -->Tympan
I am getting error 'The XpsDocument has no FixedDocumentSequence.' on writer.Write("C:\test.oxps");Kablesh
I solved it. XPS and OXPS are not similar due to that it was causing issue.Kablesh
I tried this solution and it worked fine on Windows 10 machines on a desktop application. However, it does not work on Windows 11 machines with the same build version on the app. The problem is that it returns no Jobid when i use the jobId = StartDocPrinter(hPrinter, 1, documentInfo);. Anyone got any solution to that?Silicon
@Silicon It works for me on Windows 11. Did you make sure that the Microsoft Print to PDF feature is installed? Also please check what Marshal.GetLastWin32Error() returns right after StartDocPrinter fails to see what went wrong.Erlin
@ShahinDohan the error is 0. also the jobId = StartDocPrinter(hPrinter, 1, documentInfo); returns 0. And the error right after the 0 of StartDocPrinter is 123Silicon
I found it. The error 123 is supposed to be "The filename, directory name, or volume label syntax is incorrect." witch was strange since on windows 10 the exactly same name, path and file worked. After some tests i realised that the default chcp on my windows 11 was different from my windows 10. chcp 437 vs 737. The path contained greek letters and the printer could not find it. Thats why it could not start a job. All i had to do in my case, was to change from windows the "Current language for non-unicode programs to Greek.Silicon
@Silicon Nice find! I wonder if using the Unicode equivalents of these native functions would have any effect? Like StartDocPrinterW for example.Erlin

© 2022 - 2024 — McMap. All rights reserved.