Skip to content

Commit 0a2849d

Browse files
feat(image-source): modern internals, fixed legacy APIs, new functions
Public API stays backwards compatible. Existing method names and signatures are unchanged; new methods are additive. Every method has an automated test in apps/automated/src/image-source that runs the same assertion on iOS and Android. ## Why existing internals were swapped - iOS resize/resizeAsync/fromFontIconCodeSync and NativeScriptUtils.scaleImage used UIGraphicsBeginImageContextWithOptions, deprecated since iOS 17. On wide-gamut (Display P3 / HEIC) photos it returned nil, which surfaced as NaNxNaN ImageSources and failed saves. Replaced with UIGraphicsImageRenderer at scale 1 and preferredRange = .standard, so maxSize is now real pixels on both platforms (it was points times the image scale before). - iOS async loaders (fromFile, fromResource, fromData) never settled when the decode returned nil, and fromBase64 resolved an ImageSource wrapping nil. They now reject with a descriptive error. - iOS async encode/resize used dispatch_get_current_queue (deprecated since iOS 6) and ran UIKit drawing on a global concurrent queue. The work now runs in NativeScriptUtils and completes on the main queue. - iOS saveToFile used a non-atomic createFileAtPath, which let a reader see a half-written file. It now uses NSData writeToFile:atomically:. Android writes to a temp file and renames for the same guarantee. - Default JPEG quality was 90 on iOS and 100 on Android while the docs said "maximum". Both platforms now default to 100. - Android fromFileSync decoded at full size with the framework ExifInterface and left the rotation pending in rotationAngle. resize() then dropped that angle and saveToFile wrote the unrotated pixels, so a portrait photo loaded, resized and saved came out landscape. Decoding now goes through the androidx ExifInterface (already a widgets dependency), supports inSampleSize bounded decodes, and bakes the full EXIF orientation (including mirrored variants) into the pixels. Every pixel operation and encoder now works on the upright bitmap. - Android resize used createScaledBitmap with filtering off, giving nearest-neighbour aliasing on photos. Filtering now defaults to true and scaling draws through a Canvas with FILTER_BITMAP. - Android toBase64StringAsync read the ByteArrayOutputStream before the Base64OutputStream was closed, truncating the tail of the string. Fixed. - Android "async" loaders decoded synchronously inside the Promise executor. They now run on a worker thread in ImageUtils. - Android loadImageAsync leaked its ParcelFileDescriptor on some failure paths. It now closes in finally. - Utils.snapshotView (iOS) used the same deprecated UIGraphics context; it now renders through UIGraphicsImageRenderer. ## API Full table with iOS/Android implementation columns: packages/core/image-source/Readme.md All sizes are pixels. Every method that returns an ImageSource returns a NEW instance and never mutates its input. EXIF orientation is baked into pixels before any geometry work, so callers never reason about orientation flags after loading. Every method has an automated test in apps/automated/src/image-source that runs the same assertion on iOS and Android. ### Loading (static) - fromFileSync(path, { maxSize? }) / fromFile(path, { maxSize? }) - iOS: Without maxSize: UIImage.imageWithContentsOfFile / TNSWidgets background decode. With maxSize: CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize + CreateThumbnailWithTransform - Android: ImageUtils.decodeFile: BitmapFactory bounds pass + inSampleSize, then androidx ExifInterface orientation baked in (was: full decode + framework ExifInterface, rotation left pending) - Does: Loads an image from disk. With maxSize it shrinks while decoding so the result is never bigger than that many pixels on its long edge. The async form rejects instead of hanging when the file cannot be decoded. Returns a new ImageSource. - Use: Load each photo in a list at 200 px to fill a thumbnail grid - fromDataSync(data, { maxSize? }) / fromData(data, { maxSize? }) - iOS: NSData from ArrayBuffer/typed array, then UIImage.imageWithData or ImageIO thumbnail - Android: ImageUtils.decodeBuffer from java.nio.ByteBuffer with EXIF orientation read from the bytes and baked in, or legacy BitmapFactory.decodeStream for an InputStream - Does: Turns raw image bytes into an image. Accepts an ArrayBuffer or typed array on both platforms as well as the legacy native types. Returns a new ImageSource. - Use: Show an image received from a fetch response or a plugin - fromBase64Sync(source, { maxSize? }) / fromBase64(source, { maxSize? }) - iOS: Base64 → NSData → same path as fromData - Android: ImageUtils.decodeBase64 on a worker thread (was: synchronous inside the Promise) - Does: Decodes a base64 string into an image. Rejects on invalid input. Returns a new ImageSource. - Use: Display an inline image from an API payload - fromResourceSync(name) / fromResource(name) - iOS: UIImage.imageNamed via TNSWidgets - Android: Resources.getDrawable - Does: Loads a bundled app resource by name. The async form now rejects when the resource is missing. - Use: Load an icon from App_Resources - fromFileOrResourceSync(path) - iOS: Routes to file, res:// or sys:// loaders - Android: Routes to file or res:// loaders - Does: Loads from whichever source the prefix names. - Use: Resolve a src string the way the Image view does - fromSystemImageSync(name) / fromSystemImage(name) - iOS: UIImage.systemImageNamed (SF Symbols) - Android: Falls back to fromResource - Does: Loads a platform system icon. - Use: Show an SF Symbol on iOS - fromUrl(url) - iOS: http module then UIImage.imageWithData - Android: http module then BitmapFactory - Does: Downloads and decodes a remote image. - Use: Fetch a profile picture - fromAsset(asset) - iOS: PHImageManager request via ImageAsset - Android: Utils.loadImageAsync via ImageAsset - Does: Decodes a picker ImageAsset at the asset's requested size. - Use: Load a photo the user picked - fromFontIconCodeSync(code, font, color) - iOS: NSAttributedString drawn through UIGraphicsImageRenderer (was: deprecated UIGraphicsBeginImageContext) - Android: Canvas.drawText - Does: Renders a font glyph into an image. - Use: Use an icon font glyph as a tab icon - getMetadataSync(path) / getMetadata(path) - iOS: CGImageSourceCopyPropertiesAtIndex - Android: androidx ExifInterface + BitmapFactory bounds pass - Does: Reads the information stored inside an image file, such as its pixel size, which way the camera was held, when it was taken and where, without decoding the picture itself. Returns { width, height, orientation, mimeType, hasAlpha, colorSpace, dpi, dateTaken, gps }. - Use: Sort photos by date taken, or skip ones smaller than 1000 px - fromView(view, scale?) - iOS: UIGraphicsImageRenderer + drawViewHierarchyInRect - Android: Utils.getBitmapFromView (View.draw(Canvas)) - Does: Takes a picture of any NativeScript view exactly as it is drawn on screen. Returns a new ImageSource. - Use: Share a screenshot of a trip summary card ### Properties - width / height - iOS: UIImage.size (points) - Android: Bitmap.getWidth/getHeight (pixels) - Legacy size. Use getPixelSize() when you need pixels on both platforms. - rotationAngle - iOS: Always NaN - Android: Pending rotation for bitmaps loaded before orientation was baked in - Kept for compatibility. New loaders bake orientation in, so this is 0. - ios / android - iOS: UIImage - Android: android.graphics.Bitmap - The native image. ### Saving and encoding - saveToFile(path, format, quality?) / saveToFileAsync - iOS: NSData.writeToFile(atomically:) (was: non-atomic createFileAtPath) - Android: ImageUtils.saveToFile: temp file + rename (was: direct stream) - Does: Writes the image to disk safely, so the file is either complete or not there. Default quality is now 100 on both platforms (was 90 on iOS). Encoding from pixels never carries EXIF, so no camera or GPS metadata is written. Returns true on success. - Use: Persist a picked photo to the app's documents folder - toBase64String(format, quality?) / toBase64StringAsync - iOS: NSData.base64EncodedStringWithOptions - Android: Base64.encodeToString (async: closes the Base64 stream before reading, fixing truncated output) - Does: Encodes the image and returns it as a base64 string. - Use: Embed an image in a JSON payload - toData(format, quality?) / toDataAsync - iOS: UIImageJPEGRepresentation / UIImagePNGRepresentation → NSData exposed as ArrayBuffer - Android: Bitmap.compress → direct ByteBuffer exposed as ArrayBuffer - Does: Encodes the image as JPEG or PNG and hands you the bytes in memory instead of writing a file. Returns an ArrayBuffer. - Use: Put the bytes straight into an HTTP upload body - compressToFit(maxBytes, format?) / compressToFitAsync - iOS: Binary search over JPEG quality in NativeScriptUtils - Android: Binary search over JPEG quality in ImageUtils - Does: Keeps re-encoding the image at lower quality until it fits under a byte limit you give it. Returns { data, quality }, or null when even the lowest quality is over budget (resize first). - Use: Shrink a photo under 500 KB before uploading to an API with a size limit ### Geometry - getPixelSize() - iOS: UIImage.size * scale - Android: Bitmap.getWidth/getHeight (swapped when a rotation is pending) - Does: Tells you the true size of the image in pixels, on both platforms. Returns { width, height }. - Use: Warn the user that a chosen cover image is too small - resize(maxSize, options?) / resizeAsync - iOS: UIGraphicsImageRenderer, scale 1, preferredRange = .standard (was: deprecated UIGraphicsBeginImageContext at the image's scale, which returned nil for wide-gamut photos and gave 2x/3x pixels) - Android: Canvas.drawBitmap with Paint(FILTER_BITMAP); filter now defaults to true (was: unfiltered createScaledBitmap, pending rotation dropped) - Does: Scales the image so its longest edge is at most maxSize pixels, keeping the aspect ratio. Never upscales. Returns a new ImageSource. - Use: Make a 1080 px copy for storage - resizeTo(width, height, { mode?, background? }) - iOS: Renderer with fit/fill/stretch rect maths - Android: Canvas.drawBitmap with fit/fill/stretch rect maths - Does: Makes an exact width and height: fit letterboxes (padding with background, transparent by default), fill covers and centre-crops, stretch ignores aspect. Returns a new ImageSource. - Use: A 400×300 card cover with fill - normalizeOrientation() - iOS: Redraw through the renderer when imageOrientation != Up - Android: Bitmap.createBitmap with rotation Matrix from rotationAngle - Does: Rotates the pixels so the picture is upright according to its EXIF orientation, then clears the flag. Returns a new ImageSource. - Use: Run before crop or saveToFile so portrait shots don't come out sideways - crop(x, y, width, height) - iOS: Renderer draw with negative offset - Android: Bitmap.createBitmap(src, x, y, w, h) - Does: Keeps only the rectangle you specify. Throws when the rectangle is outside the image. Returns a new ImageSource. - Use: Cut the user's selection out of a photo - rotate(degrees) - iOS: Renderer with CGContextRotateCTM - Android: Bitmap.createBitmap with Matrix.setRotate - Does: Turns the image clockwise by the given degrees. Returns a new ImageSource. - Use: Rotate-left button in an editor - flip(axis) - iOS: Renderer with CGContextScaleCTM(-1, 1) - Android: Bitmap.createBitmap with Matrix.setScale(-1, 1) - Does: Mirrors the image left-right, top-bottom, or both. Returns a new ImageSource. - Use: Un-mirror a selfie - transform({ crop, rotate, flip, resize }) / transformAsync - iOS: One native pass, fixed order normalize → crop → rotate → flip → resize - Android: Same order in ImageUtils.transform - Does: Runs several edits in one native call, in a fixed order, without handing intermediate results back to JavaScript. Returns one new ImageSource. - Use: Apply the user's crop, rotation and output size in one pass when they tap Done ### Compositing - roundCorners(radius) / circleCrop() - iOS: UIBezierPath clip inside the renderer - Android: Canvas.drawRoundRect / drawCircle with a BitmapShader - Does: Makes the corners transparent with the radius you give, or masks the whole image to a circle. Returns a new ImageSource with alpha. - Use: Render a round avatar - overlay(other, { x?, y?, opacity? }) - iOS: drawInRect:blendMode:alpha: inside the renderer - Android: Canvas.drawBitmap with Paint.setAlpha - Does: Draws another image on top of this one at a position and opacity you choose. Returns a new combined ImageSource. - Use: Stamp a logo or watermark on a photo before sharing - drawText(text, { x, y, font?, fontSize?, color? }) - iOS: NSString.drawAtPoint:withAttributes: inside the renderer - Android: Canvas.drawText with a Paint from Font.getAndroidTypeface() - Does: Draws a string onto the image with its top-left corner at (x, y). Returns a new ImageSource with the text baked in. - Use: Burn a date or trip name into a photo - tint(color) - iOS: UIRectFillUsingBlendMode(SourceIn) - Android: PorterDuffColorFilter(SRC_IN) - Does: Recolours every visible pixel to one colour while keeping transparency, the way template icons work. Returns a new ImageSource. - Use: Recolour a monochrome icon to the current theme ### Filters and analysis - applyFilters([...]) / applyFiltersAsync — grayscale, sepia, invert, brightness, contrast, saturation - iOS: CIColorControls, CISepiaTone, CIColorInvert rendered via CIContext to CGImage - Android: One ColorMatrixColorFilter built from the filter list - Does: Applies colour adjustments in the order given: black-and-white, sepia (amount 0..1), invert, brightness (-1..1), contrast (0..2, 1 = unchanged), saturation (0..2, 1 = unchanged). Returns a new ImageSource. - Use: Offer black-and-white, sepia, or brightness sliders in an editor - applyFilters([{ type: 'blur', radius }]) - iOS: CIGaussianBlur with edge clamping - Android: ImageUtils.stackBlur (CPU stack blur, a close gaussian approximation that gives identical output on every API level) - Does: Softens the whole image with a blur of the radius you give. Returns a new ImageSource. - Use: Soft blurred background behind a card, or hide a licence plate - averageColor() - iOS: 32 px downscale, pixels sampled in ObjC - Android: 32 px downscale, pixels sampled in Java - Does: Works out the single average colour of the image, ignoring transparent pixels. Returns a Color, or null when nothing is visible. - Use: Pick a background colour that matches the photo - dominantColors(count?) - iOS: Same sample, 4-bit-per-channel buckets - Android: Same sample, 4-bit-per-channel buckets - Does: Finds the handful of colours that appear most, most common first. Returns Color[]. - Use: Build a palette from a photo - perceptualHash() - iOS: 9×8 grayscale difference hash in ObjC - Android: Same algorithm in Java - Does: Computes a short fingerprint of what the picture looks like, so resized copies score nearly the same. Returns a 16-character hex string. - Use: Store a fingerprint alongside each saved photo - isSimilarTo(other, threshold?) - iOS: Hamming distance of the two hashes - Android: Same - Does: True when the two images' fingerprints differ in at most threshold bits (default 10). - Use: Skip a photo the user already added, even if it was resized ### Deprecated instance loaders loadFromFile, loadFromResource, loadFromData, loadFromBase64, loadFromFontIconCode and the instance forms of fromFile, fromResource, fromData, fromBase64, fromAsset remain for compatibility and forward to the static loaders above. ### Explicitly out of scope for core Face / text detection, QR encode / decode (Android needs ML Kit or ZXing), and HEIC / AVIF output (Android has no CompressFormat for them; androidx.heifwriter is an extra dependency) belong in plugins.
1 parent 04d4a92 commit 0a2849d

