diff --git a/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift b/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift index 1b1577cc..2e7cd9e3 100644 --- a/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift +++ b/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift @@ -1313,6 +1313,30 @@ public enum RsnapExportEncoder { return try data(from: outPNG, context: "taking encoded export PNG") } + public static func pngData( + from image: RGBARegionSnapshot, + screenScaleFactor: CGFloat + ) throws -> Data { + let scaleFactorX1000 = encode(screenScaleFactor: screenScaleFactor) + var outPNG = RsnapOwnedBytes() + let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in + guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else { + return RSNAP_STATUS_INVALID_INPUT + } + return rsnap_export_rgba_to_png_with_screen_scale( + UInt32(max(image.width, 0)), + UInt32(max(image.height, 0)), + baseAddress, + image.rgba.count, + scaleFactorX1000, + &outPNG + ) + } + try rsnapRequireOk(status, context: "encoding export PNG with screen scale") + + return try data(from: outPNG, context: "taking encoded export PNG with screen scale") + } + public static func pngData(from image: RGBARegionSnapshot, crop: CGRect) throws -> Data { let cropRect = try encode(crop: crop) var outPNG = RsnapOwnedBytes() @@ -1334,6 +1358,34 @@ public enum RsnapExportEncoder { return try data(from: outPNG, context: "taking encoded cropped export PNG") } + public static func pngData( + from image: RGBARegionSnapshot, + crop: CGRect, + screenScaleFactor: CGFloat + ) throws -> Data { + let cropRect = try encode(crop: crop) + let scaleFactorX1000 = encode(screenScaleFactor: screenScaleFactor) + var outPNG = RsnapOwnedBytes() + let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in + guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else { + return RSNAP_STATUS_INVALID_INPUT + } + return rsnap_export_rgba_crop_to_png_with_screen_scale( + UInt32(max(image.width, 0)), + UInt32(max(image.height, 0)), + baseAddress, + image.rgba.count, + cropRect, + scaleFactorX1000, + &outPNG + ) + } + try rsnapRequireOk(status, context: "encoding cropped export PNG with screen scale") + + return try data( + from: outPNG, context: "taking encoded cropped export PNG with screen scale") + } + public static func frozenDisplayCropRect( imageWidth: Int, imageHeight: Int, @@ -1442,6 +1494,13 @@ public enum RsnapExportEncoder { ) } + private static func encode(screenScaleFactor: CGFloat) -> UInt32 { + let scale = screenScaleFactor.isFinite ? max(screenScaleFactor, 1) : 1 + let scaled = min((scale * 1_000).rounded(), CGFloat(UInt32.max)) + + return UInt32(scaled) + } + private static func data(from outPNG: RsnapOwnedBytes, context: String) throws -> Data { guard outPNG.len > 0, let bytes = outPNG.bytes else { throw HostBridgeError.ffiStatus(context: context, code: RSNAP_STATUS_EMPTY.rawValue) diff --git a/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift b/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift index 1257b938..698e1956 100644 --- a/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift +++ b/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift @@ -366,6 +366,24 @@ enum RsnapHostBridgeProbe { guard let fullPNGDimensions = pngDimensions(png), fullPNGDimensions == (16, 120) else { fatalError("unexpected PNG export dimensions") } + guard pngPixelDensity(png) == nil else { + fatalError("unexpected PNG density metadata on default export") + } + let scaledPNG = try RsnapExportEncoder.pngData( + from: scrollExport, + screenScaleFactor: 2 + ) + let scaledPNGDensity = pngPixelDensity(scaledPNG) + guard + let scaledPNGDimensions = pngDimensions(scaledPNG), + scaledPNGDimensions == (16, 120), + let scaledPNGDensity, + scaledPNGDensity.0 == 5_669, + scaledPNGDensity.1 == 5_669, + scaledPNGDensity.2 == 1 + else { + fatalError("unexpected scaled PNG export metadata") + } let croppedPNG = try RsnapExportEncoder.pngData( from: scrollExport, crop: CGRect(x: 1, y: 2, width: 4, height: 8) @@ -749,6 +767,52 @@ enum RsnapHostBridgeProbe { return (width, height) } + private static func pngPixelDensity(_ data: Data) -> (Int, Int, UInt8)? { + let bytes = [UInt8](data) + guard + bytes.count >= 24, + bytes[0..<8].elementsEqual([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + else { + return nil + } + + var offset = 8 + while offset + 12 <= bytes.count { + let length = + Int(UInt32(bytes[offset]) << 24) + | Int(UInt32(bytes[offset + 1]) << 16) + | Int(UInt32(bytes[offset + 2]) << 8) + | Int(UInt32(bytes[offset + 3])) + let typeStart = offset + 4 + let dataStart = offset + 8 + let dataEnd = dataStart + length + let nextOffset = dataEnd + 4 + guard nextOffset <= bytes.count else { + return nil + } + if bytes[typeStart.. Data? { + nonisolated static func losslessPNGData( + from image: CGImage, + screenScaleFactor: CGFloat + ) throws -> Data? { guard let snapshot = NativeHostImageBridge.rgbaSnapshot(from: image) else { return nil } - return try RsnapExportEncoder.pngData(from: snapshot) + return try RsnapExportEncoder.pngData( + from: snapshot, + screenScaleFactor: screenScaleFactor + ) } func compositeFrozenOverlay(on image: CGImage, selection: CGRect) throws -> CGImage { let elements = chromeState.frozenOverlay.exportElements diff --git a/packages/rsnap-capture-core/src/export.rs b/packages/rsnap-capture-core/src/export.rs index be73dbec..99621bf3 100644 --- a/packages/rsnap-capture-core/src/export.rs +++ b/packages/rsnap-capture-core/src/export.rs @@ -1,11 +1,14 @@ //! Lossless export-image primitives owned by the Rust product core. use color_eyre::eyre::{self, Result, WrapErr}; -use image::codecs::png::{CompressionType, FilterType, PngEncoder}; -use image::{ExtendedColorType, ImageEncoder, RgbaImage, imageops}; +use image::{RgbaImage, imageops}; +use png::{BitDepth, ColorType, Compression, Encoder, Filter, PixelDimensions, Unit}; use crate::RectPoints; +const BASE_SCREEN_DPI: f64 = 72.0; +const INCHES_PER_METER: f64 = 39.370_078_740_157_48; + /// Rectangle in display point space used for export geometry decisions. #[derive(Clone, Copy, Debug, PartialEq)] pub struct DisplayPointRect { @@ -111,6 +114,11 @@ impl RgbaExportImage { pub fn to_png_bytes(&self) -> Result> { encode_png_lossless_fast(&self.image) } + + /// Encodes this export image with display-density metadata for the screen scale. + pub fn to_png_bytes_with_screen_scale(&self, scale_factor_x1000: u32) -> Result> { + encode_png_lossless_fast_with_screen_scale(&self.image, scale_factor_x1000) + } } /// Returns a pixel-exact crop when the requested rectangle lies inside the image. @@ -176,6 +184,27 @@ pub fn frozen_display_crop_rect( /// the image byte-exact after decoding while avoiding expensive deflate work on /// the capture hot path. pub fn encode_png_lossless_fast(image: &RgbaImage) -> Result> { + encode_png_lossless_fast_inner(image, None) +} + +/// Encodes an RGBA export image with PNG physical-pixel density metadata. +/// +/// A 2x Retina capture still stores every physical pixel. The density metadata lets +/// consumers that honor PNG `pHYs` display it as `points @ scale` instead of as a +/// larger 1x image. +pub fn encode_png_lossless_fast_with_screen_scale( + image: &RgbaImage, + scale_factor_x1000: u32, +) -> Result> { + let pixel_dims = screen_scale_pixel_dimensions(scale_factor_x1000)?; + + encode_png_lossless_fast_inner(image, Some(pixel_dims)) +} + +fn encode_png_lossless_fast_inner( + image: &RgbaImage, + pixel_dims: Option, +) -> Result> { let raw_len = image.as_raw().len(); let mut bytes = Vec::new(); @@ -184,19 +213,35 @@ pub fn encode_png_lossless_fast(image: &RgbaImage) -> Result> { let _ = bytes.try_reserve_exact(raw_len.saturating_add(extra)); } - let encoder = PngEncoder::new_with_quality( - &mut bytes, - CompressionType::Uncompressed, - FilterType::NoFilter, - ); + let mut encoder = Encoder::new(&mut bytes, image.width(), image.height()); + + encoder.set_color(ColorType::Rgba); + encoder.set_depth(BitDepth::Eight); + encoder.set_compression(Compression::NoCompression); + encoder.set_filter(Filter::NoFilter); + encoder.set_pixel_dims(pixel_dims); - encoder - .write_image(image.as_raw(), image.width(), image.height(), ExtendedColorType::Rgba8) - .wrap_err("failed to encode screenshot as PNG")?; + let mut writer = encoder.write_header().wrap_err("failed to encode screenshot as PNG")?; + + writer.write_image_data(image.as_raw()).wrap_err("failed to encode screenshot as PNG")?; + + drop(writer); Ok(bytes) } +fn screen_scale_pixel_dimensions(scale_factor_x1000: u32) -> Result { + if scale_factor_x1000 == 0 { + return Err(eyre::eyre!("PNG screen scale factor must be non-zero")); + } + + let scale = f64::from(scale_factor_x1000) / 1_000.0; + let pixels_per_meter = + (BASE_SCREEN_DPI * scale * INCHES_PER_METER).round().clamp(1.0, f64::from(u32::MAX)) as u32; + + Ok(PixelDimensions { xppu: pixels_per_meter, yppu: pixels_per_meter, unit: Unit::Meter }) +} + fn integral_image_intersection( left: f64, top: f64, @@ -365,6 +410,23 @@ mod tests { let png = crate::encode_png_lossless_fast(&image).expect("PNG encode should succeed"); assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + assert_eq!(png_phys_chunk(&png), None); + } + + #[test] + fn encode_png_lossless_fast_with_screen_scale_writes_retina_density() { + let image = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255])); + let png = crate::encode_png_lossless_fast_with_screen_scale(&image, 2_000) + .expect("PNG encode should succeed"); + + assert_eq!(png_phys_chunk(&png), Some((5_669, 5_669, 1))); + } + + #[test] + fn encode_png_lossless_fast_with_screen_scale_rejects_zero_scale() { + let image = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255])); + + assert!(crate::encode_png_lossless_fast_with_screen_scale(&image, 0).is_err()); } #[test] @@ -379,4 +441,35 @@ mod tests { assert_eq!(crop.height(), 2); assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); } + + fn png_phys_chunk(bytes: &[u8]) -> Option<(u32, u32, u8)> { + if bytes.len() < 8 || !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return None; + } + + let mut offset = 8_usize; + + while offset.checked_add(12)? <= bytes.len() { + let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into().ok()?) as usize; + let chunk_type = &bytes[offset + 4..offset + 8]; + let data_start = offset.checked_add(8)?; + let data_end = data_start.checked_add(length)?; + let chunk_end = data_end.checked_add(4)?; + + if chunk_end > bytes.len() { + return None; + } + if chunk_type == b"pHYs" && length == 9 { + let xppu = u32::from_be_bytes(bytes[data_start..data_start + 4].try_into().ok()?); + let yppu = + u32::from_be_bytes(bytes[data_start + 4..data_start + 8].try_into().ok()?); + + return Some((xppu, yppu, bytes[data_start + 8])); + } + + offset = chunk_end; + } + + None + } } diff --git a/packages/rsnap-capture-core/src/lib.rs b/packages/rsnap-capture-core/src/lib.rs index 2b2ec9ca..e3140a37 100644 --- a/packages/rsnap-capture-core/src/lib.rs +++ b/packages/rsnap-capture-core/src/lib.rs @@ -30,7 +30,10 @@ pub use self::{ render_capture_frame_effect, }, export::{DisplayPointRect, frozen_display_crop_rect}, - export::{RgbaExportImage, crop_export_image, crop_rgba_image, encode_png_lossless_fast}, + export::{ + RgbaExportImage, crop_export_image, crop_rgba_image, encode_png_lossless_fast, + encode_png_lossless_fast_with_screen_scale, + }, geometry::{ GlobalPoint, GlobalRect, MonitorRect, MonitorRectPoints, RectPoints, Rgb, WindowHit, WindowRect, diff --git a/packages/rsnap-host-ffi/include/rsnap_host_ffi.h b/packages/rsnap-host-ffi/include/rsnap_host_ffi.h index 52cabc49..192b4b4b 100644 --- a/packages/rsnap-host-ffi/include/rsnap_host_ffi.h +++ b/packages/rsnap-host-ffi/include/rsnap_host_ffi.h @@ -8,7 +8,7 @@ extern "C" { #endif -#define RSNAP_HOST_FFI_ABI_VERSION 35u +#define RSNAP_HOST_FFI_ABI_VERSION 36u #define RSNAP_TOOLBAR_ITEM_CAPACITY 16u #define RSNAP_STATUS_MESSAGE_CAPACITY 256u #define RSNAP_LIVE_SAMPLE_PATCH_CAPACITY 4096u @@ -540,6 +540,14 @@ enum RsnapStatus rsnap_export_rgba_to_png( size_t rgba_len, struct RsnapOwnedBytes *out_png ); +enum RsnapStatus rsnap_export_rgba_to_png_with_screen_scale( + uint32_t width, + uint32_t height, + const uint8_t *rgba, + size_t rgba_len, + uint32_t scale_factor_x1000, + struct RsnapOwnedBytes *out_png +); enum RsnapStatus rsnap_export_rgba_crop_to_png( uint32_t width, uint32_t height, @@ -548,6 +556,15 @@ enum RsnapStatus rsnap_export_rgba_crop_to_png( struct RsnapPixelRect crop_rect, struct RsnapOwnedBytes *out_png ); +enum RsnapStatus rsnap_export_rgba_crop_to_png_with_screen_scale( + uint32_t width, + uint32_t height, + const uint8_t *rgba, + size_t rgba_len, + struct RsnapPixelRect crop_rect, + uint32_t scale_factor_x1000, + struct RsnapOwnedBytes *out_png +); enum RsnapStatus rsnap_frozen_overlay_export_render_rgba( uint32_t width, uint32_t height, diff --git a/packages/rsnap-host-ffi/src/lib.rs b/packages/rsnap-host-ffi/src/lib.rs index 8caf903a..188cb43c 100644 --- a/packages/rsnap-host-ffi/src/lib.rs +++ b/packages/rsnap-host-ffi/src/lib.rs @@ -44,7 +44,7 @@ use rsnap_overlay::scroll_stitching::{ }; /// ABI version exported by the thin C host bridge. -pub const RSNAP_HOST_FFI_ABI_VERSION: u32 = 35; +pub const RSNAP_HOST_FFI_ABI_VERSION: u32 = 36; const RSNAP_TOOLBAR_ITEM_CAPACITY: usize = 16; const RSNAP_STATUS_MESSAGE_CAPACITY: usize = 256; @@ -1627,6 +1627,43 @@ pub unsafe extern "C" fn rsnap_export_rgba_to_png( RsnapStatus::Ok } +/// Encodes a full RGBA export image as lossless PNG with physical-pixel density metadata. +/// +/// # Safety +/// +/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` +/// row-major RGBA data, and `out_png` must be writable. The returned buffer must +/// be released with `rsnap_owned_bytes_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_export_rgba_to_png_with_screen_scale( + width: u32, + height: u32, + rgba: *const u8, + rgba_len: usize, + scale_factor_x1000: u32, + out_png: *mut RsnapOwnedBytes, +) -> RsnapStatus { + if out_png.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let Ok(image) = RgbaExportImage::from_raw(width, height, bytes.to_vec()) else { + return RsnapStatus::InvalidInput; + }; + let Ok(png) = image.to_png_bytes_with_screen_scale(scale_factor_x1000) else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_png, owned_bytes_from_vec(png)); + } + + RsnapStatus::Ok +} + /// Encodes a pixel-space RGBA export crop as lossless PNG through the Rust product core. /// /// # Safety @@ -1667,6 +1704,47 @@ pub unsafe extern "C" fn rsnap_export_rgba_crop_to_png( RsnapStatus::Ok } +/// Encodes a pixel-space RGBA crop as lossless PNG with physical-pixel density metadata. +/// +/// # Safety +/// +/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` +/// row-major RGBA data, and `out_png` must be writable. The returned buffer must +/// be released with `rsnap_owned_bytes_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_export_rgba_crop_to_png_with_screen_scale( + width: u32, + height: u32, + rgba: *const u8, + rgba_len: usize, + crop_rect: RsnapPixelRect, + scale_factor_x1000: u32, + out_png: *mut RsnapOwnedBytes, +) -> RsnapStatus { + if out_png.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let Ok(image) = RgbaExportImage::from_raw(width, height, bytes.to_vec()) else { + return RsnapStatus::InvalidInput; + }; + let Some(cropped) = image.crop(decode_pixel_rect(crop_rect)) else { + return RsnapStatus::InvalidInput; + }; + let Ok(png) = cropped.to_png_bytes_with_screen_scale(scale_factor_x1000) else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_png, owned_bytes_from_vec(png)); + } + + RsnapStatus::Ok +} + /// Composites frozen-overlay annotations into a full RGBA export image through Rust. /// /// # Safety @@ -4162,6 +4240,41 @@ mod tests { (width, height) } + fn png_phys_chunk(png: &[u8]) -> Option<(u32, u32, u8)> { + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + + let mut offset = 8; + + while offset + 12 <= png.len() { + let length = u32::from_be_bytes( + png[offset..offset + 4].try_into().expect("PNG chunk length bytes"), + ) as usize; + let data_start = offset + 8; + let data_end = data_start.checked_add(length)?; + let next_offset = data_end.checked_add(4)?; + + if next_offset > png.len() { + return None; + } + if &png[offset + 4..offset + 8] == b"pHYs" { + assert_eq!(length, 9); + + let x = u32::from_be_bytes( + png[data_start..data_start + 4].try_into().expect("pHYs x bytes"), + ); + let y = u32::from_be_bytes( + png[data_start + 4..data_start + 8].try_into().expect("pHYs y bytes"), + ); + + return Some((x, y, png[data_start + 8])); + } + + offset = next_offset; + } + + None + } + #[test] fn ffi_session_enters_live_and_emits_request() { let handle = unsafe { crate::rsnap_session_create(default_config()) }; @@ -4298,7 +4411,11 @@ mod tests { RsnapStatus::Ok ); assert!(png.len > 0); - assert_eq!(png_dimensions(unsafe { slice::from_raw_parts(png.bytes, png.len) }), (4, 4)); + + let png_bytes = unsafe { slice::from_raw_parts(png.bytes, png.len) }; + + assert_eq!(png_dimensions(png_bytes), (4, 4)); + assert_eq!(png_phys_chunk(png_bytes), None); unsafe { crate::rsnap_owned_bytes_release(&mut png); @@ -4309,6 +4426,56 @@ mod tests { assert_eq!(png.capacity, 0); } + #[test] + fn ffi_export_rgba_to_png_with_screen_scale_writes_density() { + let rgba = scroll_frame(4, 4, 0); + let mut png = RsnapOwnedBytes::default(); + + assert_eq!( + unsafe { + crate::rsnap_export_rgba_to_png_with_screen_scale( + 4, + 4, + rgba.as_ptr(), + rgba.len(), + 2_000, + &mut png, + ) + }, + RsnapStatus::Ok + ); + + let png_bytes = unsafe { slice::from_raw_parts(png.bytes, png.len) }; + + assert_eq!(png_dimensions(png_bytes), (4, 4)); + assert_eq!(png_phys_chunk(png_bytes), Some((5_669, 5_669, 1))); + + unsafe { + crate::rsnap_owned_bytes_release(&mut png); + } + } + + #[test] + fn ffi_export_rgba_to_png_with_screen_scale_rejects_zero_scale() { + let rgba = scroll_frame(4, 4, 0); + let mut png = RsnapOwnedBytes::default(); + + assert_eq!( + unsafe { + crate::rsnap_export_rgba_to_png_with_screen_scale( + 4, + 4, + rgba.as_ptr(), + rgba.len(), + 0, + &mut png, + ) + }, + RsnapStatus::InvalidInput + ); + assert!(png.bytes.is_null()); + } + #[test] fn ffi_export_rgba_crop_to_png_crops_dimensions() { let rgba = scroll_frame(4, 4, 0); @@ -4335,6 +4502,37 @@ mod tests { } } + #[test] + fn ffi_export_rgba_crop_to_png_with_screen_scale_writes_density() { + let rgba = scroll_frame(4, 4, 0); + let crop = RsnapPixelRect { x: 1, y: 0, width: 2, height: 3 }; + let mut png = RsnapOwnedBytes::default(); + + assert_eq!( + unsafe { + crate::rsnap_export_rgba_crop_to_png_with_screen_scale( + 4, + 4, + rgba.as_ptr(), + rgba.len(), + crop, + 2_000, + &mut png, + ) + }, + RsnapStatus::Ok + ); + + let png_bytes = unsafe { slice::from_raw_parts(png.bytes, png.len) }; + + assert_eq!(png_dimensions(png_bytes), (2, 3)); + assert_eq!(png_phys_chunk(png_bytes), Some((5_669, 5_669, 1))); + + unsafe { + crate::rsnap_owned_bytes_release(&mut png); + } + } + #[test] fn ffi_export_rgba_crop_to_png_rejects_out_of_bounds_crop() { let rgba = scroll_frame(4, 4, 0); diff --git a/packages/rsnap-host-ffi/tests/header_smoke.c b/packages/rsnap-host-ffi/tests/header_smoke.c index caade6ad..e2ee4ba1 100644 --- a/packages/rsnap-host-ffi/tests/header_smoke.c +++ b/packages/rsnap-host-ffi/tests/header_smoke.c @@ -89,17 +89,38 @@ int main(void) { return 11; } rsnap_owned_bytes_release(&png_export); - if (rsnap_export_rgba_crop_to_png(4, 4, rgba, sizeof(rgba), crop, &png_export) != RSNAP_STATUS_OK) { + if (rsnap_export_rgba_to_png_with_screen_scale(4, 4, rgba, sizeof(rgba), 2000, &png_export) != + RSNAP_STATUS_OK) { rsnap_scroll_session_destroy(scroll_handle); rsnap_session_destroy(handle); return 12; } rsnap_owned_bytes_release(&png_export); + if (rsnap_export_rgba_crop_to_png(4, 4, rgba, sizeof(rgba), crop, &png_export) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 13; + } + rsnap_owned_bytes_release(&png_export); + if (rsnap_export_rgba_crop_to_png_with_screen_scale( + 4, + 4, + rgba, + sizeof(rgba), + crop, + 2000, + &png_export + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 14; + } + rsnap_owned_bytes_release(&png_export); if (rsnap_frozen_display_crop_rect(2880, 1800, display_frame, selection, &display_crop) != RSNAP_STATUS_OK) { rsnap_scroll_session_destroy(scroll_handle); rsnap_session_destroy(handle); - return 13; + return 15; } if (display_crop.x != 200 || display_crop.y != 1100 || display_crop.width != 600 || display_crop.height != 300) { diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index f6ee0aac..1114d333 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -1269,8 +1269,30 @@ impl WindowRenderer { size_points: RectPoints, ) -> String { let size_pixels = monitor.local_rect_to_pixels(size_points); + let size_text = format!("{}x{} px", size_pixels.width, size_pixels.height); - format!("{}x{}", size_pixels.width, size_pixels.height) + if monitor.scale_factor_x1000 == 1_000 { + return size_text; + } + + format!("{} @{}x", size_text, Self::selection_size_badge_scale_text(monitor)) + } + + fn selection_size_badge_scale_text(monitor: MonitorRect) -> String { + let scale_integer = monitor.scale_factor_x1000 / 1_000; + let scale_fraction = monitor.scale_factor_x1000 % 1_000; + + if scale_fraction == 0 { + return scale_integer.to_string(); + } + + let mut scale_fraction_text = format!("{scale_fraction:03}"); + + while scale_fraction_text.ends_with('0') { + scale_fraction_text.pop(); + } + + format!("{scale_integer}.{scale_fraction_text}") } fn selection_size_badge_visual_overflow(pixels_per_point: f32) -> SelectionSizeBadgePadding { diff --git a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors/capture_affordances.rs b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors/capture_affordances.rs index 2ded0fda..88632341 100644 --- a/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors/capture_affordances.rs +++ b/packages/rsnap-overlay/src/overlay/tests/rendering_behaviors/capture_affordances.rs @@ -306,18 +306,34 @@ fn selection_size_badge_reserved_rect_accepts_overlap_when_no_non_overlapping_sl #[test] fn selection_size_badge_text_uses_monitor_pixel_dimensions() { - let monitor = tests::test_monitor_with_scale(1_000, 800, 2_000); - assert_eq!( - WindowRenderer::selection_size_badge_text(monitor, RectPoints::new(10, 20, 120, 80)), - "240x160" + WindowRenderer::selection_size_badge_text( + tests::test_monitor_with_scale(1_000, 800, 2_000), + RectPoints::new(10, 20, 120, 80), + ), + "240x160 px @2x" + ); + assert_eq!( + WindowRenderer::selection_size_badge_text( + tests::test_monitor_with_scale(1_000, 800, 1_500), + RectPoints::new(10, 20, 120, 80), + ), + "180x120 px @1.5x" + ); + assert_eq!( + WindowRenderer::selection_size_badge_text( + tests::test_monitor_with_scale(1_000, 800, 1_000), + RectPoints::new(10, 20, 120, 80), + ), + "120x80 px" ); } #[test] fn selection_size_badge_layout_keeps_visual_bounds_inside_badge_rect() { let ctx = tests::test_egui_context(); - let layout = WindowRenderer::selection_size_badge_layout(&ctx, "240x160", HudTheme::Light, 1.0); + let layout = + WindowRenderer::selection_size_badge_layout(&ctx, "240x160 px @2x", HudTheme::Light, 1.0); let screen_rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)); for (label, capture_rect) in [