Skip to content

Commit 260a119

Browse files
Merge pull request #3258 from SixLabors/fix/jpeg-marker-recovery-v4
[V4] Recover JPEG restart markers through the shared reader
2 parents fa206e0 + 195aa7c commit 260a119

27 files changed

Lines changed: 281 additions & 29 deletions

‎src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticDecodingTable.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ namespace SixLabors.ImageSharp.Formats.Jpeg.Components.Decoder;
55

66
internal class ArithmeticDecodingTable
77
{
8+
/// <summary>
9+
/// Initializes a new instance of the <see cref="ArithmeticDecodingTable"/> class with the JPEG conditioning defaults.
10+
/// </summary>
11+
/// <param name="tableClass">The table class: zero for DC or one for AC.</param>
12+
/// <param name="identifier">The table identifier from the scan or DAC segment.</param>
813
public ArithmeticDecodingTable(byte tableClass, byte identifier)
914
{
1015
this.TableClass = tableClass;
1116
this.Identifier = identifier;
17+
18+
// ITU T.81 F.1.4.4.1.4 specifies DC bounds L = 0 and U = 1 when DAC is absent.
19+
// U occupies the upper nibble, so the encoded DC value is 0x10. F.1.4.4.2.1 specifies AC Kx = 5.
20+
this.Configure(tableClass == 0 ? (byte)0x10 : (byte)5);
1221
}
1322

1423
public byte TableClass { get; }

‎src/ImageSharp/Formats/Jpeg/Components/Decoder/ArithmeticScanDecoder.cs‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ public ArithmeticScanDecoder(BufferedReadStream stream, SpectralConverter conver
202202
this.ct = -16; // Force reading 2 initial bytes to fill C.
203203
}
204204

205+
/// <inheritdoc/>
206+
public SegmentIntegrityHandling SegmentIntegrityHandling { get; set; }
207+
205208
/// <inheritdoc/>
206209
public int ResetInterval
207210
{
@@ -276,6 +279,13 @@ public void InjectFrameData(JpegFrame frame, IRawJpegData jpegData)
276279
this.spectralConverter.InjectFrameData(frame, jpegData);
277280
}
278281

282+
/// <summary>
283+
/// Gets the declared conditioning table or stores the JPEG defaults for an undeclared table.
284+
/// </summary>
285+
/// <param name="arithmeticDecodingTables">The conditioning tables for this image.</param>
286+
/// <param name="isDcTable">True for a DC table. False for an AC table.</param>
287+
/// <param name="identifier">The table identifier selected by the component.</param>
288+
/// <returns>The declared table or a table with the standard default values.</returns>
279289
private static ArithmeticDecodingTable GetArithmeticTable(List<ArithmeticDecodingTable> arithmeticDecodingTables, bool isDcTable, int identifier)
280290
{
281291
int tableClass = isDcTable ? 0 : 1;
@@ -288,7 +298,11 @@ private static ArithmeticDecodingTable GetArithmeticTable(List<ArithmeticDecodin
288298
}
289299
}
290300

291-
return null;
301+
// Omitted DAC entries are valid. Cache the defaults so later scans reuse the table without another allocation.
302+
// A later DAC segment replaces this entry through the existing table-update path.
303+
ArithmeticDecodingTable table = new((byte)tableClass, (byte)identifier);
304+
arithmeticDecodingTables.Add(table);
305+
return table;
292306
}
293307

