react-native-nitro-image-pipeline 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +142 -18
  2. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +72 -16
  3. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +47 -8
  4. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/FitGeometry.kt +104 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/PipelineRoundedCornersTransformation.kt +103 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/ResizeTransformation.kt +69 -16
  7. package/ios/FitResizeProcessor.swift +127 -0
  8. package/ios/HybridNitroImagePipeline.swift +88 -24
  9. package/ios/PipelineImageLoader.swift +108 -18
  10. package/lib/commonjs/NativePipelineImage.js +20 -4
  11. package/lib/commonjs/NativePipelineImage.js.map +1 -1
  12. package/lib/commonjs/PipelineImage.js +13 -1
  13. package/lib/commonjs/PipelineImage.js.map +1 -1
  14. package/lib/commonjs/index.js +13 -0
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/resizeForStyle.js +3 -1
  17. package/lib/commonjs/resizeForStyle.js.map +1 -1
  18. package/lib/commonjs/resolveImageSource.js +89 -0
  19. package/lib/commonjs/resolveImageSource.js.map +1 -0
  20. package/lib/commonjs/useImage.js +14 -6
  21. package/lib/commonjs/useImage.js.map +1 -1
  22. package/lib/commonjs/usePipelineImageLoader.js +114 -11
  23. package/lib/commonjs/usePipelineImageLoader.js.map +1 -1
  24. package/lib/module/NativePipelineImage.js +20 -4
  25. package/lib/module/NativePipelineImage.js.map +1 -1
  26. package/lib/module/PipelineImage.js +13 -1
  27. package/lib/module/PipelineImage.js.map +1 -1
  28. package/lib/module/index.js +1 -0
  29. package/lib/module/index.js.map +1 -1
  30. package/lib/module/resizeForStyle.js +3 -1
  31. package/lib/module/resizeForStyle.js.map +1 -1
  32. package/lib/module/resolveImageSource.js +83 -0
  33. package/lib/module/resolveImageSource.js.map +1 -0
  34. package/lib/module/useImage.js +14 -6
  35. package/lib/module/useImage.js.map +1 -1
  36. package/lib/module/usePipelineImageLoader.js +115 -12
  37. package/lib/module/usePipelineImageLoader.js.map +1 -1
  38. package/lib/typescript/src/NativePipelineImage.d.ts +51 -5
  39. package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -1
  40. package/lib/typescript/src/PipelineImage.d.ts +35 -3
  41. package/lib/typescript/src/PipelineImage.d.ts.map +1 -1
  42. package/lib/typescript/src/index.d.ts +2 -1
  43. package/lib/typescript/src/index.d.ts.map +1 -1
  44. package/lib/typescript/src/resizeForStyle.d.ts +3 -1
  45. package/lib/typescript/src/resizeForStyle.d.ts.map +1 -1
  46. package/lib/typescript/src/resolveImageSource.d.ts +56 -0
  47. package/lib/typescript/src/resolveImageSource.d.ts.map +1 -0
  48. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +95 -8
  49. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  50. package/lib/typescript/src/useImage.d.ts +13 -6
  51. package/lib/typescript/src/useImage.d.ts.map +1 -1
  52. package/lib/typescript/src/usePipelineImageLoader.d.ts +7 -3
  53. package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -1
  54. package/nitrogen/generated/android/NitroImagePipelineOnLoad.cpp +4 -0
  55. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  56. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  57. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +8 -0
  58. package/nitrogen/generated/android/c++/JOptions.hpp +2 -0
  59. package/nitrogen/generated/android/c++/JResizeFit.hpp +64 -0
  60. package/nitrogen/generated/android/c++/JResizeOptions.hpp +14 -4
  61. package/nitrogen/generated/android/c++/JViewOptions.hpp +42 -3
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_double_double.kt +78 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_std__string.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeFit.kt +25 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeOptions.kt +14 -4
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +29 -5
  67. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +16 -0
  68. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +107 -0
  69. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +4 -0
  70. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +4 -0
  71. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  72. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  73. package/nitrogen/generated/ios/swift/ResizeFit.swift +48 -0
  74. package/nitrogen/generated/ios/swift/ResizeOptions.swift +31 -2
  75. package/nitrogen/generated/ios/swift/ViewOptions.swift +82 -1
  76. package/nitrogen/generated/shared/c++/ResizeFit.hpp +84 -0
  77. package/nitrogen/generated/shared/c++/ResizeOptions.hpp +14 -4
  78. package/nitrogen/generated/shared/c++/ViewOptions.hpp +24 -3
  79. package/package.json +1 -1
  80. package/src/NativePipelineImage.tsx +75 -6
  81. package/src/PipelineImage.tsx +46 -3
  82. package/src/index.ts +6 -0
  83. package/src/resizeForStyle.ts +3 -1
  84. package/src/resolveImageSource.ts +83 -0
  85. package/src/specs/nitro-image-toolkit.nitro.ts +96 -8
  86. package/src/useImage.ts +38 -17
  87. package/src/usePipelineImageLoader.ts +126 -11
