Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/lib/screens/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1453,7 +1453,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
.first;
final suggestedName = 'CopyPaste_Backup_$ts';

final path = await FilePicker.platform.saveFile(
final path = await FilePicker.saveFile(
dialogTitle: 'Save Backup',
fileName: '$suggestedName.zip',
type: FileType.custom,
Expand Down Expand Up @@ -1494,7 +1494,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _restoreBackup() async {
final l = AppLocalizations.of(context);

final result = await FilePicker.platform.pickFiles(
final result = await FilePicker.pickFiles(
dialogTitle: l.restoreDialogTitle,
type: FileType.custom,
allowedExtensions: ['zip'],
Expand Down
2 changes: 1 addition & 1 deletion app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ dependencies:
hotkey_manager: ^0.2.0
window_manager: ^0.5.1
ffi: ^2.2.0
file_picker: ^10.0.0
file_picker: ^11.0.0
flutter_acrylic: ^1.1.4
cryptography: ^2.7.0
path: ^1.9.0
Expand Down
15 changes: 15 additions & 0 deletions core/lib/services/image_processor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ class ImageProcessor {
}
if (decoded == null) return null;

if (decoded.numChannels == 4 && decoded.bitsPerChannel == 8) {
var allTransparent = true;
for (final px in decoded) {
if (px.a != 0) {
allTransparent = false;
break;
}
}
if (allTransparent) {
for (final px in decoded) {
px.a = 255;
}
}
}

// Encoding and file-write errors (disk full, permissions) are NOT silenced —
// they propagate out of the Isolate and are caught + logged by the caller.
final pngBytes = img.encodePng(decoded);
Expand Down
119 changes: 119 additions & 0 deletions core/test/image_processor_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,123 @@ void main() {
expect(result.fileSize, equals(savedSize));
});
});

group('alpha normalization', () {
test('all-transparent RGBA8 pixels are opaquified with RGB preserved', () {
final src = img.Image(width: 4, height: 4, numChannels: 4);
for (final px in src) {
px.r = 100;
px.g = 150;
px.b = 200;
px.a = 0;
}
final inputBytes = Uint8List.fromList(img.encodePng(src));

final result = ImageProcessor.processSync(
imageBytes: inputBytes,
id: 'all-transparent',
imagesDir: tempDir.path,
);

expect(result, isNotNull);
final decoded = img.decodeImage(
File(result!.imagePath).readAsBytesSync(),
);
expect(decoded, isNotNull);
for (final px in decoded!) {
expect(px.a, equals(255));
expect(px.r, equals(100));
expect(px.g, equals(150));
expect(px.b, equals(200));
}
});

test('partial-transparency RGBA8 alpha distribution is preserved', () {
final src = img.Image(width: 4, height: 4, numChannels: 4);
var i = 0;
for (final px in src) {
px.r = 80;
px.g = 80;
px.b = 80;
px.a = switch (i % 3) {
0 => 0,
1 => 128,
_ => 255,
};
i++;
}
final inputBytes = Uint8List.fromList(img.encodePng(src));

final result = ImageProcessor.processSync(
imageBytes: inputBytes,
id: 'partial-transparent',
imagesDir: tempDir.path,
);

expect(result, isNotNull);
final decoded = img.decodeImage(
File(result!.imagePath).readAsBytesSync(),
);
expect(decoded, isNotNull);
final alphas = decoded!.map((px) => px.a).toList();
expect(alphas.where((a) => a == 0).length, greaterThan(0));
expect(alphas.where((a) => a == 128).length, greaterThan(0));
expect(alphas.where((a) => a == 255).length, greaterThan(0));
});

test(
'all-transparent RGBA16 alpha is not mutated (bitsPerChannel guard)',
() {
final src = img.Image(
width: 4,
height: 4,
numChannels: 4,
format: img.Format.uint16,
);
for (final px in src) {
px.r = 1000;
px.g = 2000;
px.b = 3000;
px.a = 0;
}
final inputBytes = Uint8List.fromList(img.encodePng(src));

final result = ImageProcessor.processSync(
imageBytes: inputBytes,
id: 'rgba16-transparent',
imagesDir: tempDir.path,
);

expect(result, isNotNull);
final decoded = img.decodeImage(
File(result!.imagePath).readAsBytesSync(),
);
expect(decoded, isNotNull);
for (final px in decoded!) {
expect(px.a, equals(0));
}
},
);

test('RGB8 (no alpha channel) round-trips with correct dimensions', () {
final src = img.Image(width: 4, height: 4);
for (final px in src) {
px.r = 10;
px.g = 20;
px.b = 30;
}
final inputBytes = Uint8List.fromList(img.encodePng(src));

final result = ImageProcessor.processSync(
imageBytes: inputBytes,
id: 'rgb-no-alpha',
imagesDir: tempDir.path,
);

expect(result, isNotNull);
expect(result!.width, equals(4));
expect(result.height, equals(4));
expect(File(result.imagePath).existsSync(), isTrue);
});
});
}
84 changes: 41 additions & 43 deletions listener/windows/listener_plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -878,31 +878,36 @@ bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) {

std::wstring wpath = Utf8ToWide(imagePath);

// Use GDI+ to load any image format (PNG, BMP, JPEG, etc.)
Gdiplus::Bitmap bitmap(wpath.c_str());
if (bitmap.GetLastStatus() != Gdiplus::Ok) return false;

HBITMAP hBitmap = nullptr;
Gdiplus::Color bg(0, 255, 255, 255);
Gdiplus::Color bg(255, 255, 255, 255);
if (bitmap.GetHBITMAP(bg, &hBitmap) != Gdiplus::Ok || !hBitmap)
return false;

BITMAP bm = {};
GetObject(hBitmap, sizeof(bm), &bm);

BITMAPINFOHEADER bih = {};
bih.biSize = sizeof(BITMAPINFOHEADER);
bih.biWidth = bm.bmWidth;
bih.biHeight = bm.bmHeight;
bih.biPlanes = 1;
bih.biBitCount = 32;
bih.biCompression = BI_RGB;

size_t rowBytes = static_cast<size_t>(bm.bmWidth) * 4;
size_t imgSize = rowBytes * bm.bmHeight;
bih.biSizeImage = static_cast<DWORD>(imgSize);

size_t dibSize = sizeof(BITMAPINFOHEADER) + imgSize;
BITMAPV5HEADER bv5 = {};
bv5.bV5Size = sizeof(BITMAPV5HEADER);
bv5.bV5Width = bm.bmWidth;
bv5.bV5Height = bm.bmHeight;
bv5.bV5Planes = 1;
bv5.bV5BitCount = 32;
bv5.bV5Compression = BI_BITFIELDS;
bv5.bV5SizeImage = static_cast<DWORD>(imgSize);
bv5.bV5RedMask = 0x00FF0000;
bv5.bV5GreenMask = 0x0000FF00;
bv5.bV5BlueMask = 0x000000FF;
bv5.bV5AlphaMask = 0xFF000000;
bv5.bV5CSType = LCS_WINDOWS_COLOR_SPACE;
bv5.bV5Intent = LCS_GM_IMAGES;

size_t dibSize = sizeof(BITMAPV5HEADER) + imgSize;
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, dibSize);
if (!hMem) {
DeleteObject(hBitmap);
Expand All @@ -916,14 +921,32 @@ bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) {
return false;
}

