PDF type safety with explicit mode
Explicit is better than implicit.
— Tim Peters, The Zen of Python (PEP 20)
pikepdf is moving from implicit conversion, where values read from a PDF are quietly turned into Python types, to explicit conversion, where they keep their PDF type until you ask for a Python value. This page explains why, shows what the difference looks like in real code, and sets out the timetable. For the reference details of each API mentioned here, see Explicit conversion mode in the object model topic.
PDFs are weakly typed
Every object in a PDF has a type: boolean, integer, real, string, name, array,
dictionary, stream, or null. The file’s syntax makes the type of each value
unambiguous: 1024 is an integer, (1024) is a string, /1024 is a name.
The file format does not say what type a value should have. That knowledge
lives in the prose of the PDF specification, table by table: an image’s
/Width must be an integer, a page’s /MediaBox must be an array of four
numbers, and so on. There is no schema in the file and nothing that enforces
one. A program that reads a PDF only finds out whether /Width is an integer
when it looks.
pikepdf’s development began in 2016, when Python type checking was still in its infancy. Type hints had been standardized only the year before, mypy was experimental, and most Python libraries, this one included, were designed around duck typing. The natural choice then was to convert each PDF scalar to the nearest Python type and let Python’s dynamism do the rest. That choice has aged poorly, for the reasons below.
PDF types are not Python types
The “nearest Python type” for a PDF scalar is close to it, but not the same thing.
A PDF integer is not a Python int. Python integers are unbounded. PDF
integers are bounded; qpdf, and so pikepdf, stores them as signed 64-bit
values. A PDF that contains an integer too large for that range cannot be
read as an integer at all:
>>> pikepdf.Object.parse(b'99999999999999999999')
Traceback (most recent call last):
...
pikepdf._core.PdfError: parsed object (offset 0): treating object as null because of error during parsing: overflow/underflow converting 99999999999999999999 to 64-bit integer
Going the other way, a Python int outside that range cannot be stored in a
PDF:
>>> pikepdf.Array([2**64])
Traceback (most recent call last):
...
OverflowError: value is out of range for a 64-bit PDF integer
A PDF real is not a Python float or Decimal. PDF reals are written
as plain decimal numbers with no exponent: 0.00001 is a real, and 1e-5 is
not a number at all. A Python float is a binary approximation that may not
equal the decimal digits in the file. Decimal, which pikepdf
uses to preserve those digits, can hold values such as Decimal('1E-20') that
no PDF real can express. When pikepdf writes such a value it has to round it
to a fixed number of decimal places, and a small enough value becomes 0.
>>> from decimal import Decimal
>>> pikepdf.Array([Decimal('1E-20')]).unparse()
b'[ 0 ]'
A PDF string is not a Python str. A PDF string is a sequence of bytes.
Some strings hold text, such as a document title or a form field value,
encoded in PDFDocEncoding or UTF-16 (or, in PDF 2.0, UTF-8). For those,
treating the string as a str is the right thing to do. Other strings hold
arbitrary binary data, such as a document’s /ID, and have no meaningful text
decoding. A pikepdf.String gives you either view: str() decodes it
as text and bytes() returns the raw data.
Well-formed is not the same as valid
A PDF can be well-formed, meaning its syntax parses, and still be invalid,
meaning its contents break the rules of the specification. Consider an image
XObject whose /Width, which the specification requires to be an integer,
was written by some buggy software as a string, a real, or even a name:
<< /Type /XObject /Subtype /Image /Width 1024 ... >> % valid
<< /Type /XObject /Subtype /Image /Width (1024) ... >> % a string
<< /Type /XObject /Subtype /Image /Width 1024.0 ... >> % a real
<< /Type /XObject /Subtype /Image /Width /1024 ... >> % a name
All four are perfectly good PDF syntax. qpdf is very good at repairing PDFs
that are not well-formed: broken cross-reference tables, wrong stream
lengths, missing endobj keywords. But it cannot repair validity. It has no
way to know that (1024) was meant to be 1024, or whether a /Width of
/1024 is a typo or garbage.
In practice, many PDFs in the wild have problems with their internal typing. pikepdf is a low-level tool, and reading, inspecting and fixing invalid PDFs is squarely within its job. So it must report what is actually in the file, not what ought to be there, and code that uses it needs a clean way to tell the two apart.
The implicit way
In implicit conversion mode, the current default, pikepdf converts PDF
scalars to Python types as you read them: integers become int, booleans
become bool, reals become Decimal, and null becomes None. Strings, names,
arrays and dictionaries stay as pikepdf objects.
For a valid PDF this works well and reads naturally. Here is how to find the
width of the image named /Im0 on a page:
width = page.Resources.XObject.Im0.Width
This gives 1024 for a valid file. For the invalid ones above, it gives
pikepdf.String("1024"), Decimal('1024.0') or pikepdf.Name("/1024"), with
no error. The bug appears later, somewhere else: range(width) raises a
TypeError, width * height quietly produces a Decimal, and a /Width
of true passes isinstance(width, int) because bool is a subclass of
int.
The one-liner also assumes a lot about the file. Robust code has to ask:
What if the page has no
/Resourcesdictionary?What if the resources have no
/XObjectdictionary?What if there is no
/Im0?What if
/Im0is not a stream?What if it is a stream, but not an image (a form XObject, say)?
What if the image has no
/Width?What if
/Widthhas the wrong type?
Answering all of those in implicit mode looks something like this:
def image_width(page, name):
resources = page.obj.get(Name.Resources)
if not isinstance(resources, Dictionary):
return None
xobjects = resources.get(Name.XObject)
if not isinstance(xobjects, Dictionary):
return None
image = xobjects.get(name)
if not isinstance(image, Stream):
return None
if image.get(Name.Subtype) != Name.Image:
return None
width = image.get(Name.Width)
if isinstance(width, bool) or not isinstance(width, int):
return None
return width
print(image_width(page, Name.Im0))
1024
All of that just to find out how wide an image is. Every check is needed, and
forgetting any one of them is a latent bug that only an unusual file will
reveal. Note the isinstance(width, bool) test, which is easy to forget.
Type checkers cannot help, either. pikepdf’s type hints say that
image.Width returns a pikepdf.Object, since that is the only honest
answer when the value could be anything. In implicit mode it may actually be
an int, a Decimal, None, or an object, so a type checker that believes
the hints is wrong about common pikepdf code, and cannot spot the bugs above.
The explicit way
In explicit conversion mode, reading a value from a PDF always gives you a
pikepdf object: pikepdf.Integer, pikepdf.Boolean,
pikepdf.Real, pikepdf.String, and so on. You convert it to Python
when you need a Python value, and the conversion checks the type for you.
Getting a value or a default
When a missing or wrong-typed value should just be treated as absent,
Page.resources, a
NamePath and a typed getter such as
get_int() answer the questions in the list above in one
line:
>>> page.resources.get_int(NamePath.XObject.Im0.Width)
1024
page.resources finds the page’s resources even when they are inherited from
the page tree, and gives an empty dictionary if the page has none. From there,
if any step of the path is missing or is not a dictionary, or if the final
value is not an integer, you get the default, which is None unless you give
one:
>>> image.Width = String('1024')
>>> page.resources.get_int(NamePath.XObject.Im0.Width) is None
True
>>> page.resources.get_int(NamePath.XObject.Missing.Width, 0)
0
The typed getters work the same in either conversion mode, so this line is
safe to use today. Two questions are left over. A page whose /Resources is
not a dictionary at all makes page.resources raise TypeError. And the
line does not check the image’s /Subtype; add a check of
NamePath.XObject.Im0.Subtype if that matters to you.
Converting a value already in hand
The typed getters look a value up by key. For a value you already hold – an
array element, a content stream operand, a value from .items(), or a
parameter of a function that may be called from either kind of code – the
module-level functions pikepdf.as_int(), pikepdf.as_bool(),
pikepdf.as_float(), pikepdf.as_decimal(), pikepdf.as_dict(),
pikepdf.as_list(), pikepdf.as_str() and pikepdf.as_bytes()
apply the same rules. They accept the pikepdf object explicit mode gives you
and the native value implicit mode would have given you instead, so the answer
does not depend on the mode:
>>> box = pikepdf.Array([0, 0, pikepdf.Real('612.5'), True])
>>> [pikepdf.as_float(v) for v in box]
[0.0, 0.0, 612.5, None]
>>> with pikepdf.explicit_conversion():
... [pikepdf.as_float(v) for v in box]
[0.0, 0.0, 612.5, None]
A native value counts only if it is the type implicit mode would have produced
for the PDF type asked for, so pikepdf.as_int(True) gives the default: a
Boolean is not an Integer, even though Python’s bool is a subclass of int.
Unlike pikepdf.unbox(), which passes a value of an unexpected type
through, these return the default for it.
Retrieving or failing
When a malformed file should be an error, explicit mode and the as_*
accessors let you retrieve a value or fail with a precise message:
>>> with pikepdf.explicit_conversion():
... image = page.resources[NamePath.XObject.Im0]
... image.Width.as_int()
Traceback (most recent call last):
...
TypeError: Expected integer, got string
>>> with pikepdf.explicit_conversion():
... page.resources[NamePath.XObject.Im1]
Traceback (most recent call last):
...
KeyError: 'Key /Im1 not found; traversed NamePath.XObject'
A bad file now fails at the point where the bad value was read, with a message that says what was wrong, instead of somewhere downstream.
Repairing
Because pikepdf is often used to fix broken files, the accessors and getters
accept coerce=True, which also accepts values stored with a “nearby” type:
for as_int, a real (truncated toward zero) or a numeric string. That makes a
repair straightforward:
>>> with pikepdf.explicit_conversion():
... width = image.get_int(Name.Width, coerce=True)
... if width is not None and not isinstance(image.Width, pikepdf.Integer):
... image.Width = width # write back a proper integer
... image.Width
pikepdf.Integer(1024)
A value that cannot be coerced, such as the name /1024, still returns the
default (or raises TypeError if no default is given), so coerce=True
widens what is accepted without hiding garbage.
Tip
These examples use image dimensions because they are easy to follow. For real
work with images, use pikepdf.Page.get_images() and
pikepdf.PdfImage, which walk the resources for you (including images
nested inside form XObjects) and raise TypeError for a malformed image
rather than returning a wrong answer:
>>> image.Width = Name('/1024')
>>> try:
... pikepdf.PdfImage(image).width
... except TypeError as e:
... print(e)
Image /Width has a value of the wrong type: pikepdf.Name("/1024")
Implicit vs explicit
Implicit mode |
Explicit mode |
|---|---|
PDF scalars are silently converted to Python types. |
PDF scalars stay PDF objects until you convert them. |
A wrong type in an invalid PDF passes through unnoticed and causes bugs elsewhere, so safe access needs complicated checks. |
A wrong type is caught when the value is unboxed, and the |
Type checkers cannot tell what common pikepdf code returns. |
Type checkers can read pikepdf code and spot bugs early. |
Terse on the happy path. |
A little more verbose on the happy path. |
Verbose on the failure path. |
Concise on the failure path. |
Explicit mode does not make everything more verbose. Arithmetic and
comparisons on Integer and Real work directly and give native Python
results, so mediabox[2] - mediabox[0] > 100 needs no change, and raises
TypeError instead of giving a wrong answer if a coordinate is not a number.
See Explicit conversion mode for the full
behavior.
Three ways to use explicit mode
Per document. Pass conversion_mode='explicit' when opening or creating a
PDF. All objects read from that document are explicit, whichever thread reads
them, and no other document is affected. This is the right choice for a
library that handles PDFs on behalf of an application.
pdf = pikepdf.open('input.pdf', conversion_mode='explicit')
new_pdf = pikepdf.new(conversion_mode='explicit')
pdf.conversion_mode = 'implicit' # can be changed later
Globally. Set the default for the whole process. This suits applications, scripts and test suites; a library should not change a global setting that belongs to its host application.
pikepdf.set_object_conversion_mode('explicit')
For a block of code. The pikepdf.explicit_conversion() and
pikepdf.implicit_conversion() context managers change the mode for the
current thread for the duration of a with block. They are useful for
migrating a code base one function at a time, or for calling old code that
still expects implicit mode.
with pikepdf.explicit_conversion():
width = image.Width.as_int()
with pikepdf.implicit_conversion():
legacy_function(pdf)
When more than one applies, the context manager wins, then the per-document mode, then the global default.
If your code must work in both modes during a transition, use the
mode-independent APIs: the get_* typed getters, get_raw(),
the as_* accessors, and pikepdf.unbox(). The
migration checklist lists the changes that
switching modes can silently break.
Deprecation schedule
Implicit mode is being phased out over three major releases:
Version |
Change |
|---|---|
v11 |
Implicit mode remains the default, but pikepdf issues a |
v12 |
Explicit mode becomes the default. Implicit mode is still available, but you must opt in to it. |
v13 |
Implicit mode is removed. |
You do not need to wait for v11. The APIs described on this page are available
now, and code written with them works in both modes. A good first step is to
run your test suite with pikepdf.set_object_conversion_mode('explicit') in
effect and see what breaks.