Disclosure: I'm the developer of QuickDial, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the app's source code. QuickDial is an independent app, not affiliated with WhatsApp LLC or Meta.
QuickDial has a Share as PDF button: you select product photos in a chat, and they become one PDF with each photo's caption printed under it, ready to send as a quotation or a small catalogue. The button then reads Share PDF (N pages) and hands the file to whichever app you pick.
The first version used Android's PdfDocument. It worked, and the files were far too big. This post is about the roughly 90-line PDF writer that replaced it, and the three decisions around it.
Why PdfDocument makes huge files
PdfDocument gives you a Canvas per page. You draw a Bitmap on it and the framework writes the page. The catch is what it writes: the decoded bitmap, stored losslessly. A phone photo that was a 300 KB JPEG becomes a few megabytes of pixels once decoded, and the PDF keeps every one of them. The note in the source puts it plainly: with PdfDocument, 20 photos came out at tens of megabytes. That is too big to attach to an email, and slow to send on a weak connection.
A PDF can hold a JPEG as it is. An image object with /Filter /DCTDecode is just the JPEG file's bytes, and every PDF viewer decodes it. So if the photos are already JPEGs, the PDF can be almost exactly the size of the photos.
The writer: four object types and an offset table
A minimal PDF needs a catalog, a page tree, and for each page an image, a content stream that draws it, and the page itself. Then a cross-reference table listing the byte offset of every object. That's all.
class JpegPdfWriter(private val out: OutputStream) {
private var written = 0L
private val offsets = mutableListOf<Long>() // index = object number - 1
private val pageRefs = mutableListOf<Int>()
private var nextObject = 3 // 1 = catalog, 2 = page tree (written last)
fun addPage(jpeg: ByteArray, width: Int, height: Int) {
val landscape = width > height
val pageW = if (landscape) A4_LONG else A4_SHORT
val pageH = if (landscape) A4_SHORT else A4_LONG
val scale = minOf((pageW - 2 * MARGIN) / width, (pageH - 2 * MARGIN) / height)
// ... centre it, then write three objects:
begin(image)
write("<< /Type /XObject /Subtype /Image /Width $width /Height $height " +
"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpeg.size} >>\nstream\n")
write(jpeg)
write("\nendstream\nendobj\n")
// content stream: "q w 0 0 h x y cm /Im0 Do Q", then the /Page that points at both
}
}
Details that are easy to get wrong:
-
Objects 1 and 2 are reserved and written last. The page tree has to list every page, which you only know at the end. Numbering pages from 3 lets the writer stream each photo to disk as soon as it's ready, then write the catalog and page tree in
finish(). PDF does not require objects to appear in number order; the offset table is what readers use. -
Count bytes yourself. The offset table needs the exact position of each
N 0 obj. The writer wraps the stream and adds every byte it writes to a counter, so it never has to seek or buffer the whole file. -
Text goes out as ISO-8859-1. The PDF syntax is bytes, and the binary comment line after
%PDF-1.4must survive as written. Encoding the syntax as UTF-8 would shift every offset after the first non-ASCII byte. -
Numbers use
Locale.US."%.2f".format(...)on a phone set to a language that writes decimals with a comma produces595,28, which is not a valid PDF number. Formatting with a fixed locale avoids a bug that only appears on some users' phones. - The page turns to suit the photo. A landscape photo gets a landscape A4 page, so it isn't shrunk into a strip across a portrait page.
Preparing each photo: upright, capped, and the caption drawn in
DCTDecode embeds the JPEG as it is, which means the JPEG has to be right before it goes in:
-
Downsample while decoding. The code reads the size first with
inJustDecodeBounds, then picks a power-of-twoinSampleSizeso a 4000-pixel photo is never fully decoded into memory. - Apply the EXIF rotation. Phone cameras often save the pixels sideways and record the rotation in EXIF. A PDF viewer ignores EXIF inside an embedded image, so the bitmap is rotated before it's re-encoded. Skip this and some pages come out lying on their side.
- Cap the long side at 1,600 pixels and re-encode at quality 80. That's sharp on an A4 page, and the source comment estimates about 150–300 KB a page.
-
Draw the caption into the image. This is the decision I'd defend hardest. Putting the caption in the PDF as text would mean embedding a font that covers whatever the user typed: Hindi, Gujarati, Tamil, emoji, a rupee sign. Instead the caption is laid out with
StaticLayouton a white strip below the photo, using the phone's own fonts, and the strip becomes part of the JPEG. Anything the phone can display, the PDF shows the same way. The cost is that the caption isn't selectable text in the PDF, which for a quotation is acceptable.
Which caption goes with which photo follows the chat: a photo's own caption sits under that photo, and an album's single caption sits under the album's last selected photo. The captions are passed to the PDF step inside the app's own process and never put into an Intent.
Handing the file over
The PDF is written to the app's cache directory and shared through a FileProvider URI with FLAG_GRANT_READ_URI_PERMISSION. It's also attached as typed ClipData, because some receiving apps only look there for the file. Old generated PDFs are cleaned up later rather than straight away, since the receiving app may still be reading a recent one.
If a single photo can't be decoded, that page is skipped and the rest of the PDF is still produced. If the whole thing fails, the screen says so instead of sharing an empty file.
The test that matters
The unit test feeds three fake JPEGs (portrait, landscape and square) and then checks the structure, not the pixels:
val xref = text.substringAfter("xref\n").substringBefore("trailer").lines().drop(2).filter { it.isNotBlank() }
xref.forEachIndexed { i, line ->
val offset = line.take(10).toInt()
assertEquals("${i + 1} 0 obj", text.substring(offset, offset + "${i + 1} 0 obj".length))
}
Every offset in the table must land exactly on its own N 0 obj, and startxref must point at the word xref. Most viewers will quietly repair a broken table, so a bad offset can go unnoticed on the developer's phone and still break a stricter reader on the customer's side. The test also checks that the landscape photo got a landscape page and that there are three image objects.
Takeaways
- If your inputs are already JPEGs, embed them with
DCTDecoderather than drawing bitmaps onPdfDocument. - Fix orientation and size before embedding, because the PDF won't do it for you.
- Render text you can't control the script of into the image.
- Test the offset table, since viewers hide its mistakes.
QuickDial is on Google Play. It never sends anything by itself: you pick the app and press send. Independent app, not affiliated with WhatsApp LLC or Meta.
Top comments (0)