Skip to content

Commit b8235d9

Browse files
authored
Merge pull request #32 from samalone/samalone/tile-corruption
Fix baseline JPEG rejection by Apple ImageIO when using multiple scans
2 parents 7f54b5d + 3c4642f commit b8235d9

2 files changed

Lines changed: 338 additions & 16 deletions

File tree

Sources/JPEG/encode.swift

Lines changed: 163 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1925,35 +1925,182 @@ extension JPEG.Data.Spectral
19251925

19261926
let frame:JPEG.Header.Frame = self.encode()
19271927
try stream.format(marker: .frame(frame.process), tail: frame.serialized())
1928-
for (qi, scans):([JPEG.Table.Quantization.Key], [JPEG.Scan]) in
1929-
self.layout.definitions
1928+
1929+
switch frame.process
19301930
{
1931-
let quanta:[JPEG.Table.Quantization] = qi.map
1931+
case .progressive:
1932+
// Progressive JPEGs may redefine tables between scans (e.g.
1933+
// between successive-approximation passes). Keep the existing
1934+
// interleaved table/scan emission order.
1935+
for (qi, scans):([JPEG.Table.Quantization.Key], [JPEG.Scan]) in
1936+
self.layout.definitions
19321937
{
1933-
self.quanta[self.quanta.index(forKey: $0)]
1938+
let quanta:[JPEG.Table.Quantization] = qi.map
1939+
{
1940+
self.quanta[self.quanta.index(forKey: $0)]
1941+
}
1942+
1943+
if !quanta.isEmpty
1944+
{
1945+
try stream.format(marker: .quantization,
1946+
tail: JPEG.Table.serialize(quanta))
1947+
}
1948+
1949+
for scan:JPEG.Scan in scans
1950+
{
1951+
let dc:[JPEG.Table.HuffmanDC],
1952+
ac:[JPEG.Table.HuffmanAC],
1953+
header:JPEG.Header.Scan,
1954+
ecs:[UInt8]
1955+
1956+
(dc, ac, header, ecs) = self.encode(scan: scan)
1957+
1958+
if !dc.isEmpty || !ac.isEmpty
1959+
{
1960+
try stream.format(marker: .huffman,
1961+
tail: JPEG.Table.serialize(dc, ac))
1962+
}
1963+
1964+
try stream.format(marker: .scan, tail: header.serialized())
1965+
try stream.format(prefix: ecs)
1966+
}
19341967
}
19351968

1936-
if !quanta.isEmpty
1969+
default:
1970+
// For baseline, extended, and lossless processes, emit all table
1971+
// definitions (DQT, DHT) before the first SOS marker.
1972+
//
1973+
// While ITU-T T.81 technically permits inter-scan table
1974+
// definitions for all processes, many real-world decoders —
1975+
// notably Apple's ImageIO (CGImageSourceCreateImageAtIndex) —
1976+
// reject baseline JPEGs that contain DQT or DHT markers between
1977+
// SOS markers, returning error -59.
1978+
1979+
// Pre-encode every scan, preserving definition-group association
1980+
// so we can fall back to interleaved emission if needed.
1981+
var encodedGroups:
1982+
[(
1983+
quanta: [JPEG.Table.Quantization],
1984+
scans:
1985+
[(
1986+
dc: [JPEG.Table.HuffmanDC],
1987+
ac: [JPEG.Table.HuffmanAC],
1988+
header: JPEG.Header.Scan,
1989+
ecs: [UInt8]
1990+
)]
1991+
)] = []
1992+
for (qi, scans):([JPEG.Table.Quantization.Key], [JPEG.Scan]) in
1993+
self.layout.definitions
19371994
{
1938-
try stream.format(marker: .quantization, tail: JPEG.Table.serialize(quanta))
1995+
let quanta:[JPEG.Table.Quantization] = qi.map
1996+
{
1997+
self.quanta[self.quanta.index(forKey: $0)]
1998+
}
1999+
var encoded:
2000+
[(
2001+
dc: [JPEG.Table.HuffmanDC],
2002+
ac: [JPEG.Table.HuffmanAC],
2003+
header: JPEG.Header.Scan,
2004+
ecs: [UInt8]
2005+
)] = []
2006+
for scan:JPEG.Scan in scans
2007+
{
2008+
encoded.append(self.encode(scan: scan))
2009+
}
2010+
encodedGroups.append((quanta: quanta, scans: encoded))
19392011
}
19402012

1941-
for scan:JPEG.Scan in scans
2013+
// Check whether any Huffman table selector is reused across
2014+
// scans. When selectors collide, we cannot pre-emit all
2015+
// Huffman tables because later tables would overwrite earlier
2016+
// ones for the same slot, corrupting earlier scans.
2017+
var seenDC:Set<UInt8> = [],
2018+
seenAC:Set<UInt8> = []
2019+
var selectorsCollide:Bool = false
2020+
for (_, scans) in encodedGroups
19422021
{
1943-
let dc:[JPEG.Table.HuffmanDC],
1944-
ac:[JPEG.Table.HuffmanAC],
1945-
header:JPEG.Header.Scan,
1946-
ecs:[UInt8]
2022+
for (dc, ac, _, _) in scans
2023+
{
2024+
for table:JPEG.Table.HuffmanDC in dc
2025+
where !seenDC.insert(
2026+
JPEG.Table.HuffmanDC.serialize(
2027+
selector: table.target)).inserted
2028+
{
2029+
selectorsCollide = true
2030+
}
2031+
for table:JPEG.Table.HuffmanAC in ac
2032+
where !seenAC.insert(
2033+
JPEG.Table.HuffmanAC.serialize(
2034+
selector: table.target)).inserted
2035+
{
2036+
selectorsCollide = true
2037+
}
2038+
}
2039+
}
2040+
2041+
if selectorsCollide
2042+
{
2043+
// Selectors are reused across scans — fall back to
2044+
// per-scan table emission, matching the progressive path.
2045+
// This preserves correctness for non-Apple decoders.
2046+
// (Apple ImageIO will still reject inter-scan table
2047+
// markers for baseline, but that is inherent to the
2048+
// selector-reuse configuration and cannot be fixed
2049+
// without building cross-scan merged Huffman tables.)
2050+
for (quanta, scans) in encodedGroups
2051+
{
2052+
if !quanta.isEmpty
2053+
{
2054+
try stream.format(marker: .quantization,
2055+
tail: JPEG.Table.serialize(quanta))
2056+
}
2057+
for (dc, ac, header, ecs) in scans
2058+
{
2059+
if !dc.isEmpty || !ac.isEmpty
2060+
{
2061+
try stream.format(marker: .huffman,
2062+
tail: JPEG.Table.serialize(dc, ac))
2063+
}
2064+
try stream.format(marker: .scan,
2065+
tail: header.serialized())
2066+
try stream.format(prefix: ecs)
2067+
}
2068+
}
2069+
}
2070+
else
2071+
{
2072+
// No selector collision — safe to pre-emit all tables.
19472073

1948-
(dc, ac, header, ecs) = self.encode(scan: scan)
2074+
// 1. Emit all quantization tables.
2075+
for (quanta, _) in encodedGroups where !quanta.isEmpty
2076+
{
2077+
try stream.format(marker: .quantization,
2078+
tail: JPEG.Table.serialize(quanta))
2079+
}
19492080

1950-
if !dc.isEmpty || !ac.isEmpty
2081+
// 2. Emit all Huffman tables.
2082+
for (_, scans) in encodedGroups
19512083
{
1952-
try stream.format(marker: .huffman, tail: JPEG.Table.serialize(dc, ac))
2084+
for (dc, ac, _, _) in scans
2085+
{
2086+
if !dc.isEmpty || !ac.isEmpty
2087+
{
2088+
try stream.format(marker: .huffman,
2089+
tail: JPEG.Table.serialize(dc, ac))
2090+
}
2091+
}
19532092
}
19542093

1955-
try stream.format(marker: .scan, tail: header.serialized())
1956-
try stream.format(prefix: ecs)
2094+
// 3. Emit all scan headers and entropy-coded segments.
2095+
for (_, scans) in encodedGroups
2096+
{
2097+
for (_, _, header, ecs) in scans
2098+
{
2099+
try stream.format(marker: .scan,
2100+
tail: header.serialized())
2101+
try stream.format(prefix: ecs)
2102+
}
2103+
}
19572104
}
19582105
}
19592106

