Since iOS/iPadOS 27.0 (also macOS 27.0), saving a PDF with PDFDocument.dataRepresentation() (or write(to:)) writes every stream whose raw length is 0 as an empty indirect object:
6 0 obj
endobj
That object is null, not a stream. When it is referenced from a page's /Contents array, Adobe Acrobat/Reader renders the whole page blank. Browser PDF viewers tolerate it, so the corruption is easy to miss. qpdf --check reports empty object treated as null and ignoring non-stream in an array of streams.
No edits are needed to trigger it. Zero-length content streams are legal and fairly common; for example, PDFs generated by TallComponents PDFKit.NET contain a << /Filter /FlateDecode /Length 0 >> content stream on every page. In our case users sign such documents in an iPad app (custom PDFAnnotation) and we upload the result of dataRepresentation(); on iPadOS 27 every one of them opens blank in Acrobat.
Minimal repro (builds a 1-page PDF whose /Contents is [text stream, zero-length stream], then re-serializes it):
import PDFKit
func minimalPDF() -> Data {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents [4 0 R 6 0 R] >>",
"<< /Length 36 >>\nstream\nBT /F1 24 Tf 72 700 Td (Hello) Tj ET\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Length 0 >>\nstream\n\nendstream", // legal zero-length stream
]
var pdf = "%PDF-1.7\n", offsets: [Int] = []
for (i, body) in objects.enumerated() {
offsets.append(pdf.utf8.count)
pdf += "\(i + 1) 0 obj\n\(body)\nendobj\n"
}
let xref = pdf.utf8.count
pdf += "xref\n0 \(objects.count + 1)\n0000000000 65535 f \n"
offsets.forEach { pdf += String(format: "%010d 00000 n \n", $0) }
pdf += "trailer\n<< /Size \(objects.count + 1) /Root 1 0 R >>\nstartxref\n\(xref)\n%%EOF\n"
return Data(pdf.utf8)
}
let saved = PDFDocument(data: minimalPDF())!.dataRepresentation()!
print(String(decoding: saved, as: UTF8.self).contains("obj\nendobj") ? "BROKEN: null object written" : "OK")
- iOS 18.1: OK (PDFKit re-renders the page through Quartz).
- iOS 27.0 (24A5370g) / macOS 27.0 (26A428): BROKEN. With
CG_PDF_VERBOSE=1CoreGraphics logswriteStream: Failed to get data from stream.
What triggers it: only the raw (encoded) length being 0. It happens with or without /Filter /FlateDecode, for a single /Contents stream, and for a zero-length Form XObject. A FlateDecode stream holding an 8-byte compressed empty payload (decoded length 0) is written correctly, so it is not about the decoded data.
Where it seems to come from: iOS 27 introduced a structure-preserving serializer in CoreGraphics (CG::PDFDocumentSerializer). Its writeStream emits N 0 obj, asks for the stream's raw bytes, and when that returns NULL it logs the error above, skips the dictionary/stream/endstream, but still emits endobj. A legal empty stream returns NULL there, so it is treated as a read failure.
Workaround we ship: after dataRepresentation(), find the empty objects (N G obj immediately followed by endobj) that are referenced from a page's /Contents, and append an incremental update that redefines them as << /Length 0 >>\nstream\n\nendstream, with a new xref section and a trailer whose /Prev points at the original startxref. The original bytes are untouched, so signatures/annotations written by PDFKit stay as they are, and files from iOS 18 (which contain no such objects) are returned unchanged.
Filed as FB25282060. If you hit this too, please file a duplicate referencing that number.
Possibly related iOS 27 save-path regressions: https://developer.apple.com/forums/thread/851708 (16-bpc annotation images truncated).