JPEG buffer mutated in place during EXIF parsing: corrupts orientation across renders in long-lived processes
Hi, I've encountered a seemingly random issue with the images orientation on generated PDFs.
I've found the root cause and patched it with yarn patch and here is the explanation.
To be fully transparent with you, as english is not my primary language, I've let Claude write it for me, I've read it and it's 100% correct, I hope it's OK.
Here is the bug report :
Summary
@react-pdf/image's resolveImage() caches the resolved image object, including its raw .data buffer, by reference in a module-level IMAGE_CACHE, for the life of the process. Nothing clones that buffer before handing it to a consumer. Separately, jay-peg's decode() (used to read EXIF orientation) mutates the buffer it's given, in place, while parsing.
Combine the two and you get: correct on the first render, silently wrong on some later render of the same image, in the same process. In a server generating many PDFs (any long-lived Node process, not just a test harness), that's every deployment.
This is very likely the root cause behind #1848 and #2972 ("images rotated 90deg, most of the time correct, I don't know when this happens"). Both symptoms match exactly: random per image, and regenerating "fixes" it because you're re-triggering the same mutation cycle from a different starting state.
Environment
@react-pdf/renderer4.3.0 (bundles its own@react-pdf/pdfkit4.0.3, distinct from whatever top-level@react-pdf/pdfkityou might have installed)@react-pdf/image3.0.3jay-peg1.1.1- Node 22, long-lived process (a Fastify server rendering PDFs on demand)
Reproduces on a plain portrait photo with EXIF orientation: 8 (phone photo, nothing exotic in the metadata).
Repro
const { Document, Page, Image, renderToBuffer } = require('@react-pdf/renderer')
// same JPEG, same URL, rendered N times in the same process
for (let i = 0; i < 4; i++) {
const doc = (
<Document>
<Page size="A4">
<Image src={avatarUrl} style={{ width: '12mm', height: '12mm', objectFit: 'cover' }} />
</Page>
</Document>
)
await renderToBuffer(doc)
}Instrument PDFDocument.prototype.openImage (in the @react-pdf/pdfkit copy @react-pdf/renderer actually resolves at runtime, not necessarily the top-level one, check node_modules/@react-pdf/renderer/node_modules/@react-pdf/pdfkit) to log image.orientation and the md5 of the buffer it receives. Across 4 renders of the exact same URL in the same process:
610ee1b1... orientation=1
ca81afc2... orientation=8
3b81e34c... orientation=1
d14e85ea... orientation=8 <- happens to match the original file, by luckFour different hashes, same in-memory Buffer object every time (checked reference identity, not just content). The file on disk never changes: 10 fetches spread over ~45s came back byte-identical every time.
Root cause
Two bugs, and you need both to get the symptom:
1. jay-peg mutates its input. _JPEG.decode(buffer), called from @react-pdf/image's JPEG class constructor, rewrites bytes in the buffer it's handed while scanning EXIF markers. Diffed before/after on a real file: 98 bytes changed, all inside the EXIF/TIFF block (offset 22-819). Several of them are TIFF tag IDs with their two bytes swapped, including 0x0112, which is the Orientation tag itself: 12 01 becomes 01 12. A second decode() call on that same buffer no longer recognizes the tag and falls back to the default (1, no rotation).
2. IMAGE_CACHE shares that same buffer across renders. resolveImage() (@react-pdf/image/lib/index.js) caches the resolved image object by URL for the life of the process, and returns it by reference on every cache hit, no cloning. So render N's parse mutates the exact buffer render N+1 reads.
Put together: @react-pdf/image parses the fetched JPEG once for layout, gets the correct swapped width/height, but leaves the tag IDs scrambled as a side effect. That same, now-scrambled buffer becomes node.image.data, handed to @react-pdf/pdfkit's own, independent JPEG parser for the final embed/rotate step. That second parse can't find the orientation tag anymore, defaults to 1, and draws the image unrotated into a box that was already sized for the rotated version. That's your rotated-and-stretched image.
Why it looks random
It isn't. It's state that depends on things you don't control from the call site: which pass happens to read a fresh vs. already-mutated buffer, and how many times that particular image has been rendered since the process started. Same image, different call, different point in the cycle.
Fix
Patched @react-pdf/image locally with a yarn patch, happy to open a PR if useful:
// 1. Never let decode() touch the buffer we're keeping.
const markers = _JPEG.decode(Buffer.from(this.data)) // was: _JPEG.decode(this.data)
// 2. Never hand the cached reference to a consumer, hit or miss.
const cloneImageData = (imagePromise) => imagePromise.then((img) =>
img?.data && Buffer.isBuffer(img.data)
? { ...img, data: Buffer.from(img.data) }
: img
)
// applied on both the IMAGE_CACHE.get() hit path and the first-resolution
// return, so the version living in the cache is never itself handed out.Verified with the repro above: 10 successive renders, same correct orientation every time, matches the source file's actual EXIF tag.
I didn't audit whether pdfkit's own JPEG parser has the same in-place mutation bug on its own. Given the symptom disappears once @react-pdf/image stops leaking a shared, mutating buffer, it doesn't matter for this particular bug, but it's probably worth a separate look if you're in this code anyway. Confirmed by isolation, not just by inference: pull cloneImageData back out while keeping the decode-clone fix, and the bug reappears starting on the second render, so pdfkit's parser is mutating its own copy too.
Related
- #1848, #2972: same symptom, unresolved since 2022.
- #3398 / foliojs/pdfkit#1717: Buffer -> Uint8Array portability refactor on the EXIF parser, doesn't touch caching or in-place mutation, doesn't fix this.
Happy to share the full patch file or a minimal repro repo if useful.
Source: diegomura/react-pdf