diff --git a/example/__tests__/fixtures/exif-orientation.ts b/example/__tests__/fixtures/exif-orientation.ts new file mode 100644 index 0000000..6c2dd53 --- /dev/null +++ b/example/__tests__/fixtures/exif-orientation.ts @@ -0,0 +1,183 @@ +import type { EncodedImageData } from 'react-native-nitro-image' + +export type ExifOrientation = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 +export type FixtureColor = 'red' | 'green' | 'blue' | 'yellow' + +export interface ExifOrientationCase { + orientation: ExifOrientation + name: string + width: number + height: number + corners: readonly [FixtureColor, FixtureColor, FixtureColor, FixtureColor] +} + +export const STORED_WIDTH = 120 +export const STORED_HEIGHT = 80 + +/** + * The JPEG's stored pixels are four solid quadrants: + * + * red | green + * blue | yellow + * + * `corners` describes the logical top-left, top-right, bottom-left, and + * bottom-right colors after applying each EXIF orientation. + */ +export const EXIF_ORIENTATION_CASES: readonly ExifOrientationCase[] = [ + { + orientation: 1, + name: 'up', + width: 120, + height: 80, + corners: ['red', 'green', 'blue', 'yellow'], + }, + { + orientation: 2, + name: 'up mirrored', + width: 120, + height: 80, + corners: ['green', 'red', 'yellow', 'blue'], + }, + { + orientation: 3, + name: 'down', + width: 120, + height: 80, + corners: ['yellow', 'blue', 'green', 'red'], + }, + { + orientation: 4, + name: 'down mirrored', + width: 120, + height: 80, + corners: ['blue', 'yellow', 'red', 'green'], + }, + { + orientation: 5, + name: 'left mirrored', + width: 80, + height: 120, + corners: ['red', 'blue', 'green', 'yellow'], + }, + { + orientation: 6, + name: 'right', + width: 80, + height: 120, + corners: ['blue', 'red', 'yellow', 'green'], + }, + { + orientation: 7, + name: 'right mirrored', + width: 80, + height: 120, + corners: ['yellow', 'green', 'blue', 'red'], + }, + { + orientation: 8, + name: 'left', + width: 80, + height: 120, + corners: ['green', 'yellow', 'red', 'blue'], + }, +] + +// A 120x80 JPEG with an EXIF orientation entry and the quadrant pattern above. +// Keeping it inline gives every Harness runner the exact same encoded bytes. +const JPEG_BASE64 = [ + '/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAA', + 'AAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAeKADAAQAAAABAAAAUAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklN', + 'BAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAUAB4AwEiAAIRAQMRAf/EAB8AAAEFAQEB', + 'AQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGh', + 'CCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6', + 'g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz', + '9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMR', + 'BAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZX', + 'WFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT', + '1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgICAgICAwICAgIDBAMDAwMDBAUEBAQEBAQF', + 'BQUFBQUFBQYGBgYGBgcHBwcHCAgICAgICAgICP/bAEMBAQEBAgICAwICAwgFBQUICAgICAgICAgICAgICAgICAgI', + 'CAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICP/dAAQACP/aAAwDAQACEQMRAD8A/Oeiiiv85z/tICiiigAo', + 'oooAK+xP2Tf+Y/8A9uP/ALXr47r7E/ZN/wCY/wD9uP8A7Xr4fxI/5EuI/wC3f/S4n+Rv7dr/AJRU43/7kf8A1ZYM', + '+xKKKK/lc/8AOpCiiigAooooAKKKKAP/0Pznooor/Oc/7SAooooAKKKKACvsT9k3/mP/APbj/wC16+O6+xP2Tf8A', + 'mP8A/bj/AO16+H8SP+RLiP8At3/0uJ/kb+3a/wCUVON/+5H/ANWWDPsSiiiv5XP/ADqQooooAKKKKACiiigD/9H8', + '56K/Meiv9GP+KAf/AFXf/lh/9+n+sH/Fbv8A6ov/AMvf/vQ/TiivzHoo/wCKAf8A1Xf/AJYf/fof8Vu/+qL/APL3', + '/wC9D9OKK/Meij/igH/1Xf8A5Yf/AH6H/Fbv/qi//L3/AO9D9OK+xP2Tf+Y//wBuP/tevwDr+lj/AIN3/wDmr/8A', + '3Kn/ALlq/hj9pR+yi/4g14KcR+JP+tP176j9W/c/VfY8/tsXQw/8T6zV5eX2vP8Aw5X5eXS91/OH0t/p2/8AEwnh', + '7mvhB/Yf9mf2l7L/AGj2/wBY9n9Xr0sV/B9jQ5+f2HJ/Fjy83N73Lyv2iiv3wor/AJSv+I/f9QH/AJU/+0P8Uv8A', + 'ikH/ANVZ/wCWn/30fgfRX74UUf8AEfv+oD/yp/8AaB/xSD/6qz/y0/8Avo/A+iv3woo/4j9/1Af+VP8A7QP+KQf/', + 'AFVn/lp/99H4H0V++FFH/Efv+oD/AMqf/aB/xSD/AOqs/wDLT/76P//S/mvooor/ALSD+dwooooAKKKKACv6WP8A', + 'g3f/AOav/wDcqf8AuWr+aev6WP8Ag3f/AOav/wDcqf8AuWr/ABp/0gj/AJRE49/7kP8A1Z4I/VPBP/kp8F/2/wD+', + 'm5n9LFFFFf8Amen9+BRRRQAUUUUAFFFFAH//0/5r6KKK/wC0g/ncKKKKACiiigAr+lj/AIN3/wDmr/8A3Kn/ALlq', + '/mnr+lj/AIN3/wDmr/8A3Kn/ALlq/wAaf9II/wCUROPf+5D/ANWeCP1TwT/5KfBf9v8A/puZ/SxRRRX/AJnp/fgU', + 'UUUAFFFFABRRRQB//9k=', +].join('') + +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +const ORIENTATION_ENTRY_PREFIX = [0x01, 0x12, 0x00, 0x03, 0, 0, 0, 1] + +function decodeBase64(value: string): Uint8Array { + const bytes: number[] = [] + let accumulator = 0 + let bitCount = 0 + + for (const character of value) { + if (character === '=') break + + const decoded = BASE64_ALPHABET.indexOf(character) + if (decoded < 0) { + throw new Error(`Invalid Base64 character: ${character}`) + } + + accumulator = (accumulator << 6) | decoded + bitCount += 6 + + if (bitCount >= 8) { + bitCount -= 8 + bytes.push((accumulator >> bitCount) & 0xff) + accumulator &= (1 << bitCount) - 1 + } + } + + return Uint8Array.from(bytes) +} + +function findSequence(bytes: Uint8Array, sequence: readonly number[]): number { + const finalStart = bytes.length - sequence.length + for (let start = 0; start <= finalStart; start++) { + let matches = true + for (let offset = 0; offset < sequence.length; offset++) { + if (bytes[start + offset] !== sequence[offset]) { + matches = false + break + } + } + if (matches) return start + } + return -1 +} + +export function makeExifOrientationJpeg( + orientation: ExifOrientation, +): EncodedImageData { + const bytes = decodeBase64(JPEG_BASE64) + const entryOffset = findSequence(bytes, ORIENTATION_ENTRY_PREFIX) + if (entryOffset < 0) { + throw new Error('EXIF orientation entry is missing from the JPEG fixture') + } + + // The fixture's TIFF data is big-endian. A SHORT value is stored in the first + // two bytes of the four-byte value field immediately following the entry. + bytes[entryOffset + 8] = 0 + bytes[entryOffset + 9] = orientation + + const buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + + return { + buffer, + width: STORED_WIDTH, + height: STORED_HEIGHT, + imageFormat: 'jpg', + } +} diff --git a/example/__tests__/image-exif-orientation.harness.ts b/example/__tests__/image-exif-orientation.harness.ts new file mode 100644 index 0000000..2dafb8f --- /dev/null +++ b/example/__tests__/image-exif-orientation.harness.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'react-native-harness' +import type { + ImageFormat, + PixelFormat, + RawPixelData, +} from 'react-native-nitro-image' +import { Images } from 'react-native-nitro-image' +import { + EXIF_ORIENTATION_CASES, + type ExifOrientation, + type ExifOrientationCase, + type FixtureColor, + makeExifOrientationJpeg, +} from './fixtures/exif-orientation' + +type Channel = 'r' | 'g' | 'b' | 'a' | 'x' +interface Rgb { + r: number + g: number + b: number +} + +const FORMAT_CHANNELS: Partial> = { + ARGB: ['a', 'r', 'g', 'b'], + BGRA: ['b', 'g', 'r', 'a'], + ABGR: ['a', 'b', 'g', 'r'], + RGBA: ['r', 'g', 'b', 'a'], + XRGB: ['x', 'r', 'g', 'b'], + BGRX: ['b', 'g', 'r', 'x'], + XBGR: ['x', 'b', 'g', 'r'], + RGBX: ['r', 'g', 'b', 'x'], + RGB: ['r', 'g', 'b'], + BGR: ['b', 'g', 'r'], +} + +const RIGHT_ORIENTATION = EXIF_ORIENTATION_CASES.find( + (testCase) => testCase.orientation === 6, +) +if (RIGHT_ORIENTATION == null) { + throw new Error('Missing EXIF orientation 6 test case') +} + +function loadFixture(orientation: ExifOrientation) { + return Images.loadFromEncodedImageData(makeExifOrientationJpeg(orientation)) +} + +function readPixel(raw: RawPixelData, x: number, y: number): Rgb { + const channels = FORMAT_CHANNELS[raw.pixelFormat] + if (channels == null) { + throw new Error(`Unsupported pixel format: ${raw.pixelFormat}`) + } + + const bytes = new Uint8Array(raw.buffer) + const offset = (Math.floor(y) * raw.width + Math.floor(x)) * channels.length + const pixel: Rgb = { r: 0, g: 0, b: 0 } + + for (let index = 0; index < channels.length; index++) { + const channel = channels[index] + const byte = bytes[offset + index] + if (channel == null || byte == null) { + throw new Error(`Pixel (${x}, ${y}) is outside the raw image buffer`) + } + + if (channel === 'r' || channel === 'g' || channel === 'b') { + pixel[channel] = byte + } + } + + return pixel +} + +function expectColor(pixel: Rgb, color: FixtureColor) { + switch (color) { + case 'red': + expect(pixel.r).toBeGreaterThan(180) + expect(pixel.g).toBeLessThan(80) + expect(pixel.b).toBeLessThan(80) + break + case 'green': + expect(pixel.r).toBeLessThan(80) + expect(pixel.g).toBeGreaterThan(160) + expect(pixel.b).toBeLessThan(80) + break + case 'blue': + expect(pixel.r).toBeLessThan(80) + expect(pixel.g).toBeLessThan(80) + expect(pixel.b).toBeGreaterThan(180) + break + case 'yellow': + expect(pixel.r).toBeGreaterThan(180) + expect(pixel.g).toBeGreaterThan(160) + expect(pixel.b).toBeLessThan(80) + break + } +} + +function expectCorners( + raw: RawPixelData, + corners: ExifOrientationCase['corners'], +) { + const points: ReadonlyArray = [ + [raw.width * 0.25, raw.height * 0.25], + [raw.width * 0.75, raw.height * 0.25], + [raw.width * 0.25, raw.height * 0.75], + [raw.width * 0.75, raw.height * 0.75], + ] + + for (let index = 0; index < points.length; index++) { + const point = points[index] + const color = corners[index] + if (point == null || color == null) { + throw new Error(`Missing corner at index ${index}`) + } + expectColor(readPixel(raw, point[0], point[1]), color) + } +} + +function expectLogicalRawImage( + raw: RawPixelData, + testCase: ExifOrientationCase, +) { + expect(raw.width).toBe(testCase.width) + expect(raw.height).toBe(testCase.height) + expectCorners(raw, testCase.corners) +} + +describe('Image - EXIF orientation dimensions', () => { + for (const testCase of EXIF_ORIENTATION_CASES) { + it(`reports logical dimensions for orientation ${testCase.orientation} (${testCase.name})`, () => { + const image = loadFixture(testCase.orientation) + expect(image.width).toBe(testCase.width) + expect(image.height).toBe(testCase.height) + }) + + it(`exports logical raw dimensions for orientation ${testCase.orientation} (${testCase.name})`, () => { + const image = loadFixture(testCase.orientation) + const raw = image.toRawPixelData() + expect(raw.width).toBe(image.width) + expect(raw.height).toBe(image.height) + expect(raw.width).toBe(testCase.width) + expect(raw.height).toBe(testCase.height) + }) + } +}) + +describe('Image - EXIF orientation pixel layout', () => { + for (const testCase of EXIF_ORIENTATION_CASES) { + it(`applies orientation ${testCase.orientation} (${testCase.name}) to raw pixels`, () => { + const raw = loadFixture(testCase.orientation).toRawPixelData() + expectCorners(raw, testCase.corners) + }) + } + + it('applies EXIF orientation through the async loader and raw exporter', async () => { + const image = await Images.loadFromEncodedImageDataAsync( + makeExifOrientationJpeg(RIGHT_ORIENTATION.orientation), + ) + const raw = await image.toRawPixelDataAsync() + expectLogicalRawImage(raw, RIGHT_ORIENTATION) + }) +}) + +describe('Image - transforms of EXIF-oriented images', () => { + it('crops in logical orientation coordinates', () => { + const image = loadFixture(RIGHT_ORIENTATION.orientation) + const cropped = image.crop(0, 0, image.width / 2, image.height / 2) + expect(cropped.width).toBe(RIGHT_ORIENTATION.width / 2) + expect(cropped.height).toBe(RIGHT_ORIENTATION.height / 2) + }) + + it('crops the visually top-left quadrant', () => { + const image = loadFixture(RIGHT_ORIENTATION.orientation) + const cropped = image.crop(0, 0, image.width / 2, image.height / 2) + const raw = cropped.toRawPixelData() + expectColor(readPixel(raw, raw.width / 2, raw.height / 2), 'blue') + }) + + it('resizes the logically oriented pixels', () => { + const resized = loadFixture(RIGHT_ORIENTATION.orientation).resize(40, 60) + const raw = resized.toRawPixelData() + expect(raw.width).toBe(40) + expect(raw.height).toBe(60) + expectCorners(raw, RIGHT_ORIENTATION.corners) + }) + + it('mirrors the logically oriented pixels horizontally', () => { + const mirrored = loadFixture( + RIGHT_ORIENTATION.orientation, + ).mirrorHorizontally() + const raw = mirrored.toRawPixelData() + expect(raw.width).toBe(RIGHT_ORIENTATION.width) + expect(raw.height).toBe(RIGHT_ORIENTATION.height) + expectCorners(raw, ['red', 'blue', 'green', 'yellow']) + }) + + it('rotates the logically oriented pixels by 180 degrees', () => { + const rotated = loadFixture(RIGHT_ORIENTATION.orientation).rotate(180) + const raw = rotated.toRawPixelData() + expect(raw.width).toBe(RIGHT_ORIENTATION.width) + expect(raw.height).toBe(RIGHT_ORIENTATION.height) + expectCorners(raw, ['green', 'yellow', 'red', 'blue']) + }) +}) + +describe('Image - encoding EXIF-oriented images', () => { + const formats: readonly ImageFormat[] = ['jpg', 'png'] + + for (const format of formats) { + it(`bakes EXIF orientation into ${format.toUpperCase()} output`, () => { + const source = loadFixture(RIGHT_ORIENTATION.orientation) + const encoded = source.toEncodedImageData(format, 95) + expect(encoded.width).toBe(RIGHT_ORIENTATION.width) + expect(encoded.height).toBe(RIGHT_ORIENTATION.height) + + const decoded = Images.loadFromEncodedImageData(encoded) + expectLogicalRawImage(decoded.toRawPixelData(), RIGHT_ORIENTATION) + }) + } +})