Skip to content

Taking a PDF apart with PDFLang

On screen a PDF looks like one flat thing, a picture of a page. It is not. It is a box of parts, and most of the questions people ask about a file — is this going to print correctly? is anything missing? — are really questions about one of those parts.

PDFLang gives you five ways to reach inside. Four of them are already there when your script starts. The fifth you draw yourself.

Every script begins with a variable called doc. Nothing to open, nothing to load: doc is the file being checked.

check "What am I looking at" {
print("file:", doc.filename)
print("pages:", doc.page_count)
print("title:", doc.title)
}

doc.title and doc.author come from the file’s metadata — the information a PDF carries about itself, which you never see on the page. It is often empty, because whoever exported the file never filled it in. That is worth checking on its own:

check "The file says what it is" {
assert doc.title != "", "the PDF has no title in its metadata"
}

doc also holds three lists: doc.pages, doc.fonts and doc.images. They are ordinary lists, so anything you can do to a list you can do to them.

doc.pages gives you the pages one at a time. Each one knows its own size, how much ink it carries, and which of its boxes are defined.

Sizes are in points, the unit PDFs think in: 72 points to the inch, so an A4 page is 595 × 842. You do not have to think in points, though — write 210mm or 8.5in and the comparison works:

check "Everything is A4" {
doc.pages.each { |page|
assert page.width > 200mm,
"page #{page.number} is too narrow: #{page.width}pt"
}
}

Note page.number. Pages count from 1, the way they do in conversation, so the number in a failure message is the page a person will turn to.

Then there are the boxes. A print-ready page carries several rectangles that say what to do with it: the TrimBox is where the guillotine cuts, the BleedBox is the slightly larger area where colour must run past the cut so a millimetre of drift does not leave a white edge. A file without them can look perfect on screen and still be unusable at the printer:

check "Ready for the guillotine" {
doc.pages.each { |page|
assert page.has_trim_box, "page #{page.number} has no TrimBox"
assert page.has_bleed_box, "page #{page.number} has no BleedBox"
}
}

A page also reports page.tac: how much ink lands on its heaviest spot, as a percentage. Four inks at full strength would be 400%, which no press will take; 300% is a common ceiling.

doc.fonts lists the typefaces the file uses, and each one answers a single important question: is it embedded?

An embedded font travels inside the PDF. A font that is not embedded is only named, and the machine opening the file substitutes something it has. The text does not disappear — it changes shape. Lines re-break, a heading that fitted on one line now takes two, and nobody notices until it is printed.

check "Every font travels with the file" {
doc.fonts.each { |font|
assert font.is_embedded, "font '#{font.name}' is not embedded"
}
}

doc.images gives you every image in the file, and page.images the ones on a particular page. The property worth understanding is dpi.

DPI is dots per inch — how much detail there is in each inch of print. PDFLang does not report the number stored in the image’s metadata. It reports the effective resolution: the pixels the image actually has, divided by the size it is printed at. Stretch a 1000-pixel photo across 20 centimetres and it is a low-resolution image, whatever it claims about itself. That is the number that decides whether the print looks sharp.

check "Sharp enough to print" {
doc.images.each { |img|
assert img.dpi >= 300,
"image on page #{img.page_number}: #{img.dpi} DPI, needs 300"
}
}

img.color_space is the other one people reach for. Offset presses work in CMYK; an image still in RGB has to be converted, and the conversion shifts colours. Better to find it before the plates are made.

The four above come with the file. The fifth does not: a region is a rectangle you define, so you can ask about one part of a page instead of the whole thing.

footer = region(0, 0, 595, 60, "footer")
band = region(20mm, 250mm, 60mm, 15mm, "warning band")

The one thing to remember is where the origin sits. In a PDF, 0, 0 is the bottom-left corner of the page, not the top-left. Y grows upwards.

Regions are what let you write the rules that actually matter to a job. Not “does this file contain the legal text” but “is the legal text in the band reserved for it”:

check "Warning band" {
band = region(0, 700, 595, 142, "band")
content = text::extract_from_region(1, band)
assert content.contains("PRESCRIPTION ONLY"),
"the band is missing the mandatory text"
}

Regions can be compared, too. footer.intersects(header) tells you whether two areas overlap, which is how you catch an element that has wandered into space reserved for something else. footer.expand(5mm) and footer.inset(3mm) give you a looser or tighter copy, for tolerance.

The document types chapter lists every property and method of all five, with examples for each. It is reference material — read it when you need a specific thing, not front to back.

If you want to see the five used together on real jobs, the recipes and the examples are the place.

DigitalOceanThanks to DigitalOcean for hosting this site.