A photo in a PDF keeps its GPS
“Our image-to-PDF export embeds each phone photo's original JPEG bytes, so the GPS position in its EXIF ends up in every PDF a user shares.”
A PDF keeps document metadata in two places, and a strip that misses one of them leaves the file looking clean while the data is still in the bytes. That was the subject of an earlier post here, which ended by naming something it did not cover: metadata attached to individual pages or images rather than to the document.
This is that case, and it is worse than an omission. A document-level strip is not merely incomplete against image metadata. It cannot reach it at all, because of what a PDF usually does with a photograph.
A test that required the leak
In October, an open-source browser PDF tool,
plainmile/pdf-tools, fixed phone photos importing
sideways. The fix,
commit 09ccec1, read the EXIF
Orientation tag and kept handing the original JPEG bytes to pdf-lib's embedJpg. Its end-to-end
test then asserted that the image stream in the downloaded PDF was byte-for-byte equal to the input
file, under the comment "pdf-lib retains JPEG data in an Image XObject rather than recompressing."
That comment is accurate, and it is the problem. Twenty-nine minutes later the project had issue #28 open and fixed: "E2E requires the entire JPEG APP1 payload to remain byte-for-byte in the exported PDF image XObject. This may retain GPS EXIF, XMP, IPTC and JPEG comments in PDFs later shared by users." The test had been pinning the leak in place.
The stream is the file
When a PDF stores a JPEG photograph, the usual encoding is an image XObject with
/Filter /DCTDecode. ISO 32000-1 explains why a writer can simply copy the file in: the JPEG
encoding parameters "shall be stored in the encoded data", and DCTDecode "may obtain the
parameter values it requires directly from the encoded data" (section 7.4.8). A footnote to the
same section names what else rides along: "Parameters that control the decoding process as well as
other metadata is embedded within the encoded data stream using a notation referred to as
'markers'."
Here is that measured rather than asserted. A 320x200 JPEG was built carrying GPS coordinates, a
camera make, a user comment, an XMP packet, an IPTC block, a JPEG comment and an ICC colour
profile, then placed in a PDF by two writers with no code in common: img2pdf 0.6.3 in Python and
pdf-lib 1.17.1 in Node. The image stream was then pulled back out by two extractors with no code
in common, pikepdf 10.17.0 (libqpdf 12.4.2) and pypdf 6.21.4.
original photo.jpg 12281 bytes sha256[:16]=88d8d8e5e591f8a4
img2pdf output
pikepdf /Im0 /DCTDecode 12281 bytes sha=88d8d8e5e591f8a4 equal: True
pypdf /Im0 /DCTDecode 12281 bytes sha=88d8d8e5e591f8a4 equal: True
pdf-lib output
pikepdf /Image-7098480789 /DCTDecode 12281 bytes sha=88d8d8e5e591f8a4 equal: True
pypdf /Image-7098480789 /DCTDecode 12281 bytes sha=88d8d8e5e591f8a4 equal: True
Same length, same hash, and a direct byte comparison, four times over. The stream inside each PDF
is the source JPEG file, from its FF D8 start-of-image marker to its last byte. Two EXIF readers
with independent implementations, Pillow 13.0.0 and ExifRead 3.5.1, read the coordinates straight
out of every extracted stream:
GPS via Pillow : {1: 'N', 2: (48.0, 51.0, 29.6), 3: 'E', 4: (2.0, 17.0, 40.2)}
GPS via exifread: {'GPS GPSLatitudeRef': 'N', 'GPS GPSLatitude': '[48, 51, 148/5]',
'GPS GPSLongitudeRef': 'E', 'GPS GPSLongitude': '[2, 17, 201/5]'}
Nothing here is a bug in either writer. Both do what they advertise. img2pdf describes itself,
in its own command-line help, as a tool to "Losslessly convert raster images to PDF without
re-encoding PNG, JPEG, and JPEG2000 images", with "the only added file size coming from the PDF
container itself". In pdf-lib, the JPEG embedder's embedIntoContext hands the input buffer to
the stream constructor directly:
xObject = context.stream(this.imageData, {
Type: 'XObject',
Subtype: 'Image',
...
Filter: 'DCTDecode',
this.imageData is the file you passed in. The embedder reads a few numbers out of it to fill in
Width, Height, BitsPerComponent and ColorSpace, and copies the rest through untouched.
That is the right design: re-encoding a photograph to put it in a document would throw away image
quality for nothing. The metadata rides along as a side effect of not damaging the picture.
A document-level strip does not reach it
Exif puts its data in the JPEG's APP1 segment: "APP1 consists of the APP1 marker, Exif identifier code, and the attribute information itself" (Exif 2.3, section 4.5.4). XMP uses a second APP1, and IPTC sits in APP13 (XMP Specification Part 3, Table 5). None of that is a PDF object.
So take the PDF, give it document metadata as well, and strip it properly. Two strips were run: a
generic one that removes /Info from the trailer and /Metadata from the catalogue and rewrites
the file, and one that replicates, object for object, what our own PDF tool's sanitize pass
removed when this post was first published.
size doc image GPS in image
0. JPEG embedded + document metadata set 14199B 2/2 5/5 present
A. strip Info + XMP, full rewrite 13561B 0/2 5/5 present
B. old AegisPDF sanitize set, replicated 13561B 0/2 5/5 present
C. decoded to pixels, re-encoded 6984B 0/2 1/5 gone
D. APP1/APP13/COM dropped before embedding 12088B 0/2 0/5 gone
The doc and image columns count sentinel strings planted in the document metadata and in the
image's metadata segments. Rows A and B are the point: both remove every document-level sentinel,
and neither moves the image count off 5. The GPS coordinates are still readable from the image
stream afterwards by both readers. The file is 638 bytes smaller and no more private.
Neither strip is sloppy. /Info and /Metadata are document-level objects, and the EXIF is inside
a stream that a strip treats as opaque image payload, which is what it is. It is also the strip a
careful reviewer proposes. One well-argued
issue about uploaded PDFs specifies the fix
as "rewrite the /Info dictionary and the XMP metadata stream, leaving every content stream
untouched." That is row A. It passes its own tests and a look in a PDF viewer, and it misses the
photograph.
The thumbnail problem
GPS is the headline, but an Exif block can carry a second, smaller copy of the image. Exif 2.3
allows it in the same APP1 segment ("The 1st IFD may be used to record a thumbnail image"), so it
travels the same way. A JPEG was built whose thumbnail has a green block the main picture does not,
embedded with pdf-lib, and the thumbnail pulled back out of the PDF's image stream:
thumbnail 1413 bytes, JPEG (80, 50), top-left pixel (0, 200, 0)
main image top-left pixel (0, 2, 7)
That is a separate, decodable picture, extracted from inside a PDF, showing content the page does not show. Whether a real photo's thumbnail disagrees with its picture depends on what edited it. Software that changes the pixels and leaves the thumbnail alone produces exactly this file.
What does reach it
The JPEG standard has had an opinion about this since 1992. ITU-T T.81 (ISO/IEC 10918-1), section B.2.4.6: "The APPn (Application) segments are reserved for application use. Since these segments may be defined differently for different applications, they should be removed when the data are exchanged between application environments." Camera software did the opposite, and Exif is built on it, but the advice still gives you the fix.
Row D is that fix, and it is the one plainmile shipped in
commit 2a73d1e. Walk the JPEG's
marker segments before embedding, drop APP1 (Exif and XMP), APP13 (IPTC) and COM (free-text
comments), keep everything else, and copy the scan data from the first start-of-scan marker
unchanged. On the test file that removed 2100 bytes and left the picture exactly as it was:
segments before: APP0(JFIF) APP1(Exif) APP2(ICC_PROFILE) APP1(XMP) APP13(Photoshop 3.0) COM DQT ... SOS
segments after: APP0(JFIF) APP2(ICC_PROFILE) DQT ... SOS
scan data (SOS onward) byte-identical: True
decoded pixels identical: True
ICC profile kept: True
The kept segments are kept for reasons. APP0 is the JFIF header. APP2 is where an embedded ICC colour profile goes, identified by the string "ICC_PROFILE" (ICC.1:2004-10, Annex B.5). And APP14 carries Adobe's marker, which, per ISO 32000-1's Table 13, decides whether a PDF reader applies a colour transform to the decoded samples. Drop segments indiscriminately and the picture can change colour.
Plainmile's version also fails closed. If a segment length does not parse, it throws "JPEG metadata could not be safely removed." rather than embedding the file as it was, and its help text now states the boundary in plain words: "This is not a general metadata-removal tool for existing PDFs."
Row C is the other way, and it is a worse trade than it looks. Decoding to pixels and re-encoding with Pillow at its default settings changed most of the picture and did not even remove everything:
pixels changed by re-encoding: 50411 of 64000 (78.8%), max channel deviation 62
JPEG comment after re-encoding: b'SENTINEL_IMG_COM' (kept)
ICC profile after re-encoding: absent (dropped)
Pillow carries the JPEG comment through a decode and re-save, so one sentinel survived, and it dropped the colour profile, which was the one segment worth keeping. A second lossy pass costs detail you cannot get back. Dropping three kinds of marker segment achieves more and provably does not touch the image.
The order matters too. EXIF Orientation is the tag that tells a viewer to rotate the picture, because phones store the sensor orientation as a tag rather than rotating the pixels. Strip APP1 before reading it and a sideways photo stays sideways. Plainmile reads the tag first and applies the rotation through the image's placement on the PDF page, then strips.
Where stopping at the first scan stops
Copying everything from the first start-of-scan marker onward is what keeps the picture intact. It also means anything after the image is kept, and one format puts a second image there. A multi-picture file (MPO, CIPA DC-007) stores several "Individual Images", each "a valid JPEG image, delimited by an SOI and EOI marker", one after another, and CIPA's own structure diagram gives each of them its own Exif APP1.
A file built that way, with GPS in the second image, went through the same strip and the same two writers:
two JPEGs, one file img2pdf stream == input: True second image GPS: N 40 41 21.4, W 74 2 40.2
pdf-lib stream == input: True second image GPS: N 40 41 21.4, W 74 2 40.2
after APP1/APP13/COM strip img2pdf stream == input: True second image GPS: N 40 41 21.4, W 74 2 40.2
pdf-lib stream == input: True second image GPS: N 40 41 21.4, W 74 2 40.2
Both writers embedded the whole file, and the strip removed the first image's metadata while the second image's coordinates went into the PDF untouched. A complete strip either cuts the stream at the first image's end-of-image marker or walks every image in the file.
Where AegisPDF fits
AegisPDF is our browser-based PDF tool. The file is never uploaded, which is the property that matters when the document you are cleaning is the one you cannot hand to a web converter.
When this post was first published on 4 November, AegisPDF's Sanitize did not reach image
metadata, and its verdict did not say so. The pass removed document metadata, annotations, form
fields, attachments and earlier revisions, and it never opened an image. Row B above is that set
replicated. A PDF whose only private data was the GPS position inside a photograph got "No hidden
data found. This PDF is already clean.", and for that file the sentence was wrong. The fix shipped
on 6 November (commit 6ec7a14), and the engine and page source were read again for this update.
- Sanitize now opens every JPEG stream. It walks each one's marker segments, never a byte search, and reports how many photos carry camera data and how many of those record a GPS position. Removal is on by default and does not re-encode, so the pixels are identical. It keeps only the three segments that change how the picture looks, each in its exact standard form: the JFIF header (with any thumbnail cut), a complete ICC colour profile, and Adobe's colour-transform marker. Every other application segment and every comment is dropped, including ones between the scans of a progressive JPEG. That is an allowlist, which is stricter than the APP1/APP13/COM rule above: vendor segments that rule keeps are dropped too.
- A second picture is cut, not walked. For the multi-picture case above, the stream is cut at the first image's end-of-image marker whenever anything follows it, and the cut is reported. The source gives the reason: PDF viewers decode only the first image, so the page loses nothing.
- It fails closed, and the verdict names its scope. A JPEG it cannot read, or one wrapped in a second compression filter, is left as it was and reported as "could not be inspected", never as clean. The clean verdict now lists what was checked: metadata, annotations, attached files, earlier versions, hidden text and camera data in JPEG photos.
- Its own image-to-PDF path never carried the metadata in. Building a PDF from photographs decodes each one to pixels, reads the EXIF Orientation off the decoder and bakes the rotation into those pixels, then hands the decoded image to PDFium. The original JPEG bytes are never embedded, so there is no APP1 in the output. How PDFium encodes those pixels, and so what the decode costs in size or quality, was not checked for this post.
- Compress is not a metadata strip, and the source says why. The compress pass downsamples over-resolution images and re-encodes them as JPEG, which drops their metadata as a side effect. But it returns early on an image that is already JPEG and does not need shrinking, so that "repeated compressions don't slowly requant and degrade it". That is the right call for image quality, and it means compression cannot be relied on to remove EXIF. Sanitize is the step that does.
What it does not cover is where this post stopped too. Sanitize inspects JPEG streams only, so PNG-derived images and JPEG 2000 streams, the cases left untested below, are neither checked nor cleaned.
What this post covers, and what it does not
The measurements were run on 4 November 2026 with Python 3.12.3 and Node 24.19.0, using img2pdf 0.6.3, pdf-lib 1.17.1, pikepdf 10.17.0 (libqpdf 12.4.2), pypdf 6.21.4, Pillow 13.0.0, piexif 1.1.3 and ExifRead 3.5.1, on synthetic test images, not camera files. The quotations from img2pdf's help text and pdf-lib's embedder are from those installed packages. Other versions may behave differently, and two writers agreeing is evidence about those two writers, not proof about every PDF generator. Plainmile's code was read, not run; row D applies its rule in a separate implementation.
The standards quoted are ISO 32000-1:2008, CIPA DC-008-2012 (Exif 2.3), ITU-T T.81, XMP Specification Part 3 (January 2020), ICC.1:2004-10, and CIPA's white paper for DC-007 (DC-X007-2009), not the DC-007 standard itself.
The scope is JPEG. PNG images embedded into a PDF are a related case with a different answer,
since PNG keeps metadata in chunks rather than marker segments, and that was not tested. Also
untested: JPEG 2000 streams, CMYK and YCCK JPEGs, progressive JPEGs, a /Metadata stream attached
to the image XObject itself, and what any particular PDF viewer or search index chooses to read out
of an embedded image. The AegisPDF claims come from reading the engine and page source at the
production commit (019e0de on 4 November, 6ec7a14 after the fix on 6 November), not from
running it, and not from our marketing copy.