feat(clipboard): pass original JPEG/GIF through verbatim beside the PNG floor
android / android (push) Has been cancelled
audit / bun-audit (push) Successful in 14s
audit / cargo-audit (push) Successful in 2m29s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 30s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 18s
apple / swift (push) Successful in 1m23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
ci / bench (push) Successful in 5m58s
ci / rust (push) Failing after 6m54s
release / apple (push) Successful in 5m52s
arch / build-publish (push) Failing after 11m7s
flatpak / build-publish (push) Failing after 8m7s
deb / build-publish (push) Successful in 13m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m31s
docker / deploy-docs (push) Successful in 24s
apple / screenshots (push) Successful in 6m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m5s
windows-host / package (push) Has been cancelled
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Has been cancelled
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Has been cancelled
windows / build (aarch64-pc-windows-msvc) (push) Has been cancelled
windows / build (x86_64-pc-windows-msvc) (push) Has been cancelled

Transcoding every image to PNG was the Phase-1 floor, but it bloats lossy
originals (a copied JPEG re-encoded lossless is 5-10x the bytes for zero
quality gain — feeding the render-timeout on constrained links) and flattens
GIFs to one frame. The wire already carries kind LISTS, so offer the original
format beside the portable fallback and let the destination pick:

- New wire kinds image/jpeg + image/gif (§3.5 extension; strings only, no
  protocol change). image/png stays the universal floor every peer accepts.
- macOS: public.jpeg / com.compuserve.gif rows pass through verbatim both
  directions; the PNG floor is announced whenever any image is present.
- Windows: registered JFIF/GIF formats read + promised verbatim; every image
  offer still promises CF_DIB, whose delayed render now fetches the richest
  offered kind (png > jpeg > gif) through the generalized image_to_dib.