17 files changed

Lines changed: 4427 additions & 271 deletions

File tree

apps/automated/src/image-source/image-source-tests.ts

Lines changed: 413 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,79 @@
11
Contains the ImageSource class, which encapsulates the common abstraction behind a platform specific object (typically a Bitmap) that is used as a source for images.
2+
3+
## API reference
4+
5+
All sizes are pixels. Every method that returns an `ImageSource` returns a NEW instance and never mutates its input. EXIF orientation is baked into pixels before any geometry work, so callers never reason about orientation flags after loading. Every method has an automated test in `apps/automated/src/image-source` that runs the same assertion on iOS and Android.
6+
7+
### Loading (static)
8+
9+
| Method | iOS implementation | Android implementation | What it does | Example use |
10+
| --- | --- | --- | --- | --- |
11+
| `fromFileSync(path, { maxSize? })` / `fromFile(path, { maxSize? })` | Without `maxSize`: `UIImage.imageWithContentsOfFile` / TNSWidgets background decode. With `maxSize`: `CGImageSourceCreateThumbnailAtIndex` with `kCGImageSourceThumbnailMaxPixelSize` + `CreateThumbnailWithTransform` | `ImageUtils.decodeFile`: `BitmapFactory` bounds pass + `inSampleSize`, then androidx `ExifInterface` orientation baked in (was: full decode + framework `ExifInterface`, rotation left pending) | Loads an image from disk. With `maxSize` it shrinks while decoding so the result is never bigger than that many pixels on its long edge. The async form rejects instead of hanging when the file cannot be decoded. Returns a new `ImageSource`. | Load each photo in a list at 200 px to fill a thumbnail grid |
12+
| `fromDataSync(data, { maxSize? })` / `fromData(data, { maxSize? })` | `NSData` from `ArrayBuffer`/typed array, then `UIImage.imageWithData` or ImageIO thumbnail | `ImageUtils.decodeBuffer` from `java.nio.ByteBuffer` with EXIF orientation read from the bytes and baked in, or legacy `BitmapFactory.decodeStream` for an `InputStream` | Turns raw image bytes into an image. Accepts an `ArrayBuffer` or typed array on both platforms as well as the legacy native types. Returns a new `ImageSource`. | Show an image received from a fetch response or a plugin |
13+
| `fromBase64Sync(source, { maxSize? })` / `fromBase64(source, { maxSize? })` | Base64 → `NSData` → same path as `fromData` | `ImageUtils.decodeBase64` on a worker thread (was: synchronous inside the Promise) | Decodes a base64 string into an image. Rejects on invalid input. Returns a new `ImageSource`. | Display an inline image from an API payload |
14+
| `fromResourceSync(name)` / `fromResource(name)` | `UIImage.imageNamed` via TNSWidgets | `Resources.getDrawable` | Loads a bundled app resource by name. The async form now rejects when the resource is missing. | Load an icon from App_Resources |
15+
| `fromFileOrResourceSync(path)` | Routes to file, `res://` or `sys://` loaders | Routes to file or `res://` loaders | Loads from whichever source the prefix names. | Resolve a `src` string the way `<Image>` does |
16+
| `fromSystemImageSync(name)` / `fromSystemImage(name)` | `UIImage.systemImageNamed` (SF Symbols) | Falls back to `fromResource` | Loads a platform system icon. | Show an SF Symbol on iOS |
17+
| `fromUrl(url)` | `http` module then `UIImage.imageWithData` | `http` module then `BitmapFactory` | Downloads and decodes a remote image. | Fetch a profile picture |
18+
| `fromAsset(asset)` | `PHImageManager` request via `ImageAsset` | `Utils.loadImageAsync` via `ImageAsset` | Decodes a picker `ImageAsset` at the asset's requested size. | Load a photo the user picked |
19+
| `fromFontIconCodeSync(code, font, color)` | `NSAttributedString` drawn through `UIGraphicsImageRenderer` (was: deprecated `UIGraphicsBeginImageContext`) | `Canvas.drawText` | Renders a font glyph into an image. | Use an icon font glyph as a tab icon |
20+
| `getMetadataSync(path)` / `getMetadata(path)` | `CGImageSourceCopyPropertiesAtIndex` | androidx `ExifInterface` + `BitmapFactory` bounds pass | Reads the information stored inside an image file, such as its pixel size, which way the camera was held, when it was taken and where, without decoding the picture itself. Returns `{ width, height, orientation, mimeType, hasAlpha, colorSpace, dpi, dateTaken, gps }`. | Sort photos by date taken, or skip ones smaller than 1000 px |
21+
| `fromView(view, scale?)` | `UIGraphicsImageRenderer` + `drawViewHierarchyInRect` | `Utils.getBitmapFromView` (`View.draw(Canvas)`) | Takes a picture of any NativeScript view exactly as it is drawn on screen. Returns a new `ImageSource`. | Share a screenshot of a trip summary card |
22+
23+
### Properties
24+
25+
| Property | iOS | Android | What it is |
26+
| --- | --- | --- | --- |
27+
| `width` / `height` | `UIImage.size` (points) | `Bitmap.getWidth/getHeight` (pixels) | Legacy size. Use `getPixelSize()` when you need pixels on both platforms. |
28+
| `rotationAngle` | Always `NaN` | Pending rotation for bitmaps loaded before orientation was baked in | Kept for compatibility. New loaders bake orientation in, so this is 0. |
29+
| `ios` / `android` | `UIImage` | `android.graphics.Bitmap` | The native image. |
30+
31+
### Saving and encoding
32+
33+
| Method | iOS implementation | Android implementation | What it does | Example use |
34+
| --- | --- | --- | --- | --- |
35+
| `saveToFile(path, format, quality?)` / `saveToFileAsync` | `NSData.writeToFile(atomically:)` (was: non-atomic `createFileAtPath`) | `ImageUtils.saveToFile`: temp file + rename (was: direct stream) | Writes the image to disk safely, so the file is either complete or not there. Default quality is now 100 on both platforms (was 90 on iOS). Encoding from pixels never carries EXIF, so no camera or GPS metadata is written. Returns `true` on success. | Persist a picked photo to the app's documents folder |
36+
| `toBase64String(format, quality?)` / `toBase64StringAsync` | `NSData.base64EncodedStringWithOptions` | `Base64.encodeToString` (async: closes the Base64 stream before reading, fixing truncated output) | Encodes the image and returns it as a base64 string. | Embed an image in a JSON payload |
37+
| `toData(format, quality?)` / `toDataAsync` | `UIImageJPEGRepresentation` / `UIImagePNGRepresentation``NSData` exposed as `ArrayBuffer` | `Bitmap.compress` → direct `ByteBuffer` exposed as `ArrayBuffer` | Encodes the image as JPEG or PNG and hands you the bytes in memory instead of writing a file. Returns an `ArrayBuffer`. | Put the bytes straight into an HTTP upload body |
38+
| `compressToFit(maxBytes, format?)` / `compressToFitAsync` | Binary search over JPEG quality in `NativeScriptUtils` | Binary search over JPEG quality in `ImageUtils` | Keeps re-encoding the image at lower quality until it fits under a byte limit you give it. Returns `{ data, quality }`, or null when even the lowest quality is over budget (resize first). | Shrink a photo under 500 KB before uploading to an API with a size limit |
39+
40+
### Geometry
41+
42+
| Method | iOS implementation | Android implementation | What it does | Example use |
43+
| --- | --- | --- | --- | --- |
44+
| `getPixelSize()` | `UIImage.size * scale` | `Bitmap.getWidth/getHeight` (swapped when a rotation is pending) | Tells you the true size of the image in pixels, on both platforms. Returns `{ width, height }`. | Warn the user that a chosen cover image is too small |
45+
| `resize(maxSize, options?)` / `resizeAsync` | `UIGraphicsImageRenderer`, scale 1, `preferredRange = .standard` (was: deprecated `UIGraphicsBeginImageContext` at the image's scale, which returned nil for wide-gamut photos and gave 2x/3x pixels) | `Canvas.drawBitmap` with `Paint(FILTER_BITMAP)`; filter now defaults to true (was: unfiltered `createScaledBitmap`, pending rotation dropped) | Scales the image so its longest edge is at most `maxSize` pixels, keeping the aspect ratio. Never upscales. Returns a new `ImageSource`. | Make a 1080 px copy for storage |
46+
| `resizeTo(width, height, { mode?, background? })` | Renderer with fit/fill/stretch rect maths | `Canvas.drawBitmap` with fit/fill/stretch rect maths | Makes an exact width and height: `fit` letterboxes (padding with `background`, transparent by default), `fill` covers and centre-crops, `stretch` ignores aspect. Returns a new `ImageSource`. | A 400×300 card cover with `fill` |
47+
| `normalizeOrientation()` | Redraw through the renderer when `imageOrientation != Up` | `Bitmap.createBitmap` with rotation `Matrix` from `rotationAngle` | Rotates the pixels so the picture is upright according to its EXIF orientation, then clears the flag. Returns a new `ImageSource`. | Run before `crop` or `saveToFile` so portrait shots don't come out sideways |
48+
| `crop(x, y, width, height)` | Renderer draw with negative offset | `Bitmap.createBitmap(src, x, y, w, h)` | Keeps only the rectangle you specify. Throws when the rectangle is outside the image. Returns a new `ImageSource`. | Cut the user's selection out of a photo |
49+
| `rotate(degrees)` | Renderer with `CGContextRotateCTM` | `Bitmap.createBitmap` with `Matrix.setRotate` | Turns the image clockwise by the given degrees. Returns a new `ImageSource`. | Rotate-left button in an editor |
50+
| `flip(axis)` | Renderer with `CGContextScaleCTM(-1, 1)` | `Bitmap.createBitmap` with `Matrix.setScale(-1, 1)` | Mirrors the image left-right, top-bottom, or both. Returns a new `ImageSource`. | Un-mirror a selfie |
51+
| `transform({ crop, rotate, flip, resize })` / `transformAsync` | One native pass, fixed order normalize → crop → rotate → flip → resize | Same order in `ImageUtils.transform` | Runs several edits in one native call, in a fixed order, without handing intermediate results back to JavaScript. Returns one new `ImageSource`. | Apply the user's crop, rotation and output size in one pass when they tap Done |
52+
53+
### Compositing
54+
55+
| Method | iOS implementation | Android implementation | What it does | Example use |
56+
| --- | --- | --- | --- | --- |
57+
| `roundCorners(radius)` / `circleCrop()` | `UIBezierPath` clip inside the renderer | `Canvas.drawRoundRect` / `drawCircle` with a `BitmapShader` | Makes the corners transparent with the radius you give, or masks the whole image to a circle. Returns a new `ImageSource` with alpha. | Render a round avatar |
58+
| `overlay(other, { x?, y?, opacity? })` | `drawInRect:blendMode:alpha:` inside the renderer | `Canvas.drawBitmap` with `Paint.setAlpha` | Draws another image on top of this one at a position and opacity you choose. Returns a new combined `ImageSource`. | Stamp a logo or watermark on a photo before sharing |
59+
| `drawText(text, { x, y, font?, fontSize?, color? })` | `NSString.drawAtPoint:withAttributes:` inside the renderer | `Canvas.drawText` with a `Paint` from `Font.getAndroidTypeface()` | Draws a string onto the image with its top-left corner at (x, y). Returns a new `ImageSource` with the text baked in. | Burn a date or trip name into a photo |
60+
| `tint(color)` | `UIRectFillUsingBlendMode(SourceIn)` | `PorterDuffColorFilter(SRC_IN)` | Recolours every visible pixel to one colour while keeping transparency, the way template icons work. Returns a new `ImageSource`. | Recolour a monochrome icon to the current theme |
61+
62+
### Filters and analysis
63+
64+
| Method | iOS implementation | Android implementation | What it does | Example use |
65+
| --- | --- | --- | --- | --- |
66+
| `applyFilters([...])` / `applyFiltersAsync` — grayscale, sepia, invert, brightness, contrast, saturation | `CIColorControls`, `CISepiaTone`, `CIColorInvert` rendered via `CIContext` to `CGImage` | One `ColorMatrixColorFilter` built from the filter list | Applies colour adjustments in the order given: black-and-white, sepia (amount 0..1), invert, brightness (-1..1), contrast (0..2, 1 = unchanged), saturation (0..2, 1 = unchanged). Returns a new `ImageSource`. | Offer black-and-white, sepia, or brightness sliders in an editor |
67+
| `applyFilters([{ type: 'blur', radius }])` | `CIGaussianBlur` with edge clamping | `ImageUtils.stackBlur` (CPU stack blur, a close gaussian approximation that gives identical output on every API level) | Softens the whole image with a blur of the radius you give. Returns a new `ImageSource`. | Soft blurred background behind a card, or hide a licence plate |
68+
| `averageColor()` | 32 px downscale, pixels sampled in ObjC | 32 px downscale, pixels sampled in Java | Works out the single average colour of the image, ignoring transparent pixels. Returns a `Color`, or null when nothing is visible. | Pick a background colour that matches the photo |
69+
| `dominantColors(count?)` | Same sample, 4-bit-per-channel buckets | Same sample, 4-bit-per-channel buckets | Finds the handful of colours that appear most, most common first. Returns `Color[]`. | Build a palette from a photo |
70+
| `perceptualHash()` | 9×8 grayscale difference hash in ObjC | Same algorithm in Java | Computes a short fingerprint of what the picture looks like, so resized copies score nearly the same. Returns a 16-character hex string. | Store a fingerprint alongside each saved photo |
71+
| `isSimilarTo(other, threshold?)` | Hamming distance of the two hashes | Same | True when the two images' fingerprints differ in at most `threshold` bits (default 10). | Skip a photo the user already added, even if it was resized |
72+
73+
### Deprecated instance loaders
74+
75+
`loadFromFile`, `loadFromResource`, `loadFromData`, `loadFromBase64`, `loadFromFontIconCode` and the instance forms of `fromFile`, `fromResource`, `fromData`, `fromBase64`, `fromAsset` remain for compatibility and forward to the static loaders above.
76+
77+
### Explicitly out of scope for core
78+
79+
Face / text detection, QR encode / decode (Android needs ML Kit or ZXing), and HEIC / AVIF output (Android has no `CompressFormat` for them; `androidx.heifwriter` is an extra dependency) belong in plugins.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { assertPositiveInteger, getScaledDimensions, hammingDistance, normalizeFilters, normalizeFormat, normalizeQuality, normalizeTransformOptions, toImageMetadata } from './image-source-common';
3+
4+
describe('image-source-common', () => {
5+
describe('getScaledDimensions', () => {
6+
it('keeps images that already fit', () => {
7+
expect(getScaledDimensions(100, 50, 200)).toEqual({ width: 100, height: 50 });
8+
});
9+
10+
it('bounds the longest edge and keeps the aspect ratio', () => {
11+
expect(getScaledDimensions(400, 200, 100)).toEqual({ width: 100, height: 50 });
12+
expect(getScaledDimensions(200, 400, 100)).toEqual({ width: 50, height: 100 });
13+
});
14+
});
15+
16+
describe('normalizeQuality', () => {
17+
it('defaults to maximum quality', () => {
18+
expect(normalizeQuality(undefined)).toBe(100);
19+
expect(normalizeQuality(NaN)).toBe(100);
20+
});
21+
22+
it('keeps zero and clamps out-of-range values', () => {
23+
expect(normalizeQuality(0)).toBe(0);
24+
expect(normalizeQuality(150)).toBe(100);
25+
expect(normalizeQuality(-5)).toBe(0);
26+
expect(normalizeQuality(72.6)).toBe(73);
27+
});
28+
});
29+
30+
describe('normalizeFormat', () => {
31+
it('treats jpg and jpeg the same and everything else as png only when asked', () => {
32+
expect(normalizeFormat('jpg')).toBe('jpeg');
33+
expect(normalizeFormat('JPEG')).toBe('jpeg');
34+
expect(normalizeFormat('png')).toBe('png');
35+
expect(normalizeFormat('PNG')).toBe('png');
36+
});
37+
});
38+
39+
describe('assertPositiveInteger', () => {
40+
it('rounds valid values and rejects the rest', () => {
41+
expect(assertPositiveInteger(10.4, 'x')).toBe(10);
42+
expect(() => assertPositiveInteger(0, 'maxSize')).toThrow(/maxSize/);
43+
expect(() => assertPositiveInteger(-1, 'x')).toThrow();
44+
expect(() => assertPositiveInteger(NaN, 'x')).toThrow();
45+
});
46+
});
47+
48+
describe('normalizeFilters', () => {
49+
it('passes known filters through with only their numeric fields', () => {
50+
expect(normalizeFilters([{ type: 'grayscale' }, { type: 'brightness', amount: 0.5 }, { type: 'blur', radius: -3 }])).toEqual([{ type: 'grayscale' }, { type: 'brightness', amount: 0.5 }, { type: 'blur', radius: 0 }]);
51+
});
52+
53+
it('rejects unknown filters and non-arrays', () => {
54+
expect(() => normalizeFilters([{ type: 'posterize' } as any])).toThrow(/posterize/);
55+
expect(() => normalizeFilters(null as any)).toThrow();
56+
});
57+
});
58+
59+
describe('normalizeTransformOptions', () => {
60+
it('rounds the crop rect and defaults the resize mode', () => {
61+
expect(normalizeTransformOptions({ crop: { x: 1.4, y: 2.6, width: 10.2, height: 20.7 }, rotate: 90, flip: 'both', resize: { width: 30, height: 40 } })).toEqual({
62+
crop: { x: 1, y: 3, width: 10, height: 21 },
63+
rotate: 90,
64+
flip: 'both',
65+
resize: { width: 30, height: 40, mode: 'fit' },
66+
});
67+
});
68+
69+
it('supports the maxSize resize form and validates sizes', () => {
70+
expect(normalizeTransformOptions({ resize: { maxSize: 100 } })).toEqual({ resize: { maxSize: 100 } });
71+
expect(() => normalizeTransformOptions({ crop: { x: 0, y: 0, width: 0, height: 10 } })).toThrow(/crop.width/);
72+
expect(() => normalizeTransformOptions(null)).toThrow();
73+
});
74+
});
75+
76+
describe('toImageMetadata', () => {
77+
it('converts the native shape and drops missing optionals', () => {
78+
const metadata = toImageMetadata({ width: '10', height: 20, orientation: 6, hasAlpha: 0, dateTaken: 1700000000000, gps: { latitude: 1.5, longitude: -2.5 }, dpi: 72 });
79+
expect(metadata).toEqual({
80+
width: 10,
81+
height: 20,
82+
orientation: 6,
83+
hasAlpha: false,
84+
dpi: 72,
85+
dateTaken: new Date(1700000000000),
86+
gps: { latitude: 1.5, longitude: -2.5 },
87+
});
88+
expect(toImageMetadata(null)).toBeNull();
89+
expect(toImageMetadata({ width: 1, height: 1 }).orientation).toBe(1);
90+
});
91+
});
92+
93+
describe('hammingDistance', () => {
94+
it('counts differing bits between two hex hashes', () => {
95+
expect(hammingDistance('0000000000000000', '0000000000000000')).toBe(0);
96+
expect(hammingDistance('0000000000000000', 'ffffffffffffffff')).toBe(64);
97+
expect(hammingDistance('000000000000000f', '0000000000000001')).toBe(3);
98+
});
99+
100+
it('returns -1 for invalid input', () => {
101+
expect(hammingDistance('abc', '0000000000000000')).toBe(-1);
102+
expect(hammingDistance('zzzzzzzzzzzzzzzz', '0000000000000000')).toBe(-1);
103+
expect(hammingDistance(null, undefined)).toBe(-1);
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)