tests/unit/tests.swift

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ extension Test
2929
("huffman-table-building", .void(Self.huffmanBuilding)),
3030
("huffman-table-coding-asymmetric", .void(Self.huffmanCoding)),
3131
("huffman-table-coding-symmetric", .int (Self.huffmanCodingSymmetric(_:), [16, 256, 4096, 65536])),
32+
("tables-before-sos", .void(Self.tablesBeforeSOS)),
3233
]
3334
}
3435

@@ -501,4 +502,178 @@ extension Test
501502

502503
return .success(())
503504
}
505+
506+
// MARK: - Table placement tests
507+
508+
/// In-memory JPEG bytestream for encoding.
509+
private
510+
struct Blob:JPEG.Bytestream.Destination
511+
{
512+
var data:[UInt8] = []
513+
514+
mutating
515+
func write(_ bytes:[UInt8]) -> Void?
516+
{
517+
self.data.append(contentsOf: bytes)
518+
return ()
519+
}
520+
}
521+
522+
/// Scan raw JPEG bytes for marker positions.
523+
/// Returns an array of (marker-code, byte-offset) pairs.
524+
private
525+
static func markers(in data:[UInt8]) -> [(marker:UInt8, offset:Int)]
526+
{
527+
var result:[(marker:UInt8, offset:Int)] = []
528+
var i:Int = 0
529+
while i < data.count - 1
530+
{
531+
guard data[i] == 0xFF
532+
else
533+
{
534+
i += 1
535+
continue
536+
}
537+
538+
let marker:UInt8 = data[i + 1]
539+
// skip padding and stuffed bytes
540+
if marker == 0x00 || marker == 0xFF
541+
{
542+
i += 1
543+
continue
544+
}
545+
546+
result.append((marker, i))
547+
548+
// standalone markers (no length field)
549+
if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7)
550+
{
551+
i += 2
552+
continue
553+
}
554+
555+
// read length and skip payload
556+
guard i + 3 < data.count
557+
else
558+
{
559+
break
560+
}
561+
let length:Int = Int(data[i + 2]) << 8 | Int(data[i + 3])
562+
563+
// for SOS, skip past entropy-coded data
564+
if marker == 0xDA
565+
{
566+
i += 2 + length
567+
while i < data.count - 1
568+
{
569+
if data[i] == 0xFF && data[i + 1] != 0x00 && data[i + 1] != 0xFF
570+
{
571+
break
572+
}
573+
i += 1
574+
}
575+
}
576+
else
577+
{
578+
i += 2 + length
579+
}
580+
}
581+
return result
582+
}
583+
584+
/// Verify that all DQT and DHT markers appear before the first SOS marker
585+
/// when encoding a baseline JPEG with multiple scans.
586+
///
587+
/// Apple's ImageIO (CGImageSourceCreateImageAtIndex) rejects baseline JPEGs
588+
/// that contain table-definition markers (DQT, DHT) between SOS markers.
589+
/// While ITU-T T.81 technically permits inter-scan table definitions, many
590+
/// real-world decoders — including iOS/macOS ImageIO — do not accept them
591+
/// in baseline sequential JPEGs.
592+
static
593+
func tablesBeforeSOS() -> Result<Void, Failure>
594+
{
595+
// Encode a 256×256 image using the exact layout that triggered the bug:
596+
// Baseline YCbCr 4:2:0, separate scans for Y and Cb+Cr.
597+
let width:Int = 256
598+
let height:Int = 256
599+
600+
// Simple gradient pattern (any pattern reproduces the issue)
601+
var pixels:[JPEG.RGB] = []
602+
pixels.reserveCapacity(width * height)
603+
for y:Int in 0 ..< height
604+
{
605+
for x:Int in 0 ..< width
606+
{
607+
pixels.append(.init(
608+
UInt8(truncatingIfNeeded: x),
609+
UInt8(truncatingIfNeeded: y),
610+
UInt8(truncatingIfNeeded: x &+ y)))
611+
}
612+
}
613+
614+
let layout:JPEG.Layout<JPEG.Common> = .init(
615+
format: .ycc8,
616+
process: .baseline,
617+
components:
618+
[
619+
1: (factor: (2, 2), qi: 0 as JPEG.Table.Quantization.Key),
620+
2: (factor: (1, 1), qi: 1 as JPEG.Table.Quantization.Key),
621+
3: (factor: (1, 1), qi: 1 as JPEG.Table.Quantization.Key),
622+
],
623+
scans:
624+
[
625+
.sequential((1, \.0, \.0)),
626+
.sequential((2, \.1, \.1), (3, \.1, \.1)),
627+
])
628+
629+
let image:JPEG.Data.Rectangular<JPEG.Common> = .pack(
630+
size: (x: width, y: height),
631+
layout: layout,
632+
metadata: [],
633+
pixels: pixels)
634+
635+
var blob:Blob = .init()
636+
do
637+
{
638+
try image.compress(stream: &blob, quanta:
639+
[
640+
0: JPEG.CompressionLevel.luminance( 1.0).quanta,
641+
1: JPEG.CompressionLevel.chrominance(1.0).quanta,
642+
])
643+
}
644+
catch
645+
{
646+
return .failure(.init(message:
647+
"encoding failed: \(error)"))
648+
}
649+
650+
// Parse JPEG markers and verify table placement
651+
let found:[(marker:UInt8, offset:Int)] = Self.markers(in: blob.data)
652+
653+
guard let firstSOSIndex:Int = found.firstIndex(where: { $0.marker == 0xDA })
654+
else
655+
{
656+
return .failure(.init(message:
657+
"encoded JPEG contains no SOS marker"))
658+
}
659+
660+
// Check for DQT (0xDB) or DHT (0xC4) markers after the first SOS
661+
for (marker, offset):(UInt8, Int) in found[found.index(after: firstSOSIndex)...]
662+
{
663+
if marker == 0xDB
664+
{
665+
return .failure(.init(message:
666+
"DQT marker at byte offset \(offset) appears after first SOS marker (at byte offset \(found[firstSOSIndex].offset)); "
667+
+ "Apple ImageIO rejects baseline JPEGs with inter-scan table definitions"))
668+
}
669+
if marker == 0xC4
670+
{
671+
return .failure(.init(message:
672+
"DHT marker at byte offset \(offset) appears after first SOS marker (at byte offset \(found[firstSOSIndex].offset)); "
673+
+ "Apple ImageIO rejects baseline JPEGs with inter-scan table definitions"))
674+
}
675+
}
676+
677+
return .success(())
678+
}
504679
}

0 commit comments

Comments
 (0)