memcpy(ptr, &bih, sizeof(BITMAPINFOHEADER));
std::memcpy(ptr, &bv5, sizeof(BITMAPV5HEADER));

BITMAPINFOHEADER bih = {};
bih.biSize = sizeof(BITMAPINFOHEADER);
bih.biWidth = bm.bmWidth;
bih.biHeight = bm.bmHeight;
bih.biPlanes = 1;
bih.biBitCount = 32;
bih.biCompression = BI_RGB;
bih.biSizeImage = static_cast<DWORD>(imgSize);

HDC hDC = GetDC(nullptr);
auto* bi = reinterpret_cast<BITMAPINFO*>(ptr);
int scanLines = GetDIBits(hDC, hBitmap, 0, bm.bmHeight,
static_cast<uint8_t*>(ptr) + sizeof(BITMAPINFOHEADER),
bi, DIB_RGB_COLORS);
auto* bi = reinterpret_cast<BITMAPINFO*>(&bih);
int scanLines = GetDIBits(
hDC, hBitmap, 0, bm.bmHeight,
static_cast<uint8_t*>(ptr) + sizeof(BITMAPV5HEADER),
bi, DIB_RGB_COLORS);
ReleaseDC(nullptr, hDC);

if (scanLines > 0) {
uint8_t* pixels = static_cast<uint8_t*>(ptr) + sizeof(BITMAPV5HEADER);
for (size_t i = 3; i < imgSize; i += 4) {
pixels[i] = 0xFF;
}
}

GlobalUnlock(hMem);
DeleteObject(hBitmap);

Expand All @@ -941,34 +964,9 @@ bool ListenerPlugin::SetImageToClipboard(const std::string& imagePath) {
}

EmptyClipboard();
bool ok = SetClipboardData(CF_DIB, hMem) != nullptr;
bool ok = SetClipboardData(CF_DIBV5, hMem) != nullptr;
if (!ok) GlobalFree(hMem);

if (ok) {
DWORD attr = GetFileAttributesW(wpath.c_str());
if (attr != INVALID_FILE_ATTRIBUTES) {
size_t pathBytes = (wpath.size() + 1) * sizeof(wchar_t);
size_t dropSize = sizeof(DROPFILES) + pathBytes + sizeof(wchar_t);
HGLOBAL hDrop = GlobalAlloc(GHND, dropSize);
if (hDrop) {
auto* df = static_cast<DROPFILES*>(GlobalLock(hDrop));
if (df) {
df->pFiles = sizeof(DROPFILES);
df->fWide = TRUE;
auto* dest = reinterpret_cast<wchar_t*>(
reinterpret_cast<uint8_t*>(df) + sizeof(DROPFILES));
memcpy(dest, wpath.c_str(), pathBytes);
GlobalUnlock(hDrop);
if (!SetClipboardData(CF_HDROP, hDrop)) {
GlobalFree(hDrop);
}
} else {
GlobalFree(hDrop);
}
}
}
}

CloseClipboard();
if (ok) last_write_tick_ = GetTickCount64();
return ok;
Expand Down
16 changes: 4 additions & 12 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ packages:
dependency: transitive
description:
name: build_runner
sha256: "521daf8d189deb79ba474e43a696b41c49fb3987818dbacf3308f1e03673a75e"
sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6"
url: "https://pub.dev"
source: hosted
version: "2.13.1"
version: "2.15.0"
built_collection:
dependency: transitive
description:
Expand Down Expand Up @@ -169,14 +169,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
Expand Down Expand Up @@ -301,10 +293,10 @@ packages:
dependency: transitive
description:
name: file_picker
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
url: "https://pub.dev"
source: hosted
version: "10.3.10"
version: "11.0.2"
fixnum:
dependency: transitive
description:
Expand Down
Loading