1. The PDFLang language
PDFLang is designed to be read by people who do not program. There are no classes, no inheritance, no type declarations and no semicolons. A script is a list of checks written almost in plain language.
1.1 The shape of a script
Section titled “1.1 The shape of a script”// Comments start with two slashes and run to the end of the line.
profile "profile-name" { // profile is optional: it groups and names // the set; the name shows up in the report.
const LIMIT = 300% // constants: uppercase by convention
check "Check Name" { // each check becomes a report section require doc.page_count > 0 // one validation }
check "Another Check" { // as many checks as you need require doc.title != "" }}The profile wrapper is optional — a script can be just a list of checks:
check "Simple" { require doc.page_count > 0}Tags on checks
Section titled “Tags on checks”Tags organize and visually filter checks in the report:
check "Ink within limit" tags: ["prepress", "colors"] { require prepress::validate_tac_limits(300)}Severity of a check
Section titled “Severity of a check”By default a failing check is an error and the run exits 2. A check can declare itself advisory instead:
check "Image resolution" severity: warning { require !visual::detect_low_resolution(300)}error (the default), warning and info. Warnings and info do not fail the
run — they exit 1 and 0 — unless you pass --fail-on warning, which is how CI
decides how strict to be without the script changing.
tags: and severity: may appear in either order.
A runtime error inside the check — a misspelled variable, a missing file — stays an error whatever the check declared. A broken script is not advisory.
1.2 Two ways to validate
Section titled “1.2 Two ways to validate”Every validation uses require or assert. The only difference is the message
that appears in the report when the validation fails.
check "Comparing both forms" {
// require: the message is generated from the expression itself. // On failure the report shows: // "requirement not met: doc.page_count > 0" require doc.page_count > 0
// assert: you write the message the end user will read. // On failure the report shows exactly: // "PDF has no title in its metadata" assert doc.title != "", "PDF has no title in its metadata"}Rule of thumb: use require for obvious checks (where the expression speaks
for itself) and assert when whoever reads the report needs to understand the
problem without knowing the script.
One failure does not stop the others
Section titled “One failure does not stop the others”check "Three independent validations" { assert doc.page_count > 100, "too few pages" // fails assert doc.title != "", "no title" // still runs assert doc.author != "", "no author" // this one too}The report lists every problem at once. That is deliberate: whoever gets the file back wants the complete list of fixes, not one at a time.
The same holds between checks — if a check hits a runtime error (an undefined variable, say), it becomes a diagnostic and the remaining checks keep running.
1.3 Values and types
Section titled “1.3 Values and types”Numbers and units
Section titled “Numbers and units”check "Numbers" { x = 42 // integer y = 2.5 // decimal number
// Measurement units become POINTS automatically (1 pt = 1/72 in): a = 3mm // 8.5039... pt b = 2.5cm // 70.866... pt c = 1in // 72 pt d = 10pt // 10 pt
// Percentages keep their numeric value: limit = 300% // 300
require a < b // compare directly, everything is points require c == 72.0 require limit == 300}Writing 3mm instead of 8.504 is the whole point: the script reads naturally
for someone who thinks in millimetres, and the conversion cannot go wrong.
check "Strings" { simple = "plain text"
// Interpolation: #{...} inserts the value of any expression name = "document.pdf" message = "Analyzing #{name} with #{doc.page_count} pages"
// Escapes: \n (newline), \t (tab), \" (quote), \\ (backslash) quoted = "he said \"hello\""
// Unknown backslashes pass through untouched — that lets you write // regular expressions without double escaping: pattern = "\d{3}\.\d{3}\.\d{3}-\d{2}" // Brazilian CPF
require message.contains("pages")}Booleans and what counts as true
Section titled “Booleans and what counts as true”check "True and false" { yes = true no = false
// Only false and null are falsy. Everything else is truthy — // including 0, the empty string and the empty list. require 0 // passes (zero is truthy) require "" // passes (empty string is truthy)
// So to test for content, compare explicitly: require doc.title != "" // correct require doc.pages.length > 0 // correct}This matters for functions that return null when they find nothing:
check "Taking advantage of null" { description = data::lookup_value("batches.csv", "L2026-08") // null is falsy, so this works directly: assert description, "batch not found in the table"}check "Lists" { numbers = [1, 2, 3] words = ["a", "b", "c"] mixed = [1, "two", true]
require numbers.length == 3 require numbers.contains(2) require words.join(", ") == "a, b, c"
// Access is 1-based: the first item is item 1 require numbers.get(1) == 1 require numbers.first() == 1 require numbers.last() == 3}1.4 Operators
Section titled “1.4 Operators”check "Operators" { // Comparison require 10 > 5 require 10 >= 10 require 3 < 4 require 3 <= 3 require "a" == "a" require "a" != "b"
// Arithmetic require 2 + 3 == 5 require 10 - 4 == 6 require 3 * 4 == 12 require 10 / 4 == 2.5 // inexact division yields a decimal require 10 / 5 == 2 // exact division stays an integer
// Logic (short-circuiting: the right side is only evaluated if needed) require true && true require false || true require !false
// Short-circuit in practice: with no pages, the second half is never // evaluated — no error on an empty document. require doc.page_count == 0 || doc.pages.first().width > 0}1.5 if: choosing between two things
Section titled “1.5 if: choosing between two things”if is an expression, like everything else that produces a value here: it
gives back the last expression of whichever branch ran. So it works as a value:
check "Ink limit depends on the stock" { // vars.stock comes from --var stock=coated const LIMIT = if vars.stock == "coated" { 300 } else { 260 }
doc.pages.each { |page| assert page.tac <= LIMIT, "page #{page.number}: #{page.tac}% ink, limit #{LIMIT}%" }}and as a guard around statements, with no else:
check "Only check the cover if there is one" { if doc.page_count > 1 { require doc.pages.first().width > 0 }}else if chains without extra braces:
size = if doc.page_count > 500 { "large" } else if doc.page_count > 50 { "medium" } else { "small" }Three things worth knowing:
- A branch that does not run yields
null. Anifwithoutelsewhose condition is false isnull— which is falsy, so it can be tested directly. - A branch has its own scope, like a block or a function. A variable created inside a branch does not exist after it. Assigning to a variable that already exists outside still updates that one.
- The
{after the condition opens the body. If the condition itself ends in a block, wrap it in parentheses, or the body’s brace would be read as that block’s:
// wrong: the { is taken as the body of the if// if doc.pages.all { |p| p.width > 0 } { ... }
// rightif (doc.pages.all { |p| p.width > 0 }) { require doc.page_count > 0}
ifis for choosing a value or guarding a statement — not for replacing a failed check with a quiet one. A validation that should fail must still fail; see 1.2.
1.6 Blocks: repeating for each item
Section titled “1.6 Blocks: repeating for each item”Blocks are pieces of code in braces that take a parameter between vertical bars. They read like a sentence: “for each page, do…”.
check "Walking through pages" {
// each: runs the block for every item doc.pages.each { |page| assert page.width > 0, "page #{page.number} has no width" }
// each_with_index: also receives the position (0, 1, 2...) doc.fonts.each_with_index { |font, i| print("font", i, ":", font.name) }
// all: true when EVERY item satisfies the condition require doc.fonts.all { |f| f.is_embedded }
// any: true when AT LEAST ONE item satisfies it require doc.pages.any { |p| p.extract_text() != "" }
// filter: keeps only the items that satisfy the condition blank = doc.pages.filter { |p| p.extract_text() == "" } assert blank.length == 0, "#{blank.length} blank page(s)"
// map: transforms each item into a new list names = doc.fonts.map { |f| f.name } print("fonts in use:", names.join(", "))}Blocks can be chained — on a single line, with no break before the dot:
check "Chaining" { // non-embedded fonts, names only, joined by commas problems = doc.fonts.filter { |f| !f.is_embedded }.map { |f| f.name } assert problems.length == 0, "fonts not embedded: #{problems.join(", ")}"}If the line gets too long, break it into named steps instead of breaking the chain — it reads better anyway:
check "Named steps" { loose = doc.fonts.filter { |f| !f.is_embedded } names = loose.map { |f| f.name } assert names.length == 0, "fonts not embedded: #{names.join(", ")}"}1.7 Functions: naming your rules
Section titled “1.7 Functions: naming your rules”When the same verification shows up in several places, give it a name:
// A function's value is that of its LAST expression — there is no "return".function is_a4(page) { abs(page.width - 595.0) < 5.0 && abs(page.height - 842.0) < 5.0}
function exceeds_ink(page, limit) { page.tac > limit}
check "Format and ink" { // now the check reads almost like a sentence require doc.pages.all { |p| is_a4(p) }
doc.pages.each { |page| assert !exceeds_ink(page, 300), "page #{page.number} has too much ink" }}Rules for functions:
- Parameters exist only inside the function.
- Functions may call other functions.
- Recursion is allowed but capped at 200 calls (so a runaway script cannot hang the process).
1.8 Imports: sharing between profiles
Section titled “1.8 Imports: sharing between profiles”Put common rules in one file and import it wherever you need.
library.pdfl:
// Constants and functions shared across the teamconst OFFSET_TAC = 300%const DEFAULT_BLEED = 3mm
function a4_page(page) { abs(page.width - 595.0) < 5.0 && abs(page.height - 842.0) < 5.0}magazine.pdfl:
// The path is relative to THIS fileimport "library.pdfl"
check "Format" { // OFFSET_TAC and a4_page came from the import require doc.pages.all { |p| a4_page(p) } require prepress::validate_tac_limits(OFFSET_TAC)}Each file is loaded exactly once, even if several scripts import it — so circular imports do not hang.
1.9 Rules (rule): validating page by page
Section titled “1.9 Rules (rule): validating page by page”A rule is a check that runs once per page, with the page already bound to the
page variable:
// Without "on": runs on every pagerule "Every page has text" { assert page.extract_text().trim() != "", "page #{page.number} is blank"}With on, you choose which pages the rule applies to:
rule "Body pages numbered" on doc.pages.filter { |p| p.number > 2 } { footer = region(0, 0, page.width, 60) assert text::extract_from_region(page.number, footer) != "", "page #{page.number} has no page number in the footer"}Syntax note: if the
onselection ends in a property (e.g.on doc.pages), wrap it in parentheses — without them, the{of the body would be read as a block belonging to that call:rule "Example" on (doc.pages) { // parentheses requiredrequire page.width > 0}
1.10 Variables and scope
Section titled “1.10 Variables and scope”const GLOBAL = 100 // visible throughout the file
check "Scope" { local = 42 // visible only inside this check
doc.pages.each { |page| inner = page.width // visible only inside the block require inner > 0 }
require local == 42 // still visible require GLOBAL == 100 // still visible}Convention: constants in UPPERCASE, variables in lowercase. The language does not enforce it, but the examples and shipped profiles follow it.
Values from the command line
Section titled “Values from the command line”--var name=value on pdfl run, pdfl test and pdfl watch reaches the script
as vars.name, always as text. test and watch forward the same value to
every case or file — one client name for the whole run, not one per file. It is what keeps one profile from becoming five near-identical copies:
check "Job matches the order" { assert doc.title.contains(vars.order), "the file says \"#{doc.title}\", the order is #{vars.order}"}pdfl run intake.pdfl received.pdf --var order=SO-4471A name that was not passed is an error naming the flag that would supply it, not an empty string: a check comparing against nothing would otherwise pass and report a file nobody validated.
1.11 Messages that help whoever gets the file
Section titled “1.11 Messages that help whoever gets the file”The quality of the report depends on the messages you write. Compare:
check "Poor messages" { require doc.pages.all { |p| p.tac <= 300 } // report: "requirement not met: doc.pages.all() { ... }" // — the recipient has no idea which page or by how much}
check "Good messages" { doc.pages.each { |page| assert page.tac <= 300, "Page #{page.number}: ink coverage #{page.tac}% (max 300%)" } // report: "Page 7: ink coverage 324% (max 300%)" // — the operator knows exactly what to fix}Use print() for context that is not an error. It goes to stderr, so it never
pollutes the report:
check "Context" { print("Analyzing", doc.page_count, "pages") print("Fonts:", prepress::list_fonts().join(", ")) require doc.page_count > 0}1.12 Common errors
Section titled “1.12 Common errors”| Message | Cause | Fix |
|---|---|---|
expected end of line after statement |
two statements on one line | one statement per line |
unknown variable: x |
used before assignment, or out of scope | declare it first, at the same level |
unknown function: text::xyz |
wrong name or nonexistent function | check the namespace chapter |
fix:: is only available in the 'pdfl fix' command |
fix:: used under pdfl run |
use pdfl fix input.pdf script.pdfl --output out.pdf |
unknown unit: 'kg' |
invalid unit suffix | use pt, mm, cm, in or % |
expected '{' with the rule body |
on selection ends in a property |
wrap the selection in parentheses |
the '{' here opens the body of the if |
the if condition ends in a block |
wrap the condition in parentheses |
unexpected expression: Dot |
chain split across lines | keep .method on the same line, or use intermediate variables |
Before running, it is always worth doing:
pdfl lint my_profile.pdfl # unused variables, duplicate checks...pdfl fmt my_profile.pdfl # standardize formatting