Recursive AI

Extract images from a PDF in C#

All onepdf recipes

Run each page through a small class that overrides DrawImage: OnePDF calls it once for every image the page draws. A JPEG in a PDF is a complete JPEG file, so its bytes can be saved as they are; every other image is raw pixels, which the program writes as PNG with OneImage. It runs on a USGS fact sheet, a public PDF with JPEG 2000 photos, CMYK maps and transparent icons.

Terminal
dotnet new console -o PdfImages && cd PdfImages
dotnet add package RecursiveAI.OnePdf --version 1.14.0
dotnet add package RecursiveAI.OneImage.Pdf --version 1.10.4
curl -LO https://pubs.usgs.gov/fs/2016/3020/fs20163020.pdf

Put this in Program.cs and run dotnet run:

C#
using System.Runtime.InteropServices;
using OneImage;
using OneImage.Pdf;
using OnePdf;
 
// OnePDF decodes Flate, LZW, CCITT and the other PDF filters.
// This adds JPEG, JPEG 2000 and JBIG2.
PdfImageCodecs.Register(PdfImageDecoderRegistry.Default);
 
using PdfDocument doc = PdfDocument.Open("fs20163020.pdf");
var saver = new ImageSaver("images");
foreach (PdfPage page in doc.Pages)
{
    saver.PageNumber = page.Index + 1;
    // runs the page and calls DrawImage for every image it draws
    page.Interpret(saver);
}
Console.WriteLine($"{saver.Jpegs} saved as .jpg (original bytes), "
                + $"{saver.Pngs} as .png, {saver.Skipped} skipped");
 
sealed class ImageSaver(string folder) : PdfDevice
{
    // an image drawn on many pages is saved once
    readonly HashSet<PdfStream> _seen = [];
    public int PageNumber { get; set; }
    public int Jpegs { get; private set; }
    public int Pngs { get; private set; }
    public int Skipped { get; private set; }
 
    public override void DrawImage(in PdfImage image, in PdfGraphicsState state)
    {
        if (image.Stream is { } stream && !_seen.Add(stream)) return;
        var encoded = new PdfImageOptions { PreferEncoded = true };
        PdfImageData? data = image.GetData(encoded);
        // a stencil is a shape painted in one color
        if (data is null || data.IsStencil) return;
        Directory.CreateDirectory(folder);
        int n = Jpegs + Pngs + Skipped + 1;
        string path = Path.Combine(folder, $"page{PageNumber}-{n}");
 
        // A gray or RGB JPEG with no mask and no /Decode is a complete JPEG file.
        if (data.EncodedFilter == "DCTDecode" && data.Alpha is null
            && data.Semantics.Decode is null
            && data.Semantics.ColorSpace?.ComponentCount is 1 or 3)
        {
            File.WriteAllBytes(path + ".jpg", data.EncodedData.ToArray());
            Jpegs++;
            return;
        }
 
        // decoded: 8-bit samples in their final color space, mask as alpha
        data = image.GetData();
        if (data is not { IsDecoded: true }
            || data.ColorSpace!.Kind == PdfColorSpaceKind.Lab)
        {
            Skipped++;
            return;
        }
        ToImage(data).Save(path + ".png");
        Pngs++;
    }
 
    static Image ToImage(PdfImageData data)
    {
        ReadOnlySpan<byte> s = data.Samples.Span;
        int w = data.Width, h = data.Height;
        PdfColorSpace cs = data.ColorSpace!;
        Image img = cs.ComponentCount switch
        {
            1 => Image.FromPixels(MemoryMarshal.Cast<byte, Gray8>(s), w, h),
            3 => Image.FromPixels(MemoryMarshal.Cast<byte, Rgb24>(s), w, h),
            _ => Image.FromPixels(MemoryMarshal.Cast<byte, Cmyk32>(s), w, h),
        };
        if (!cs.IccProfile.IsEmpty)
            // keep its profile
            img = img.AssignProfile(ColorProfile.FromIcc(cs.IccProfile.Span));
        if (cs.ComponentCount == 4)
            img = img.ConvertColor(ColorProfile.Srgb);   // PNG has no CMYK
        if (data.Alpha is { } alpha)
        {
            GrayAlpha8[] values =
                [.. alpha.Values.ToArray().Select(a => new GrayAlpha8(0, a))];
            var mask = Image.FromPixels<GrayAlpha8>(values, alpha.Width, alpha.Height);
            if (alpha.Width != w || alpha.Height != h)
                // a mask may have its own resolution
                mask = mask.Resize(w, h, Fit.Stretch);
            img = img.AddAlpha().Composite(mask, alpha: AlphaComposition.DestIn);
        }
        return img;
    }
}
Text
5 saved as .jpg (original bytes), 38 as .png, 0 skipped

The images folder now holds every distinct image of the six pages. Poppler’s pdfimages -list counts the same 43 image objects in the file, plus the soft masks that the program applies as transparency instead of saving separately.

What each part handles

  • Duplicates. image.Stream is the image object, so a logo drawn on every page is saved once. Inline images have no stream and are saved each time.
  • JPEGs as they are. The bytes are only a faithful file when the PDF adds nothing on top: no mask, no /Decode array, gray or RGB. CMYK JPEGs can carry an inverting /Decode, so they go through the decoder.
  • Decoded pixels. GetData() applies every PDF rule first: palettes, spot colors, /Decode arrays and color-key masks.
  • Color. An ICC-based image keeps its profile in the PNG. CMYK is converted to sRGB. Lab images are skipped here.
  • Transparency. data.Alpha is the soft mask, color-key mask or JPEG 2000 alpha, stretched to the image’s size when it has its own resolution.

Limits

  • Images, not pages. This saves the pictures embedded in the file. To get a picture of a whole page, with its text and drawings, render it with OneImage.
  • Codecs. Without RecursiveAI.OneImage.Pdf, OnePDF still returns JPEG bytes and decodes Flate and the other PDF filters, but JPEG 2000 and JBIG2 images come back undecoded, and you need your own PNG encoder.
  • Size limits. An image’s declared size is checked against PdfLimits before any memory is allocated, and GetData throws PdfLimitException above it. That matters for PDFs users upload.
  • Stencil masks (one-bit shapes painted in a single color, such as the black layer of some scans) are skipped.

To put an image into a PDF instead, see Add an image to a PDF. The drawing calls a device receives are in Display-list text format.