From c8e86bd7440a4cb29741f5b58072d80718bb79ac Mon Sep 17 00:00:00 2001 From: Stephan Vedder Date: Tue, 3 Feb 2026 15:03:37 +0100 Subject: [PATCH 1/4] feat(transcode): support transcoding --- src/api/wado/routes.rs | 3 + src/api/wado/service.rs | 97 +++++++++++++++++++++++++++++- src/backend/dimse/cmove/movescu.rs | 2 + src/backend/dimse/wado.rs | 23 ++++++- 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/src/api/wado/routes.rs b/src/api/wado/routes.rs index a2cd627..938a3cb 100644 --- a/src/api/wado/routes.rs +++ b/src/api/wado/routes.rs @@ -70,6 +70,7 @@ async fn instance_resource( request: RetrieveInstanceRequest, ) -> impl IntoResponse { if let Some(wado) = provider.wado { + let transfer_syntax = request.transfer_syntax.clone(); let study_instance_uid: UI = request.query.study_instance_uid.clone(); let response = wado.retrieve(request).await; @@ -92,6 +93,7 @@ async fn instance_resource( ) .body(Body::from_stream(DicomMultipartStream::new( stream.into_stream(), + transfer_syntax.as_deref(), ))) .unwrap() } @@ -131,6 +133,7 @@ async fn rendered_resource( trace!("Using default rendering"); let instance_request = RetrieveInstanceRequest { query: request.query, + transfer_syntax: None, }; let stream = wado diff --git a/src/api/wado/service.rs b/src/api/wado/service.rs index aee00f1..253b544 100644 --- a/src/api/wado/service.rs +++ b/src/api/wado/service.rs @@ -64,6 +64,7 @@ impl IntoResponse for RetrieveError { } pub struct RetrieveInstanceRequest { pub query: ResourceQuery, + pub transfer_syntax: Option, } pub struct ThumbnailRequest { @@ -157,6 +158,33 @@ where } } +/// Extracts the transfer-syntax parameter from an Accept header value. +/// +/// According to https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_8.7.3.5.2.html +/// the syntax is: transfer-syntax-mtp = OWS ";" OWS %s"transfer-syntax=" ts-value +/// +/// Examples: +/// - "application/dicom; transfer-syntax=1.2.840.10008.1.2.4.50" +/// - "multipart/related; type=\"application/dicom\"; transfer-syntax=1.2.840.10008.1.2.4.50" +fn extract_transfer_syntax_from_accept(accept_header: &str) -> Option { + // Split by semicolons to get individual parameters + for part in accept_header.split(';') { + let trimmed = part.trim(); + // Look for the transfer-syntax parameter + if let Some(value) = trimmed.strip_prefix("transfer-syntax=") { + let value = value.trim(); + // The value might be quoted or unquoted + let transfer_syntax = if value.starts_with('"') && value.ends_with('"') { + value.trim_matches('"') + } else { + value + }; + return Some(transfer_syntax.to_string()); + } + } + None +} + impl FromRequestParts for RetrieveInstanceRequest where AppState: FromRef, @@ -169,7 +197,21 @@ where .await .map_err(PathRejection::into_response)?; - Ok(Self { query }) + let accept = parts + .headers + .get(ACCEPT) + .map(|h| String::from(h.to_str().unwrap_or_default())); + + // Extract the requested transfer-syntax from the Accept header if present + // https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_8.7.3.5.2.html + let transfer_syntax = accept + .as_ref() + .and_then(|accept_str| extract_transfer_syntax_from_accept(accept_str)); + + Ok(Self { + query, + transfer_syntax, + }) } } @@ -456,6 +498,59 @@ mod tests { ); } + #[test] + fn test_extract_transfer_syntax_from_accept() { + // Test simple case + assert_eq!( + extract_transfer_syntax_from_accept( + "application/dicom; transfer-syntax=1.2.840.10008.1.2.4.50" + ), + Some("1.2.840.10008.1.2.4.50".to_string()) + ); + + // Test with extra whitespace + assert_eq!( + extract_transfer_syntax_from_accept( + "application/dicom; transfer-syntax=1.2.840.10008.1.2.4.50" + ), + Some("1.2.840.10008.1.2.4.50".to_string()) + ); + + // Test wildcard + assert_eq!( + extract_transfer_syntax_from_accept("application/dicom; transfer-syntax=*"), + Some("*".to_string()) + ); + + // Test multipart/related with type parameter + assert_eq!( + extract_transfer_syntax_from_accept("multipart/related; type=\"application/dicom\"; transfer-syntax=1.2.840.10008.1.2.4.50"), + Some("1.2.840.10008.1.2.4.50".to_string()) + ); + + // Test with quoted value (though not typical for transfer-syntax) + assert_eq!( + extract_transfer_syntax_from_accept( + "application/dicom; transfer-syntax=\"1.2.840.10008.1.2.4.50\"" + ), + Some("1.2.840.10008.1.2.4.50".to_string()) + ); + + // Test without transfer-syntax parameter + assert_eq!( + extract_transfer_syntax_from_accept("application/dicom"), + None + ); + + // Test with other parameters but no transfer-syntax + assert_eq!( + extract_transfer_syntax_from_accept( + "multipart/related; type=\"application/dicom\"; boundary=example" + ), + None + ); + } + #[test] fn parse_rendered_query_params() { let uri = diff --git a/src/backend/dimse/cmove/movescu.rs b/src/backend/dimse/cmove/movescu.rs index 1bcc677..15b4ee4 100644 --- a/src/backend/dimse/cmove/movescu.rs +++ b/src/backend/dimse/cmove/movescu.rs @@ -87,6 +87,8 @@ pub enum MoveError { #[error(transparent)] Write(#[from] WriteError), #[error(transparent)] + Transcode(#[from] dicom::pixeldata::TranscodeError), + #[error(transparent)] Association(#[from] PoolError), #[error("Sub-operation failed")] OperationFailed, diff --git a/src/backend/dimse/wado.rs b/src/backend/dimse/wado.rs index 02b91a0..90ca34a 100644 --- a/src/backend/dimse/wado.rs +++ b/src/backend/dimse/wado.rs @@ -17,7 +17,10 @@ use async_stream::stream; use async_trait::async_trait; use dicom::core::VR; use dicom::dictionary_std::tags; +use dicom::encoding::TransferSyntaxIndex; use dicom::object::{FileDicomObject, InMemDicomObject}; +use dicom::transfer_syntax::TransferSyntaxRegistry; +use dicom_pixeldata::Transcode; use futures::stream::BoxStream; use futures::{Stream, StreamExt}; use pin_project::pin_project; @@ -104,6 +107,7 @@ impl WadoService for DimseWadoService { async fn metadata(&self, request: MetadataRequest) -> Result { self.retrieve(RetrieveInstanceRequest { query: request.query, + transfer_syntax: None, }) .await } @@ -263,11 +267,26 @@ impl<'a> DicomMultipartStream<'a> { stream: impl Stream>, MoveError>> + Send + 'a, + transfer_syntax_uid: Option<&str>, ) -> Self { + let transfer_syntax_uid = transfer_syntax_uid + .map(|ts_uid| TransferSyntaxRegistry.get(&ts_uid)) + .flatten(); + let multipart_stream = stream - .map(|item| { + .map(move |item| { + let transfer_syntax_uid = transfer_syntax_uid.clone(); item.and_then(|object| { - Self::write(&object).map_err(|err| MoveError::Write(WriteError::Io(err))) + if let Some(ts) = transfer_syntax_uid { + let mut transcoded = (*object).clone(); + transcoded + .transcode(&ts) + .map_err(|err| MoveError::Transcode(err))?; + Self::write(&transcoded) + .map_err(|err| MoveError::Write(WriteError::Io(err))) + } else { + Self::write(&object).map_err(|err| MoveError::Write(WriteError::Io(err))) + } }) }) .chain(futures::stream::once(async { From 549b61bb4debadedf16a79638eccb9431f4937cf Mon Sep 17 00:00:00 2001 From: Stephan Vedder Date: Tue, 3 Feb 2026 17:43:11 +0100 Subject: [PATCH 2/4] feat(transcode): add transfer-syntax-uid to multipart items --- src/backend/dimse/wado.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/backend/dimse/wado.rs b/src/backend/dimse/wado.rs index 90ca34a..c1a58b8 100644 --- a/src/backend/dimse/wado.rs +++ b/src/backend/dimse/wado.rs @@ -308,8 +308,13 @@ impl<'a> DicomMultipartStream<'a> { let mut buffer = Vec::new(); writeln!(buffer, "--boundary\r")?; - writeln!(buffer, "Content-Type: application/dicom\r")?; - writeln!(buffer, "Content-Length: {file_length}\r")?; + writeln!( + buffer, + "Content-Type: {}; transfer-syntax=\"{}\"\r", + "application/dicom", + file.meta().transfer_syntax + )?; + writeln!(buffer, "Content-Length: {}\r", file_length)?; writeln!(buffer, "\r")?; buffer.append(&mut dcm); writeln!(buffer, "\r")?; From e61db5d5ccb9dcf9e1d0df656e4379bd098f3e83 Mon Sep 17 00:00:00 2001 From: Stephan Vedder Date: Thu, 5 Feb 2026 09:47:12 +0100 Subject: [PATCH 3/4] feat(transcode): trim any trailing null in the transfer_syntax --- src/backend/dimse/wado.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/dimse/wado.rs b/src/backend/dimse/wado.rs index c1a58b8..d8e2561 100644 --- a/src/backend/dimse/wado.rs +++ b/src/backend/dimse/wado.rs @@ -312,7 +312,7 @@ impl<'a> DicomMultipartStream<'a> { buffer, "Content-Type: {}; transfer-syntax=\"{}\"\r", "application/dicom", - file.meta().transfer_syntax + file.meta().transfer_syntax.trim_end_matches('\0') )?; writeln!(buffer, "Content-Length: {}\r", file_length)?; writeln!(buffer, "\r")?; From c7759c21331f76dfd1269b571694ace89bc5fa89 Mon Sep 17 00:00:00 2001 From: Stephan Vedder Date: Mon, 23 Feb 2026 15:33:41 +0100 Subject: [PATCH 4/4] feat(transcode): clippy fixes --- src/api/wado/service.rs | 6 +++--- src/backend/dimse/wado.rs | 16 ++++++---------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/api/wado/service.rs b/src/api/wado/service.rs index 253b544..11b8054 100644 --- a/src/api/wado/service.rs +++ b/src/api/wado/service.rs @@ -52,10 +52,10 @@ pub enum RetrieveError { impl IntoResponse for RetrieveError { fn into_response(self) -> Response { match self { - RetrieveError::Backend { source } => { + Self::Backend { source } => { (StatusCode::INTERNAL_SERVER_ERROR, source.to_string()).into_response() } - RetrieveError::Unimplemented => Response::builder() + Self::Unimplemented => Response::builder() .status(StatusCode::NOT_IMPLEMENTED) .body(Body::from("This transaction is not implemented.")) .unwrap(), @@ -160,7 +160,7 @@ where /// Extracts the transfer-syntax parameter from an Accept header value. /// -/// According to https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_8.7.3.5.2.html +/// According to /// the syntax is: transfer-syntax-mtp = OWS ";" OWS %s"transfer-syntax=" ts-value /// /// Examples: diff --git a/src/backend/dimse/wado.rs b/src/backend/dimse/wado.rs index d8e2561..4cab123 100644 --- a/src/backend/dimse/wado.rs +++ b/src/backend/dimse/wado.rs @@ -269,19 +269,16 @@ impl<'a> DicomMultipartStream<'a> { + 'a, transfer_syntax_uid: Option<&str>, ) -> Self { - let transfer_syntax_uid = transfer_syntax_uid - .map(|ts_uid| TransferSyntaxRegistry.get(&ts_uid)) - .flatten(); + let transfer_syntax_uid = + transfer_syntax_uid.and_then(|ts_uid| TransferSyntaxRegistry.get(ts_uid)); let multipart_stream = stream .map(move |item| { - let transfer_syntax_uid = transfer_syntax_uid.clone(); + let transfer_syntax_uid = transfer_syntax_uid; item.and_then(|object| { if let Some(ts) = transfer_syntax_uid { let mut transcoded = (*object).clone(); - transcoded - .transcode(&ts) - .map_err(|err| MoveError::Transcode(err))?; + transcoded.transcode(ts).map_err(MoveError::Transcode)?; Self::write(&transcoded) .map_err(|err| MoveError::Write(WriteError::Io(err))) } else { @@ -310,11 +307,10 @@ impl<'a> DicomMultipartStream<'a> { writeln!(buffer, "--boundary\r")?; writeln!( buffer, - "Content-Type: {}; transfer-syntax=\"{}\"\r", - "application/dicom", + "Content-Type: application/dicom; transfer-syntax=\"{}\"\r", file.meta().transfer_syntax.trim_end_matches('\0') )?; - writeln!(buffer, "Content-Length: {}\r", file_length)?; + writeln!(buffer, "Content-Length: {file_length}\r")?; writeln!(buffer, "\r")?; buffer.append(&mut dcm); writeln!(buffer, "\r")?;