diff --git a/NAPS2.Sdk.Tests/ImageResources.Designer.cs b/NAPS2.Sdk.Tests/ImageResources.Designer.cs index 1f57116620..f04d2e42f0 100644 --- a/NAPS2.Sdk.Tests/ImageResources.Designer.cs +++ b/NAPS2.Sdk.Tests/ImageResources.Designer.cs @@ -299,6 +299,16 @@ internal static byte[] dog_clustered_gray { } } + /// + /// Looks up a localized resource of type System.Byte[]. + /// + internal static byte[] dog_exif { + get { + object obj = ResourceManager.GetObject("dog_exif", resourceCulture); + return ((byte[])(obj)); + } + } + /// /// Looks up a localized resource of type System.Byte[]. /// diff --git a/NAPS2.Sdk.Tests/ImageResources.resx b/NAPS2.Sdk.Tests/ImageResources.resx index b2aa6bc974..895ab75983 100644 --- a/NAPS2.Sdk.Tests/ImageResources.resx +++ b/NAPS2.Sdk.Tests/ImageResources.resx @@ -295,4 +295,7 @@ Resources\filled_form_annotated.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Resources\dog_exif.jpg;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Pdf/JpegFormatHelperTests.cs b/NAPS2.Sdk.Tests/Pdf/JpegFormatHelperTests.cs new file mode 100644 index 0000000000..3905f8cf32 --- /dev/null +++ b/NAPS2.Sdk.Tests/Pdf/JpegFormatHelperTests.cs @@ -0,0 +1,47 @@ +using NAPS2.Pdf; +using Xunit; + +namespace NAPS2.Sdk.Tests.Pdf; + +public class JpegFormatHelperTests +{ + [Fact] + public void Jfif() + { + var header = JpegFormatHelper.ReadHeader(new MemoryStream(ImageResources.dog)); + Assert.NotNull(header); + Assert.Equal(788, header.Width); + Assert.Equal(525, header.Height); + Assert.Equal(3, header.NumComponents); + Assert.Equal(72, header.HorizontalDpi); + Assert.Equal(72, header.VerticalDpi); + Assert.True(header.HasJfifHeader); + } + + [Fact] + public void JfifGrey() + { + var header = JpegFormatHelper.ReadHeader(new MemoryStream(ImageResources.dog_gray)); + Assert.NotNull(header); + Assert.Equal(788, header.Width); + Assert.Equal(525, header.Height); + Assert.Equal(1, header.NumComponents); + Assert.Equal(72, header.HorizontalDpi); + Assert.Equal(72, header.VerticalDpi); + Assert.True(header.HasJfifHeader); + } + + [Fact] + public void Exif() + { + var header = JpegFormatHelper.ReadHeader(new MemoryStream(ImageResources.dog_exif)); + Assert.NotNull(header); + Assert.Equal(788, header.Width); + Assert.Equal(525, header.Height); + Assert.Equal(3, header.NumComponents); + Assert.Equal(72, header.HorizontalDpi); + Assert.Equal(72, header.VerticalDpi); + Assert.False(header.HasJfifHeader); + Assert.True(header.HasExifHeader); + } +} \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Pdf/PdfImportExportTests.cs b/NAPS2.Sdk.Tests/Pdf/PdfImportExportTests.cs index 0b981ccc1e..ad8fa250d8 100644 --- a/NAPS2.Sdk.Tests/Pdf/PdfImportExportTests.cs +++ b/NAPS2.Sdk.Tests/Pdf/PdfImportExportTests.cs @@ -1,3 +1,4 @@ +using NAPS2.ImportExport; using NAPS2.Pdf; using NAPS2.Sdk.Tests.Asserts; using Xunit; @@ -204,4 +205,36 @@ public async Task ImportVariousAndExport(OcrTestConfig config) PdfAsserts.AssertDoesNotContainText("Sized for printing unscaled", _exportPath); } } + + [Fact] + public async Task ImportJpegExportWithoutEncoding() + { + SetUpFileStorage(); + + var path = CopyResourceToFile(ImageResources.dog, "image.jpg"); + var images = await new ImageImporter(ScanningContext).Import(path).ToListAsync(); + Assert.Single(images); + + await _exporter.Export(_exportPath, images); + + var renderer = new PdfiumPdfRenderer(); + var pdfImage = renderer.Render(ImageContext, _exportPath, PdfRenderSize.Default).Single(); + ImageAsserts.Similar(ImageResources.dog, pdfImage, 0); + } + + [Fact] + public async Task ImportExifJpegExportWithoutEncoding() + { + SetUpFileStorage(); + + var path = CopyResourceToFile(ImageResources.dog_exif, "image.jpg"); + var images = await new ImageImporter(ScanningContext).Import(path).ToListAsync(); + Assert.Single(images); + + await _exporter.Export(_exportPath, images); + + var renderer = new PdfiumPdfRenderer(); + var pdfImage = renderer.Render(ImageContext, _exportPath, PdfRenderSize.Default).Single(); + ImageAsserts.Similar(ImageResources.dog_exif, pdfImage, 0); + } } \ No newline at end of file diff --git a/NAPS2.Sdk.Tests/Resources/dog_exif.jpg b/NAPS2.Sdk.Tests/Resources/dog_exif.jpg new file mode 100644 index 0000000000..d7423932b5 Binary files /dev/null and b/NAPS2.Sdk.Tests/Resources/dog_exif.jpg differ diff --git a/NAPS2.Sdk/Pdf/JpegFormatHelper.cs b/NAPS2.Sdk/Pdf/JpegFormatHelper.cs index 9b8c2f9042..423ec6e64f 100644 --- a/NAPS2.Sdk/Pdf/JpegFormatHelper.cs +++ b/NAPS2.Sdk/Pdf/JpegFormatHelper.cs @@ -4,13 +4,12 @@ internal static class JpegFormatHelper { // JPEG format doc: https://github.com/corkami/formats/blob/master/image/jpeg.md - public static JpegHeader? ReadHeader(FileStream stream) + public static JpegHeader? ReadHeader(Stream stream) { var sig1 = stream.ReadByte(); var sig2 = stream.ReadByte(); if (sig1 != 0xFF || sig2 != 0xD8) return null; - var header = new JpegHeader(0, 0, 0, 0, 0); - bool isJfif = false; + var header = new JpegHeader(0, 0, 0, 0, 0, false, false); while (true) { var marker = stream.ReadByte(); @@ -23,7 +22,7 @@ internal static class JpegFormatHelper if (stream.Read(buf, 0, len) < len) return null; if (type == 0xE0 && len >= 12 && buf is [0x4A, 0x46, 0x49, 0x46, 0x00, ..]) // Application data (JFIF) { - isJfif = true; + header = header with { HasJfifHeader = true }; var units = buf[7]; var hRes = buf[8] * 256 + buf[9]; var vRes = buf[10] * 256 + buf[11]; @@ -52,12 +51,76 @@ internal static class JpegFormatHelper }; } } + if (type == 0xE1 && len >= 12 && buf is [0x45, 0x78, 0x69, 0x66, 0x00, 0x00, ..]) // Application data (EXIF) + { + // https://docs.fileformat.com/image/exif/ + + // 0x4949 = little-endian, 0x4d4d = big-endian + var flipEnd = (buf[6] == 0x49 && buf[7] == 0x49) != BitConverter.IsLittleEndian; + var reader = new EndianReader(flipEnd); + + var number = reader.ReadInt16(buf, 8); + + if (number == 42) + { + header = header with { HasExifHeader = true }; + + var ifdOffset = reader.ReadInt32(buf, 10); + var dirCount = reader.ReadInt16(buf, ifdOffset + 6); + + double xRes = 0; + double yRes = 0; + int resUnit = 0; + + for (int i = 0; i < dirCount; i++) + { + var offset = ifdOffset + 8 + i * 12; + + var tag = reader.ReadInt16(buf, offset); + + if (tag == 282) + { + var valueOffset = reader.ReadInt32(buf, offset + 8); + var num = reader.ReadInt32(buf, valueOffset + 6); + var den = reader.ReadInt32(buf, valueOffset + 10); + xRes = num / (double) den; + } + if (tag == 283) + { + var valueOffset = reader.ReadInt32(buf, offset + 8); + var num = reader.ReadInt32(buf, valueOffset + 6); + var den = reader.ReadInt32(buf, valueOffset + 10); + yRes = num / (double) den; + } + if (tag == 296) + { + resUnit = reader.ReadInt16(buf, offset + 8); + } + } + + if (xRes > 0 && yRes > 0) + { + if (resUnit == 3) // cm + { + header = header with + { + HorizontalDpi = xRes * 2.54, + VerticalDpi = yRes * 2.54 + }; + } + else // inch + { + header = header with + { + HorizontalDpi = xRes, + VerticalDpi = yRes + }; + } + } + } + } if (type == 0xC0 && len >= 6) // Start of frame { - // JPEGs can come with several header varieties: JFIF-only, EXIF-only, or JFIF+EXIF. - // As long as we have a JFIF header we should have accurate resolution information. - // If it's EXIF-only we're not currently able to parse that. - if (!isJfif) return null; return header with { Height = buf[1] * 256 + buf[2], @@ -68,5 +131,6 @@ internal static class JpegFormatHelper } } - public record JpegHeader(double HorizontalDpi, double VerticalDpi, int Width, int Height, int NumComponents); + public record JpegHeader(double HorizontalDpi, double VerticalDpi, int Width, int Height, int NumComponents, + bool HasJfifHeader, bool HasExifHeader); } \ No newline at end of file diff --git a/NAPS2.Sdk/Util/EndianReader.cs b/NAPS2.Sdk/Util/EndianReader.cs new file mode 100644 index 0000000000..c36741ae31 --- /dev/null +++ b/NAPS2.Sdk/Util/EndianReader.cs @@ -0,0 +1,27 @@ +using System.Buffers.Binary; + +namespace NAPS2.Util; + +public class EndianReader +{ + private readonly bool _reverseEndianness; + + public EndianReader(bool reverseEndianness) + { + _reverseEndianness = reverseEndianness; + } + + public int ReadInt16(byte[] buf, int offset) + { + var value = BitConverter.ToInt16(buf, offset); + if (_reverseEndianness) value = BinaryPrimitives.ReverseEndianness(value); + return value; + } + + public int ReadInt32(byte[] buf, int offset) + { + var value = BitConverter.ToInt32(buf, offset); + if (_reverseEndianness) value = BinaryPrimitives.ReverseEndianness(value); + return value; + } +} \ No newline at end of file