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)}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 Blocks: repeating for each item
Section titled “1.5 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.6 Functions: naming your rules
Section titled “1.6 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.7 Imports: sharing between profiles
Section titled “1.7 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.8 Rules (rule): validating page by page
Section titled “1.8 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.9 Variables and scope
Section titled “1.9 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.
1.10 Messages that help whoever gets the file
Section titled “1.10 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.11 Common errors
Section titled “1.11 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 |
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