From 5b766b32bf2b2a71babf35fab75511bdf7921866 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Sun, 23 Aug 2026 21:11:34 +0200 Subject: [PATCH 1/2] docs: correct JSDoc comments --- .../src/NativeNitroImage.tsx | 2 +- .../src/specs/Image.nitro.ts | 16 ++++++++-------- .../src/specs/ImageFactory.nitro.ts | 16 ++++++++-------- .../src/specs/ImageView.nitro.ts | 4 ++-- .../src/specs/WebImageFactory.nitro.ts | 4 ++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/react-native-nitro-image/src/NativeNitroImage.tsx b/packages/react-native-nitro-image/src/NativeNitroImage.tsx index c0fcdd06..d6f036ec 100644 --- a/packages/react-native-nitro-image/src/NativeNitroImage.tsx +++ b/packages/react-native-nitro-image/src/NativeNitroImage.tsx @@ -10,7 +10,7 @@ import type { * @example * ```tsx * function App() { - * const image = useImage('https://picsum.photos/seed/123/400') + * const { image } = useImage({ url: 'https://picsum.photos/seed/123/400' }) * return * } * ``` diff --git a/packages/react-native-nitro-image/src/specs/Image.nitro.ts b/packages/react-native-nitro-image/src/specs/Image.nitro.ts index 8306ca16..311e7801 100644 --- a/packages/react-native-nitro-image/src/specs/Image.nitro.ts +++ b/packages/react-native-nitro-image/src/specs/Image.nitro.ts @@ -69,7 +69,7 @@ export interface Image readonly height: number /** - * Returns an {@linkcode ArrayBuffer} containing the raw pixel data of the Image. + * Returns {@linkcode RawPixelData} containing the raw pixel data of the Image in an {@linkcode ArrayBuffer}. * @note The returned {@linkcode PixelFormat} describes the literal byte order in memory - * always read it instead of assuming a fixed format. It is typically * {@linkcode PixelFormat | 'RGBA'} on Android (the memory layout of `ARGB_8888` Bitmaps), @@ -96,10 +96,10 @@ export interface Image toRawPixelDataAsync(allowGpu?: boolean): Promise /** - * Returns an {@linkcode ArrayBuffer} containing the encoded data of an Image in + * Returns {@linkcode EncodedImageData} containing the encoded data of an Image in * the requested container {@linkcode format}. * @note If the requested {@linkcode format} is {@linkcode ImageFormat | 'jpg'}, you can use - * {@linkcode quality} to compress the image. Quality ranges from 0(most)...100(least). In {@linkcode ImageFormat | 'png'}, the + * {@linkcode quality} to compress the image. Quality ranges from `0` (worst) to `100` (best) and defaults to `100`. In {@linkcode ImageFormat | 'png'}, the * {@linkcode quality} flag is ignored. * @example * ```ts @@ -127,7 +127,7 @@ export interface Image * the newly created {@linkcode Image}. * * @param degrees The degrees to rotate the Image. May be any arbitrary number, and can be negative. - * @param allowFastFlagRotation When {@linkcode allowFastFlagRotation} is set to `true`, the implementation may choose to only change the orientation flag on the underying image instead of physicaly rotating the buffers. This may only work when {@linkcode degrees} is a multiple of `90`, and will only apply rotation when displaying the Image (via view transforms) or exporting it to a file (via EXIF flags). The actual buffer (e.g. obtained via {@linkcode toRawPixelData | toRawPixelData()}) may remain untouched. + * @param allowFastFlagRotation When {@linkcode allowFastFlagRotation} is set to `true`, the implementation may choose to only change the orientation flag on the underlying image instead of physically rotating the buffers. This may only work when {@linkcode degrees} is a multiple of `90`, and will only apply rotation when displaying the Image (via view transforms) or exporting it to a file (via EXIF flags). The actual buffer (e.g. obtained via {@linkcode toRawPixelData | toRawPixelData()}) may remain untouched. * @example * ```ts * const upsideDown = image.rotate(180) @@ -166,11 +166,11 @@ export interface Image /** * Saves this image in the given {@linkcode ImageFormat} to the given filesystem {@linkcode path}. * - * @param path A filesystem path including filename and extension - for example: `/tmp/image.jpg`. This is not a URL, so omit the `file://` prefix. The file extension is not authorative and does not affect encoding/file format - but typically it should be the same extension as the {@linkcode ImageFormat} passed to the {@linkcode format} parameter. + * @param path A filesystem path including filename and extension - for example: `/tmp/image.jpg`. This is not a URL, so omit the `file://` prefix. The file extension is not authoritative and does not affect encoding/file format, but typically it should match the {@linkcode ImageFormat} passed to the {@linkcode format} parameter. * * @param format The {@linkcode ImageFormat} to use for encoding this Image to a file. * - * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressable {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. + * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. * * @example * ```ts @@ -188,7 +188,7 @@ export interface Image * * @param format The {@linkcode ImageFormat} to use for encoding this Image to a file. The given {@linkcode ImageFormat} will also be used as the generated file's extension. * - * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressable {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. + * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. * * @returns A filesystem path such as `/tmp/image.jpg`. This is not a URL, so the returned path does not have a `file://` prefix. If another API needs a file URL, prepend `file://`, for example `fetch('file://' + path)`. * @@ -206,7 +206,7 @@ export interface Image /** * Encodes this Image into a ThumbHash. * To convert the returned ThumbHash to a string, use `thumbHashToBase64String(...)`. - * @note To keep this efficient, {@linkcode resize} this image to a small size (<100x100) first. + * @note Resize this image to `100x100` or smaller first. Android rejects larger Images. * @example * ```ts * const small = image.resize(100, 100) diff --git a/packages/react-native-nitro-image/src/specs/ImageFactory.nitro.ts b/packages/react-native-nitro-image/src/specs/ImageFactory.nitro.ts index 17e6f91d..2fb9890f 100644 --- a/packages/react-native-nitro-image/src/specs/ImageFactory.nitro.ts +++ b/packages/react-native-nitro-image/src/specs/ImageFactory.nitro.ts @@ -86,20 +86,20 @@ export interface ImageFactory /** * Synchronously loads an {@linkcode Image} from the given {@linkcode RawPixelData}'s {@linkcode ArrayBuffer}. - * @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing it's format. + * @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing its format. * @param allowGpu If `allowGpu` is set to `true` and the given {@linkcode data} is a GPU-buffer, the {@linkcode Image} * might be wrapping the given GPU-buffer without performing a copy. By default, `allowGpu` is `false` * @throws If the given {@linkcode RawPixelData} is not a valid RGB buffer representing an {@linkcode Image}. - * @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 4-bytes-per-pixel. + * @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 3- or 4-bytes-per-pixel. */ loadFromRawPixelData(data: RawPixelData, allowGpu?: boolean): Image /** * Asynchronously loads an {@linkcode Image} from the given {@linkcode RawPixelData}'s {@linkcode ArrayBuffer}. - * @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing it's format. + * @param data The {@linkcode RawPixelData} object carrying the **raw** RGB image data and describing its format. * @param allowGpu If `allowGpu` is set to `true` and the given {@linkcode data} is a GPU-buffer, the {@linkcode Image} * might be wrapping the given GPU-buffer without performing a copy. By default, `allowGpu` is `false` * @throws If the given {@linkcode RawPixelData} is not a valid RGB buffer representing an {@linkcode Image}. - * @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 4-bytes-per-pixel. + * @note The given pixel data has to have pre-multiplied alpha, and be some kind of RGB format with 3- or 4-bytes-per-pixel. */ loadFromRawPixelDataAsync( data: RawPixelData, @@ -108,21 +108,21 @@ export interface ImageFactory /** * Synchronously loads an {@linkcode Image} from the given {@linkcode EncodedImageData}'s {@linkcode ArrayBuffer}. - * @param buffer The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...) + * @param data The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...) * @throws If the given {@linkcode EncodedImageData} is not a valid representation of an {@linkcode Image}. */ loadFromEncodedImageData(data: EncodedImageData): Image /** * Asynchronously loads an {@linkcode Image} from the given {@linkcode EncodedImageData}'s {@linkcode ArrayBuffer}. - * @param buffer The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...) + * @param data The ArrayBuffer carrying the encoded Image data in any supported image format (JPG, PNG, ...) * @throws If the given {@linkcode EncodedImageData} is not a valid representation of an {@linkcode Image}. */ loadFromEncodedImageDataAsync(data: EncodedImageData): Promise /** - * Synchronously decodes the given {@linkcode thumbhash} (and {@linkcode ArrayBuffer}) + * Synchronously decodes the given {@linkcode thumbhash} (an {@linkcode ArrayBuffer}) * into an {@linkcode Image}. - * @param buffer The ArrayBuffer carrying the ThumbHash's data + * @param thumbhash The ArrayBuffer carrying the ThumbHash's data * @throws If the given {@linkcode thumbhash} is not a valid ThumbHash. */ loadFromThumbHash(thumbhash: ArrayBuffer): Image diff --git a/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts b/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts index 572a3a69..79ce1eb3 100644 --- a/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts +++ b/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts @@ -11,7 +11,7 @@ import type { ImageLoader } from './ImageLoader.nitro' * - `cover`: Scale the content to fill the size of the view. Some portion of the content may be clipped to fill the view’s bounds. * - `contain`: Scale the content to fit the size of the view by maintaining the aspect ratio. Any remaining area of the view’s bounds is transparent. * - `center`: Center the content in the view’s bounds, keeping the proportions the same. - * - `stretch`: Scale the content to fit the size of itself by changing the aspect ratio of the content if necessary. + * - `stretch`: Scale the content to fill the view's bounds, changing the content's aspect ratio if necessary. */ export type ResizeMode = 'cover' | 'contain' | 'center' | 'stretch' @@ -51,7 +51,7 @@ export interface NativeNitroImageViewProps extends HybridViewProps { * @default undefined * @example * ```tsx - * + * * ``` */ recyclingKey?: string diff --git a/packages/react-native-nitro-web-image/src/specs/WebImageFactory.nitro.ts b/packages/react-native-nitro-web-image/src/specs/WebImageFactory.nitro.ts index a7ed4d32..5df00d5d 100644 --- a/packages/react-native-nitro-web-image/src/specs/WebImageFactory.nitro.ts +++ b/packages/react-native-nitro-web-image/src/specs/WebImageFactory.nitro.ts @@ -11,8 +11,8 @@ export interface AsyncImageLoadOptions { priority?: AsyncImagePriority /** - * Forces a cache refresh even if the URL is changed. - * Use this if you cannot make your URLs static. + * Forces a cache refresh even if the URL has not changed. + * Use this when the content can change without a cache-busting URL. * @default false */ forceRefresh?: boolean From 6d45539cbc0338fc7dcb58b53a9e25a3072f27b0 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Sun, 23 Aug 2026 21:32:34 +0200 Subject: [PATCH 2/2] fix: unify image quality behavior --- example/__tests__/image-encoding.harness.ts | 56 +++++++++++++++++++ .../com/margelo/nitro/image/HybridImage.kt | 21 +++++-- .../ios/Extensions/UIImage+getData.swift | 12 ++-- .../UIImage+toEncodedImageData.swift | 2 +- .../src/specs/Image.nitro.ts | 9 ++- 5 files changed, 85 insertions(+), 15 deletions(-) diff --git a/example/__tests__/image-encoding.harness.ts b/example/__tests__/image-encoding.harness.ts index e2773665..f02f2d0a 100644 --- a/example/__tests__/image-encoding.harness.ts +++ b/example/__tests__/image-encoding.harness.ts @@ -4,6 +4,23 @@ import { Images } from 'react-native-nitro-image' const makeImage = () => Images.createBlankImage(16, 16, false, { r: 0, g: 0, b: 1, a: 1 }) +const makeDetailedImage = () => { + const size = 64 + const bytes = new Uint8Array(size * size * 4) + for (let i = 0; i < size * size; i++) { + bytes[i * 4] = (i * 17) % 256 + bytes[i * 4 + 1] = (i * 31) % 256 + bytes[i * 4 + 2] = (i * 47) % 256 + bytes[i * 4 + 3] = 255 + } + return Images.loadFromRawPixelData({ + buffer: bytes.buffer, + width: size, + height: size, + pixelFormat: 'RGBA', + }) +} + const expectTemporaryPath = (path: string, extension: 'jpg' | 'png') => { expect(path.length).toBeGreaterThan(0) expect(path.startsWith('/')).toBe(true) @@ -52,6 +69,45 @@ describe('Image - toEncodedImageData', () => { expect(encoded.imageFormat).toBe('jpg') expect(encoded.buffer.byteLength).toBeGreaterThan(0) }) + + it('uses a 0...100 JPEG quality range with 100 as the default', () => { + const image = makeDetailedImage() + const lowest = image.toEncodedImageData('jpg', 0) + const highest = image.toEncodedImageData('jpg', 100) + const defaultQuality = image.toEncodedImageData('jpg') + + expect(lowest.buffer.byteLength).toBeLessThan(highest.buffer.byteLength) + expect(Array.from(new Uint8Array(defaultQuality.buffer))).toEqual( + Array.from(new Uint8Array(highest.buffer)), + ) + }) + + it('rounds fractional JPEG quality to the nearest integer', () => { + const image = makeDetailedImage() + const quality0 = image.toEncodedImageData('jpg', 0) + const quality0Point4 = image.toEncodedImageData('jpg', 0.4) + const quality99Point5 = image.toEncodedImageData('jpg', 99.5) + const quality100 = image.toEncodedImageData('jpg', 100) + + expect(Array.from(new Uint8Array(quality0Point4.buffer))).toEqual( + Array.from(new Uint8Array(quality0.buffer)), + ) + expect(Array.from(new Uint8Array(quality99Point5.buffer))).toEqual( + Array.from(new Uint8Array(quality100.buffer)), + ) + }) + + it('rejects quality values outside 0...100', async () => { + const image = makeImage() + expect(() => image.toEncodedImageData('jpg', -0.5)).toThrow() + expect(() => image.toEncodedImageData('jpg', 100.5)).toThrow() + await expect( + image.toEncodedImageDataAsync('jpg', -0.5), + ).rejects.toBeDefined() + await expect( + image.saveToTemporaryFileAsync('jpg', 100.5), + ).rejects.toBeDefined() + }) }) describe('Images - loadFromEncodedImageData', () => { diff --git a/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImage.kt b/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImage.kt index d3c706eb..1a728972 100644 --- a/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImage.kt +++ b/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImage.kt @@ -25,6 +25,15 @@ import com.margelo.nitro.image.extensions.toMutable import java.io.File import java.nio.ByteBuffer import kotlin.math.ceil +import kotlin.math.roundToInt + +private fun resolveImageQuality(quality: Double?): Int { + val resolved = quality ?: 100.0 + if (!resolved.isFinite() || resolved < 0.0 || resolved > 100.0) { + throw Error("Image quality has to be between 0 and 100! (Received: $resolved)") + } + return resolved.roundToInt() +} @Suppress("ConvertSecondaryConstructorToPrimary") @Keep @@ -71,8 +80,8 @@ class HybridImage: HybridImageSpec { } override fun toEncodedImageData(format: ImageFormat, quality: Double?): EncodedImageData { - val quality = quality ?: 100.0 - val byteBuffer = bitmap.compressInMemory(format, quality.toInt()) + val resolvedQuality = resolveImageQuality(quality) + val byteBuffer = bitmap.compressInMemory(format, resolvedQuality) val arrayBuffer = ArrayBuffer.copy(byteBuffer) return EncodedImageData(arrayBuffer, width, height, format) } @@ -170,17 +179,17 @@ class HybridImage: HybridImageSpec { format: ImageFormat, quality: Double? ): Promise { - val quality = quality ?: 100.0 return Promise.async { - bitmap.saveToFile(path.toFilePath(), format, quality.toInt()) + val resolvedQuality = resolveImageQuality(quality) + bitmap.saveToFile(path.toFilePath(), format, resolvedQuality) } } override fun saveToTemporaryFileAsync(format: ImageFormat, quality: Double?): Promise { - val quality = quality ?: 100.0 return Promise.async { + val resolvedQuality = resolveImageQuality(quality) val tempFile = File.createTempFile("nitro_image_", ".${format.name.lowercase()}") - bitmap.saveToFile(tempFile.path, format, quality.toInt()) + bitmap.saveToFile(tempFile.path, format, resolvedQuality) return@async tempFile.path } } diff --git a/packages/react-native-nitro-image/ios/Extensions/UIImage+getData.swift b/packages/react-native-nitro-image/ios/Extensions/UIImage+getData.swift index bba455ba..a6682ee6 100644 --- a/packages/react-native-nitro-image/ios/Extensions/UIImage+getData.swift +++ b/packages/react-native-nitro-image/ios/Extensions/UIImage+getData.swift @@ -11,15 +11,17 @@ import NitroModules extension UIImage { /** * Convert/Compress this Image into the given `format`. - * `quality` specifies compression quality from 0(most)...100(least). + * `quality` specifies compression quality from 0 (worst) to 100 (best). */ func getData(in format: ImageFormat, quality: CGFloat) throws -> Data { + guard quality.isFinite, quality >= 0, quality <= 100 else { + throw RuntimeError.error(withMessage: "Image quality has to be between 0 and 100! (Received: \(quality))") + } + let resolvedQuality = quality.rounded() + switch format { case .jpg: - guard quality >= 0 && quality <= 100 else { - throw RuntimeError.error(withMessage: "Image quality has to be between 0 and 100! (Received: \(quality))") - } - let qualityNormalized = quality / 100.0 + let qualityNormalized = resolvedQuality / 100.0 guard let data = self.jpegData(compressionQuality: qualityNormalized) else { throw RuntimeError.error(withMessage: "Failed to compress \(size.width)x\(size.height) Image to JPEG! (Quality: \(quality))") } diff --git a/packages/react-native-nitro-image/ios/Extensions/UIImage+toEncodedImageData.swift b/packages/react-native-nitro-image/ios/Extensions/UIImage+toEncodedImageData.swift index 7413a83d..3ab5d77d 100644 --- a/packages/react-native-nitro-image/ios/Extensions/UIImage+toEncodedImageData.swift +++ b/packages/react-native-nitro-image/ios/Extensions/UIImage+toEncodedImageData.swift @@ -13,7 +13,7 @@ extension UIImage { /** * Returns encoded Image data of this Image (JPG, PNG, ...) */ - func toEncodedImageData(format: ImageFormat, quality: Double = 1.0) throws -> EncodedImageData { + func toEncodedImageData(format: ImageFormat, quality: Double) throws -> EncodedImageData { let data = try getData(in: format, quality: quality) let arrayBuffer = try ArrayBuffer.copy(data: data) return EncodedImageData(buffer: arrayBuffer, diff --git a/packages/react-native-nitro-image/src/specs/Image.nitro.ts b/packages/react-native-nitro-image/src/specs/Image.nitro.ts index 311e7801..b61aef15 100644 --- a/packages/react-native-nitro-image/src/specs/Image.nitro.ts +++ b/packages/react-native-nitro-image/src/specs/Image.nitro.ts @@ -99,8 +99,9 @@ export interface Image * Returns {@linkcode EncodedImageData} containing the encoded data of an Image in * the requested container {@linkcode format}. * @note If the requested {@linkcode format} is {@linkcode ImageFormat | 'jpg'}, you can use - * {@linkcode quality} to compress the image. Quality ranges from `0` (worst) to `100` (best) and defaults to `100`. In {@linkcode ImageFormat | 'png'}, the + * {@linkcode quality} to compress the image. Quality ranges from `0` (worst) to `100` (best), rounds to the nearest integer, and defaults to `100`. In {@linkcode ImageFormat | 'png'}, the * {@linkcode quality} flag is ignored. + * @throws If {@linkcode quality} is outside the `0...100` range. * @example * ```ts * const compressed = image.toEncodedImageData('jpg', 70) @@ -170,7 +171,8 @@ export interface Image * * @param format The {@linkcode ImageFormat} to use for encoding this Image to a file. * - * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. + * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression), rounded to the nearest integer. Defaults to `100`. {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. + * @throws If {@linkcode quality} is outside the `0...100` range. * * @example * ```ts @@ -188,7 +190,8 @@ export interface Image * * @param format The {@linkcode ImageFormat} to use for encoding this Image to a file. The given {@linkcode ImageFormat} will also be used as the generated file's extension. * - * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression). {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. + * @param quality The target Image quality to use, from `0` (worst quality/highest compression) to `100` (best quality/least compression), rounded to the nearest integer. Defaults to `100`. {@linkcode quality} is ignored for non-compressible {@linkcode ImageFormat}s, such as {@linkcode ImageFormat | 'png'}. + * @throws If {@linkcode quality} is outside the `0...100` range. * * @returns A filesystem path such as `/tmp/image.jpg`. This is not a URL, so the returned path does not have a `file://` prefix. If another API needs a file URL, prepend `file://`, for example `fetch('file://' + path)`. *