@@ -0,0 +1,103 @@
1
+ package com.margelo.nitro.nitroimagepipeline.transform
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapShader
5
+ import android.graphics.Canvas
6
+ import android.graphics.Paint
7
+ import android.graphics.Path
8
+ import android.graphics.RectF
9
+ import android.graphics.Shader
10
+ import coil3.size.Size
11
+ import coil3.transform.Transformation
12
+ import kotlin.math.min
13
+
14
+ /**
15
+ * Rounds the corners of the input **at its own size**, in pixels of that bitmap — a port of
16
+ * `ios/RoundedCornersProcessor.swift`, including its CSS-style clamping of radii that overlap.
17
+ *
18
+ * Coil's own `RoundedCornersTransformation` scale-fills and crops its input to the *request* size
19
+ * as part of its draw. That is right for the default resize (the request size is the output size)
20
+ * and is why [ResizeTransformation] is skipped for "rounded corners, no blur" there. Under any
21
+ * other fit the request size is only the box — a `contain` or `center` bitmap is smaller — so
22
+ * Coil's transformation would scale the result back up to the box. This one leaves the size alone.
23
+ */
24
+ class PipelineRoundedCornersTransformation(
25
+ topLeft: Float,
26
+ topRight: Float,
27
+ bottomLeft: Float,
28
+ bottomRight: Float,
29
+ ) : Transformation() {
30
+ // Negative radii would make the path undefined; treat them as square.
31
+ private val topLeft = topLeft.coerceAtLeast(0f)
32
+ private val topRight = topRight.coerceAtLeast(0f)
33
+ private val bottomLeft = bottomLeft.coerceAtLeast(0f)
34
+ private val bottomRight = bottomRight.coerceAtLeast(0f)
35
+
36
+ override val cacheKey =
37
+ "${PipelineRoundedCornersTransformation::class.java.name}-$topLeft-$topRight-$bottomLeft-$bottomRight"
38
+
39
+ override suspend fun transform(input: Bitmap, size: Size): Bitmap {
40
+ val softwareInput =
41
+ if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
42
+ else input
43
+ val width = softwareInput.width
44
+ val height = softwareInput.height
45
+ if (width <= 0 || height <= 0) return softwareInput
46
+
47
+ // Clamp the way CSS `border-radius` does: if two radii on one edge
48
+ // overlap, all four scale down proportionally until they fit.
49
+ var scale = 1f
50
+ for ((edge, pair) in
51
+ listOf(
52
+ width to topLeft + topRight,
53
+ width to bottomLeft + bottomRight,
54
+ height to topLeft + bottomLeft,
55
+ height to topRight + bottomRight,
56
+ )) {
57
+ if (pair > edge) scale = min(scale, edge / pair)
58
+ }
59
+ val tl = topLeft * scale
60
+ val tr = topRight * scale
61
+ val bl = bottomLeft * scale
62
+ val br = bottomRight * scale
63
+
64
+ val path =
65
+ Path().apply {
66
+ addRoundRect(
67
+ RectF(0f, 0f, width.toFloat(), height.toFloat()),
68
+ // x/y radius pairs, clockwise from the top-left corner.
69
+ floatArrayOf(tl, tl, tr, tr, br, br, bl, bl),
70
+ Path.Direction.CW,
71
+ )
72
+ }
73
+ val paint =
74
+ Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
75
+ shader = BitmapShader(softwareInput, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
76
+ }
77
+ // Always ARGB_8888: the corners need an alpha channel whatever the input has.
78
+ val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
79
+ Canvas(output).drawPath(path, paint)
80
+ return output
81
+ }
82
+
83
+ override fun equals(other: Any?): Boolean {
84
+ if (this === other) return true
85
+ return other is PipelineRoundedCornersTransformation &&
86
+ topLeft == other.topLeft &&
87
+ topRight == other.topRight &&
88
+ bottomLeft == other.bottomLeft &&
89
+ bottomRight == other.bottomRight
90
+ }
91
+
92
+ override fun hashCode(): Int {
93
+ var result = topLeft.hashCode()
94
+ result = 31 * result + topRight.hashCode()
95
+ result = 31 * result + bottomLeft.hashCode()
96
+ result = 31 * result + bottomRight.hashCode()
97
+ return result
98
+ }
99
+
100
+ override fun toString() =
101
+ "PipelineRoundedCornersTransformation(topLeft=$topLeft, topRight=$topRight, " +
102
+ "bottomLeft=$bottomLeft, bottomRight=$bottomRight)"
103
+ }
@@ -6,53 +6,106 @@ import android.graphics.Matrix
6
6
  import android.graphics.Paint