- Linux maps gain the matching rows (native MIME pass-through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 19:32:03 +02:00
parent bbe4380b41
commit f4b52d0bf5
6 changed files with 198 additions and 21 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ quinn = "0.11"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
# CF_DIB <-> PNG conversion (winfmt) - most Windows apps paste bitmaps, not the "PNG" format.
# Unconditional (not windows-gated) so winfmt's pure-conversion unit tests run on every host.
image = { version = "0.25", default-features = false, features = ["png", "bmp"] }
image = { version = "0.25", default-features = false, features = ["png", "bmp", "jpeg", "gif"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Mutter's direct RemoteDesktop clipboard is raw D-Bus via `ashpd::zbus` — NOT the xdg
+12
View File
@@ -235,6 +235,12 @@ pub const WIRE_HTML: &str = "text/html";
pub const WIRE_RTF: &str = "text/rtf";
/// Wire MIME for a PNG image.
pub const WIRE_PNG: &str = "image/png";
/// Wire MIME for a JPEG image — passed through VERBATIM when the source clipboard carries one
/// (no PNG transcode: a lossy original re-encoded lossless is pure bloat). [`WIRE_PNG`] remains
/// the universal fallback every peer must accept; JPEG/GIF are richer options beside it.
pub const WIRE_JPEG: &str = "image/jpeg";
/// Wire MIME for a GIF image — verbatim pass-through preserves animation end to end.
pub const WIRE_GIF: &str = "image/gif";
/// Map a Wayland selection MIME to its canonical wire MIME, or `None` to drop it (internal targets
/// like `TARGETS`/`TIMESTAMP`/`SAVE_TARGETS`, and formats we don't sync in Phase 1). Aliases
@@ -248,6 +254,8 @@ pub fn wayland_to_wire(wl: &str) -> Option<&'static str> {
"text/html" => Some(WIRE_HTML),
"text/rtf" | "application/rtf" | "text/richtext" => Some(WIRE_RTF),
"image/png" => Some(WIRE_PNG),
"image/jpeg" => Some(WIRE_JPEG),
"image/gif" => Some(WIRE_GIF),
_ => match base {
"text/plain" | "UTF8_STRING" | "STRING" | "TEXT" => Some(WIRE_TEXT),
_ => None,
@@ -270,6 +278,8 @@ pub fn wayland_candidates(wire: &str) -> &'static [&'static str] {
WIRE_HTML => &["text/html"],
WIRE_RTF => &["text/rtf", "application/rtf", "text/richtext"],
WIRE_PNG => &["image/png"],
WIRE_JPEG => &["image/jpeg"],
WIRE_GIF => &["image/gif"],
_ => &[],
}
}
@@ -330,6 +340,8 @@ pub fn wayland_offers_for(wire_mimes: &[String]) -> Vec<String> {
push("text/rtf");
}
WIRE_PNG => push("image/png"),
WIRE_JPEG => push("image/jpeg"),
WIRE_GIF => push("image/gif"),
other => push(other),
}
}
+66 -11
View File
@@ -48,7 +48,9 @@ use ::windows::Win32::UI::WindowsAndMessaging::{
};
use super::winfmt;
use super::{ClipEvent, PasteResponder, WIRE_HTML, WIRE_PNG, WIRE_RTF, WIRE_TEXT};
use super::{
ClipEvent, PasteResponder, WIRE_GIF, WIRE_HTML, WIRE_JPEG, WIRE_PNG, WIRE_RTF, WIRE_TEXT,
};
/// Custom app message that wakes the pump to drain the [`Cmd`] channel.
const WM_APP_CMD: u32 = WM_APP + 1;
@@ -98,6 +100,13 @@ struct WinClip {
fmt_html: u32,
fmt_rtf: u32,
fmt_png: u32,
/// Registered `"JFIF"` — the conventional raw-JPEG clipboard format (Office/browsers).
fmt_jfif: u32,
/// Registered `"GIF"` — raw GIF bytes (animation preserved by verbatim pass-through).
fmt_gif: u32,
/// The wire MIMEs of the offer currently promised via delayed rendering — `WM_RENDERFORMAT`
/// for the synthesized `CF_DIB` picks the richest image kind among them to fetch + convert.
offered_wires: RefCell<Vec<String>>,
/// Our own message window — used for the owner-check and clipboard opens.
own_hwnd: HWND,
}
@@ -137,6 +146,15 @@ impl WinClip {
if avail(self.fmt_png) || avail(CF_DIB.0 as u32) {
out.push(WIRE_PNG.to_string());
}
// Original lossy/animated formats offered VERBATIM beside the PNG floor — the client
// picks the richest kind it can place, so a copied JPEG never balloons into PNG and a
// GIF keeps its animation.
if avail(self.fmt_jfif) {
out.push(WIRE_JPEG.to_string());
}
if avail(self.fmt_gif) {
out.push(WIRE_GIF.to_string());
}
out
}
@@ -192,6 +210,7 @@ impl WinClip {
}
}
*self.offered.borrow_mut() = fmts;
*self.offered_wires.borrow_mut() = wire.to_vec();
}
/// Drop the selection we own (empty the clipboard iff we're still its owner).
@@ -265,8 +284,23 @@ impl WinClip {
/// `WM_RENDERFORMAT`: a host app is pasting a format we promised. Fetch the bytes from the client
/// (blocking this thread, bounded) and `SetClipboardData` them for the paster.
fn on_render_format(&self, fmt: u32) {
let Some(wire) = self.wire_for_format(fmt) else {
return;
// The synthesized CF_DIB promise has no wire kind of its own: fetch the richest image
// kind the client offered (PNG first — lossless with alpha — then JPEG, then GIF's first
// frame) and convert. Every other format maps 1:1.
let wire: &str = if fmt == CF_DIB.0 as u32 {
let offered = self.offered_wires.borrow();
match [WIRE_PNG, WIRE_JPEG, WIRE_GIF]
.into_iter()
.find(|w| offered.iter().any(|o| o == w))
{
Some(w) => w,
None => return,
}
} else {
match self.wire_for_format(fmt) {
Some(w) => w,
None => return,
}
};
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let ev = ClipEvent::Paste {
@@ -281,9 +315,9 @@ impl WinClip {
Err(_) => return, // timeout / dropped → leave the format unrendered (empty paste)
};
let win_bytes = if fmt == CF_DIB.0 as u32 {
// The app asked for a bitmap: the client served PNG — convert. A PNG that doesn't
// decode leaves the format unrendered (empty paste), matching the timeout path.
match winfmt::png_to_dib(&bytes) {
// The app asked for a bitmap: convert whatever image kind the client served. Bytes
// that don't decode leave the format unrendered (empty paste), like the timeout path.
match winfmt::image_to_dib(&bytes) {
Some(d) => d,
None => return,
}
@@ -310,6 +344,8 @@ impl WinClip {
WIRE_HTML => Some(self.fmt_html),
WIRE_RTF => Some(self.fmt_rtf),
WIRE_PNG => Some(self.fmt_png),
WIRE_JPEG => Some(self.fmt_jfif),
WIRE_GIF => Some(self.fmt_gif),
_ => None,
}
}
@@ -322,8 +358,12 @@ impl WinClip {
Some(WIRE_HTML)
} else if fmt == self.fmt_rtf {
Some(WIRE_RTF)
} else if fmt == self.fmt_png || fmt == CF_DIB.0 as u32 {
} else if fmt == self.fmt_png {
Some(WIRE_PNG)
} else if fmt == self.fmt_jfif {
Some(WIRE_JPEG)
} else if fmt == self.fmt_gif {
Some(WIRE_GIF)
} else {
None
}
@@ -340,9 +380,12 @@ impl WinClip {
}
}
// Image offers also promise CF_DIB — most pasting apps (Paint, Office, chat clients)
// ask for the bitmap family, not the registered "PNG"; Windows synthesizes
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` converts on demand.
if w == WIRE_PNG && !out.contains(&(CF_DIB.0 as u32)) {
// ask for the bitmap family, not the registered image formats; Windows synthesizes
// CF_BITMAP/CF_DIBV5 from the promised CF_DIB. `on_render_format` fetches the richest
// offered image kind and converts on demand.
if matches!(w.as_str(), WIRE_PNG | WIRE_JPEG | WIRE_GIF)
&& !out.contains(&(CF_DIB.0 as u32))
{
out.push(CF_DIB.0 as u32);
}
}
@@ -376,12 +419,18 @@ impl WindowsClipboard {
let fmt_html = register_format(w!("HTML Format"))?;
let fmt_rtf = register_format(w!("Rich Text Format"))?;
let fmt_png = register_format(w!("PNG"))?;
let fmt_jfif = register_format(w!("JFIF"))?;
let fmt_gif = register_format(w!("GIF"))?;
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<anyhow::Result<isize>>();
let cw = Arc::clone(&current_wire);
let join = std::thread::Builder::new()
.name("punktfunk-clipboard-win".into())
.spawn(move || pump_thread(clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, ready_tx))
.spawn(move || {
pump_thread(
clip_tx, cmd_rx, cw, fmt_html, fmt_rtf, fmt_png, fmt_jfif, fmt_gif, ready_tx,
)
})
.context("spawn windows clipboard thread")?;
let hwnd = match tokio::time::timeout(Duration::from_secs(3), ready_rx).await {
@@ -573,6 +622,7 @@ fn create_window() -> anyhow::Result<HWND> {
}
/// The message-loop thread body: build the window, wire up state, then pump until `WM_QUIT`.
#[allow(clippy::too_many_arguments)]
fn pump_thread(
clip_tx: ClipTx,
cmd_rx: tokio::sync::mpsc::UnboundedReceiver<Cmd>,
@@ -580,6 +630,8 @@ fn pump_thread(
fmt_html: u32,
fmt_rtf: u32,
fmt_png: u32,
fmt_jfif: u32,
fmt_gif: u32,
ready_tx: tokio::sync::oneshot::Sender<anyhow::Result<isize>>,
) {
let hwnd = match create_window() {
@@ -598,9 +650,12 @@ fn pump_thread(
current_wire,
cmd_rx: RefCell::new(cmd_rx),
offered: RefCell::new(Vec::new()),
offered_wires: RefCell::new(Vec::new()),
fmt_html,
fmt_rtf,
fmt_png,
fmt_jfif,
fmt_gif,
own_hwnd: hwnd,
});
let ptr = Box::into_raw(state);
+6 -5
View File
@@ -265,7 +265,7 @@ mod tests {
image::DynamicImage::ImageRgba8(img.clone())
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let dib = png_to_dib(&png).expect("png -> dib");
let dib = image_to_dib(&png).expect("png -> dib");
// BITMAPINFOHEADER sanity: 40-byte header, 3x2, 32bpp.
assert_eq!(u32::from_le_bytes(dib[0..4].try_into().unwrap()), 40);
assert_eq!(i32::from_le_bytes(dib[4..8].try_into().unwrap()), 3);
@@ -290,10 +290,11 @@ mod tests {
// the backend converts. A CF_DIB HGLOBAL is a BMP file minus its 14-byte BITMAPFILEHEADER:
// BITMAPINFOHEADER (or V4/V5) + optional palette/masks + pixel rows.
/// PNG wire bytes → `CF_DIB` HGLOBAL bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up).
/// `None` when the PNG doesn't decode — the caller leaves the format unrendered (empty paste).
pub fn png_to_dib(png: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?;
/// Image wire bytes (PNG / JPEG / GIF — any format the `image` crate sniffs) → `CF_DIB` HGLOBAL
/// bytes (BITMAPINFOHEADER, 32bpp BGRA, BI_RGB, bottom-up). GIFs contribute their first frame.
/// `None` when the bytes don't decode — the caller leaves the format unrendered (empty paste).
pub fn image_to_dib(bytes: &[u8]) -> Option<Vec<u8>> {
let img = image::load_from_memory(bytes).ok()?;
let rgba = img.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
if w == 0 || h == 0 || w > 32767 || h > 32767 {