Skip to content

Commit 3282c2d

Browse files
committed
fix: rotate slow path clipped content by keeping canvas at input size
The rotate slow path on iOS (NativeImage.swift) and Android (HybridImage.kt) sized the output canvas to the input image's dimensions. When degrees is not a multiple of 180 (e.g., a 90 degree rotation of a portrait image into a landscape), the rotated content extends beyond the canvas and gets clipped -- the caller sees an image at the input dimensions with half the rotated content missing. Fix: compute the rotated bounding box as outputW = abs(cos(r)) * w + abs(sin(r)) * h outputH = abs(sin(r)) * w + abs(cos(r)) * h Size the renderer / destination bitmap to that, then translate so the rotated content lands centered on the output canvas. Verified end-to-end on iPhone 15 Pro (3x) and a Pixel-class Android device via a vision-camera v5 photo-scanning app: rotating a portrait card 90 degrees now produces a correctly sized landscape JPEG with complete content, rather than a portrait JPEG with a clipped landscape rectangle inside. Works for any angle (the cos/sin formula gives a tight bounding box for arbitrary rotations), but the bug is most visible at 90/270 where the canvas is transposed from the content.
1 parent 7bec013 commit 3282c2d

2 files changed

Lines changed: 40 additions & 15 deletions

File tree

packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImage.kt

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,31 @@ class HybridImage: HybridImageSpec {
8282
override fun rotate(degrees: Double, allowFastFlagRotation: Boolean?): HybridImageSpec {
8383
// 1. Make sure the Bitmap we want to draw is drawable (HARDWARE isn't)
8484
val source = bitmap.toCpuAccessible()
85-
// 2. Create a rotation Matrix
85+
86+
// 2. Compute the rotated bounding box size. The output Bitmap must be
87+
// sized to contain the rotated content; using the original width/
88+
// height clips content when degrees % 180 != 0 (e.g., rotating a
89+
// portrait 90 degrees produces a landscape rect that doesn't fit a
90+
// portrait canvas).
91+
val radians = Math.toRadians(degrees)
92+
val srcW = source.width.toDouble()
93+
val srcH = source.height.toDouble()
94+
val outputW = (Math.abs(Math.cos(radians)) * srcW + Math.abs(Math.sin(radians)) * srcH).toInt()
95+
val outputH = (Math.abs(Math.sin(radians)) * srcW + Math.abs(Math.cos(radians)) * srcH).toInt()
96+
97+
// 3. Create a rotation matrix centered on the source, then translate so
98+
// the rotated bounding box lands at (0, 0) of the output canvas.
8699
val matrix = Matrix()
87100
matrix.setRotate(degrees.toFloat(), source.width / 2f, source.height / 2f)
88-
// 3. Create a new blank Bitmap as our output
89-
val destination = createBitmap(bitmap.width, bitmap.height)
90-
// 4. Draw the Bitmap to our destination
101+
matrix.postTranslate((outputW - source.width) / 2f, (outputH - source.height) / 2f)
102+
103+
// 4. Create the output Bitmap sized to the rotated bounding box.
104+
val destination = createBitmap(outputW, outputH)
105+
106+
// 5. Draw source into the destination with the rotation matrix applied.
91107
Canvas(destination).apply {
92108
drawBitmap(source, matrix, null)
93109
}
94-
// 5. Return it!
95110
return HybridImage(destination)
96111
}
97112

packages/react-native-nitro-image/ios/Public/NativeImage.swift

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,21 +63,31 @@ public extension NativeImage {
6363
return HybridImage(uiImage: rotated)
6464
} else {
6565
// Slow path: we actually rotate using UIGraphicsImageRenderer.
66-
// Force scale=1 so output pixel dims match uiImage.size. Without a format,
67-
// UIGraphicsImageRenderer defaults to UIScreen.main.scale (2x/3x on modern
68-
// iPhones), producing a pixel-inflated bitmap.
66+
// Force scale=1 so output pixel dims match the rotated bounding box
67+
// (avoids UIScreen.main.scale default).
68+
//
69+
// Output canvas must be sized to the ROTATED bounding box, not the original
70+
// image. Using uiImage.size clips content when degrees % 180 != 0 (e.g., a
71+
// portrait rotated 90 degrees produces a landscape rect that extends
72+
// outside a portrait canvas).
73+
let radians = degrees * .pi / 180
74+
let width = uiImage.size.width
75+
let height = uiImage.size.height
76+
let outputWidth = abs(cos(radians)) * width + abs(sin(radians)) * height
77+
let outputHeight = abs(sin(radians)) * width + abs(cos(radians)) * height
78+
let outputSize = CGSize(width: outputWidth, height: outputHeight)
79+
6980
let format = UIGraphicsImageRendererFormat()
7081
format.scale = 1
71-
let renderer = UIGraphicsImageRenderer(size: uiImage.size, format: format)
82+
let renderer = UIGraphicsImageRenderer(size: outputSize, format: format)
7283
let rotatedImage = renderer.image { context in
73-
let width = uiImage.size.width
74-
let height = uiImage.size.height
75-
// 1. Move to the center of the image so our origin is the center
76-
context.cgContext.translateBy(x: width / 2, y: height / 2)
84+
// 1. Move to the center of the OUTPUT canvas so our origin is its center
85+
context.cgContext.translateBy(x: outputWidth / 2, y: outputHeight / 2)
7786
// 2. Rotate by the given radians
78-
let radians = degrees * .pi / 180
7987
context.cgContext.rotate(by: radians)
80-
// 3. Draw the Image offset by half the frame so we counter our center origin from step 1.
88+
// 3. Draw the Image offset by half the ORIGINAL frame so we counter our
89+
// center origin from step 1. The rotated content now fills the
90+
// output canvas exactly (tight bounding box).
8191
let rect = CGRect(x: -(width / 2),
8292
y: -(height / 2),
8393
width: width,

0 commit comments

Comments
 (0)