7
7
  import coil3.size.Size
8
8
  import coil3.transform.Transformation
9
+ import com.margelo.nitro.nitroimagepipeline.ResizeFit
9
10
  import kotlin.math.max
10
11
 
11
12
  /**
12
- * Scales the input to fill exactly [width] × [height] pixels and center-crops the overflow (CSS
13
- * `object-fit: cover`), upscaling smaller sources. It runs before [BlurTransformation] and rounded
14
- * corners so their pixel units refer to the final bitmap matching Nuke's
15
- * `ImageProcessors.Resize(contentMode: .aspectFill, crop: true)` on iOS.
13
+ * Resizes the input into a [width] × [height] pixel box under [fit] (see [FitGeometry] for the size
14
+ * each mode produces). The default — [ResizeFit.COVER] with [allowUpscale] scales the input to
15
+ * fill the box exactly and center-crops the overflow (CSS `object-fit: cover`), upscaling smaller
16
+ * sources, on the very same code path (and cache key) it had before `fit` existed, matching Nuke's
17
+ * `ImageProcessors.Resize(contentMode: .aspectFill, crop: true)` on iOS. Every other fit runs
18
+ * through [FitGeometry], matching iOS's `FitResizeProcessor`.
19
+ *
20
+ * It runs before [BlurTransformation] and rounded corners so their pixel units refer to the final
21
+ * bitmap.
16
22
  */