294308
private ArithmeticStatistics CreateOrGetStatisticsBin(bool dc, int identifier, bool reset = false)
@@ -1100,9 +1114,10 @@ private bool HandleRestart()
11001114
{
11011115
if (this.restartInterval > 0 && (--this.todo) == 0)
11021116
{
1103-
if (this.scanBuffer.Marker == JpegConstants.Markers.XFF)
1117+
// Use the same marker recovery and integrity policy as Huffman-coded scans.
1118+
if (this.scanBuffer.Marker == JpegConstants.Markers.XFF || this.scanBuffer.HasReservedMarker())
11041119
{
1105-
if (!this.scanBuffer.FindNextMarker())
1120+
if (!this.scanBuffer.FindNextMarker(this.SegmentIntegrityHandling))
11061121
{
11071122
return false;
11081123
}
@@ -1119,8 +1134,7 @@ private bool HandleRestart()
11191134
component.AcStatistics?.Reset();
11201135
}
11211136

1122-
this.Reset();
1123-
1137+
// Reset clears the detected marker. Classify it first so a real segment marker can be read again.
11241138
if (this.scanBuffer.HasRestartMarker())
11251139
{
11261140
this.Reset();

‎src/ImageSharp/Formats/Jpeg/Components/Decoder/HuffmanScanDecoder.cs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ public HuffmanScanDecoder(
8585
this.acHuffmanTables = new HuffmanTable[maxTables];
8686
}
8787

88+
/// <inheritdoc/>
89+
public SegmentIntegrityHandling SegmentIntegrityHandling { get; set; }
90+
8891
/// <summary>
8992
/// Sets reset interval determined by RST markers.
9093
/// </summary>
@@ -745,9 +748,10 @@ private bool HandleRestart()
745748
{
746749
if (this.restartInterval > 0 && (--this.todo) == 0)
747750
{
748-
if (this.scanBuffer.Marker == JpegConstants.Markers.XFF)
751+
// A reserved marker cannot complete a restart interval. The shared reader applies the recovery policy.
752+
if (this.scanBuffer.Marker == JpegConstants.Markers.XFF || this.scanBuffer.HasReservedMarker())
749753
{
750-
if (!this.scanBuffer.FindNextMarker())
754+
if (!this.scanBuffer.FindNextMarker(this.SegmentIntegrityHandling))
751755
{
752756
return false;
753757
}

‎src/ImageSharp/Formats/Jpeg/Components/Decoder/IJpegScanDecoder.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ namespace SixLabors.ImageSharp.Formats.Jpeg.Components.Decoder;
1010
/// </summary>
1111
internal interface IJpegScanDecoder
1212
{
13+
/// <summary>
14+
/// Sets the recovery policy for errors in compressed scan data.
15+
/// </summary>
16+
public SegmentIntegrityHandling SegmentIntegrityHandling { set; }
17+
1318
/// <summary>
1419
/// Sets the reset interval.
1520
/// </summary>

‎src/ImageSharp/Formats/Jpeg/Components/Decoder/JpegBitReader.cs‎

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ public void Reset()
8484
[MethodImpl(InliningOptions.ShortMethod)]
8585
public readonly bool HasBadMarker() => this.Marker != JpegConstants.Markers.XFF && !this.HasRestartMarker();
8686

87+
/// <summary>
88+
/// Determines whether the detected marker is TEM or a reserved code before SOF0.
89+
/// </summary>
90+
/// <returns>True for TEM and reserved marker codes.</returns>
91+
[MethodImpl(InliningOptions.ShortMethod)]
92+
public readonly bool HasReservedMarker() => this.Marker < JpegConstants.Markers.SOF0;
93+
8794
[MethodImpl(InliningOptions.AlwaysInline)]
8895
public void FillBuffer()
8996
{
@@ -177,39 +184,56 @@ private ulong GetBytes()
177184
return temp;
178185
}
179186

187+
/// <summary>
188+
/// Finds the next marker at a restart boundary and applies the selected image-data recovery policy.
189+
/// </summary>
190+
/// <param name="integrityHandling">The recovery policy for compressed image data.</param>
191+
/// <returns>True if a marker was found. False if the stream ended first.</returns>
180192
[MethodImpl(InliningOptions.ShortMethod)]
181-
public bool FindNextMarker()
193+
public bool FindNextMarker(SegmentIntegrityHandling integrityHandling)
182194
{
183-
while (true)
195+
do
184196
{
185-
int b = this.stream.ReadByte();
186-
if (b == -1)
197+
// The bit reader can already hold a marker from its entropy-data lookahead.
198+
// Libjpeg treats codes before SOF0 as damaged image data and scans forward to another marker.
199+
// Only IgnoreImageData permits that recovery. Real segment markers stay with the segment parser.
200+
if (this.HasReservedMarker() && integrityHandling != SegmentIntegrityHandling.IgnoreImageData)
187201
{
188-
return false;
202+
JpegThrowHelper.ThrowInvalidImageContentException("Unexpected marker before a JPEG restart marker.");
189203
}
190204

191-
// Found a marker.
192-
if (b == JpegConstants.Markers.XFF)
205+
while (true)
193206
{
194-
while (b == JpegConstants.Markers.XFF)
207+
int b = this.stream.ReadByte();
208+
if (b == -1)
195209
{
196-
// Loop here to discard any padding FF bytes on terminating marker.
197-
b = this.stream.ReadByte();
198-
if (b == -1)
199-
{
200-
return false;
201-
}
210+
return false;
202211
}
203212

204-
// Found a valid marker. Exit loop
205-
if (b != 0)
213+
if (b == JpegConstants.Markers.XFF)
206214
{
207-
this.Marker = (byte)b;
208-
this.MarkerPosition = this.stream.Position - 2;
209-
return true;
215+
// Multiple FF bytes are marker padding. FF 00 is a stuffed data byte, not a marker.
216+
while (b == JpegConstants.Markers.XFF)
217+
{
218+
b = this.stream.ReadByte();
219+
if (b == -1)
220+
{
221+
return false;
222+
}
223+
}
224+
225+
if (b != 0)
226+
{
227+
this.Marker = (byte)b;
228+
this.MarkerPosition = this.stream.Position - 2;
229+
break;
230+
}
210231
}
211232
}
212233
}
234+
while (this.HasReservedMarker());
235+
236+
return true;
213237
}
214238

215239
[MethodImpl(InliningOptions.AlwaysInline)]

‎src/ImageSharp/Formats/Jpeg/JpegConstants.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,14 @@ internal static class Markers
271271
/// </remarks>
272272
/// </summary>
273273
public const byte RST7 = 0xD7;
274+
275+
/// <summary>
276+
/// Temporary private marker for arithmetic coding.
277+
/// </summary>
278+
/// <remarks>
279+
/// This marker stands alone. It has no length field or payload.
280+
/// </remarks>
281+
public const byte TEM = 0x01;
274282
}
275283

276284
/// <summary>

‎src/ImageSharp/Formats/Jpeg/JpegDecoderCore.cs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,9 @@ public static JpegFileMarker FindNextFileMarker(BufferedReadStream stream)
188188
}
189189
}
190190

191-
// Found a valid marker. Exit loop
192-
if (b is not 0 and (< JpegConstants.Markers.RST0 or > JpegConstants.Markers.RST7))
191+
// FF 00 is a stuffed data byte. RSTn and TEM have no length field (ITU T.81, Table B.1).
192+
// Skip these standalone markers so the next marker's bytes are not read as a length.
193+
if (b is not 0 and not JpegConstants.Markers.TEM and (< JpegConstants.Markers.RST0 or > JpegConstants.Markers.RST7))
193194
{
194195
return new JpegFileMarker((byte)(uint)b, stream.Position - 2);
195196
}
@@ -324,6 +325,9 @@ internal void ParseStream(BufferedReadStream stream, SpectralConverter spectralC
324325

325326
this.scanDecoder ??= new HuffmanScanDecoder(stream, spectralConverter, cancellationToken);
326327

328+
// Apply the image-data policy to the active decoder, including one supplied by TIFF.
329+
this.scanDecoder.SegmentIntegrityHandling = this.Options.SegmentIntegrityHandling;
330+
327331
this.Metadata ??= new ImageMetadata();
328332

329333
Span<byte> markerBuffer = stackalloc byte[2];
@@ -386,7 +390,11 @@ internal void ParseStream(BufferedReadStream stream, SpectralConverter spectralC
386390
case JpegConstants.Markers.SOF10:
387391
case JpegConstants.Markers.SOF13:
388392
case JpegConstants.Markers.SOF14:
389-
this.scanDecoder = new ArithmeticScanDecoder(stream, spectralConverter, cancellationToken);
393+
this.scanDecoder = new ArithmeticScanDecoder(stream, spectralConverter, cancellationToken)
394+
{
395+
SegmentIntegrityHandling = this.Options.SegmentIntegrityHandling
396+
};
397+
390398
if (this.resetInterval.HasValue)
391399
{
392400
this.scanDecoder.ResetInterval = this.resetInterval.Value;
@@ -1629,6 +1637,8 @@ private void ProcessStartOfScanMarker(BufferedReadStream stream, int remaining)
16291637

16301638
if (this.scanDecoder is ArithmeticScanDecoder arithmeticScanDecoder)
16311639
{
1640+
// An arithmetic JPEG can omit DAC entirely. The scan decoder adds defaults for undeclared entries.
1641+
this.arithmeticDecodingTables ??= new List<ArithmeticDecodingTable>(4);
16321642
arithmeticScanDecoder.InitDecodingTables(this.arithmeticDecodingTables);
16331643
}
16341644

‎tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
1111
using SixLabors.ImageSharp.Tests.TestUtilities.ReferenceCodecs;
1212
using Xunit.Abstractions;
13+
using static SixLabors.ImageSharp.Tests.TestImages.Jpeg;
1314

1415
// ReSharper disable InconsistentNaming
1516
namespace SixLabors.ImageSharp.Tests.Formats.Jpg;
@@ -300,6 +301,124 @@ public void Issue2136_DecodeWorks<TPixel>(TestImageProvider<TPixel> provider)
300301
image.CompareToOriginal(provider);
301302
}
302303

304+
/// <summary>
305+
/// Uses the JPEG defaults when an arithmetic conditioning table is not declared.
306+
/// </summary>
307+
/// <param name="originalFile">The input with explicit conditioning tables.</param>
308+
/// <param name="defaultFile">The input that omits default conditioning tables.</param>
309+
[Theory]
310+
[InlineData(MarkerDirectory + "arithmetic.jpg", MarkerDirectory + "arithmetic-defaults.jpg")]
311+
[InlineData(MarkerDirectory + "arithmetic-progressive.jpg", MarkerDirectory + "arithmetic-progressive-defaults.jpg")]
312+
[InlineData(MarkerDirectory + "arithmetic-progressive.jpg", MarkerDirectory + "arithmetic-progressive-default-ac.jpg")]
313+
public void Decode_ArithmeticConditioningDefaults_MatchExplicitTables(string originalFile, string defaultFile)
314+
{
315+
// These stored files contain identical coded scans. Libjpeg produces identical pixels with or without
316+
// the default DAC entries. Omission is valid JPEG syntax and does not require image-data recovery.
317+
using Image<Rgba32> expected = Image.Load<Rgba32>(TestFile.Create(originalFile).Bytes);
318+
byte[] data = TestFile.Create(defaultFile).Bytes;
319+
320+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
321+
{
322+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
323+
using Image<Rgba32> actual = Image.Load<Rgba32>(options, data);
324+
325+
ImageComparer.Exact.VerifySimilarity(expected, actual);
326+
}
327+
}
328+
329+
/// <summary>
330+
/// Checks restart recovery against clean stored files whose pixels also match in libjpeg.
331+
/// </summary>
332+
/// <param name="originalFile">The clean JPEG input.</param>
333+
/// <param name="malformedFile">The input with extra markers before restart markers.</param>
334+
[Theory]
335+
[InlineData(MarkerDirectory + "baseline.jpg", MarkerDirectory + "baseline-reserved-12.jpg")]
336+
[InlineData(MarkerDirectory + "baseline.jpg", MarkerDirectory + "baseline-tem.jpg")]
337+
[InlineData(MarkerDirectory + "baseline.jpg", MarkerDirectory + "baseline-reserved-bf.jpg")]
338+
[InlineData(MarkerDirectory + "baseline.jpg", MarkerDirectory + "baseline-reserved-chain.jpg")]
339+
[InlineData(MarkerDirectory + "noninterleaved.jpg", MarkerDirectory + "noninterleaved-reserved-12.jpg")]
340+
[InlineData(MarkerDirectory + "progressive.jpg", MarkerDirectory + "progressive-reserved-12.jpg")]
341+
[InlineData(MarkerDirectory + "arithmetic.jpg", MarkerDirectory + "arithmetic-reserved-12.jpg")]
342+
[InlineData(MarkerDirectory + "arithmetic-progressive.jpg", MarkerDirectory + "arithmetic-progressive-reserved-12.jpg")]
343+
public void Decode_ReservedMarkerBeforeRestartMarkers_RecoversWithIgnoreImageData(string originalFile, string malformedFile)
344+
{
345+
// These stored files have complete restart intervals. Libjpeg warns about the extra markers,
346+
// then finds the restart marker and produces the same pixels as the clean file.
347+
// The arithmetic inputs declare their conditioning tables to isolate restart recovery.
348+
DecoderOptions options = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreImageData };
349+
using Image<Rgba32> expected = Image.Load<Rgba32>(TestFile.Create(originalFile).Bytes);
350+
using Image<Rgba32> actual = Image.Load<Rgba32>(options, TestFile.Create(malformedFile).Bytes);
351+
352+
ImageComparer.Exact.VerifySimilarity(expected, actual);
353+
}
354+
355+
/// <summary>
356+
/// Rejects image-data errors when the selected policy permits only ancillary recovery or no recovery.
357+
/// </summary>
358+
/// <param name="fileName">The input with reserved markers before restart markers.</param>
359+
[Theory]
360+
[InlineData(MarkerDirectory + "baseline-reserved-12.jpg")]
361+
[InlineData(MarkerDirectory + "noninterleaved-reserved-12.jpg")]
362+
[InlineData(MarkerDirectory + "progressive-reserved-12.jpg")]
363+
[InlineData(MarkerDirectory + "arithmetic-reserved-12.jpg")]
364+
[InlineData(MarkerDirectory + "arithmetic-progressive-reserved-12.jpg")]
365+
public void Decode_ReservedMarkerBeforeRestartMarkers_RespectsIntegrity(string fileName)
366+
{
367+
byte[] data = TestFile.Create(fileName).Bytes;
368+
DecoderOptions strict = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.Strict };
369+
DecoderOptions ancillary = new() { SegmentIntegrityHandling = SegmentIntegrityHandling.IgnoreAncillary };
370+
371+
Assert.Throws<InvalidImageContentException>(() =>
372+
{
373+
using Image<Rgba32> image = Image.Load<Rgba32>(strict, data);
374+
});
375+
376+
Assert.Throws<InvalidImageContentException>(() =>
377+
{
378+
using Image<Rgba32> image = Image.Load<Rgba32>(ancillary, data);
379+
});
380+
}
381+
382+
/// <summary>
383+
/// Accepts a standalone TEM marker between header segments under every integrity policy.
384+
/// </summary>
385+
[Fact]
386+
public void Decode_TemMarkerBetweenHeaderSegments_IsSkipped()
387+
{
388+
byte[] data = TestFile.Create(MarkerDirectory + "header-tem.jpg").Bytes;
389+
using Image<Rgba32> expected = Image.Load<Rgba32>(TestFile.Create(MarkerDirectory + "baseline.jpg").Bytes);
390+
391+
// TEM has no length field. The following bytes belong to the next marker, not to TEM.
392+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
393+
{
394+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
395+
Assert.Equal(new Size(32, 24), Image.Identify(options, data).Size);
396+
397+
using Image<Rgba32> actual = Image.Load<Rgba32>(options, data);
398+
ImageComparer.Exact.VerifySimilarity(expected, actual);
399+
}
400+
}
401+
402+
/// <summary>
403+
/// Keeps required header lengths valid after a standalone TEM marker.
404+
/// </summary>
405+
[Fact]
406+
public void Decode_InvalidQuantizationSegmentAfterTem_RemainsFatal()
407+
{
408+
// TEM precedes DQT in this stored file. DQT declares more bytes than the file contains.
409+
// No image data has been read, so there is no safe point for recovery.
410+
byte[] data = TestFile.Create(MarkerDirectory + "header-tem-invalid-dqt-length.jpg").Bytes;
411+
412+
foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
413+
{
414+
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
415+
Assert.Throws<InvalidImageContentException>(() =>
416+
{
417+
using Image<Rgba32> image = Image.Load<Rgba32>(options, data);
418+
});
419+
}
420+
}
421+
303422
// https://github.com/SixLabors/ImageSharp/issues/2315
304423
// https://github.com/SixLabors/ImageSharp/issues/2334
305424
[Theory]

0 commit comments

Comments
 (0)