17
23
  class ResizeTransformation(
18
24
  private val width: Int,
19
25
  private val height: Int,
26
+ private val fit: ResizeFit = ResizeFit.COVER,
27
+ private val allowUpscale: Boolean = true,
20
28
  ) : Transformation() {
21
29
 
22
30
  init {
23
31
  require(width > 0 && height > 0) { "width and height must be > 0." }
24
32
  }
25
33
 
26
- override val cacheKey = "${ResizeTransformation::class.java.name}-$width-$height"
34
+ private val isDefaultFit: Boolean
35
+ get() = fit == ResizeFit.COVER && allowUpscale
36
+
37
+ // The default keeps its original key so results cached by earlier versions stay valid.
38
+ override val cacheKey = buildString {
39
+ append(ResizeTransformation::class.java.name)
40
+ .append('-')
41
+ .append(width)
42
+ .append('-')
43
+ .append(height)
44
+ if (fit != ResizeFit.COVER) append('-').append(fit.name.lowercase())
45
+ if (!allowUpscale) append("-noupscale")
46
+ }
27
47
 
28
48
  override suspend fun transform(input: Bitmap, size: Size): Bitmap {
29
49
  val softwareInput =
30
50
  if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
31
51
  else input
32
- if (softwareInput.width == width && softwareInput.height == height) return softwareInput
52
+ return if (isDefaultFit) cover(softwareInput) else fitted(softwareInput)
53
+ }
54
+
55
+ private fun cover(input: Bitmap): Bitmap {
56
+ if (input.width == width && input.height == height) return input
33
57
 
34
- val scale = max(width.toFloat() / softwareInput.width, height.toFloat() / softwareInput.height)
58
+ val scale = max(width.toFloat() / input.width, height.toFloat() / input.height)
35
59
  val matrix =
36
60
  Matrix().apply {
37
61
  setScale(scale, scale)
38
- postTranslate(
39
- (width - softwareInput.width * scale) / 2f,
40
- (height - softwareInput.height * scale) / 2f,
62
+ postTranslate((width - input.width * scale) / 2f, (height - input.height * scale) / 2f)
63
+ }
64
+ return draw(input, width, height, matrix)
65
+ }
66
+
67
+ private fun fitted(input: Bitmap): Bitmap {
68
+ val geometry = FitGeometry.of(input.width, input.height, width, height, fit, allowUpscale)
69
+ // `center` of a source that fits the box, or `contain` without upscaling
70
+ // of a small source: the bitmap is already the output.
71
+ if (geometry.isIdentity) return input
72
+
73
+ val matrix =
74
+ Matrix().apply {
75
+ // Scale to the rounded size exactly, so the drawn extent is whole pixels.
76
+ setScale(
77
+ geometry.scaledWidth.toFloat() / input.width,
78
+ geometry.scaledHeight.toFloat() / input.height,
41
79
  )
80
+ postTranslate(geometry.originX.toFloat(), geometry.originY.toFloat())
42
81
  }
43
- val config = softwareInput.config ?: Bitmap.Config.ARGB_8888
44
- val output = Bitmap.createBitmap(width, height, config)
82
+ return draw(input, geometry.outputWidth, geometry.outputHeight, matrix)
83
+ }
84
+
85
+ private fun draw(input: Bitmap, outputWidth: Int, outputHeight: Int, matrix: Matrix): Bitmap {
86
+ val config = input.config ?: Bitmap.Config.ARGB_8888
87
+ val output = Bitmap.createBitmap(outputWidth, outputHeight, config)
45
88
  Canvas(output)
46
- .drawBitmap(softwareInput, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
89
+ .drawBitmap(input, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
47
90
  return output
48
91
  }
49
92
 
50
93
  override fun equals(other: Any?): Boolean {
51
94
  if (this === other) return true
52
- return other is ResizeTransformation && width == other.width && height == other.height
95
+ return other is ResizeTransformation &&
96
+ width == other.width &&
97
+ height == other.height &&
98
+ fit == other.fit &&
99
+ allowUpscale == other.allowUpscale
53
100
  }
54
101
 
55
- override fun hashCode(): Int = 31 * width + height
102
+ override fun hashCode(): Int {
103
+ var result = 31 * width + height
104
+ result = 31 * result + fit.hashCode()
105
+ result = 31 * result + allowUpscale.hashCode()
106
+ return result
107
+ }
56
108
 
57
- override fun toString() = "ResizeTransformation(width=$width, height=$height)"
109
+ override fun toString() =
110
+ "ResizeTransformation(width=$width, height=$height, fit=$fit, allowUpscale=$allowUpscale)"
58
111
  }
@@ -0,0 +1,127 @@
1
+ //
2
+ // FitResizeProcessor.swift
3
+ // NitroImagePipeline
4
+ //
5
+ // Resizes into the `ResizeOptions` box under any `fit` other than the
6
+ // default. The default — `cover` with upscaling — stays on Nuke's own
7
+ // `ImageProcessors.Resize(.aspectFill, crop: true)`, so its output bytes and
8
+ // cache keys are exactly what they were before `fit` existed.
9
+ //
10
+
11
+ import CoreGraphics
12
+ import Foundation
13
+ import Nuke
14
+ import UIKit
15
+
16
+ /// The resize geometry both platforms implement — Android's port is
17
+ /// `transform/FitGeometry.kt`, and `example/__tests__/fit-geometry.ts` is the
18
+ /// copy the harness checks both against. With a `w` × `h` source and a
19
+ /// `W` × `H` box:
20
+ ///
21
+ /// ```
22
+ /// cover : sx = sy = max(W/w, H/h)
23
+ /// contain : sx = sy = min(W/w, H/h)
24
+ /// stretch : sx = W/w ; sy = H/h
25
+ /// center : sx = sy = 1
26
+ /// if !allowUpscale: sx = min(sx, 1) ; sy = min(sy, 1)
27
+ /// scaled = (round(w·sx), round(h·sy))
28
+ /// output = (min(W, scaled.w), min(H, scaled.h))
29
+ /// origin = trunc((output − scaled) / 2) — a centred, whole-pixel crop
30
+ /// ```
31
+ ///
32
+ /// The source is drawn scaled to `scaled` at `origin` on an `output`-sized
33
+ /// canvas. Rounding is half-away-from-zero (`.rounded()` here,
34
+ /// `roundToInt()` in Kotlin — identical for the positive values involved).
35
+ struct FitGeometry: Equatable {
36
+ let source: CGSize
37
+ let scaled: CGSize
38
+ let output: CGSize
39
+ let origin: CGPoint
40
+
41
+ init(source: CGSize, box: CGSize, fit: ResizeFit, allowUpscale: Bool) {
42
+ let boxWidth = box.width.rounded()
43
+ let boxHeight = box.height.rounded()
44
+ var scaleX: CGFloat
45
+ var scaleY: CGFloat
46
+ switch fit {
47
+ case .cover:
48
+ let scale = max(boxWidth / source.width, boxHeight / source.height)
49
+ scaleX = scale
50
+ scaleY = scale
51
+ case .contain:
52
+ let scale = min(boxWidth / source.width, boxHeight / source.height)
53
+ scaleX = scale
54
+ scaleY = scale
55
+ case .stretch:
56
+ scaleX = boxWidth / source.width
57
+ scaleY = boxHeight / source.height
58
+ case .center:
59
+ scaleX = 1
60
+ scaleY = 1
61
+ }
62
+ if !allowUpscale {
63
+ scaleX = min(scaleX, 1)
64
+ scaleY = min(scaleY, 1)
65
+ }
66
+ let scaledWidth = max((source.width * scaleX).rounded(), 1)
67
+ let scaledHeight = max((source.height * scaleY).rounded(), 1)
68
+ let outputWidth = min(boxWidth, scaledWidth)
69
+ let outputHeight = min(boxHeight, scaledHeight)
70
+ self.source = source
71
+ scaled = CGSize(width: scaledWidth, height: scaledHeight)
72
+ output = CGSize(width: outputWidth, height: outputHeight)
73
+ origin = CGPoint(
74
+ x: ((outputWidth - scaledWidth) / 2).rounded(.towardZero),
75
+ y: ((outputHeight - scaledHeight) / 2).rounded(.towardZero)
76
+ )
77
+ }
78
+
79
+ /// Whether the source is already the output — nothing to draw. Both
80
+ /// sizes are compared with the *source*: `scaled == output` alone also
81
+ /// holds for every plain downscale (nothing is cropped), which is not an
82
+ /// identity.
83
+ var isIdentity: Bool { scaled == source && output == source }
84
+ }
85
+
86
+ /// Nuke processor for the non-default fits. Runs before the blur and corner
87
+ /// processors, like `ImageProcessors.Resize`, so their pixel units refer to
88
+ /// the produced bitmap. Sizes are in points of the image, which the pipeline
89
+ /// decodes at scale 1 (1 pt = 1 px) — the same convention as
90
+ /// `RoundedCornersProcessor`.
91
+ struct FitResizeProcessor: ImageProcessing {
92
+ let width: Double
93
+ let height: Double
94
+ let fit: ResizeFit
95
+ let allowUpscale: Bool
96
+
97
+ var identifier: String {
98
+ "com.nitroimagepipeline.resize?w=\(width),h=\(height),fit=\(fit.stringValue),upscale=\(allowUpscale)"
99
+ }
100
+
101
+ var hashableIdentifier: AnyHashable { identifier }
102
+
103
+ func process(_ image: PlatformImage) -> PlatformImage? {
104
+ let source = image.size
105
+ guard source.width > 0, source.height > 0 else { return image }
106
+ let geometry = FitGeometry(
107
+ source: source,
108
+ box: CGSize(width: width, height: height),
109
+ fit: fit,
110
+ allowUpscale: allowUpscale
111
+ )
112
+ // `center` of a source that fits the box, or `contain` without
113
+ // upscaling of a small source: the bitmap is already the output.
114
+ if geometry.isIdentity { return image }
115
+
116
+ let format = UIGraphicsImageRendererFormat()
117
+ format.scale = image.scale
118
+ format.opaque = false
119
+ // See RoundedCornersProcessor: the extended range doubles the bytes
120
+ // of an 8-bit source for nothing.
121
+ format.preferredRange = .standard
122
+
123
+ return UIGraphicsImageRenderer(size: geometry.output, format: format).image { _ in
124
+ image.draw(in: CGRect(origin: geometry.origin, size: geometry.scaled))
125
+ }
126
+ }
127
+ }
@@ -138,13 +138,43 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
138
138
  private var pipeline: ImagePipeline { Self.sharedPipeline }
139
139
  private var prefetcher: ImagePrefetcher { Self.sharedPrefetcher }
140
140
 
141
- private static func cacheOptions(for cache: CacheOption?) -> ImageRequest.Options {
142
- switch cache {
141
+ /// The `URL` for a `url` string as the JS API accepts it: a URL with a
142
+ /// scheme (`https://`, `file://`, …), or a plain absolute file-system
143
+ /// path — the form react-native-nitro-image's `saveToTemporaryFileAsync`
144
+ /// returns — which becomes a `file://` URL.
145
+ static func url(from string: String) -> URL? {
146
+ if string.hasPrefix("/") {
147
+ return URL(fileURLWithPath: string)
148
+ }
149
+ return URL(string: string)
150
+ }
151
+
152
+ /// Whether `url` is fetched over the network, as opposed to read from the
153
+ /// file system (`file://`, incl. `require()`d assets in release builds)
154
+ /// or decoded inline (`data:`).
155
+ static func isNetworkURL(_ url: URL) -> Bool {
156
+ switch url.scheme?.lowercased() {
157
+ case "http", "https": true
158
+ default: false
159
+ }
160
+ }
161
+
162
+ private static func cacheOptions(for cache: CacheOption?, url: URL) -> ImageRequest.Options {
163
+ var options: ImageRequest.Options = switch cache {
143
164
  case .memory: [.disableDiskCache]
144
165
  case .disk: [.disableMemoryCache]
145
166
  case .none?: [.disableDiskCache, .disableMemoryCache]
146
167
  default: []
147
168
  }
169
+ // A local source has nothing to gain from the disk cache: the
170
+ // pipeline's `.storeAll` policy would copy the file's bytes into the
171
+ // data cache next to the original. Keep local images in memory only —
172
+ // Coil does the same on Android, where only network fetches are
173
+ // written to disk — so `cache: 'disk'` on a local URL means no cache.
174
+ if !isNetworkURL(url) {
175
+ options.insert(.disableDiskCache)
176
+ }
177
+ return options
148
178
  }
149
179
 
150
180
  /// The processor that bakes `cornerRadius` into the bitmap, or `nil` when
@@ -168,26 +198,46 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
168
198
  }
169
199
  }
170
200
 
171
- /// The target size in pixels, or `nil` when no (valid) resize was requested.
172
- private static func resizeSize(for options: Options?) -> CGSize? {
201
+ /// The requested resize, or `nil` when there is no (valid) one.
202
+ private static func requestedResize(for options: Options?) -> ResizeOptions? {
173
203
  guard let resize = options?.resize, resize.width > 0, resize.height > 0 else {
174
204
  return nil
175
205
  }
176
- return CGSize(width: resize.width, height: resize.height)
206
+ return resize
207
+ }
208
+
209
+ private static func fit(of resize: ResizeOptions) -> ResizeFit { resize.fit ?? .cover }
210
+
211
+ private static func allowsUpscale(_ resize: ResizeOptions) -> Bool { resize.allowUpscale ?? true }
212
+
213
+ /// The resize as it behaved before `fit` existed — aspect-fill, crop,
214
+ /// upscale — which keeps Nuke's own processor (and its cache keys).
215
+ private static func isDefaultFit(_ resize: ResizeOptions) -> Bool {
216
+ fit(of: resize) == .cover && allowsUpscale(resize)
177
217
  }
178
218
 
179
219
  private static func processors(for options: Options?) -> [any ImageProcessing] {
180
220
  var processors: [any ImageProcessing] = []
181
221
  // Resize first: blur sigma and corner radii are defined in pixels
182
222
  // of the bitmap they run on, so they must see the final size.
183
- if let size = resizeSize(for: options) {
184
- processors.append(ImageProcessors.Resize(
185
- size: size,
186
- unit: .pixels,
187
- contentMode: .aspectFill,
188
- crop: true,
189
- upscale: true
190
- ))
223
+ if let resize = requestedResize(for: options) {
224
+ let size = CGSize(width: resize.width, height: resize.height)
225
+ if isDefaultFit(resize) {
226
+ processors.append(ImageProcessors.Resize(
227
+ size: size,
228
+ unit: .pixels,
229
+ contentMode: .aspectFill,
230
+ crop: true,
231
+ upscale: true
232
+ ))
233
+ } else {
234
+ processors.append(FitResizeProcessor(
235
+ width: size.width,
236
+ height: size.height,
237
+ fit: fit(of: resize),
238
+ allowUpscale: allowsUpscale(resize)
239
+ ))
240
+ }
191
241
  }
192
242
  if let blur = options?.blur, blur > 0 {
193
243
  processors.append(GaussianBlurProcessor(sigma: blur))
@@ -205,19 +255,25 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
205
255
  var imgRequest = ImageRequest(
206
256
  url: url,
207
257
  processors: processors(for: options),
208
- options: cacheOptions(for: options?.cache)
258
+ options: cacheOptions(for: options?.cache, url: url)
209
259
  )
210
260
  // With a target size known, decode near it (aspect-fill, so the
211
261
  // decoded image always covers the target) instead of at full
212
262
  // resolution — a 48 MP photo displayed as a 300 pt card would
213
263
  // otherwise decompress to ~190 MB before Resize shrinks it.
214
264
  // Matches Android, where the request's size() drives subsampling;
215
- // the exact size and crop still come from the Resize processor.
216
- if let size = resizeSize(for: options) {
265
+ // the exact size and crop still come from the resize processor.
266
+ // `contain` decodes aspect-fit: its output follows the smaller scale,
267
+ // and an aspect-fill decode of a wide panorama into a small box would
268
+ // be near the source's full width before the processor shrinks it.
269
+ // Android's Scale.FIT subsampling bounds the decode the same way.
270
+ // `center` never scales, so it must see the source's own pixels: a
271
+ // subsampled decode would crop the wrong ones.
272
+ if let resize = requestedResize(for: options), fit(of: resize) != .center {
217
273
  imgRequest.thumbnail = ImageRequest.ThumbnailOptions(
218
- size: size,
274
+ size: CGSize(width: resize.width, height: resize.height),
219
275
  unit: .pixels,
220
- contentMode: .aspectFill
276
+ contentMode: fit(of: resize) == .contain ? .aspectFit : .aspectFill
221
277
  )
222
278
  }
223
279
  return imgRequest
@@ -225,7 +281,7 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
225
281
 
226
282
  func loadImage(url: String, options: Options?) throws -> Promise<any HybridImageSpec> {
227
283
  return Promise.async {
228
- guard let imageUrl = URL(string: url) else {
284
+ guard let imageUrl = Self.url(from: url) else {
229
285
  throw RuntimeError.error(withMessage: "Invalid URL: \(url)")
230
286
  }
231
287
 
@@ -243,21 +299,29 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
243
299
 
244
300
  func preLoadImage(url: String) throws -> Promise<Void> {
245
301
  return Promise.async {
246
- guard let imageUrl = URL(string: url) else {
302
+ guard let imageUrl = Self.url(from: url) else {
247
303
  throw RuntimeError.error(withMessage: "Invalid URL: \(url)")
248
304
  }
249
-
250
- self.prefetcher.startPrefetching(with: [imageUrl])
305
+ self.prefetch([imageUrl])
251
306
  }
252
307
  }
253
308
 
254
309
  func preLoadImages(urls: [String]) throws -> Promise<Void> {
255
310
  return Promise.async {
256
- let imageUrls = urls.compactMap { URL(string: $0) }
257
- self.prefetcher.startPrefetching(with: imageUrls)
311
+ self.prefetch(urls.compactMap { Self.url(from: $0) })
258
312
  }
259
313
  }
260
314
 
315
+ /// Prefetching warms the disk cache with a download; a local source has no
316
+ /// download to warm it with, so those are a no-op (as on Android, where
317
+ /// Coil never writes non-network sources to disk) rather than a copy of
318
+ /// the file into the data cache.
319
+ private func prefetch(_ urls: [URL]) {
320
+ let networkUrls = urls.filter(Self.isNetworkURL)
321
+ guard !networkUrls.isEmpty else { return }
322
+ prefetcher.startPrefetching(with: networkUrls)
323
+ }
324
+
261
325
  func setMemoryCacheLimit(bytes: Double) throws {
262
326
  guard bytes >= 0, bytes.isFinite else {
263
327
  throw RuntimeError.error(