changeset 6630:742fb41bff59 case-sensitive-find

merged default -> case-sensitive-find
author Alain Mazy <am@orthanc.team>
date Thu, 19 Mar 2026 12:31:58 +0100
parents e07596a97012 (current diff) 71400f1c6143 (diff)
children f0fec684ad91
files NEWS OrthancFramework/Sources/Toolbox.cpp OrthancFramework/Sources/Toolbox.h OrthancFramework/UnitTestsSources/FrameworkTests.cpp OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp
diffstat 90 files changed, 2864 insertions(+), 499 deletions(-) [+]
line wrap: on
line diff
--- a/INSTALL	Tue Jan 20 16:50:25 2026 +0100
+++ b/INSTALL	Thu Mar 19 12:31:58 2026 +0100
@@ -76,7 +76,8 @@
 
 # cd [...]\Orthanc\Build
 # cmake -DSTANDALONE_BUILD=ON -DSTATIC_BUILD=ON -DALLOW_DOWNLOADS=ON \
-  -DUSE_LEGACY_JSONCPP=ON -DUSE_LEGACY_BOOST=ON -G "Visual Studio 9 2008" [...]\OrthancServer
+  -DUSE_LEGACY_JSONCPP=ON -DUSE_LEGACY_BOOST=ON -DDCMTK_STATIC_VERSION=3.6.9 \
+  -G "Visual Studio 9 2008" [...]\OrthancServer
 
 Then open the "[...]\Orthanc\Build\Orthanc.sln" with Visual Studio.
 
--- a/NEWS	Tue Jan 20 16:50:25 2026 +0100
+++ b/NEWS	Thu Mar 19 12:31:58 2026 +0100
@@ -4,6 +4,8 @@
 General
 -------
 
+* New experimental configuration "PatientLevelEnabled" (TODO: work in progree)
+
 REST API
 --------
 
@@ -21,25 +23,54 @@
   - Improved behavior wrt to non latin searches and `CaseSensitive` searches that can 
     now be combined with `Since`.
   WIP: this needs to be validated from a performance point of view !
+* New option "Utf8" available in the "{...}/archive" and "/tools/create-archive" routes
+  to use UTF-8 filenames in the generated ZIP archives. It defaults to the value
+  of the new configuration option "ZipUseUtf8".
+* Support for OF and OD value representations in "/instances/{...}/file" with content type
+  "application/dicom+json" (i.e. DICOMweb) and in "/instances/{...}/tags" (contribution by
+  Yusuf Sayıta, Philips).
+* New "Content.Resources" field in "DicomModalityStore", "OrthancPeerStore" and "ResourceModification" 
+  jobs that contains JSON objects with "ID" and "Type" of each resource.  The "Content.ParentResources"
+  field that only contains the IDs is preserved for backward compatibility.
+
+Lua
+---
+
+* Added a new "OutgoingCStoreInstanceFilter()" function. (TODO: document in the book + implement in plugins SDK)
+
+Plugins
+-------
+
+* When listing the contents of a folder in WebDAV, it is possible to use the special value
+  ORTHANC_PLUGINS_WEBDAV_UNKNOWN_FILE_SIZE if the file size is unknown prior to download.
 
 Maintenance
 -----------
 
 * Fix "LimitFindResults" and "LimitFindInstances" configurations that were not
   applied to C-FIND (bug introduced in 1.12.5).
-* Allow executing multiple "/queries/../answers/../retrieve" in parallel
+* Allow multiple calls to "/queries/../answers/../retrieve" in parallel
   https://discourse.orthanc-server.org/t/synchronous-jobs-limited-to-one-at-a-time/6365
-* Fix /tools/lookup and OrthancPluginLookupPatient that were considering '1234', '_1234' and '%1234'
-  as the same PatientID.
+* Fix "/tools/lookup" and "OrthancPluginLookupPatient()" that were considering
+  "1234", "_1234" and "%1234" as the same PatientID.
+* libcurl: Enabling IPv6 if linking statically
+  https://github.com/orthanc-server/orthanc-builder/issues/33
+* Fix /tools/reset fails to restart server with error "Internal error: Cannot initialize ICU: U_INVALID_FORMAT_ERROR"
+  https://orthanc.uclouvain.be/bugs/show_bug.cgi?id=255
+* Save the jobs registry in DB only if it has changed.
+  https://discourse.orthanc-server.org/t/frequent-idle-messages-between-postgres-and-orthanc/6406
+* New CMake option: "USE_SYSTEM_MINIZIP" to use the system-wide version of minizip
 * Upgraded dependencies for static builds:
   - boost 1.89.0
   - dcmtk 3.7.0
-* LibCurl: now enabling ipv6 when linking statically.
-  https://github.com/orthanc-server/orthanc-builder/issues/33
-  
+  - minizip 1.3.2
 
 NB: To maintain compatibility with Windows XP, which is not supported
-by DCMTK 3.7.0, Windows 32-bit builds still use DCMTK 3.6.9.
+by DCMTK 3.7.0, Windows 32-bit builds must use
+"-DDCMTK_STATIC_VERSION=3.6.9" during the invocation of CMake. This is
+because the citrus library of DCMTK makes use of SRWLOCK and
+associated functions such as AcquireSRWLockShared(), which were only
+introduced in Windows Vista and Windows Server 2008.
 
 
 Version 1.12.10 (2025-11-26)
@@ -1713,7 +1744,7 @@
 * API version has been upgraded to 3
 * "/modalities/{id}/query": New argument "Normalize" can be set to "false"
   to bypass the automated correction of outgoing C-FIND queries
-* Reporting of "ParentResources" in "DicomModalityStore" and "DicomModalityStore" jobs
+* Reporting of "ParentResources" in "DicomModalityStore", "OrthancPeerStore" and "ResourceModification" jobs
 
 Plugins
 -------
--- a/OrthancFramework/Resources/CMake/DownloadOrthancFramework.cmake	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/CMake/DownloadOrthancFramework.cmake	Thu Mar 19 12:31:58 2026 +0100
@@ -231,6 +231,11 @@
         # for BlockingSharedMessageQueue.WaitEmpty()
         set(ORTHANC_FRAMEWORK_PRE_RELEASE ON)
         set(ORTHANC_FRAMEWORK_MD5 "c037cd2ddbe1b65b431692855483161b")
+      elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "56eb61c86f93")
+        # OE2 1.11.0 (framework post-1.12.10)
+        # for HttpClient that returns the answer body in case of HTTP error
+        set(ORTHANC_FRAMEWORK_PRE_RELEASE ON)
+        set(ORTHANC_FRAMEWORK_MD5 "665f8aa70d7c5091bc20da37cf664910")
       endif()
     endif()
   endif()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Resources/CMake/MinizipConfiguration.cmake	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,53 @@
+# Orthanc - A Lightweight, RESTful DICOM Store
+# Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+# Department, University Hospital of Liege, Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
+# Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program. If not, see
+# <http://www.gnu.org/licenses/>.
+
+
+if (NOT ENABLE_ZLIB)
+  message(FATAL_ERROR "This file cannot be used if zlib is disabled")
+endif()
+
+if (STATIC_BUILD OR
+    ORTHANC_SANDBOXED OR   # For WebAssembly
+    NOT USE_SYSTEM_MINIZIP)
+  add_definitions(-DORTHANC_USE_SYSTEM_MINIZIP=0)
+
+  list(APPEND ORTHANC_CORE_SOURCES_DEPENDENCIES
+    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/ioapi.c
+    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/unzip.c
+    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/zip.c
+    )
+
+else()
+  add_definitions(-DORTHANC_USE_SYSTEM_MINIZIP=1)
+
+  CHECK_INCLUDE_FILE_CXX(minizip/zip.h HAVE_MINIZIP_H)
+  if (NOT HAVE_MINIZIP_H)
+    message(FATAL_ERROR "Please install the libminizip-dev package")
+  endif()
+
+  CHECK_LIBRARY_EXISTS(minizip "zipOpen2_64" "" HAVE_MINIZIP_LIB)
+  if (NOT HAVE_MINIZIP_LIB)
+    message(FATAL_ERROR "Please install the libminizip-dev package")
+  endif()
+
+  link_libraries(minizip)
+
+endif()
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Thu Mar 19 12:31:58 2026 +0100
@@ -752,13 +752,9 @@
   ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/base64/base64.cpp
   )
 
-if (ENABLE_ZLIB AND NOT ORTHANC_SANDBOXED)
-  list(APPEND ORTHANC_CORE_SOURCES_DEPENDENCIES
-    # This is the minizip distribution to create/decode ZIP files using zlib
-    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/ioapi.c
-    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/unzip.c
-    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/zip.c
-    )
+if (ENABLE_ZLIB)
+  # This is the minizip distribution to create/decode ZIP files using zlib
+  include(${CMAKE_CURRENT_LIST_DIR}/MinizipConfiguration.cmake)
 endif()
 
 
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkParameters.cmake	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkParameters.cmake	Thu Mar 19 12:31:58 2026 +0100
@@ -69,6 +69,7 @@
 set(USE_SYSTEM_LIBP11 OFF CACHE BOOL "Use the system version of libp11 (PKCS#11 wrapper library)")
 set(USE_SYSTEM_LIBPNG ON CACHE BOOL "Use the system version of libpng")
 set(USE_SYSTEM_LUA ON CACHE BOOL "Use the system version of Lua")
+set(USE_SYSTEM_MINIZIP OFF CACHE BOOL "Use the system version minizip (new in Orthanc 1.12.11)")
 set(USE_SYSTEM_MONGOOSE ON CACHE BOOL "Use the system version of Mongoose")
 set(USE_SYSTEM_OPENSSL ON CACHE BOOL "Use the system version of OpenSSL")
 set(USE_SYSTEM_PROTOBUF ON CACHE BOOL "Use the system version of Google Protocol Buffers")
@@ -77,21 +78,8 @@
 set(USE_SYSTEM_UUID ON CACHE BOOL "Use the system version of the uuid library from e2fsprogs")
 set(USE_SYSTEM_ZLIB ON CACHE BOOL "Use the system version of ZLib")
 
-# Parameters specific to DCMTK
-if (WIN32 AND
-    CMAKE_SIZEOF_VOID_P EQUAL 4)
-  # This is because the citrus library of DCMTK makes use of SRWLOCK
-  # and associated functions such as AcquireSRWLockShared(), which
-  # were only introduced in Windows Vista and Windows Server 2008. The
-  # Orthanc project wants to preserve compatibility with Windows XP.
-  message(STATUS "As you are targeting Windows 32bit, the default version of DCMTK is 3.6.9")
-  set(DCMTK_STATIC_VERSION_DEFAULT "3.6.9")
-else()
-  set(DCMTK_STATIC_VERSION_DEFAULT "3.7.0")
-endif()
-
 set(DCMTK_DICTIONARY_DIR "" CACHE PATH "Directory containing the DCMTK dictionaries \"dicom.dic\" and \"private.dic\" (only when using system version of DCMTK)")
-set(DCMTK_STATIC_VERSION "${DCMTK_STATIC_VERSION_DEFAULT}" CACHE STRING "Version of DCMTK to be used in static builds (can be \"3.6.0\", \"3.6.2\", \"3.6.4\", \"3.6.5\", \"3.6.6\", \"3.6.7\", \"3.6.8\", \"3.6.9\", or \"3.7.0\")")
+set(DCMTK_STATIC_VERSION "3.7.0" CACHE STRING "Version of DCMTK to be used in static builds (can be \"3.6.0\", \"3.6.2\", \"3.6.4\", \"3.6.5\", \"3.6.6\", \"3.6.7\", \"3.6.8\", \"3.6.9\", or \"3.7.0\")")
 set(USE_DCMTK_362_PRIVATE_DIC ON CACHE BOOL "Use the dictionary of private tags from DCMTK 3.6.2 if using DCMTK 3.6.0")
 set(USE_SYSTEM_DCMTK ON CACHE BOOL "Use the system version of DCMTK")
 set(ENABLE_DCMTK_LOG ON CACHE BOOL "Enable logging internal to DCMTK")
--- a/OrthancFramework/Resources/ThirdParty/minizip/NOTES	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/NOTES	Thu Mar 19 12:31:58 2026 +0100
@@ -1,2 +1,2 @@
-These files come from the "contrib/minizip" directory in zlib 1.3+.
-It was last synced on this commit: https://github.com/madler/zlib/commit/73331a6a0481067628f065ffe87bb1d8f787d10c.
+These files come from the "contrib/minizip" directory in zlib.
+The Python script "./SyncMinizip.py" can be used to update the version of minizip.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Resources/ThirdParty/minizip/SyncMinizip.py	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+
+import os
+import requests
+
+ZLIB_VERSION = '1.3.2'
+URL = 'https://raw.githubusercontent.com/madler/zlib/refs/tags/v%s/contrib/minizip' % ZLIB_VERSION
+TARGET = os.path.join(os.path.dirname(__file__))
+
+for f in [
+        'crypt.h',
+        'ioapi.c',
+        'ioapi.h',
+        'skipset.h',
+        'unzip.c',
+        'unzip.h',
+        'zip.c',
+        'zip.h',
+]:
+    r = requests.get('%s/%s' % (URL, f))
+    r.raise_for_status()
+    with open(os.path.join(TARGET, f), 'wb') as f:
+        f.write(r.content)
--- a/OrthancFramework/Resources/ThirdParty/minizip/crypt.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/crypt.h	Thu Mar 19 12:31:58 2026 +0100
@@ -1,9 +1,6 @@
 /* crypt.h -- base code for crypt/uncrypt ZIPfile
 
-
-   Version 1.01e, February 12th, 2005
-
-   Copyright (C) 1998-2005 Gilles Vollant
+   Copyright (C) 1998-2026 Gilles Vollant
 
    This code is a modified version of crypting code in Infozip distribution
 
@@ -23,7 +20,7 @@
    This code support the "Traditional PKWARE Encryption".
 
    The new AES encryption added on Zip format by Winzip (see the page
-   http://www.winzip.com/aes_info.htm ) and PKWare PKZip 5.x Strong
+   https://www.winzip.com/aes_info.htm ) and PKWare PKZip 5.x Strong
    Encryption is not supported.
 */
 
@@ -50,7 +47,7 @@
     (*(pkeys+1)) += (*(pkeys+0)) & 0xff;
     (*(pkeys+1)) = (*(pkeys+1)) * 134775813L + 1;
     {
-      register int keyshift = (int)((*(pkeys+1)) >> 24);
+      int keyshift = (int)((*(pkeys+1)) >> 24);
       (*(pkeys+2)) = CRC32((*(pkeys+2)), keyshift);
     }
     return c;
@@ -106,7 +103,7 @@
      */
     if (++calls == 1)
     {
-        srand((unsigned)(time(NULL) ^ ZCR_SEED2));
+        srand((unsigned)time(NULL) ^ ZCR_SEED2);
     }
     init_keys(passwd, pkeys, pcrc_32_tab);
     for (n = 0; n < RAND_HEAD_LEN-2; n++)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Resources/ThirdParty/minizip/ints.h	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,16 @@
+#ifndef INTS_H
+#define INTS_H
+
+#include <stdint.h>   /* This one is provided by Orthanc */
+
+typedef int16_t   i16_t;
+typedef int32_t   i32_t;
+typedef int64_t   i64_t;
+typedef int8_t    i8_t;
+
+typedef uint16_t  ui16_t;
+typedef uint32_t  ui32_t;
+typedef uint64_t  ui64_t;
+typedef uint8_t   ui8_t;
+
+#endif
--- a/OrthancFramework/Resources/ThirdParty/minizip/ioapi.c	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/ioapi.c	Thu Mar 19 12:31:58 2026 +0100
@@ -1,10 +1,10 @@
 /* ioapi.h -- IO base function header for compress/uncompress .zip
-   part of the MiniZip project - ( http://www.winimage.com/zLibDll/minizip.html )
+   part of the MiniZip project - ( https://www.winimage.com/zLibDll/minizip.html )
 
-         Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html )
+         Copyright (C) 1998-2026 Gilles Vollant (minizip) ( https://www.winimage.com/zLibDll/minizip.html )
 
          Modifications for Zip64 support
-         Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com )
+         Copyright (C) 2009-2010 Mathias Svensson ( https://result42.com )
 
          For more info read MiniZip_info.txt
 
@@ -14,8 +14,8 @@
         #define _CRT_SECURE_NO_WARNINGS
 #endif
 
-#if defined(__APPLE__) || defined(IOAPI_NO_64) || defined(__HAIKU__) || defined(MINIZIP_FOPEN_NO_64)
-// In darwin and perhaps other BSD variants off_t is a 64 bit value, hence no need for specific 64 bit functions
+#if defined(__APPLE__) || defined(IOAPI_NO_64) || defined(__HAIKU__) || defined(MINIZIP_FOPEN_NO_64) || (defined(__ANDROID_API__) && __ANDROID_API__ < 24)
+/* In darwin and perhaps other BSD variants off_t is a 64 bit value, hence no need for specific 64 bit functions */
 #define FOPEN_FUNC(filename, mode) fopen(filename, mode)
 #define FTELLO_FUNC(stream) ftello(stream)
 #define FSEEKO_FUNC(stream, offset, origin) fseeko(stream, offset, origin)
--- a/OrthancFramework/Resources/ThirdParty/minizip/ioapi.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/ioapi.h	Thu Mar 19 12:31:58 2026 +0100
@@ -1,10 +1,10 @@
 /* ioapi.h -- IO base function header for compress/uncompress .zip
-   part of the MiniZip project - ( http://www.winimage.com/zLibDll/minizip.html )
+   part of the MiniZip project - ( https://www.winimage.com/zLibDll/minizip.html )
 
-         Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html )
+         Copyright (C) 1998-2026 Gilles Vollant (minizip) ( https://www.winimage.com/zLibDll/minizip.html )
 
          Modifications for Zip64 support
-         Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com )
+         Copyright (C) 2009-2010 Mathias Svensson ( https://result42.com )
 
          For more info read MiniZip_info.txt
 
@@ -18,13 +18,13 @@
 
 */
 
-#ifndef _ZLIBIOAPI64_H
-#define _ZLIBIOAPI64_H
+#ifndef ZLIBIOAPI64_H
+#define ZLIBIOAPI64_H
 
-#if (!defined(_WIN32)) && (!defined(WIN32)) && (!defined(__APPLE__))
+#if (!defined(_WIN32)) && (!defined(WIN32)) && (!defined(__APPLE__)) && (!(defined(__ANDROID_API__) || __ANDROID_API__ >= 24))
 
-  // Linux needs this to support file operation on files larger then 4+GB
-  // But might need better if/def to select just the platforms that needs them.
+  /* Linux needs this to support file operation on files larger then 4+GB */
+  /* But might need better if/def to select just the platforms that needs them.*/
 
         #ifndef __USE_FILE_OFFSET64
                 #define __USE_FILE_OFFSET64
@@ -67,39 +67,12 @@
 #endif
 #endif
 
-/*
-#ifndef ZPOS64_T
-  #ifdef _WIN32
-                #define ZPOS64_T fpos_t
-  #else
-    #include <stdint.h>
-    #define ZPOS64_T uint64_t
-  #endif
-#endif
-*/
-
 #ifdef HAVE_MINIZIP64_CONF_H
 #include "mz64conf.h"
 #endif
 
-/* a type chosen by DEFINE */
-#ifdef HAVE_64BIT_INT_CUSTOM
-typedef  64BIT_INT_CUSTOM_TYPE ZPOS64_T;
-#else
-#ifdef HAS_STDINT_H
-#include "stdint.h"
-typedef uint64_t ZPOS64_T;
-#else
-
-
-
-#if defined(_MSC_VER) || defined(__BORLANDC__)
-typedef unsigned __int64 ZPOS64_T;
-#else
-typedef unsigned long long int ZPOS64_T;
-#endif
-#endif
-#endif
+#include "ints.h"
+typedef ui64_t ZPOS64_T;
 
 /* Maximum unsigned 32-bit value used as placeholder for zip64 */
 #ifndef MAXU32
@@ -188,8 +161,8 @@
 
 #define ZREAD64(filefunc,filestream,buf,size)     ((*((filefunc).zfile_func64.zread_file))   ((filefunc).zfile_func64.opaque,filestream,buf,size))
 #define ZWRITE64(filefunc,filestream,buf,size)    ((*((filefunc).zfile_func64.zwrite_file))  ((filefunc).zfile_func64.opaque,filestream,buf,size))
-//#define ZTELL64(filefunc,filestream)            ((*((filefunc).ztell64_file)) ((filefunc).opaque,filestream))
-//#define ZSEEK64(filefunc,filestream,pos,mode)   ((*((filefunc).zseek64_file)) ((filefunc).opaque,filestream,pos,mode))
+/*#define ZTELL64(filefunc,filestream)            ((*((filefunc).ztell64_file)) ((filefunc).opaque,filestream)) */
+/*#define ZSEEK64(filefunc,filestream,pos,mode)   ((*((filefunc).zseek64_file)) ((filefunc).opaque,filestream,pos,mode)) */
 #define ZCLOSE64(filefunc,filestream)             ((*((filefunc).zfile_func64.zclose_file))  ((filefunc).zfile_func64.opaque,filestream))
 #define ZERROR64(filefunc,filestream)             ((*((filefunc).zfile_func64.zerror_file))  ((filefunc).zfile_func64.opaque,filestream))
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Resources/ThirdParty/minizip/skipset.h	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,372 @@
+/* skipset.h -- set operations using a skiplist
+// Copyright (C) 2024-2026 Mark Adler
+// See MiniZip_info.txt for the license.
+
+// This implements a skiplist set, i.e. just keys, no data, with ~O(log n) time
+// insert and search operations. The application defines the type of a key, and
+// provides a function to compare two keys.
+
+// This header is not definitions of functions found in another source file --
+// it creates the set functions, with the application's key type, right where
+// the #include is. Before this header is #included, these must be defined:
+//
+// 1. A macro or typedef for set_key_t, the type of a key.
+// 2. A macro or function set_cmp(a, b) to compare two keys. The return values
+//    are < 0 for a < b, 0 for a == b, and > 0 for a > b.
+// 3. A macro or function set_drop(s, k) to release the key k's resources, if
+//    any, when doing a set_end() or set_clear(). s is a pointer to the set
+//    that key is in, for use with set_free() if desired.
+//
+// Example usage:
+//
+//      typedef int set_key_t;
+//      #define set_cmp(a, b) ((a) < (b) ? -1 : (a) == (b) ? 0 : 1)
+//      #define set_drop(s, k)
+//      #include "skipset.h"
+//
+//      int test(void) {        // return 0: good, 1: bad, -1: out of memory
+//          set_t set;
+//          if (setjmp(set.env))
+//              return -1;
+//          set_start(&set);
+//          set_insert(&set, 2);
+//          set_insert(&set, 1);
+//          set_insert(&set, 7);
+//          int bad = !set_found(&set, 2);
+//          bad = bad || set_found(&set, 5);
+//          set_end(&set);
+//          return bad;
+//      }
+//
+// Interface summary (see more details below):
+// - set_t is the type of the set being operated on (a set_t pointer is passed)
+// - set_start() initializes a new, empty set (initialize set.env first)
+// - set_insert() inserts a new key into the set, or not if it's already there
+// - set_found() determines whether or not a key is in the set
+// - set_end() ends the use of the set, freeing all memory
+// - set_clear() empties the set, equivalent to set_end() and then set_start()
+// - set_ok() checks if set appears to be usable, i.e. started and not ended
+//
+// Auxiliary functions available to the application:
+// - set_alloc() allocates memory with optional tracking (#define SET_TRACK)
+// - set_free() deallocates memory allocated by set_alloc()
+// - set_rand() returns 32 random bits (seeded by set_start()) */
+
+#ifndef SKIPSET_H
+#define SKIPSET_H
+
+#include <stdlib.h>     /* realloc(), free(), NULL, size_t */
+#include <stddef.h>     /* ptrdiff_t */
+#include <setjmp.h>     /* jmp_buf, longjmp() */
+#include <errno.h>      /* ENOMEM */
+#include <time.h>       /* time(), clock() */
+#include <assert.h>     /* assert.h */
+#include "ints.h"       /* i16_t, ui32_t, ui64_t */
+
+/* Structures and functions below noted as "--private--" should not be used by
+// the application. set_t is partially private and partially public -- see the
+// comments there.
+
+// There is no POSIX random() in MSVC, and rand() is awful. For portability, we
+// cannot rely on a library function for random numbers. Instead we use the
+// fast and effective algorithm below, invented by Melissa O'Neill.
+
+// *Really* minimal PCG32 code / (c) 2014 M.E. O'Neill / www.pcg-random.org
+// Licensed under Apache License 2.0 (NO WARRANTY, etc. see website)
+// --private-- Random number generator state. */
+typedef struct {
+    ui64_t state;       /* 64-bit generator state */
+    ui64_t inc;         /* 63-bit sequence id */
+} set_rand_t;
+/* --private-- Initialize the state *gen using seed and seq. seed seeds the
+// advancing 64-bit state. seq is a sequence selection constant. */
+void set_seed(set_rand_t *gen, ui64_t seed, ui64_t seq) {
+    gen->inc = (seq << 1) | 1;
+    gen->state = (seed + gen->inc) * 6364136223846793005ULL + gen->inc;
+}
+/* Start a unique random number sequence using bits from noise sources. */
+void set_uniq(set_rand_t *gen, const void *ptr) {
+    set_seed(gen, ((ui64_t)(ptrdiff_t)ptr << 32) ^
+                  ((ui64_t)time(NULL) << 12) ^ clock(), 0);
+}
+/* Return 32 random bits, advancing the state *gen. */
+ui32_t set_rand(set_rand_t *gen) {
+    ui32_t mix;
+    int rot;
+    ui64_t state = gen->state;
+    gen->state = state * 6364136223846793005ULL + gen->inc;
+    mix = (ui32_t)(((state >> 18) ^ state) >> 27);
+    rot = state >> 59;
+    return (mix >> rot) | (mix << ((-rot) & 31));
+}
+/* End of PCG32 code. */
+
+/* --private-- Linked-list node. */
+typedef struct set_node_s set_node_t;
+struct set_node_s {
+    set_key_t key;          /* the key (not used for head or path) */
+    i16_t size;             /* number of allocated pointers in right[] */
+    i16_t fill;             /* number of pointers in right[] filled in */
+    set_node_t **right;     /* pointer for each level, each to the right */
+};
+
+/* A set. The application sets env, may use gen with set_rand(), and may read
+// allocs and memory. The remaining variables are --private-- . */
+typedef struct set_s {
+    set_node_t *head;       /* skiplist head -- no key, just links */
+    set_node_t *path;       /* right[] is path to key from set_found() */
+    set_node_t *node;       /* node under construction, in case of longjmp() */
+    i16_t depth;            /* maximum depth of the skiplist */
+    ui64_t ran;             /* a precious trove of random bits */
+    set_rand_t gen;         /* random number generator state */
+    jmp_buf env;            /* setjmp() environment for allocation errors */
+#ifdef SET_TRACK
+    size_t allocs;          /* number of allocations */
+    size_t memory;          /* total amount of allocated memory (>= requests) */
+#endif
+} set_t;
+
+/* Memory allocation and deallocation. set_alloc(set, ptr, size) returns a
+// pointer to an allocation of size bytes if ptr is NULL, or the previous
+// allocation ptr resized to size bytes. set_alloc() will never return NULL.
+// set_free(set, ptr) frees an allocation created by set_alloc(). These may be
+// used by the application. e.g. if allocation tracking is desired. */
+#ifdef SET_TRACK
+/* Track the number of allocations and the total backing memory size. */
+#  if defined(_WIN32)
+#    include <malloc.h>
+#    define SET_ALLOC_SIZE(ptr) _msize(ptr)
+#  elif defined(__MACH__)
+#    include <malloc/malloc.h>
+#    define SET_ALLOC_SIZE(ptr) malloc_size(ptr)
+#  elif defined(__linux__)
+#    include <malloc.h>
+#    define SET_ALLOC_SIZE(ptr) malloc_usable_size(ptr)
+#  elif defined(__FreeBSD__)
+#    include <malloc_np.h>
+#    define SET_ALLOC_SIZE(ptr) malloc_usable_size(ptr)
+#  elif defined(__NetBSD__)
+#    include <jemalloc/jemalloc.h>
+#    define SET_ALLOC_SIZE(ptr) malloc_usable_size(ptr)
+#  else     // e.g. OpenBSD
+#    define SET_ALLOC_SIZE(ptr) 0
+#  endif
+// With tracking.
+void *set_alloc(set_t *set, void *ptr, size_t size) {
+    size_t had = ptr == NULL ? 0 : SET_ALLOC_SIZE(ptr);
+    void *mem = realloc(ptr, size);
+    if (mem == NULL)
+        longjmp(set->env, ENOMEM);
+    set->allocs += ptr == NULL;
+    set->memory += SET_ALLOC_SIZE(mem) - had;
+    return mem;
+}
+void set_free(set_t *set, void *ptr) {
+    if (ptr != NULL) {
+        set->allocs--;
+        set->memory -= SET_ALLOC_SIZE(ptr);
+        free(ptr);
+    }
+}
+#else
+/* Without tracking. */
+void *set_alloc(set_t *set, void *ptr, size_t size) {
+    void *mem = realloc(ptr, size);
+    if (mem == NULL)
+        longjmp(set->env, ENOMEM);
+    return mem;
+}
+void set_free(set_t *set, void *ptr) {
+    (void)set;
+    free(ptr);
+}
+#endif
+
+/* --private-- Grow node's array right[] as needed to be able to hold at least
+// want links. If fill is true, assure that the first want links are filled in,
+// setting them to set->head if not previously filled in. Otherwise it is
+// assumed that the first want links are about to be filled in. */
+void set_grow(set_t *set, set_node_t *node, int want, int fill) {
+    int i;
+    if (node->size < want) {
+        int more = node->size ? node->size : 1;
+        while (more < want)
+            more <<= 1;
+        node->right = set_alloc(set, node->right,
+                                (size_t)more * sizeof(set_node_t *));
+        node->size = (i16_t)more;
+    }
+    if (fill)
+        for (i = node->fill; i < want; i++)
+            node->right[i] = set->head;
+    node->fill = (i16_t)want;
+}
+
+/* --private-- Return a new node. key is left uninitialized. */
+set_node_t *set_node(set_t *set) {
+    set_node_t *node = set_alloc(set, NULL, sizeof(set_node_t));
+    node->size = 0;
+    node->fill = 0;
+    node->right = NULL;
+    return node;
+}
+
+/* --private-- Free the list linked from head, along with the keys. */
+void set_sweep(set_t *set) {
+    set_node_t *step = set->head->right[0];
+    while (step != set->head) {
+        set_node_t *next = step->right[0];      /* save link to next node */
+        set_drop(set, step->key);
+        set_free(set, step->right);
+        set_free(set, step);
+        step = next;
+    }
+}
+
+/* Initialize a new set. set->env must be initialized using setjmp() before
+// set_start() is called. A longjmp(set->env, ENOMEM) will be used to handle a
+// memory allocation failure during any of the operations. (See setjmp.h and
+// errno.h.) The set can still be used if this happens, assuming that it didn't
+// happen during set_start(). Whether set_start() completed or not, set_end()
+// can be used to free the set's memory after a longjmp(). */
+void set_start(set_t *set) {
+#ifdef SET_TRACK
+    set->allocs = 0;
+    set->memory = 0;
+#endif
+    set->head = set->path = set->node = NULL;   /* in case set_node() fails */
+    set->path = set_node(set);
+    set->head = set_node(set);
+    set_grow(set, set->head, 1, 1); /* one link back to head for an empty set */
+    *(unsigned char *)&set->head->key = 137;    /* set id */
+    set->depth = 0;
+    set_uniq(&set->gen, set);
+    set->ran = 1;
+}
+
+/* Return true if *set appears to be in a usable state. If *set has been zeroed
+// out, then set_ok(set) will be false and set_end(set) will be safe. */
+int set_ok(set_t *set) {
+    return set->head != NULL &&
+           set->head->right != NULL &&
+           *(unsigned char *)&set->head->key == 137;
+}
+
+/* Empty the set. This frees the memory used for the previous set contents.
+// After set_clear(), *set is ready for use, as if after a set_start(). */
+void set_clear(set_t *set) {
+    assert(set_ok(set) && "improper use");
+
+    /* Free all the keys and their nodes. */
+    set_sweep(set);
+
+    /* Leave the head and path allocations as is. Clear their contents, with
+    // head pointing to itself and setting depth to zero, for an empty set. */
+    set->head->right[0] = set->head;
+    set->head->fill = 1;
+    set->path->fill = 0;
+    set->depth = 0;
+}
+
+/* Done using the set -- free all allocations. The only operation on *set
+// permitted after this is set_start(). Though another set_end() would do no
+// harm. This can be done at any time after a set_start(), or after a longjmp()
+// on any allocation failure, including during a set_start(). */
+void set_end(set_t *set) {
+    if (set->head != NULL) {
+        /* Empty the set and free the head node. */
+        if (set->head->right != NULL) {
+            set_sweep(set);
+            set_free(set, set->head->right);
+        }
+        set_free(set, set->head);
+        set->head = NULL;
+    }
+    if (set->path != NULL) {
+        /* Free the path work area. */
+        set_free(set, set->path->right);
+        set_free(set, set->path);
+        set->path = NULL;
+    }
+    if (set->node != NULL) {
+        /* Free the node that was under construction when longjmp() hit. */
+        set_drop(set, set->node->key);
+        set_free(set, set->node->right);
+        set_free(set, set->node);
+        set->node = NULL;
+    }
+}
+
+/* Look for key. Return 1 if found or 0 if not. This also puts the path to get
+// there in set->path, for use by set_insert(). */
+int set_found(set_t *set, set_key_t key) {
+    set_node_t *head, *here;
+    int i;
+    assert(set_ok(set) && "improper use");
+
+    /* Start at depth and work down and right as determined by key comparisons. */
+    head = set->head;
+    here = head;
+    i = set->depth;
+    set_grow(set, set->path, i + 1, 0);
+    do {
+        while (here->right[i] != head &&
+               set_cmp(here->right[i]->key, key) < 0)
+            here = here->right[i];
+        set->path->right[i] = here;
+    } while (i--);
+
+    /* See if the key matches. */
+    here = here->right[0];
+    return here != head && set_cmp(here->key, key) == 0;
+}
+
+/* Insert the key key. Return 0 on success, or 1 if key is already in the set. */
+int set_insert(set_t *set, set_key_t key) {
+    int level = 0;
+    int bit;
+    int i;
+    assert(set_ok(set) && "improper use");
+
+    if (set_found(set, key))
+        /* That key is already in the set. */
+        return 1;
+
+    /* Randomly generate a new level-- level 0 with probability 1/2, 1 with
+    // probability 1/4, 2 with probability 1/8, etc. */
+    for (;;) {
+        if (set->ran == 1)
+            /* Ran out. Get another 32 random bits. */
+            set->ran = set_rand(&set->gen) | (1ULL << 32);
+        bit = set->ran & 1;
+        set->ran >>= 1;
+        if (bit)
+            break;
+        assert(level < 32767 &&
+               "Overhead, without any fuss, the stars were going out.");
+        level++;
+    }
+    if (level > set->depth) {
+        /* The maximum depth is now deeper. Update the structures. */
+        set_grow(set, set->path, level + 1, 1);
+        set_grow(set, set->head, level + 1, 1);
+        set->depth = (i16_t)level;
+    }
+
+    /* Make a new node for the provided key, and insert it in the lists up to
+    // and including level. */
+    set->node = set_node(set);
+    set->node->key = key;
+    set_grow(set, set->node, level + 1, 0);
+    for (i = 0; i <= level; i++) {
+        set->node->right[i] = set->path->right[i]->right[i];
+        set->path->right[i]->right[i] = set->node;
+    }
+    set->node = NULL;
+    return 0;
+}
+
+#else
+#error ** another skiplist set already created here
+/* Would need to implement a prefix in order to support multiple sets. */
+#endif
--- a/OrthancFramework/Resources/ThirdParty/minizip/unzip.c	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/unzip.c	Thu Mar 19 12:31:58 2026 +0100
@@ -1,14 +1,13 @@
 /* unzip.c -- IO for uncompress .zip files using zlib
-   Version 1.1, February 14h, 2010
-   part of the MiniZip project - ( http://www.winimage.com/zLibDll/minizip.html )
+   part of the MiniZip project - ( https://www.winimage.com/zLibDll/minizip.html )
 
-         Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html )
+         Copyright (C) 1998-2026 Gilles Vollant (minizip) ( https://www.winimage.com/zLibDll/minizip.html )
 
          Modifications of Unzip for Zip64
          Copyright (C) 2007-2008 Even Rouault
 
          Modifications for Zip64 support on both zip and unzip
-         Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com )
+         Copyright (C) 2009-2010 Mathias Svensson ( https://result42.com )
 
          For more info read MiniZip_info.txt
 
@@ -68,10 +67,9 @@
 #include <stdlib.h>
 #include <string.h>
 
-#ifndef NOUNCRYPT
-        #define NOUNCRYPT
+#ifdef ZLIB_DLL
+#  undef ZLIB_DLL
 #endif
-
 #include "zlib.h"
 #include "unzip.h"
 
@@ -92,7 +90,7 @@
 
 
 #ifndef CASESENSITIVITYDEFAULT_NO
-#  if !defined(unix) && !defined(CASESENSITIVITYDEFAULT_YES)
+#  if (!defined(__unix__) && !defined(__unix) || defined(__CYGWIN__))  && !defined(CASESENSITIVITYDEFAULT_YES)
 #    define CASESENSITIVITYDEFAULT_NO
 #  endif
 #endif
@@ -115,9 +113,9 @@
 
 
 const char unz_copyright[] =
-   " unzip 1.01 Copyright 1998-2004 Gilles Vollant - http://www.winimage.com/zLibDll";
+   " unzip 1.01 Copyright 1998-2004 Gilles Vollant - https://www.winimage.com/zLibDll/minizip.html";
 
-/* unz_file_info_interntal contain internal info about a file in zipfile*/
+/* unz_file_info64_internal contain internal info about a file in zipfile*/
 typedef struct unz_file_info64_internal_s
 {
     ZPOS64_T offset_curfile;/* relative offset of local header 8 bytes */
@@ -450,7 +448,7 @@
     if (unz64local_getLong(pzlib_filefunc_def,filestream,&uL)!=UNZ_OK)
         return CENTRALDIRINVALID;
 
-    /* number of the disk with the start of the zip64 end of  central directory */
+    /* number of the disk with the start of the zip64 end of central directory */
     if (unz64local_getLong(pzlib_filefunc_def,filestream,&uL)!=UNZ_OK)
         return CENTRALDIRINVALID;
     if (uL != 0)
@@ -497,9 +495,9 @@
     ZPOS64_T central_pos;
     uLong   uL;
 
-    uLong number_disk;          /* number of the current dist, used for
+    uLong number_disk;          /* number of the current disk, used for
                                    spanning ZIP, unsupported, always 0*/
-    uLong number_disk_with_CD;  /* number the the disk with central dir, used
+    uLong number_disk_with_CD;  /* number the disk with central dir, used
                                    for spanning ZIP, unsupported, always 0*/
     ZPOS64_T number_entry_CD;      /* total number of entries in
                                    the central dir
@@ -687,6 +685,7 @@
     {
         zlib_filefunc64_32_def zlib_filefunc64_32_def_fill;
         zlib_filefunc64_32_def_fill.zfile_func64 = *pzlib_filefunc_def;
+        zlib_filefunc64_32_def_fill.zopen32_file = NULL;
         zlib_filefunc64_32_def_fill.ztell32_file = NULL;
         zlib_filefunc64_32_def_fill.zseek32_file = NULL;
         return unzOpenInternal(path, &zlib_filefunc64_32_def_fill, 1);
@@ -846,7 +845,7 @@
     if (unz64local_getLong(&s->z_filefunc, s->filestream,&file_info.external_fa) != UNZ_OK)
         err=UNZ_ERRNO;
 
-                // relative offset of local header
+                /* relative offset of local header */
     if (unz64local_getLong(&s->z_filefunc, s->filestream,&uL) != UNZ_OK)
         err=UNZ_ERRNO;
     file_info_internal.offset_curfile = uL;
@@ -869,7 +868,7 @@
         lSeek -= uSizeRead;
     }
 
-    // Read extrafield
+    /* Read extrafield */
     if ((err==UNZ_OK) && (extraField!=NULL))
     {
         ZPOS64_T uSizeRead ;
@@ -900,7 +899,7 @@
     {
                                 uLong acc = 0;
 
-        // since lSeek now points to after the extra field we need to move back
+        /* since lSeek now points to after the extra field we need to move back */
         lSeek -= file_info.size_file_extra;
 
         if (lSeek!=0)
@@ -1529,10 +1528,10 @@
     file_in_zip64_read_info_s* pfile_in_zip_read_info;
     s=(unz64_s*)file;
     if (file==NULL)
-        return 0; //UNZ_PARAMERROR;
+        return 0; /* UNZ_PARAMERROR; */
     pfile_in_zip_read_info=s->pfile_in_zip_read;
     if (pfile_in_zip_read_info==NULL)
-        return 0; //UNZ_PARAMERROR;
+        return 0; /* UNZ_PARAMERROR; */
     return pfile_in_zip_read_info->pos_in_zipfile +
                          pfile_in_zip_read_info->byte_before_the_zipfile;
 }
@@ -1613,7 +1612,7 @@
                 uInt i;
                 for(i=0;i<uReadThis;i++)
                   pfile_in_zip_read_info->read_buffer[i] =
-                      zdecode(s->keys,s->pcrc_32_tab,
+                      (char)zdecode(s->keys,s->pcrc_32_tab,
                               pfile_in_zip_read_info->read_buffer[i]);
             }
 #            endif
@@ -1701,7 +1700,7 @@
             if (err!=BZ_OK)
               break;
 #endif
-        } // end Z_BZIP2ED
+        } /* end Z_BZIP2ED */
         else
         {
             ZPOS64_T uTotalOutBefore,uTotalOutAfter;
@@ -1944,7 +1943,7 @@
     unz64_s* s;
 
     if (file==NULL)
-          return 0; //UNZ_PARAMERROR;
+          return 0; /* UNZ_PARAMERROR; */
     s=(unz64_s*)file;
     if (!s->current_file_ok)
       return 0;
@@ -1958,7 +1957,7 @@
     ZPOS64_T offset64;
 
     if (file==NULL)
-          return 0; //UNZ_PARAMERROR;
+          return 0; /* UNZ_PARAMERROR; */
     offset64 = unzGetOffset64(file);
     return (uLong)offset64;
 }
--- a/OrthancFramework/Resources/ThirdParty/minizip/unzip.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/unzip.h	Thu Mar 19 12:31:58 2026 +0100
@@ -1,14 +1,13 @@
 /* unzip.h -- IO for uncompress .zip files using zlib
-   Version 1.1, February 14h, 2010
-   part of the MiniZip project - ( http://www.winimage.com/zLibDll/minizip.html )
+   part of the MiniZip project - ( https://www.winimage.com/zLibDll/minizip.html )
 
-         Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html )
+         Copyright (C) 1998-2026 Gilles Vollant (minizip) ( https://www.winimage.com/zLibDll/minizip.html )
 
          Modifications of Unzip for Zip64
          Copyright (C) 2007-2008 Even Rouault
 
          Modifications for Zip64 support on both zip and unzip
-         Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com )
+         Copyright (C) 2009-2010 Mathias Svensson ( https://result42.com )
 
          For more info read MiniZip_info.txt
 
@@ -70,6 +69,8 @@
 typedef voidp unzFile;
 #endif
 
+extern const char unz_copyright[];
+
 
 #define UNZ_OK                          (0)
 #define UNZ_END_OF_LIST_OF_FILE         (-100)
@@ -306,13 +307,17 @@
   Get Info about the current file
   if pfile_info!=NULL, the *pfile_info structure will contain some info about
         the current file
-  if szFileName!=NULL, the filemane string will be copied in szFileName
+  if szFileName!=NULL, the filename string will be copied in szFileName
             (fileNameBufferSize is the size of the buffer)
   if extraField!=NULL, the extra field information will be copied in extraField
             (extraFieldBufferSize is the size of the buffer).
             This is the Central-header version of the extra field
   if szComment!=NULL, the comment string of the file will be copied in szComment
             (commentBufferSize is the size of the buffer)
+  The file name and comment will be zero-terminated if there is room in the
+  provided buffer. Otherwise the buffer will contain as much as will fit. If at
+  least 65537 bytes of room is provided, then the result will always be
+  complete and zero-terminated.
 */
 
 
--- a/OrthancFramework/Resources/ThirdParty/minizip/zip.c	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/zip.c	Thu Mar 19 12:31:58 2026 +0100
@@ -1,11 +1,10 @@
 /* zip.c -- IO on .zip files using zlib
-   Version 1.1, February 14h, 2010
-   part of the MiniZip project - ( http://www.winimage.com/zLibDll/minizip.html )
+   part of the MiniZip project - ( https://www.winimage.com/zLibDll/minizip.html )
 
-         Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html )
+         Copyright (C) 1998-2026 Gilles Vollant (minizip) ( https://www.winimage.com/zLibDll/minizip.html )
 
          Modifications for Zip64 support
-         Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com )
+         Copyright (C) 2009-2010 Mathias Svensson ( https://result42.com )
 
          For more info read MiniZip_info.txt
 
@@ -25,8 +24,13 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <stdint.h>
 #include <time.h>
+#ifndef ZLIB_CONST
+#  define ZLIB_CONST
+#endif
+#ifdef ZLIB_DLL
+#  undef ZLIB_DLL
+#endif
 #include "zlib.h"
 #include "zip.h"
 
@@ -50,7 +54,7 @@
 #endif
 
 #ifndef Z_BUFSIZE
-#define Z_BUFSIZE (64*1024) //(16384)
+#define Z_BUFSIZE (64*1024) /* (16384) */
 #endif
 
 #ifndef Z_MAXFILENAMEINZIP
@@ -69,7 +73,7 @@
 /* I've found an old Unix (a SunOS 4.1.3_U1) without all SEEK_* defined.... */
 
 
-// NOT sure that this work on ALL platform
+/* NOT sure that this work on ALL platform */
 #define MAKEULONG64(a, b) ((ZPOS64_T)(((unsigned long)(a)) | ((ZPOS64_T)((unsigned long)(b))) << 32))
 
 #ifndef SEEK_CUR
@@ -91,7 +95,7 @@
 #  define DEF_MEM_LEVEL  MAX_MEM_LEVEL
 #endif
 #endif
-const char zip_copyright[] =" zip 1.01 Copyright 1998-2004 Gilles Vollant - http://www.winimage.com/zLibDll";
+const char zip_copyright[] =" zip 1.01 Copyright 1998-2004 Gilles Vollant - https://www.winimage.com/zLibDll/minizip.html";
 
 
 #define SIZEDATA_INDATABLOCK (4096-(4*4))
@@ -123,6 +127,19 @@
 } linkedlist_data;
 
 
+/* zipAlreadyThere() set functions for a set of zero-terminated strings, and
+// a block_t type for reading the central directory datablocks. */
+typedef char *set_key_t;
+#define set_cmp(a, b) strcmp(a, b)
+#define set_drop(s, k) set_free(s, k)
+#include "skipset.h"
+typedef struct {
+    unsigned char *next;        /* next byte in datablock data */
+    size_t left;                /* number of bytes left in data (at least) */
+    linkedlist_datablock_internal *node;    /* current datablock */
+} block_t;
+
+
 typedef struct
 {
     z_stream stream;            /* zLib stream structure for inflate */
@@ -174,6 +191,10 @@
     char *globalcomment;
 #endif
 
+    /* Support for zipAlreadyThere(). */
+    set_t set;              /* set for detecting name collisions */
+    block_t block;          /* block for reading the central directory */
+
 } zip64_internal;
 
 
@@ -264,6 +285,234 @@
     return ZIP_OK;
 }
 
+/* zipAlreadyThere() operations. "set" in the zip internal structure keeps the
+// set of names that are in the under-construction central directory so far. A
+// skipset provides ~O(log n) time insertion and searching. Central directory
+// records, stored in a linked list of allocated memory datablocks, is read
+// through "block" in the zip internal structure.
+
+// The block_*() functions support extracting the central directory file names
+// from the datablocks. They are designed to support a growing directory by
+// automatically continuing once more data has been appended to the linked
+// datablocks.
+
+// Initialize *block to the head of list. This should only be called once the
+// list has at least some data in it, i.e. list->first_block is not NULL. */
+local void block_init(block_t *block, linkedlist_data *list) {
+    block->node = list->first_block;
+    block->next = block->node->data;
+    block->left = block->node->filled_in_this_block;
+}
+
+/* Mark *block as bad, with all subsequent reads returning end, even if more
+// data is added to the datablocks. This is invoked if the central directory is
+// invalid, so there is no longer any point in attempting to interpret it. */
+local void block_stop(block_t *block) {
+    block->left = 0;
+    block->next = NULL;
+}
+
+/* Return true if *block has reached the end of the data in the datablocks. */
+local int block_end(block_t *block) {
+    linkedlist_datablock_internal *node = block->node;
+    if (node == NULL)
+        /* This block was previously terminated with extreme prejudice. */
+        return 1;
+    if (block->next < node->data + node->filled_in_this_block)
+        /* There are more bytes to read in the current datablock. */
+        return 0;
+    while (node->next_datablock != NULL) {
+        if (node->filled_in_this_block != 0)
+            /* There are some bytes in a later datablock. */
+            return 0;
+        node = node->next_datablock;
+    }
+    /* Reached the end of the list of datablocks. There's nothing. */
+    return 1;
+}
+
+/* Return one byte from *block, or -1 if the end is reached. */
+local int block_get(block_t *block) {
+    while (block->left == 0) {
+        if (block->node == NULL)
+            /* We've been marked bad. Return end. */
+            return -1;
+        /* Update left in case more was filled in since we were last here. */
+        block->left = block->node->filled_in_this_block -
+                      (size_t)(block->next - block->node->data);
+        if (block->left != 0)
+            /* There was indeed more data appended in the current datablock. */
+            break;
+        if (block->node->next_datablock == NULL)
+            /* No more data here, and there is no next datablock. At the end. */
+            return -1;
+        /* Try the next datablock for more data. */
+        block->node = block->node->next_datablock;
+        block->next = block->node->data;
+        block->left = block->node->filled_in_this_block;
+    }
+    /* We have a byte to return. */
+    block->left--;
+    return *block->next++;
+}
+
+/* Return a 16-bit unsigned little-endian value from block, or a negative value
+// if the end is reached. */
+local long block_get2(block_t *block) {
+    int low = block_get(block);
+    int high = block_get(block);
+    return low < 0 || high < 0 ? -1 : low | ((long)high << 8);
+}
+
+/* Read up to len bytes from block into buf. Return the number of bytes read. */
+local size_t block_read(block_t *block, unsigned char *buf, size_t len) {
+    size_t need = len;
+    size_t take;
+    while (need) {
+        if (block->left == 0) {
+            /* Get a byte to update and step through the linked list as needed. */
+            int got = block_get(block);
+            if (got == -1)
+                /* Reached the end. */
+                break;
+            *buf++ = (unsigned char)got;
+            need--;
+            continue;
+        }
+        take = need > block->left ? block->left : need;
+        memcpy(buf, block->next, take);
+        block->next += take;
+        block->left -= take;
+        buf += take;
+        need -= take;
+    }
+    return len - need;      /* return the number of bytes copied */
+}
+
+/* Skip n bytes in block. Return 0 on success or -1 if there are less than n
+// bytes to the end. */
+local int block_skip(block_t *block, size_t n) {
+    while (n > block->left) {
+        n -= block->left;
+        block->next += block->left;
+        block->left = 0;
+        if (block_get(block) == -1)
+            return -1;
+        n--;
+    }
+    block->next += n;
+    block->left -= n;
+    return 0;
+}
+
+/* Process the next central directory record at *block. Return the allocated,
+// zero-terminated file name, or NULL for end of input or invalid data. If
+// invalid, *block is marked bad. This uses *set for the allocation of memory. */
+local char *block_central_name(block_t *block, set_t *set) {
+    unsigned flen, xlen, clen;
+    char *name = NULL;
+    for (;;) {
+        if (block_end(block))
+            /* At the end of the central directory (so far). */
+            return NULL;
+
+        /* Check for a central directory record signature. */
+        if (block_get2(block) != (CENTRALHEADERMAGIC & 0xffff) ||
+            block_get2(block) != (CENTRALHEADERMAGIC >> 16))
+            /* Incorrect signature. */
+            break;
+
+        /* Go through the remaining fixed-length portion of the record,
+        // extracting the lengths of the three variable-length fields. */
+        block_skip(block, 24);
+        flen = (unsigned)block_get2(block);    /* file name length */
+        xlen = (unsigned)block_get2(block);    /* extra length */
+        clen = (unsigned)block_get2(block);    /* comment length */
+        if (block_skip(block, 12) == -1)
+            /* Premature end of the record. */
+            break;
+
+        /* Extract the name and skip over the extra and comment fields. */
+        name = set_alloc(set, NULL, flen + 1);
+        if (block_read(block, (unsigned char *)name, flen) < flen ||
+            block_skip(block, xlen + clen) == -1)
+            /* Premature end of the record. */
+            break;
+
+        /* Check for embedded nuls in the name. */
+        if (memchr(name, 0, flen) != NULL) {
+            /* This name can never match the zero-terminated name provided to
+            // zipAlreadyThere(), so we discard it and go back to get another
+            // name. (Who the heck is putting nuls inside their zip file entry
+            // names anyway?) */
+            set_free(set, name);
+            continue;
+        }
+
+        /* All good. Return the zero-terminated file name. */
+        name[flen] = 0;
+        return name;
+    }
+
+    /* Invalid signature or premature end of the central directory record.
+    // Abandon trying to process the central directory. */
+    set_free(set, name);
+    block_stop(block);
+    return NULL;
+}
+
+/* Return 0 if name is not in the central directory so far, 1 if it is, -1 if
+// the central directory is invalid, -2 if out of memory, or ZIP_PARAMERROR if
+// file is NULL. */
+extern int ZEXPORT zipAlreadyThere(zipFile file, char const *name) {
+    size_t len;
+    char *copy;
+    int found;
+    zip64_internal *zip = file;
+    if (zip == NULL)
+        return ZIP_PARAMERROR;
+    if (zip->central_dir.first_block == NULL)
+        /* No central directory yet, so no, name isn't there. */
+        return 0;
+    if (setjmp(zip->set.env)) {
+        /* Memory allocation failure. */
+        set_end(&zip->set);
+        return -2;
+    }
+    if (!set_ok(&zip->set)) {
+        /* This is the first time here with some central directory content. We
+        // construct this set of names only on demand. Prepare set and block. */
+        set_start(&zip->set);
+        block_init(&zip->block, &zip->central_dir);
+    }
+
+    /* Update the set of names from the current central directory contents.
+    // This reads any new central directory records since the last time we were
+    // here. */
+    for (;;) {
+        char *there = block_central_name(&zip->block, &zip->set);
+        if (there == NULL) {
+            if (zip->block.next == NULL)
+                /* The central directory is invalid. */
+                return -1;
+            break;
+        }
+
+        /* Add there to the set. */
+        if (set_insert(&zip->set, there))
+            /* There's already a duplicate in the central directory! We'll just
+            // let this be and carry on. */
+            set_free(&zip->set, there);
+    }
+
+    /* Return true if name is in the central directory. */
+    len = strlen(name);
+    copy = set_alloc(&zip->set, NULL, len + 1);
+    memcpy(copy, name, len + 1);
+    found = set_found(&zip->set, copy);
+    set_free(&zip->set, copy);
+    return found;
+}
 
 
 /****************************************************************************/
@@ -551,7 +800,7 @@
 
     for (i=(int)uReadSize-3; (i--)>0;)
     {
-      // Signature "0x07064b50" Zip64 end of central directory locater
+      /* Signature "0x07064b50" Zip64 end of central directory locator */
       if (((*(buf+i))==0x50) && ((*(buf+i+1))==0x4b) && ((*(buf+i+2))==0x06) && ((*(buf+i+3))==0x07))
       {
         uPosFound = uReadPos+(unsigned)i;
@@ -575,7 +824,7 @@
   if (zip64local_getLong(pzlib_filefunc_def,filestream,&uL)!=ZIP_OK)
     return 0;
 
-  /* number of the disk with the start of the zip64 end of  central directory */
+  /* number of the disk with the start of the zip64 end of central directory */
   if (zip64local_getLong(pzlib_filefunc_def,filestream,&uL)!=ZIP_OK)
     return 0;
   if (uL != 0)
@@ -599,7 +848,7 @@
   if (zip64local_getLong(pzlib_filefunc_def,filestream,&uL)!=ZIP_OK)
     return 0;
 
-  if (uL != 0x06064b50) // signature of 'Zip64 end of central directory'
+  if (uL != 0x06064b50) /* signature of 'Zip64 end of central directory' */
     return 0;
 
   return relativeOffset;
@@ -628,7 +877,7 @@
 
   int hasZIP64Record = 0;
 
-  // check first if we find a ZIP64 record
+  /* check first if we find a ZIP64 record */
   central_pos = zip64local_SearchCentralDir64(&pziinit->z_filefunc,pziinit->filestream);
   if(central_pos > 0)
   {
@@ -694,13 +943,13 @@
     if (zip64local_getLong64(&pziinit->z_filefunc, pziinit->filestream,&offset_central_dir)!=ZIP_OK)
       err=ZIP_ERRNO;
 
-    // TODO..
-    // read the comment from the standard central header.
+    /* TODO..
+    // read the comment from the standard central header. */
     size_comment = 0;
   }
   else
   {
-    // Read End of central Directory info
+    /* Read End of central Directory info */
     if (ZSEEK64(pziinit->z_filefunc, pziinit->filestream, central_pos,ZLIB_FILEFUNC_SEEK_SET)!=0)
       err=ZIP_ERRNO;
 
@@ -843,6 +1092,7 @@
     ziinit.number_entry = 0;
     ziinit.add_position_when_writing_offset = 0;
     init_linkedlist(&(ziinit.central_dir));
+    memset(&ziinit.set, 0, sizeof(set_t));  /* make sure set appears dormant */
 
 
 
@@ -858,7 +1108,7 @@
     ziinit.globalcomment = NULL;
     if (append == APPEND_STATUS_ADDINZIP)
     {
-      // Read and Cache Central Directory Records
+      /* Read and Cache Central Directory Records */
       err = LoadCentralDirectoryRecord(&ziinit);
     }
 
@@ -899,6 +1149,7 @@
     {
         zlib_filefunc64_32_def zlib_filefunc64_32_def_fill;
         zlib_filefunc64_32_def_fill.zfile_func64 = *pzlib_filefunc_def;
+        zlib_filefunc64_32_def_fill.zopen32_file = NULL;
         zlib_filefunc64_32_def_fill.ztell32_file = NULL;
         zlib_filefunc64_32_def_fill.zseek32_file = NULL;
         return zipOpen3(pathname, append, globalcomment, &zlib_filefunc64_32_def_fill);
@@ -942,7 +1193,7 @@
   if (err==ZIP_OK)
     err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)zi->ci.dosDate,4);
 
-  // CRC / Compressed size / Uncompressed size will be filled in later and rewritten later
+  /* CRC / Compressed size / Uncompressed size will be filled in later and rewritten later */
   if (err==ZIP_OK)
     err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)0,4); /* crc 32, unknown */
   if (err==ZIP_OK)
@@ -986,13 +1237,13 @@
 
   if ((err==ZIP_OK) && (zi->ci.zip64))
   {
-      // write the Zip64 extended info
+      /* write the Zip64 extended info */
       short HeaderID = 1;
       short DataSize = 16;
       ZPOS64_T CompressedSize = 0;
       ZPOS64_T UncompressedSize = 0;
 
-      // Remember position of Zip64 extended info for the local file header. (needed when we update size after done with file)
+      /* Remember position of Zip64 extended info for the local file header. (needed when we update size after done with file) */
       zi->ci.pos_zip64extrainfo = ZTELL64(zi->z_filefunc,zi->filestream);
 
       err = zip64local_putValue(&zi->z_filefunc, zi->filestream, (ZPOS64_T)HeaderID,2);
@@ -1005,6 +1256,46 @@
   return err;
 }
 
+/* Return the length of the UTF-8 code at str[0..len-1] in [1..4], or negative
+   if there is no valid UTF-8 code there. If negative, it is minus the number
+   of bytes examined in order to determine it was bad. Or if minus the return
+   code is one less than len, then at least one more byte than provided would
+   be needed to complete the code. */
+local int utf8len(unsigned char const *str, size_t len) {
+    return
+        len == 0 ? -1 :                         /* empty input */
+        str[0] < 0x80 ? 1 :                     /* good one-byte */
+        str[0] < 0xc0 ? -1 :                    /* bad first byte */
+        len < 2 || (str[1] >> 6) != 2 ? -2 :    /* missing or bad 2nd byte */
+        str[0] < 0xc2 ? -2 :                    /* overlong code */
+        str[0] < 0xe0 ? 2 :                     /* good two-byte */
+        len < 3 || (str[2] >> 6) != 2 ? -3 :    /* missing or bad 3rd byte */
+        str[0] == 0xe0 && str[1] < 0xa0 ? -3 :  /* overlong code */
+        str[0] < 0xf0 ? 3 :                     /* good three-byte */
+        len < 4 || (str[3] >> 6) != 2 ? -4 :    /* missing or bad 4th byte */
+        str[0] == 0xf0 && str[1] < 0x90 ? -4 :  /* overlong code */
+        str[0] < 0xf4 ||
+        (str[0] == 0xf4 && str[1] < 0x90) ? 4 : /* good four-byte */
+        -4;                                     /* code > 0x10ffff */
+}
+
+/* Return true if str[0..len-1] is valid UTF-8 *and* it contains at least one
+   code of two or more bytes. This is used to determine whether or not to set
+   bit 11 in the zip header flags. */
+local int isutf8(char const *str, size_t len) {
+    int utf8 = 0;
+    while (len) {
+        int code = utf8len((unsigned char const *)str, len);
+        if (code < 0)
+            return 0;
+        if (code > 1)
+            utf8 = 1;
+        str += code;
+        len -= (unsigned)code;
+    }
+    return utf8;
+}
+
 /*
  NOTE.
  When writing RAW the ZIP64 extended information in extrafield_local and extrafield_global needs to be stripped
@@ -1027,7 +1318,6 @@
     int err = ZIP_OK;
 
 #    ifdef NOCRYPT
-    (crcForCrypting);
     if (password != NULL)
         return ZIP_PARAMERROR;
 #    endif
@@ -1043,14 +1333,14 @@
       return ZIP_PARAMERROR;
 #endif
 
-    // The filename and comment length must fit in 16 bits.
+    /* The filename and comment length must fit in 16 bits. */
     if ((filename!=NULL) && (strlen(filename)>0xffff))
         return ZIP_PARAMERROR;
     if ((comment!=NULL) && (strlen(comment)>0xffff))
         return ZIP_PARAMERROR;
-    // The extra field length must fit in 16 bits. If the member also requires
+    /* The extra field length must fit in 16 bits. If the member also requires
     // a Zip64 extra block, that will also need to fit within that 16-bit
-    // length, but that will be checked for later.
+    // length, but that will be checked for later. */
     if ((size_extrafield_local>0xffff) || (size_extrafield_global>0xffff))
         return ZIP_PARAMERROR;
 
@@ -1092,6 +1382,9 @@
       zi->ci.flag |= 6;
     if (password != NULL)
       zi->ci.flag |= 1;
+    if (isutf8(filename, size_filename) &&
+        (size_comment == 0 || isutf8(comment, size_comment)))
+      zi->ci.flag |= (1 << 11);
 
     zi->ci.crc32 = 0;
     zi->ci.method = method;
@@ -1102,7 +1395,7 @@
     zi->ci.pos_local_header = ZTELL64(zi->z_filefunc,zi->filestream);
 
     zi->ci.size_centralheader = SIZECENTRALHEADER + size_filename + size_extrafield_global + size_comment;
-    zi->ci.size_centralExtraFree = 32; // Extra space we have reserved in case we need to add ZIP64 extra info data
+    zi->ci.size_centralExtraFree = 32; /* Extra space we have reserved in case we need to add ZIP64 extra info data */
 
     zi->ci.central_header = (char*)ALLOC((uInt)zi->ci.size_centralheader + zi->ci.size_centralExtraFree);
 
@@ -1197,7 +1490,7 @@
         else if(zi->ci.method == Z_BZIP2ED)
         {
 #ifdef HAVE_BZIP2
-            // Init BZip stuff here
+            /* Init BZip stuff here */
           zi->ci.bstream.bzalloc = 0;
           zi->ci.bstream.bzfree = 0;
           zi->ci.bstream.opaque = (voidpf)0;
@@ -1399,7 +1692,7 @@
         if ((zi->ci.method == Z_BZIP2ED) && (!zi->ci.raw))
         {
           uLong uTotalOutBefore_lo = zi->ci.bstream.total_out_lo32;
-//          uLong uTotalOutBefore_hi = zi->ci.bstream.total_out_hi32;
+/*          uLong uTotalOutBefore_hi = zi->ci.bstream.total_out_hi32; */
           err=BZ2_bzCompress(&zi->ci.bstream,  BZ_RUN);
 
           zi->ci.pos_in_buffered_data += (uInt)(zi->ci.bstream.total_out_lo32 - uTotalOutBefore_lo) ;
@@ -1412,7 +1705,7 @@
     else
 #endif
     {
-      zi->ci.stream.next_in = (Bytef*)(uintptr_t)buf;
+      zi->ci.stream.next_in = buf;
       zi->ci.stream.avail_in = len;
 
       while ((err==ZIP_OK) && (zi->ci.stream.avail_in>0))
@@ -1457,7 +1750,7 @@
                   zi->ci.pos_in_buffered_data += copy_this;
               }
           }
-      }// while(...)
+      }/* while(...) */
     }
 
     return err;
@@ -1563,7 +1856,7 @@
     compressed_size += zi->ci.crypt_header_size;
 #    endif
 
-    // update Current Item crc and sizes,
+    /* update Current Item crc and sizes, */
     if(compressed_size >= 0xffffffff || uncompressed_size >= 0xffffffff || zi->ci.pos_local_header >= 0xffffffff)
     {
       /*version Made by*/
@@ -1581,7 +1874,7 @@
     else
       zip64local_putValue_inmemory(zi->ci.central_header+20, compressed_size,4); /*compr size*/
 
-    /// set internal file attributes field
+    /* set internal file attributes field */
     if (zi->ci.stream.data_type == Z_ASCII)
         zip64local_putValue_inmemory(zi->ci.central_header+36,(uLong)Z_ASCII,2);
 
@@ -1590,15 +1883,15 @@
     else
       zip64local_putValue_inmemory(zi->ci.central_header+24, uncompressed_size,4); /*uncompr size*/
 
-    // Add ZIP64 extra info field for uncompressed size
+    /* Add ZIP64 extra info field for uncompressed size */
     if(uncompressed_size >= 0xffffffff)
       datasize += 8;
 
-    // Add ZIP64 extra info field for compressed size
+    /* Add ZIP64 extra info field for compressed size */
     if(compressed_size >= 0xffffffff)
       datasize += 8;
 
-    // Add ZIP64 extra info field for relative offset to local file header of current file
+    /* Add ZIP64 extra info field for relative offset to local file header of current file */
     if(zi->ci.pos_local_header >= 0xffffffff)
       datasize += 8;
 
@@ -1608,16 +1901,16 @@
 
       if((uLong)(datasize + 4) > zi->ci.size_centralExtraFree)
       {
-        // we can not write more data to the buffer that we have room for.
+        /* we cannot write more data to the buffer that we have room for. */
         return ZIP_BADZIPFILE;
       }
 
       p = zi->ci.central_header + zi->ci.size_centralheader;
 
-      // Add Extra Information Header for 'ZIP64 information'
-      zip64local_putValue_inmemory(p, 0x0001, 2); // HeaderID
+      /* Add Extra Information Header for 'ZIP64 information' */
+      zip64local_putValue_inmemory(p, 0x0001, 2); /* HeaderID */
       p += 2;
-      zip64local_putValue_inmemory(p, datasize, 2); // DataSize
+      zip64local_putValue_inmemory(p, datasize, 2); /* DataSize */
       p += 2;
 
       if(uncompressed_size >= 0xffffffff)
@@ -1638,13 +1931,13 @@
         p += 8;
       }
 
-      // Update how much extra free space we got in the memory buffer
+      /* Update how much extra free space we got in the memory buffer
       // and increase the centralheader size so the new ZIP64 fields are included
-      // ( 4 below is the size of HeaderID and DataSize field )
+      // ( 4 below is the size of HeaderID and DataSize field ) */
       zi->ci.size_centralExtraFree -= datasize + 4;
       zi->ci.size_centralheader += datasize + 4;
 
-      // Update the extra info size field
+      /* Update the extra info size field */
       zi->ci.size_centralExtra += datasize + 4;
       zip64local_putValue_inmemory(zi->ci.central_header+30,(uLong)zi->ci.size_centralExtra,2);
     }
@@ -1656,7 +1949,7 @@
 
     if (err==ZIP_OK)
     {
-        // Update the LocalFileHeader with the new values.
+        /* Update the LocalFileHeader with the new values. */
 
         ZPOS64_T cur_pos_inzip = ZTELL64(zi->z_filefunc,zi->filestream);
 
@@ -1670,7 +1963,7 @@
         {
           if(zi->ci.pos_zip64extrainfo > 0)
           {
-            // Update the size in the ZIP64 extended field.
+            /* Update the size in the ZIP64 extended field. */
             if (ZSEEK64(zi->z_filefunc,zi->filestream, zi->ci.pos_zip64extrainfo + 4,ZLIB_FILEFUNC_SEEK_SET)!=0)
               err = ZIP_ERRNO;
 
@@ -1681,7 +1974,7 @@
               err = zip64local_putValue(&zi->z_filefunc, zi->filestream, compressed_size, 8);
           }
           else
-              err = ZIP_BADZIPFILE; // Caller passed zip64 = 0, so no room for zip64 info -> fatal
+              err = ZIP_BADZIPFILE; /* Caller passed zip64 = 0, so no room for zip64 info -> fatal */
         }
         else
         {
@@ -1735,7 +2028,7 @@
   err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)ZIP64ENDHEADERMAGIC,4);
 
   if (err==ZIP_OK) /* size of this 'zip64 end of central directory' */
-    err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(ZPOS64_T)Zip64DataSize,8); // why ZPOS64_T of this ?
+    err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(ZPOS64_T)Zip64DataSize,8); /* why ZPOS64_T of this ? */
 
   if (err==ZIP_OK) /* version made by */
     err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)45,2);
@@ -1782,7 +2075,7 @@
   {
     {
       if(zi->number_entry >= 0xFFFF)
-        err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)0xffff,2); // use value in ZIP64 record
+        err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)0xffff,2); /* use value in ZIP64 record */
       else
         err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)zi->number_entry,2);
     }
@@ -1791,7 +2084,7 @@
   if (err==ZIP_OK) /* total number of entries in the central dir */
   {
     if(zi->number_entry >= 0xFFFF)
-      err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)0xffff,2); // use value in ZIP64 record
+      err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)0xffff,2); /* use value in ZIP64 record */
     else
       err = zip64local_putValue(&zi->z_filefunc,zi->filestream,(uLong)zi->number_entry,2);
   }
@@ -1871,6 +2164,8 @@
     }
     free_linkedlist(&(zi->central_dir));
 
+    set_end(&zi->set);          /* set was zeroed, so this is safe */
+
     pos = centraldir_pos_inzip - zi->add_position_when_writing_offset;
     if(pos >= 0xffffffff || zi->number_entry >= 0xFFFF)
     {
@@ -1919,13 +2214,13 @@
     header = *(short*)p;
     dataSize = *(((short*)p)+1);
 
-    if( header == sHeader ) // Header found.
+    if( header == sHeader ) /* Header found. */
     {
-      p += dataSize + 4; // skip it. do not copy to temp buffer
+      p += dataSize + 4; /* skip it. do not copy to temp buffer */
     }
     else
     {
-      // Extra Info block should not be removed, So copy it to the temp buffer.
+      /* Extra Info block should not be removed, So copy it to the temp buffer. */
       memcpy(pTmp, p, dataSize + 4);
       p += dataSize + 4;
       size += dataSize + 4;
@@ -1935,14 +2230,14 @@
 
   if(size < *dataLen)
   {
-    // clean old extra info block.
+    /* clean old extra info block. */
     memset(pData,0, *dataLen);
 
-    // copy the new extra info block over the old
+    /* copy the new extra info block over the old */
     if(size > 0)
       memcpy(pData, pNewHeader, size);
 
-    // set the new extra info size
+    /* set the new extra info size */
     *dataLen = size;
 
     retVal = ZIP_OK;
--- a/OrthancFramework/Resources/ThirdParty/minizip/zip.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Resources/ThirdParty/minizip/zip.h	Thu Mar 19 12:31:58 2026 +0100
@@ -1,11 +1,10 @@
 /* zip.h -- IO on .zip files using zlib
-   Version 1.1, February 14h, 2010
-   part of the MiniZip project - ( http://www.winimage.com/zLibDll/minizip.html )
+   part of the MiniZip project - ( https://www.winimage.com/zLibDll/minizip.html )
 
-         Copyright (C) 1998-2010 Gilles Vollant (minizip) ( http://www.winimage.com/zLibDll/minizip.html )
+         Copyright (C) 1998-2026 Gilles Vollant (minizip) ( https://www.winimage.com/zLibDll/minizip.html )
 
          Modifications for Zip64 support
-         Copyright (C) 2009-2010 Mathias Svensson ( http://result42.com )
+         Copyright (C) 2009-2010 Mathias Svensson ( https://result42.com )
 
          For more info read MiniZip_info.txt
 
@@ -35,7 +34,7 @@
 
         See header of zip.h
 
-*/
+ */
 
 #ifndef _zip12_H
 #define _zip12_H
@@ -44,7 +43,7 @@
 extern "C" {
 #endif
 
-//#define HAVE_BZIP2
+/* #define HAVE_BZIP2 */
 
 #ifndef _ZLIB_H
 #include "zlib.h"
@@ -69,6 +68,8 @@
 typedef voidp zipFile;
 #endif
 
+extern const char zip_copyright[];
+
 #define ZIP_OK                          (0)
 #define ZIP_EOF                         (0)
 #define ZIP_ERRNO                       (Z_ERRNO)
@@ -127,12 +128,12 @@
      If the zipfile cannot be opened, the return value is NULL.
      Else, the return value is a zipFile Handle, usable with other function
        of this zip package.
-*/
+ */
 
 /* Note : there is no delete function into a zipfile.
    If you want delete file into a zipfile, you must open a zipfile, and create another
    Of course, you can use RAW reading and writing to copy the file you did not want delete
-*/
+ */
 
 extern zipFile ZEXPORT zipOpen2(const char *pathname,
                                 int append,
@@ -186,7 +187,7 @@
   zip64 is set to 1 if a zip64 extended information block should be added to the local file header.
                     this MUST be '1' if the uncompressed size is >= 0xffffffff.
 
-*/
+ */
 
 
 extern int ZEXPORT zipOpenNewFileInZip2(zipFile file,
@@ -311,12 +312,12 @@
                                        unsigned len);
 /*
   Write data in the zipfile
-*/
+ */
 
 extern int ZEXPORT zipCloseFileInZip(zipFile file);
 /*
   Close the current file in the zipfile
-*/
+ */
 
 extern int ZEXPORT zipCloseFileInZipRaw(zipFile file,
                                         uLong uncompressed_size,
@@ -326,17 +327,23 @@
                                           ZPOS64_T uncompressed_size,
                                           uLong crc32);
 
+extern int ZEXPORT zipAlreadyThere(zipFile file,
+                                   char const* name);
+/*
+  See if name is already in file's central directory.
+ */
+
 /*
   Close the current file in the zipfile, for file opened with
     parameter raw=1 in zipOpenNewFileInZip2
   uncompressed_size and crc32 are value for the uncompressed size
-*/
+ */
 
 extern int ZEXPORT zipClose(zipFile file,
                             const char* global_comment);
 /*
   Close the zipfile
-*/
+ */
 
 
 extern int ZEXPORT zipRemoveExtraInfoBlock(char* pData, int* dataLen, short sHeader);
@@ -355,7 +362,7 @@
 
                         Remove ZIP64 Extra information from a Local File Header extra field data
         zipRemoveExtraInfoBlock(pLocalHeaderExtraFieldData, &nLocalHeaderExtraFieldDataLen, 0x0001);
-*/
+ */
 
 #ifdef __cplusplus
 }
--- a/OrthancFramework/Sources/Cache/SharedArchive.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Cache/SharedArchive.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -32,6 +32,9 @@
 {
   void SharedArchive::RemoveInternal(const std::string& id)
   {
+    // This function assumes that "mutex_" is locked by a WriterLock,
+    // and that "lruMutex_" is locked
+
     Archive::iterator it = archive_.find(id);
 
     if (it != archive_.end())
@@ -45,10 +48,9 @@
 
 
   SharedArchive::Accessor::Accessor(SharedArchive& that,
-                                    const std::string& id)
+                                    const std::string& id) :
+    lock_(that.mutex_)
   {
-    boost::recursive_mutex::scoped_lock lock(that.mutex_);
-
     Archive::iterator it = that.archive_.find(id);
 
     if (it == that.archive_.end())
@@ -57,7 +59,11 @@
     }
     else
     {
-      that.lru_.MakeMostRecent(id);
+      {
+        boost::mutex::scoped_lock lock(that.lruMutex_);
+        that.lru_.MakeMostRecent(id);
+      }
+
       item_ = it->second;
     }
   }
@@ -104,7 +110,8 @@
 
   std::string SharedArchive::Add(IDynamicObject* obj)
   {
-    boost::recursive_mutex::scoped_lock lock(mutex_);
+    WriterLock lock(mutex_);
+    boost::mutex::scoped_lock lruLock(lruMutex_);
 
     if (archive_.size() == maxSize_)
     {
@@ -124,7 +131,9 @@
 
   void SharedArchive::Remove(const std::string& id)
   {
-    boost::recursive_mutex::scoped_lock lock(mutex_);
+    WriterLock lock(mutex_);
+    boost::mutex::scoped_lock lruLock(lruMutex_);
+
     RemoveInternal(id);      
   }
 
@@ -134,7 +143,7 @@
     items.clear();
 
     {
-      boost::recursive_mutex::scoped_lock lock(mutex_);
+      ReaderLock lock(mutex_);
 
       for (Archive::const_iterator it = archive_.begin();
            it != archive_.end(); ++it)
--- a/OrthancFramework/Sources/Cache/SharedArchive.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Cache/SharedArchive.h	Thu Mar 19 12:31:58 2026 +0100
@@ -44,10 +44,15 @@
   {
   private:
     typedef std::map<std::string, IDynamicObject*>  Archive;
+    typedef boost::shared_lock<boost::shared_mutex> ReaderLock;
+    typedef boost::unique_lock<boost::shared_mutex> WriterLock;
 
     size_t                  maxSize_;
-    boost::recursive_mutex  mutex_;
+    boost::shared_mutex     mutex_;
     Archive                 archive_;
+
+    // The LRU index is not protected by "mutex_", but by "lruMutex_"
+    boost::mutex                        lruMutex_;
     LeastRecentlyUsedIndex<std::string> lru_;
 
     void RemoveInternal(const std::string& id);
@@ -56,8 +61,8 @@
     class ORTHANC_PUBLIC Accessor : public boost::noncopyable
     {
     private:
-      boost::recursive_mutex::scoped_lock lock_;
-      IDynamicObject*                     item_;
+      ReaderLock       lock_;
+      IDynamicObject*  item_;
 
     public:
       Accessor(SharedArchive& that,
@@ -68,7 +73,6 @@
       IDynamicObject& GetItem() const;
     };
 
-
     explicit SharedArchive(size_t maxSize);
 
     ~SharedArchive();
@@ -80,5 +84,3 @@
     void List(std::list<std::string>& items);
   };
 }
-
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Sources/CompatibilityMath.h	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,258 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public License
+ * as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#if defined(BOOST_MATH_ROUND_HPP)
+#  error You should not manually include <boost/math/special_functions/round.hpp>
+#endif
+
+#include "Compatibility.h"
+
+
+#ifdef __EMSCRIPTEN__
+/*
+   Avoid such errors:
+   ------------------
+
+   .../boost/math/special_functions/round.hpp:118:12: warning: implicit conversion from 'std::__2::numeric_limits<long long>::type' (aka 'long long') to 'float' changes value from 9223372036854775807 to 9223372036854775808 [-Wimplicit-int-float-conversion]
+   .../boost/math/special_functions/round.hpp:125:11: note: in instantiation of function template specialization 'boost::math::llround<float, boost::math::policies::policy<boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy> >' requested here
+
+   .../boost/math/special_functions/round.hpp:86:12: warning: implicit conversion from 'std::__2::numeric_limits<int>::type' (aka 'int') to 'float' changes value from 2147483647 to 2147483648 [-Wimplicit-int-float-conversion]
+   .../boost/math/special_functions/round.hpp:93:11: note: in instantiation of function template specialization 'boost::math::iround<float, boost::math::policies::policy<boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy> >' requested here
+*/
+#  pragma GCC diagnostic ignored "-Wimplicit-int-float-conversion"
+#endif
+
+
+#if defined(_MSC_VER)
+
+/**
+ * This is for compatibility with Microsoft Visual Studio <= 2015.
+ * Indeed, Boost 1.89.0 does not support MSVC 14.0, as can be seen in
+ * this file:
+ * https://www.boost.org/doc/libs/1_89_0/boost/math/tools/config.hpp
+ **/
+
+#include <cfloat>  // For _isnan()
+#include <cmath>   // For _isnanf()
+#include <limits>
+
+namespace Orthanc
+{
+  namespace Math
+  {
+    namespace Internals
+    {
+      ORTHANC_FORCE_INLINE
+      float RoundFloat(float v)
+      {
+        if (v >= 0.0f)
+        {
+          return std::floor(v + 0.5f);
+        }
+        else
+        {
+          return std::ceil(v - 0.5f);
+        }
+      }
+
+      ORTHANC_FORCE_INLINE
+      double RoundDouble(double v)
+      {
+        if (v >= 0.0)
+        {
+          return std::floor(v + 0.5);
+        }
+        else
+        {
+          return std::ceil(v - 0.5);
+        }
+      }
+
+      template <typename T>
+      ORTHANC_FORCE_INLINE
+      bool RoundFloatWithChecks(T& target,
+                                float source)
+      {
+#if defined(_WIN64)
+        // The functions "_isnanf()" and "_finitef()" are only available if targeting x64
+        if (_isnanf(source) ||
+            !_finitef(source))
+        {
+          return false;
+        }
+#else
+        if (_isnan(static_cast<double>(source)) ||
+            !_finite(static_cast<double>(source)))
+        {
+          return false;
+        }
+#endif
+
+        const float rounded = RoundFloat(source);
+        if (rounded < static_cast<float>(std::numeric_limits<T>::min()) ||
+            rounded > static_cast<float>(std::numeric_limits<T>::max()))
+        {
+          return false;
+        }
+        else
+        {
+          target = static_cast<T>(rounded);
+          return true;
+        }
+      }
+
+      template <typename T>
+      ORTHANC_FORCE_INLINE
+      bool RoundDoubleWithChecks(T& target,
+                                 double source)
+      {
+        if (_isnan(source) ||
+            !_finite(source))
+        {
+          return false;
+        }
+        else
+        {
+          const double rounded = RoundDouble(source);
+          if (rounded < static_cast<double>(std::numeric_limits<T>::min()) ||
+              rounded > static_cast<double>(std::numeric_limits<T>::max()))
+          {
+            return false;
+          }
+          else
+          {
+            target = static_cast<T>(rounded);
+            return true;
+          }
+        }
+      }
+    }
+
+    ORTHANC_FORCE_INLINE
+    int iround(float v)
+    {
+      return static_cast<int>(Internals::RoundFloat(v));
+    }
+
+    ORTHANC_FORCE_INLINE
+    int iround(double v)
+    {
+      return static_cast<int>(Internals::RoundDouble(v));
+    }
+
+    ORTHANC_FORCE_INLINE
+    long long llround(float v)
+    {
+      return static_cast<long long>(Internals::RoundFloat(v));
+    }
+
+    ORTHANC_FORCE_INLINE
+    long long llround(double v)
+    {
+      return static_cast<long long>(Internals::RoundDouble(v));
+    }
+
+    ORTHANC_FORCE_INLINE
+    bool llround(long long& target,
+                 float source)
+    {
+      return Internals::RoundFloatWithChecks<long long>(target, source);
+    }
+
+    ORTHANC_FORCE_INLINE
+    long long llround(long long& target,
+                      double source)
+    {
+      return Internals::RoundDoubleWithChecks<long long>(target, source);
+    }
+  }
+}
+
+#else
+
+#include <boost/math/special_functions/round.hpp>
+
+namespace Orthanc
+{
+  namespace Math
+  {
+    ORTHANC_FORCE_INLINE
+    int iround(float v)
+    {
+      return boost::math::iround<float>(v);
+    }
+
+    ORTHANC_FORCE_INLINE
+    int iround(double v)
+    {
+      return boost::math::iround<double>(v);
+    }
+
+    ORTHANC_FORCE_INLINE
+    long long llround(float v)
+    {
+      return boost::math::llround<float>(v);
+    }
+
+    ORTHANC_FORCE_INLINE
+    long long llround(double v)
+    {
+      return boost::math::llround<double>(v);
+    }
+
+    ORTHANC_FORCE_INLINE
+    bool llround(long long& target,
+                 float source)
+    {
+      try
+      {
+        target = boost::math::llround<float>(source);
+        return true;
+      }
+      catch (boost::math::rounding_error&)
+      {
+        return false;
+      }
+    }
+
+    ORTHANC_FORCE_INLINE
+    long long llround(long long& target,
+                      double source)
+    {
+      try
+      {
+        target = boost::math::llround<double>(source);
+        return true;
+      }
+      catch (boost::math::rounding_error&)
+      {
+        return false;
+      }
+    }
+  }
+}
+
+#endif
--- a/OrthancFramework/Sources/Compression/GzipCompressor.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Compression/GzipCompressor.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -47,7 +47,7 @@
      *
      * There is an unreliable way to determine the uncompressed size,
      * which is to look at the last four bytes of the gzip file, which
-     * is the uncompressed length of that entry modulo 232 in little
+     * is the uncompressed length of that entry modulo 2^32 in little
      * endian order.
      * 
      * It is unreliable because a) the uncompressed data may be longer
--- a/OrthancFramework/Sources/Compression/HierarchicalZipWriter.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Compression/HierarchicalZipWriter.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -32,43 +32,6 @@
 
 namespace Orthanc
 {
-  std::string HierarchicalZipWriter::Index::KeepAlphanumeric(const std::string& source)
-  {
-    std::string result;
-
-    bool lastSpace = false;
-
-    result.reserve(source.size());
-    for (size_t i = 0; i < source.size(); i++)
-    {
-      char c = source[i];
-      if (c == '^')
-        c = ' ';
-
-      if (c <= 127 && 
-          c >= 0)
-      {
-        if (isspace(c)) 
-        {
-          if (!lastSpace)
-          {
-            lastSpace = true;
-            result.push_back(' ');
-          }
-        }
-        else if (isalnum(c) || 
-                 c == '.' || 
-                 c == '_')
-        {
-          result.push_back(c);
-          lastSpace = false;
-        }
-      }
-    }
-
-    return Toolbox::StripSpaces(result);
-  }
-
   std::string HierarchicalZipWriter::Index::GetCurrentDirectoryPath() const
   {
     std::string result;
@@ -85,9 +48,12 @@
     return result;
   }
 
-  std::string HierarchicalZipWriter::Index::EnsureUniqueFilename(const char* filename)
+  std::string HierarchicalZipWriter::Index::EnsureUniqueFilename(const std::string& filename,
+                                                                 bool allowUtf8)
   {
-    std::string standardized = KeepAlphanumeric(filename);
+    const std::string standardized = Toolbox::NormalizePath(filename, allowUtf8, false /* replace (back)slashes by spaces */);
+
+    assert(standardized.find('\\') == std::string::npos);
 
     Directory& d = *stack_.back();
     Directory::Content::iterator it = d.content_.find(standardized);
@@ -122,14 +88,16 @@
     return stack_.size() == 1;
   }
 
-  std::string HierarchicalZipWriter::Index::OpenFile(const char* name)
+  std::string HierarchicalZipWriter::Index::OpenFile(const std::string& name,
+                                                     bool allowUtf8)
   {
-    return GetCurrentDirectoryPath() + EnsureUniqueFilename(name);
+    return GetCurrentDirectoryPath() + EnsureUniqueFilename(name, allowUtf8);
   }
 
-  void HierarchicalZipWriter::Index::OpenDirectory(const char* name)
+  void HierarchicalZipWriter::Index::OpenDirectory(const std::string& name,
+                                                   bool allowUtf8)
   {
-    std::string d = EnsureUniqueFilename(name);
+    std::string d = EnsureUniqueFilename(name, allowUtf8);
 
     // Push the new directory onto the stack
     stack_.push_back(new Directory);
@@ -199,15 +167,15 @@
     return writer_.IsAppendToExisting();
   }
 
-  void HierarchicalZipWriter::OpenFile(const char* name)
+  void HierarchicalZipWriter::OpenFile(const std::string& name)
   {
-    std::string p = indexer_.OpenFile(name);
+    std::string p = indexer_.OpenFile(name, IsAllowUtf8());
     writer_.OpenFile(p.c_str());
   }
 
-  void HierarchicalZipWriter::OpenDirectory(const char* name)
+  void HierarchicalZipWriter::OpenDirectory(const std::string& name)
   {
-    indexer_.OpenDirectory(name);
+    indexer_.OpenDirectory(name, IsAllowUtf8());
   }
 
   void HierarchicalZipWriter::CloseDirectory()
@@ -220,12 +188,13 @@
     return indexer_.GetCurrentDirectoryPath();
   }
 
-  void HierarchicalZipWriter::Write(const void *data, size_t length)
+  void HierarchicalZipWriter::Write(const void *data,
+                                    size_t length)
   {
     writer_.Write(data, length);
   }
 
-  void HierarchicalZipWriter::Write(const std::string &data)
+  void HierarchicalZipWriter::Write(const std::string& data)
   {
     writer_.Write(data);
   }
--- a/OrthancFramework/Sources/Compression/HierarchicalZipWriter.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Compression/HierarchicalZipWriter.h	Thu Mar 19 12:31:58 2026 +0100
@@ -41,7 +41,6 @@
   {
 #if ORTHANC_BUILD_UNIT_TESTS == 1
     FRIEND_TEST(HierarchicalZipWriter, Index);
-    FRIEND_TEST(HierarchicalZipWriter, Filenames);
 #endif
 
   private:
@@ -60,7 +59,8 @@
   
       Stack stack_;
 
-      std::string EnsureUniqueFilename(const char* filename);
+      std::string EnsureUniqueFilename(const std::string& name,
+                                       bool allowUtf8);
 
     public:
       Index();
@@ -69,15 +69,15 @@
 
       bool IsRoot() const;
 
-      std::string OpenFile(const char* name);
+      std::string OpenFile(const std::string& name,
+                           bool allowUtf8);
 
-      void OpenDirectory(const char* name);
+      void OpenDirectory(const std::string& name,
+                         bool allowUtf8);
 
       void CloseDirectory();
 
       std::string GetCurrentDirectoryPath() const;
-
-      static std::string KeepAlphanumeric(const std::string& source);
     };
 
     Index indexer_;
@@ -95,6 +95,16 @@
 
     bool IsZip64() const;
 
+    void SetAllowUtf8(bool allowUtf8)
+    {
+      writer_.SetAllowUtf8(allowUtf8);
+    }
+
+    bool IsAllowUtf8() const
+    {
+      return writer_.IsAllowUtf8();
+    }
+
     void SetCompressionLevel(uint8_t level);
 
     uint8_t GetCompressionLevel() const;
@@ -103,9 +113,9 @@
     
     bool IsAppendToExisting() const;
     
-    void OpenFile(const char* name);
+    void OpenFile(const std::string& name);
 
-    void OpenDirectory(const char* name);
+    void OpenDirectory(const std::string& name);
 
     void CloseDirectory();
 
--- a/OrthancFramework/Sources/Compression/ZipReader.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Compression/ZipReader.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -28,10 +28,20 @@
 #define NOMINMAX
 #endif
 
+#if !defined(ORTHANC_USE_SYSTEM_MINIZIP)
+#  error The macro ORTHANC_USE_SYSTEM_MINIZIP must be defined
+#endif
+
 #include "ZipReader.h"
 
+#if ORTHANC_USE_SYSTEM_MINIZIP == 1
+#  include <minizip/unzip.h>
+#else
+#  include "../../Resources/ThirdParty/minizip/unzip.h"
+#endif
+
+
 #include "../OrthancException.h"
-#include "../../Resources/ThirdParty/minizip/unzip.h"
 
 #if ORTHANC_SANDBOXED != 1
 #  include "../SystemToolbox.h"
--- a/OrthancFramework/Sources/Compression/ZipWriter.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Compression/ZipWriter.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -28,16 +28,27 @@
 #define NOMINMAX
 #endif
 
+#if !defined(ORTHANC_USE_SYSTEM_MINIZIP)
+#  error The macro ORTHANC_USE_SYSTEM_MINIZIP must be defined
+#endif
+
 #include "ZipWriter.h"
 
+
 #include <limits>
 #include <boost/filesystem.hpp>
 #include <boost/date_time/posix_time/posix_time.hpp>
 
-#include "../../Resources/ThirdParty/minizip/zip.h"
+#if ORTHANC_USE_SYSTEM_MINIZIP == 1
+#  include <minizip/zip.h>
+#else
+#  include "../../Resources/ThirdParty/minizip/zip.h"
+#endif
+
 #include "../Logging.h"
 #include "../OrthancException.h"
 #include "../SystemToolbox.h"
+#include "../Toolbox.h"
 
 
 static void PrepareFileInfo(zip_fileinfo& zfi)
@@ -440,6 +451,7 @@
   ZipWriter::ZipWriter() :
     pimpl_(new PImpl),
     isZip64_(false),
+    allowUtf8_(false),
     hasFileInZip_(false),
     append_(false),
     compressionLevel_(6)
@@ -584,6 +596,21 @@
     }
   }
 
+
+  void ZipWriter::SetAllowUtf8(bool allowUtf8)
+  {
+    allowUtf8_ = allowUtf8;
+
+#if ORTHANC_USE_SYSTEM_MINIZIP == 1
+    if (allowUtf8_)
+    {
+      LOG(WARNING) << "UTF-8 paths requested in ZIP file, but Orthanc is linked against "
+                   << "the system-wide minizip library, which may not support this feature";
+    }
+#endif
+  }
+
+
   void ZipWriter::SetCompressionLevel(uint8_t level)
   {
     if (level >= 10)
@@ -603,10 +630,12 @@
     return compressionLevel_;
   }
 
-  void ZipWriter::OpenFile(const char* filename)
+  void ZipWriter::OpenFile(const std::string& filename)
   {
     Open();
 
+    const std::string normalized = Toolbox::NormalizePath(filename, allowUtf8_, true /* allow slashes, necessary for subdirectories */);
+
     zip_fileinfo zfi;
     PrepareFileInfo(zfi);
 
@@ -614,7 +643,7 @@
 
     if (isZip64_)
     {
-      result = zipOpenNewFileInZip64(pimpl_->file_, filename,
+      result = zipOpenNewFileInZip64(pimpl_->file_, normalized.c_str(),
                                      &zfi,
                                      NULL,   0,
                                      NULL,   0,
@@ -624,7 +653,7 @@
     }
     else
     {
-      result = zipOpenNewFileInZip(pimpl_->file_, filename,
+      result = zipOpenNewFileInZip(pimpl_->file_, normalized.c_str(),
                                    &zfi,
                                    NULL,   0,
                                    NULL,   0,
--- a/OrthancFramework/Sources/Compression/ZipWriter.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Compression/ZipWriter.h	Thu Mar 19 12:31:58 2026 +0100
@@ -130,6 +130,7 @@
     boost::shared_ptr<PImpl> pimpl_;
 
     bool isZip64_;
+    bool allowUtf8_;
     bool hasFileInZip_;
     bool append_;
     uint8_t compressionLevel_;
@@ -146,6 +147,26 @@
 
     bool IsZip64() const;
 
+    /**
+     * Starting with minizip 1.3.2 (released on 17 Feb 2026), bit 11
+     * of the ZIP header flags is appropriately set if the path is
+     * encoded using UTF-8. This enables compatibility with UTF-8.
+     * The behavior of Orthanc <= 1.12.10 corresponds to
+     * "SetAllowUtf8(false)".
+     *
+     * Note that if linking against the system-wide version of minizip,
+     * there is no way to check that its version is above 1.3.2.
+     *
+     * https://zlib.net/ChangeLog.txt
+     * https://discourse.orthanc-server.org/t/seriesdescription-characters-and-removed-during-oe2-zip-export/6397
+     **/
+    void SetAllowUtf8(bool allowUtf8);
+
+    bool IsAllowUtf8() const
+    {
+      return allowUtf8_;
+    }
+
     void SetCompressionLevel(uint8_t level);
 
     uint8_t GetCompressionLevel() const;
@@ -164,9 +185,10 @@
 
     const boost::filesystem::path& GetOutputPath() const;
 
-    void OpenFile(const char* filename);
+    void OpenFile(const std::string& filename);
 
-    void Write(const void* data, size_t length);
+    void Write(const void* data,
+               size_t length);
 
     void Write(const std::string& data);
 
--- a/OrthancFramework/Sources/DicomNetworking/RemoteModalityParameters.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/DicomNetworking/RemoteModalityParameters.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -28,8 +28,10 @@
 #include "../Logging.h"
 #include "../OrthancException.h"
 #include "../SerializationToolbox.h"
+#include "../Toolbox.h"
 
 #include <boost/lexical_cast.hpp>
+#include <boost/regex.hpp>
 #include <stdexcept>
 
 
@@ -545,4 +547,5 @@
   {
     retrieveMethod_ = retrieveMethod;
   }
+
 }
--- a/OrthancFramework/Sources/DicomNetworking/RemoteModalityParameters.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/DicomNetworking/RemoteModalityParameters.h	Thu Mar 19 12:31:58 2026 +0100
@@ -52,7 +52,7 @@
     std::string           localAet_;
     uint32_t              timeout_;
     RetrieveMethod        retrieveMethod_;   // New in Orthanc 1.12.6
-    
+
     void Clear();
 
     void UnserializeArray(const Json::Value& serialized);
@@ -123,6 +123,5 @@
     RetrieveMethod GetRetrieveMethod() const;
 
     void SetRetrieveMethod(RetrieveMethod retrieveMethod);
-
   };
 }
--- a/OrthancFramework/Sources/DicomParsing/DicomWebJsonVisitor.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/DicomParsing/DicomWebJsonVisitor.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -25,13 +25,13 @@
 #include "../PrecompiledHeaders.h"
 #include "DicomWebJsonVisitor.h"
 
+#include "../CompatibilityMath.h"
 #include "../Logging.h"
 #include "../OrthancException.h"
+#include "../SerializationToolbox.h"
 #include "../Toolbox.h"
-#include "../SerializationToolbox.h"
 #include "FromDcmtkBridge.h"
 
-#include <boost/math/special_functions/round.hpp>
 #include <boost/lexical_cast.hpp>
 
 
@@ -329,10 +329,9 @@
     
   Json::Value DicomWebJsonVisitor::FormatDouble(double value)
   {
-    try
+    long long a;
+    if (Math::llround(a, value))
     {
-      long long a = boost::math::llround<double>(value);
-
       double d = fabs(value - static_cast<double>(a));
 
       if (d <= std::numeric_limits<double>::epsilon() * 100.0)
@@ -344,7 +343,7 @@
         return Json::Value(value);
       }
     }
-    catch (boost::math::rounding_error&)
+    else
     {
       // Can occur if "long long" is too small to receive this value
       // (e.g. infinity)
@@ -352,12 +351,12 @@
     }
   }
 
-  Json::Value DicomWebJsonVisitor::FormatDecimalString(double value, const std::string& originalString)
+  Json::Value DicomWebJsonVisitor::FormatDecimalString(double value,
+                                                       const std::string& originalString)
   {
-    try
+    long long a;
+    if (Math::llround(a, value))
     {
-      long long a = boost::math::llround<double>(value);
-
       double d = fabs(value - static_cast<double>(a));
 
       if (d <= std::numeric_limits<double>::epsilon() * 100.0)
@@ -369,7 +368,7 @@
         return Json::Value(originalString);  // keep the original string to avoid rounding errors e.g, transforming "0.143" into 0.14299999999999
       }
     }
-    catch (boost::math::rounding_error&)
+    else
     {
       // Can occur if "long long" is too small to receive this value
       // (e.g. infinity)
--- a/OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -803,7 +803,6 @@
          **/
 
         case EVR_OB:  // other byte
-        case EVR_OF:  // other float
         case EVR_OW:  // other word
         case EVR_UN:  // unknown value representation
         case EVR_ox:  // OB or OW depending on context
@@ -876,6 +875,63 @@
           return ApplyDcmtkToCTypeConverter<DcmtkToFloat64Converter>(element);
         }
 
+        case EVR_OF:  // other float - binary array of 32-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OF stores a binary array of 32-bit IEEE floats. Unlike FL where getVM()
+           * returns the count of values, for OF getVM() returns 1 (the entire binary
+           * blob is considered one "value"). We must use getFloat32Array() to access
+           * the raw float buffer, then build a string of all values.
+           * The resulting string is formatted as in ApplyDcmtkToCTypeConverter().
+           **/
+          DcmFloatingPointSingle& content = dynamic_cast<DcmFloatingPointSingle&>(element);
+          Float32* floatArray = NULL;
+          if (content.getFloat32Array(floatArray).good() && floatArray != NULL)
+          {
+            const unsigned long numFloats = element.getLength() / sizeof(Float32);
+            std::string result;
+            for (unsigned long i = 0; i < numFloats; i++)
+            {
+              if (i > 0)
+              {
+                result += "\\";
+              }
+              result += boost::lexical_cast<std::string>(floatArray[i]);
+            }
+            return new DicomValue(result, false);
+          }
+          return new DicomValue;
+        }
+
+#if DCMTK_VERSION_NUMBER >= 361
+        case EVR_OD:  // other double - binary array of 64-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OD stores a binary array of 64-bit IEEE doubles. Similar to OF,
+           * getVM() returns 1 for OD. We must use getFloat64Array() to access
+           * the raw double buffer.
+           * The resulting string is formatted as in ApplyDcmtkToCTypeConverter().
+           **/
+          DcmFloatingPointDouble& content = dynamic_cast<DcmFloatingPointDouble&>(element);
+          Float64* doubleArray = NULL;
+          if (content.getFloat64Array(doubleArray).good() && doubleArray != NULL)
+          {
+            const unsigned long numDoubles = element.getLength() / sizeof(Float64);
+            std::string result;
+            for (unsigned long i = 0; i < numDoubles; i++)
+            {
+              if (i > 0)
+              {
+                result += "\\";
+              }
+              result += boost::lexical_cast<std::string>(doubleArray[i]);
+            }
+            return new DicomValue(result, false);
+          }
+          return new DicomValue;
+        }
+#endif
+
 
         /**
          * Attribute tag.
@@ -3039,7 +3095,6 @@
         }
 
         case EVR_FL:  // float single-precision
-        case EVR_OF:
         {
           DcmFloatingPointSingle& content = dynamic_cast<DcmFloatingPointSingle&>(element);
 
@@ -3060,9 +3115,6 @@
         }
 
         case EVR_FD:  // float double-precision
-#if DCMTK_VERSION_NUMBER >= 361
-        case EVR_OD:
-#endif
         {
           DcmFloatingPointDouble& content = dynamic_cast<DcmFloatingPointDouble&>(element);
 
@@ -3082,6 +3134,64 @@
           break;
         }
 
+        case EVR_OF:  // other float - binary array of 32-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OF stores a binary array of 32-bit IEEE floats. Unlike FL where getVM()
+           * returns the count of values, for OF getVM() returns 1 (the entire binary
+           * blob is considered one "value"). We must use getFloat32Array() to access
+           * the raw float buffer, then iterate over all values.
+           **/
+          DcmFloatingPointSingle& content = dynamic_cast<DcmFloatingPointSingle&>(element);
+
+          std::vector<double> values;
+
+          Float32* floatArray = NULL;
+          if (content.getFloat32Array(floatArray).good() && floatArray != NULL)
+          {
+            const unsigned long numFloats = static_cast<unsigned long>(element.getLength() / sizeof(Float32));
+            values.reserve(numFloats);
+
+            for (unsigned long i = 0; i < numFloats; i++)
+            {
+              values.push_back(static_cast<double>(floatArray[i]));
+            }
+          }
+
+          action = visitor.VisitDoubles(parentTags, parentIndexes, tag, vr, values);
+          break;
+        }
+
+#if DCMTK_VERSION_NUMBER >= 361
+        case EVR_OD:  // other double - binary array of 64-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OD stores a binary array of 64-bit IEEE doubles. Unlike FD where getVM()
+           * returns the count of values, for OD getVM() returns 1 (the entire binary
+           * blob is considered one "value"). We must use getFloat64Array() to access
+           * the raw double buffer, then iterate over all values.
+           **/
+          DcmFloatingPointDouble& content = dynamic_cast<DcmFloatingPointDouble&>(element);
+
+          std::vector<double> values;
+
+          Float64* doubleArray = NULL;
+          if (content.getFloat64Array(doubleArray).good() && doubleArray != NULL)
+          {
+            const unsigned long numDoubles = static_cast<unsigned long>(element.getLength() / sizeof(Float64));
+            values.reserve(numDoubles);
+
+            for (unsigned long i = 0; i < numDoubles; i++)
+            {
+              values.push_back(doubleArray[i]);
+            }
+          }
+
+          action = visitor.VisitDoubles(parentTags, parentIndexes, tag, vr, values);
+          break;
+        }
+#endif
+
 
         /**
          * Attribute tag.
--- a/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/DicomParsing/ParsedDicomFile.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -138,7 +138,6 @@
 #include <dcmtk/dcmdata/dcpxitem.h>
 
 
-#include <boost/math/special_functions/round.hpp>
 #include <dcmtk/dcmdata/dcostrmb.h>
 #include <boost/algorithm/string/predicate.hpp>
 
--- a/OrthancFramework/Sources/HttpClient.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/HttpClient.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -1138,6 +1138,7 @@
     }
     else
     {
+      answer.FlattenBody(answerBody);
       return false;
     }
   }
--- a/OrthancFramework/Sources/HttpServer/IWebDavBucket.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/HttpServer/IWebDavBucket.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -166,12 +166,20 @@
   
   IWebDavBucket::File::File(const std::string& displayName) :
     Resource(displayName),
+    hasContentLength_(false),
     contentLength_(0),
     mime_(MimeType_Binary)
   {
   }
 
   
+  void IWebDavBucket::File::SetContentLength(uint64_t contentLength)
+  {
+    hasContentLength_ = true;
+    contentLength_ = contentLength;
+  }
+
+
   void IWebDavBucket::File::Format(pugi::xml_node& node,
                                    const std::string& parentPath) const
   {
@@ -189,11 +197,14 @@
     pugi::xml_node prop = node.first_element_by_path("D:propstat/D:prop");
     prop.append_child("D:resourcetype");
 
-    std::string s = boost::lexical_cast<std::string>(contentLength_);
-    prop.append_child("D:getcontentlength").append_child(pugi::node_pcdata).set_value(s.c_str());
+    std::string s = EnumerationToString(mime_);
+    prop.append_child("D:getcontenttype").append_child(pugi::node_pcdata).set_value(s.c_str());
 
-    s = EnumerationToString(mime_);
-    prop.append_child("D:getcontenttype").append_child(pugi::node_pcdata).set_value(s.c_str());
+    if (hasContentLength_)
+    {
+      s = boost::lexical_cast<std::string>(contentLength_);
+      prop.append_child("D:getcontentlength").append_child(pugi::node_pcdata).set_value(s.c_str());
+    }
   }
 
 
--- a/OrthancFramework/Sources/HttpServer/IWebDavBucket.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/HttpServer/IWebDavBucket.h	Thu Mar 19 12:31:58 2026 +0100
@@ -92,16 +92,14 @@
     class File : public Resource
     {
     private:
+      bool      hasContentLength_;
       uint64_t  contentLength_;
       MimeType  mime_;
 
     public:
       explicit File(const std::string& displayName);
 
-      void SetContentLength(uint64_t contentLength)
-      {
-        contentLength_ = contentLength;
-      }
+      void SetContentLength(uint64_t contentLength);
 
       void SetMimeType(MimeType mime)
       {
--- a/OrthancFramework/Sources/Images/ImageProcessing.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Images/ImageProcessing.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -25,26 +25,15 @@
 #include "../PrecompiledHeaders.h"
 #include "ImageProcessing.h"
 
+#include "../CompatibilityMath.h"
+#include "../OrthancException.h"
 #include "Image.h"
 #include "ImageTraits.h"
 #include "PixelTraits.h"
-#include "../OrthancException.h"
-
-#ifdef __EMSCRIPTEN__
-/* 
-   Avoid this error:
-   -----------------
-   .../boost/math/special_functions/round.hpp:118:12: warning: implicit conversion from 'std::__2::numeric_limits<long long>::type' (aka 'long long') to 'float' changes value from 9223372036854775807 to 9223372036854775808 [-Wimplicit-int-float-conversion]
-   .../mnt/c/osi/dev/orthanc/Core/Images/ImageProcessing.cpp:333:28: note: in instantiation of function template specialization 'boost::math::llround<float>' requested here
-   .../mnt/c/osi/dev/orthanc/Core/Images/ImageProcessing.cpp:1006:9: note: in instantiation of function template specialization 'Orthanc::MultiplyConstantInternal<unsigned char, true>' requested here
-*/
-#pragma GCC diagnostic ignored "-Wimplicit-int-float-conversion"
-#endif 
-
-#include <boost/math/special_functions/round.hpp>
 
 #include <algorithm>
 #include <cassert>
+#include <cmath>
 #include <limits>
 #include <list>
 #include <map>
@@ -384,7 +373,7 @@
         {
           assert(sizeof(long long) == sizeof(int64_t));
           // The "round" operation is very costly
-          v = boost::math::llround(static_cast<float>(*p) * factor);
+          v = Math::llround(static_cast<float>(*p) * factor);
         }
         else
         {
@@ -464,7 +453,7 @@
         {         
           // The "round" operation is very costly
           assert(sizeof(TargetType) < sizeof(int));
-          *p = static_cast<TargetType>(boost::math::iround(v));
+          *p = static_cast<TargetType>(Math::iround(v));
         }
         else
         {
@@ -2716,7 +2705,7 @@
             if (UseRound)
             {
               assert(sizeof(RawPixel) < sizeof(int));
-              *p = static_cast<RawPixel>(boost::math::iround(accumulator));
+              *p = static_cast<RawPixel>(Math::iround(accumulator));
             }
             else
             {
@@ -2868,8 +2857,8 @@
       static_cast<float>(target.GetWidth()) / cw,
       static_cast<float>(target.GetHeight()) / ch);
 
-    unsigned int sw = std::min(static_cast<unsigned int>(boost::math::iround(cw * r)), target.GetWidth());  
-    unsigned int sh = std::min(static_cast<unsigned int>(boost::math::iround(ch * r)), target.GetHeight());
+    unsigned int sw = std::min(static_cast<unsigned int>(Math::iround(cw * r)), target.GetWidth());
+    unsigned int sh = std::min(static_cast<unsigned int>(Math::iround(ch * r)), target.GetHeight());
 
     Image resized(target.GetFormat(), sw, sh, false);
   
@@ -2913,10 +2902,10 @@
                              static_cast<float>(height) / static_cast<float>(source.GetHeight()));
 
       unsigned int resizedWidth = static_cast<unsigned int>(
-        boost::math::iround(ratio * static_cast<float>(source.GetWidth())));
+        Math::iround(ratio * static_cast<float>(source.GetWidth())));
 
       unsigned int resizedHeight = static_cast<unsigned int>(
-        boost::math::iround(ratio * static_cast<float>(source.GetHeight())));
+        Math::iround(ratio * static_cast<float>(source.GetHeight())));
 
       std::unique_ptr<ImageAccessor> resized(FitSize(source, resizedWidth, resizedHeight));
 
--- a/OrthancFramework/Sources/JobsEngine/JobInfo.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/JobInfo.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -24,27 +24,11 @@
 
 #include "../PrecompiledHeaders.h"
 
-#ifdef __EMSCRIPTEN__
-/* 
-Avoid this error:
-
-.../boost/math/special_functions/round.hpp:118:12: warning: implicit conversion from 'std::__2::numeric_limits<long long>::type' (aka 'long long') to 'float' changes value from 9223372036854775807 to 9223372036854775808 [-Wimplicit-int-float-conversion]
-.../boost/math/special_functions/round.hpp:125:11: note: in instantiation of function template specialization 'boost::math::llround<float, boost::math::policies::policy<boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy> >' requested here
-.../orthanc/Core/JobsEngine/JobInfo.cpp:69:44: note: in instantiation of function template specialization 'boost::math::llround<float>' requested here
-
-.../boost/math/special_functions/round.hpp:86:12: warning: implicit conversion from 'std::__2::numeric_limits<int>::type' (aka 'int') to 'float' changes value from 2147483647 to 2147483648 [-Wimplicit-int-float-conversion]
-.../boost/math/special_functions/round.hpp:93:11: note: in instantiation of function template specialization 'boost::math::iround<float, boost::math::policies::policy<boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy, boost::math::policies::default_policy> >' requested here
-.../orthanc/Core/JobsEngine/JobInfo.cpp:133:39: note: in instantiation of function template specialization 'boost::math::iround<float>' requested here
-*/
-#pragma GCC diagnostic ignored "-Wimplicit-int-float-conversion"
-#endif 
-
 #include "JobInfo.h"
 
+#include "../CompatibilityMath.h"
 #include "../OrthancException.h"
 
-// This "include" is mandatory for Release builds using Linux Standard Base
-#include <boost/math/special_functions/round.hpp>
 
 namespace Orthanc
 {
@@ -79,7 +63,7 @@
       if (progress > 0.01f &&
           ms > 0.01f)
       {
-        long long remaining = boost::math::llround(ms / progress * (1.0f - progress));
+        long long remaining = Math::llround(ms / progress * (1.0f - progress));
         eta_ = timestamp_ + boost::posix_time::milliseconds(remaining);
         hasEta_ = true;
       }
@@ -189,7 +173,7 @@
     target["Timestamp"] = boost::posix_time::to_iso_string(timestamp_);
     target["CreationTime"] = boost::posix_time::to_iso_string(creationTime_);
     target["EffectiveRuntime"] = static_cast<double>(runtime_.total_milliseconds()) / 1000.0;
-    target["Progress"] = boost::math::iround(status_.GetProgress() * 100.0f);
+    target["Progress"] = Math::iround(status_.GetProgress() * 100.0f);
     
     if (status_.HasUserData())
     {
--- a/OrthancFramework/Sources/JobsEngine/JobsEngine.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/JobsEngine.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -109,6 +109,8 @@
     
   void JobsEngine::RetryHandler(JobsEngine* engine)
   {
+    Logging::SetCurrentThreadName("JOBS-RETRY");
+
     assert(engine != NULL);
 
     while (engine->IsRunning())
--- a/OrthancFramework/Sources/JobsEngine/JobsRegistry.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/JobsRegistry.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -47,6 +47,22 @@
   static const char* ERROR_PAYLOAD_TYPE = "ErrorPayloadType";
   static const char* ERROR_PAYLOAD = "ErrorPayload";
 
+  class JobsRegistry::LastModificationTimeUpdater
+  {
+  private:
+    JobsRegistry&  registry_;
+
+  public:
+    LastModificationTimeUpdater(JobsRegistry& registry) :
+      registry_(registry)
+    {
+    }
+
+    ~LastModificationTimeUpdater()
+    {
+      registry_.lastModificationTime_ = boost::posix_time::microsec_clock::universal_time();
+    }
+  };
 
   class JobsRegistry::JobHandler : public boost::noncopyable
   {
@@ -628,6 +644,7 @@
   void JobsRegistry::SetMaxCompletedJobs(size_t n)
   {
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     LOG(INFO) << "The size of the history of the jobs engine is set to: " << n << " job(s)";
@@ -691,6 +708,7 @@
     LOG(INFO) << "Deleting job: " << id;
 
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     JobsIndex::iterator found = jobsIndex_.find(id);
@@ -756,6 +774,7 @@
                                      const std::string& key)
   {
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     JobsIndex::const_iterator found = jobsIndex_.find(job);
@@ -792,6 +811,7 @@
 
     {
       boost::mutex::scoped_lock lock(mutex_);
+      LastModificationTimeUpdater updater(*this);
       CheckInvariants();
 
       id = handler->GetId();
@@ -845,6 +865,7 @@
   }
 
   JobsRegistry::JobsRegistry(size_t maxCompletedJobs) :
+    lastModificationTime_(boost::posix_time::neg_infin),
     maxCompletedJobs_(maxCompletedJobs),
     observer_(NULL)
   {
@@ -881,6 +902,8 @@
 
       for (;;)
       {
+        LastModificationTimeUpdater updater(*this);
+
         if (!GetStateInternal(state, id))
         {
           // Job has finished and has been lost (typically happens if
@@ -952,6 +975,7 @@
     LOG(INFO) << "Changing priority to " << priority << " for job: " << id;
 
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     JobsIndex::iterator found = jobsIndex_.find(id);
@@ -1020,6 +1044,7 @@
     LOG(INFO) << "Pausing job: " << id;
 
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     JobsIndex::iterator found = jobsIndex_.find(id);
@@ -1068,6 +1093,7 @@
     LOG(INFO) << "Canceling job: " << id;
 
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     JobsIndex::iterator found = jobsIndex_.find(id);
@@ -1125,6 +1151,7 @@
     LOG(INFO) << "Resuming job: " << id;
 
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     JobsIndex::iterator found = jobsIndex_.find(id);
@@ -1155,6 +1182,7 @@
     LOG(INFO) << "Resubmitting failed job: " << id;
 
     boost::mutex::scoped_lock lock(mutex_);
+    LastModificationTimeUpdater updater(*this);
     CheckInvariants();
 
     JobsIndex::iterator found = jobsIndex_.find(id);
@@ -1214,6 +1242,7 @@
     {
       if ((*it)->IsRetryReady(now))
       {
+        LastModificationTimeUpdater updater(*this);
         LOG(INFO) << "Retrying job: " << (*it)->GetId();
         (*it)->SetState(JobState_Pending);
         pendingJobs_.push(*it);
@@ -1280,6 +1309,8 @@
         }
       }
 
+      LastModificationTimeUpdater updater(registry_);
+
       handler_ = registry_.pendingJobs_.top();
       registry_.pendingJobs_.pop();
 
@@ -1299,6 +1330,7 @@
     if (IsValid())
     {
       boost::mutex::scoped_lock lock(registry_.mutex_);
+      LastModificationTimeUpdater updater(registry_);
 
       try
       {
@@ -1495,6 +1527,7 @@
       status.GetErrorPayload() = errorPayload;
 
       boost::mutex::scoped_lock lock(registry_.mutex_);
+      LastModificationTimeUpdater updater(registry_);
       registry_.CheckInvariants();
       assert(handler_->GetState() == JobState_Running);
 
@@ -1515,6 +1548,7 @@
       JobStatus status(code, details, *job_);
 
       boost::mutex::scoped_lock lock(registry_.mutex_);
+      LastModificationTimeUpdater updater(registry_);
       registry_.CheckInvariants();
       assert(handler_->GetState() == JobState_Running);
 
@@ -1548,6 +1582,7 @@
   JobsRegistry::JobsRegistry(IJobUnserializer& unserializer,
                              const Json::Value& s,
                              size_t maxCompletedJobs) :
+    lastModificationTime_(boost::posix_time::neg_infin),
     maxCompletedJobs_(maxCompletedJobs),
     observer_(NULL)
   {
@@ -1638,4 +1673,11 @@
       }
     }
   }
+
+  void JobsRegistry::GetLastModificationTime(boost::posix_time::ptime& modificationTime) const
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+    
+    modificationTime = lastModificationTime_;
+  }
 }
--- a/OrthancFramework/Sources/JobsEngine/JobsRegistry.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/JobsRegistry.h	Thu Mar 19 12:31:58 2026 +0100
@@ -70,6 +70,7 @@
     };
 
     class JobHandler;
+    class LastModificationTimeUpdater;
 
     struct PriorityComparator
     {
@@ -84,7 +85,8 @@
                                 std::vector<JobHandler*>,   // Could be a "std::deque"
                                 PriorityComparator>         PendingJobs;
 
-    boost::mutex               mutex_;
+    mutable boost::mutex       mutex_;
+    boost::posix_time::ptime   lastModificationTime_;
     JobsIndex                  jobsIndex_;
     PendingJobs                pendingJobs_;
     CompletedJobs              completedJobs_;
@@ -197,6 +199,8 @@
                        unsigned int& success,
                        unsigned int& errors);
 
+    void GetLastModificationTime(boost::posix_time::ptime& modificationTime) const;
+    
     class ORTHANC_PUBLIC RunningJob : public boost::noncopyable
     {
     private:
--- a/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -27,6 +27,7 @@
 
 #include "../OrthancException.h"
 #include "../SerializationToolbox.h"
+#include "../Logging.h"
 
 #include <cassert>
 
@@ -130,12 +131,11 @@
   }
 
 
-  void SetOfInstancesJob::AddParentResource(const std::string &resource)
+  void SetOfInstancesJob::AddParentResource(const std::string &resource, ResourceType level)
   {
-    parentResources_.insert(resource);
+    parentResources_[resource] = level;
   }
 
-
   void SetOfInstancesJob::AddInstance(const std::string& instance)
   {
     AddCommand(new InstanceCommand(*this, instance));
@@ -201,28 +201,46 @@
 
   static const char* KEY_TRAILING_STEP = "TrailingStep";
   static const char* KEY_FAILED_INSTANCES = "FailedInstances";
-  static const char* KEY_PARENT_RESOURCES = "ParentResources";
+  static const char* KEY_PARENT_RESOURCES = "ParentResources"; // old style but we keep it for backward compatibility
+  static const char* KEY_RESOURCES = "Resources"; // new style with the Resource type
+
+
+  static void SerializeResources(Json::Value& target, const std::map<std::string, ResourceType>& parentResources, bool includeParentResourcesField)
+  {
+    if (!parentResources.empty())
+    {
+      target[KEY_RESOURCES] = Json::arrayValue;
+      SerializationToolbox::WriteMapOfResourcesAndTypes(target[KEY_RESOURCES], parentResources);
+
+      if (includeParentResourcesField)
+      {
+        std::set<std::string> keys;
+        for (std::map<std::string, ResourceType>::const_iterator it = parentResources.begin(); it != parentResources.end(); ++it) 
+        {
+          keys.insert(it->first);
+        }
+
+        SerializationToolbox::WriteSetOfStrings(target, keys, KEY_PARENT_RESOURCES);
+      }
+    }
+  }
 
   void SetOfInstancesJob::GetPublicContent(Json::Value& target) const
   {
     SetOfCommandsJob::GetPublicContent(target);
     target["InstancesCount"] = static_cast<uint32_t>(GetInstancesCount());
     target["FailedInstancesCount"] = static_cast<uint32_t>(failedInstances_.size());
-
-    if (!parentResources_.empty())
-    {
-      SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
-    }
+    
+    SerializeResources(target, parentResources_, true);
   }
 
-
   bool SetOfInstancesJob::Serialize(Json::Value& target) const 
   {
     if (SetOfCommandsJob::Serialize(target))
     {
       target[KEY_TRAILING_STEP] = hasTrailingStep_;
       SerializationToolbox::WriteSetOfStrings(target, failedInstances_, KEY_FAILED_INSTANCES);
-      SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
+      SerializeResources(target, parentResources_, false);
       return true;
     }
     else
@@ -237,10 +255,13 @@
   {
     SerializationToolbox::ReadSetOfStrings(failedInstances_, source, KEY_FAILED_INSTANCES);
 
-    if (source.isMember(KEY_PARENT_RESOURCES))
+    if (source.isMember(KEY_PARENT_RESOURCES) && !source.isMember(KEY_RESOURCES))
     {
-      // Backward compatibility with Orthanc <= 1.5.6
-      SerializationToolbox::ReadSetOfStrings(parentResources_, source, KEY_PARENT_RESOURCES);
+      LOG(ERROR) << "Unable to read the " << KEY_PARENT_RESOURCES << " of a job that has been saved with the previous version of Orthanc";
+    }
+    else if (source.isMember(KEY_RESOURCES) && source[KEY_RESOURCES].isArray())
+    {
+      SerializationToolbox::ReadMapOfResourcesAndTypes(parentResources_, source, KEY_RESOURCES);
     }
     
     if (source.isMember(KEY_TRAILING_STEP))
--- a/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.h	Thu Mar 19 12:31:58 2026 +0100
@@ -28,6 +28,7 @@
 #include "SetOfCommandsJob.h"
 
 #include <set>
+#include <map>
 
 namespace Orthanc
 {
@@ -40,7 +41,7 @@
     
     bool                   hasTrailingStep_;
     std::set<std::string>  failedInstances_;
-    std::set<std::string>  parentResources_;
+    std::map<std::string, ResourceType> parentResources_;
 
   protected:
     virtual bool HandleInstance(const std::string& instance) = 0;
@@ -57,7 +58,7 @@
 
     // Only used for reporting in the public content
     // https://groups.google.com/d/msg/orthanc-users/9GCV88GLEzw/6wAgP_PRAgAJ
-    void AddParentResource(const std::string& resource);
+    void AddParentResource(const std::string& resource, ResourceType level);
     
     void AddInstance(const std::string& instance);
 
--- a/OrthancFramework/Sources/MetricsRegistry.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/MetricsRegistry.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -27,10 +27,10 @@
 
 #include "ChunkedBuffer.h"
 #include "Compatibility.h"
+#include "CompatibilityMath.h"
 #include "Logging.h"
 #include "OrthancException.h"
 
-#include <boost/math/special_functions/round.hpp>
 
 namespace Orthanc
 {
@@ -339,7 +339,7 @@
     
     virtual void UpdateFloat(float value) ORTHANC_OVERRIDE
     {
-      value_.Update(boost::math::llround(value), GetPolicy());
+      value_.Update(Orthanc::Math::llround(value), GetPolicy());
     }
 
     virtual void UpdateInteger(int64_t value) ORTHANC_OVERRIDE
--- a/OrthancFramework/Sources/MultiThreading/Semaphore.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/MultiThreading/Semaphore.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -30,17 +30,17 @@
 
 namespace Orthanc
 {
-  Semaphore::Semaphore(unsigned int availableResources) :
+  Semaphore::Semaphore(size_t availableResources) :
     availableResources_(availableResources)
   {
   }
 
-  unsigned int Semaphore::GetAvailableResourcesCount() const
+  size_t Semaphore::GetAvailableResourcesCount() const
   {
     return availableResources_;
   }
 
-  void Semaphore::Release(unsigned int resourceCount)
+  void Semaphore::Release(size_t resourceCount)
   {
     boost::mutex::scoped_lock lock(mutex_);
 
@@ -48,7 +48,7 @@
     condition_.notify_one();
   }
 
-  void Semaphore::Acquire(unsigned int resourceCount)
+  void Semaphore::Acquire(size_t resourceCount)
   {
     boost::mutex::scoped_lock lock(mutex_);
 
@@ -60,7 +60,7 @@
     availableResources_ -= resourceCount;
   }
 
-  bool Semaphore::TryAcquire(unsigned int resourceCount)
+  bool Semaphore::TryAcquire(size_t resourceCount)
   {
     boost::mutex::scoped_lock lock(mutex_);
 
--- a/OrthancFramework/Sources/MultiThreading/Semaphore.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/MultiThreading/Semaphore.h	Thu Mar 19 12:31:58 2026 +0100
@@ -34,29 +34,29 @@
   class ORTHANC_PUBLIC Semaphore : public boost::noncopyable
   {
   private:
-    unsigned int availableResources_;
+    size_t availableResources_;
     boost::mutex mutex_;
     boost::condition_variable condition_;
 
   public:
-    explicit Semaphore(unsigned int availableResources);
+    explicit Semaphore(size_t availableResources);
 
-    unsigned int GetAvailableResourcesCount() const;
+    size_t GetAvailableResourcesCount() const;
 
-    void Release(unsigned int resourceCount = 1);
+    void Release(size_t resourceCount = 1);
 
-    void Acquire(unsigned int resourceCount = 1);
+    void Acquire(size_t resourceCount = 1);
 
-    bool TryAcquire(unsigned int resourceCount = 1);
+    bool TryAcquire(size_t resourceCount = 1);
 
     class Locker : public boost::noncopyable
     {
     private:
       Semaphore&  that_;
-      unsigned int resourceCount_;
+      size_t resourceCount_;
 
     public:
-      explicit Locker(Semaphore& that, unsigned int resourceCount = 1) :
+      explicit Locker(Semaphore& that, size_t resourceCount = 1) :
         that_(that),
         resourceCount_(resourceCount)
       {
@@ -73,11 +73,11 @@
     {
     private:
       Semaphore&    that_;
-      unsigned int  resourceCount_;
+      size_t        resourceCount_;
       bool          isAcquired_;
 
     public:
-      explicit TryLocker(Semaphore& that, unsigned int resourceCount = 1) :
+      explicit TryLocker(Semaphore& that, size_t resourceCount = 1) :
         that_(that),
         resourceCount_(resourceCount)
       {
--- a/OrthancFramework/Sources/RestApi/RestApi.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApi.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -25,12 +25,12 @@
 #include "../PrecompiledHeaders.h"
 #include "RestApi.h"
 
+#include "../CompatibilityMath.h"
 #include "../HttpServer/StringHttpOutput.h"
 #include "../Logging.h"
 #include "../OrthancException.h"
 
 #include <boost/algorithm/string/replace.hpp>
-#include <boost/math/special_functions/round.hpp>
 #include <stdlib.h>   // To define "_exit()" under Windows
 #include <stdio.h>
 
@@ -356,7 +356,7 @@
     
         LOG(WARNING) << "The documentation of the REST API contains " << GetSuccessPathsCount()
                      << " paths over a total of " << GetTotalPathsCount() << " paths "
-                     << "(coverage: " << static_cast<unsigned int>(boost::math::iround(coverage)) << "%)";
+                     << "(coverage: " << static_cast<unsigned int>(Math::iround(coverage)) << "%)";
       }
     };
 
--- a/OrthancFramework/Sources/SerializationToolbox.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/SerializationToolbox.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -373,6 +373,35 @@
     }
   }
 
+  void SerializationToolbox::ReadMapOfResourcesAndTypes(std::map<std::string, ResourceType>& target,
+                                                        const Json::Value& value,
+                                                        const std::string& field)
+  {
+    if (!value[field].isArray())
+    {
+      throw OrthancException(ErrorCode_BadFileFormat, "Array expected in field: " + field);
+    }
+
+    target.clear();
+    
+    for (Json::ArrayIndex i = 0; i < value[field].size(); ++i)
+    {
+      target[value[field][i]["ID"].asString()] = StringToResourceType(value[field][i]["Type"].asString().c_str());
+    }
+  }
+
+  void SerializationToolbox::WriteMapOfResourcesAndTypes(Json::Value& targetArray,
+                                                         const std::map<std::string, ResourceType>& values)
+  {
+    for (std::map<std::string, ResourceType>::const_iterator it = values.begin(); it != values.end(); ++it) 
+    {
+      Json::Value resource;
+      resource["ID"] = it->first;
+      resource["Type"] = EnumerationToString(it->second);
+      targetArray.append(resource);
+    }
+  }
+
 
   void SerializationToolbox::WriteArrayOfStrings(Json::Value& target,
                                                  const std::vector<std::string>& values,
--- a/OrthancFramework/Sources/SerializationToolbox.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/SerializationToolbox.h	Thu Mar 19 12:31:58 2026 +0100
@@ -90,6 +90,10 @@
                               const Json::Value& value,
                               const std::string& field);
 
+    static void ReadMapOfResourcesAndTypes(std::map<std::string, ResourceType>& target,
+                                           const Json::Value& value,
+                                           const std::string& field);
+
     static void WriteArrayOfStrings(Json::Value& target,
                                     const std::vector<std::string>& values,
                                     const std::string& field);
@@ -105,6 +109,9 @@
     static void WriteSetOfStrings(Json::Value& targetArray,
                                   const std::set<std::string>& values);
 
+    static void WriteMapOfResourcesAndTypes(Json::Value& targetArray,
+                                            const std::map<std::string, ResourceType>& values);
+
     static void WriteSetOfTags(Json::Value& target,
                                const std::set<DicomTag>& tags,
                                const std::string& field);
--- a/OrthancFramework/Sources/Toolbox.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Toolbox.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -82,9 +82,11 @@
 
 
 #if ORTHANC_ENABLE_MD5 == 1
-// TODO - Could be replaced by <boost/uuid/detail/md5.hpp> starting
-// with Boost >= 1.66.0
-#  include "../Resources/ThirdParty/md5/md5.h"
+#  if BOOST_VERSION >= 106600
+#    include <boost/uuid/detail/md5.hpp>
+#  else
+#    include "../Resources/ThirdParty/md5/md5.h"
+#  endif
 #endif
 
 #if ORTHANC_ENABLE_BASE64 == 1
@@ -215,6 +217,114 @@
 namespace Orthanc
 {
 #if ORTHANC_ENABLE_MD5 == 1
+#  if BOOST_VERSION >= 106600
+  
+  struct Toolbox::MD5Context::PImpl
+  {
+    boost::uuids::detail::md5  md5_;
+    bool                       done_;
+
+    PImpl() :
+      done_(false)
+    {
+    }
+  };
+
+
+  Toolbox::MD5Context::MD5Context() :
+    pimpl_(new PImpl)
+  {
+  }
+
+
+  void Toolbox::MD5Context::Append(const void* data,
+                                   size_t size)
+  {
+    if (pimpl_->done_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+
+    pimpl_->md5_.process_bytes(data, size);
+  }
+
+  void Toolbox::MD5Context::Append(const std::string& source)
+  {
+    Append(reinterpret_cast<const void*>(source.c_str()), source.size());
+  }
+
+  void Toolbox::MD5Context::Export(std::string& target)
+  {
+    if (pimpl_->done_)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+
+    pimpl_->done_ = true;
+
+#    if BOOST_VERSION >= 108600
+    unsigned char digest[16];
+
+    // Sanity check for the memory layout: A MD5 digest is 128 bits wide
+    assert(sizeof(digest) == (128 / 8));
+    assert(sizeof(boost::uuids::detail::md5::digest_type) == 16);
+
+    pimpl_->md5_.get_digest(digest);
+
+    target.resize(32);
+    sprintf(&target[0], "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x",
+            digest[0], digest[1], digest[2], digest[3],
+            digest[4], digest[5], digest[6], digest[7],
+            digest[8], digest[9], digest[10], digest[11],
+            digest[12], digest[13], digest[14], digest[15]);
+
+#    elif BOOST_VERSION > 107000
+    unsigned int digest[4];
+
+    // Sanity check for the memory layout: A MD5 digest is 128 bits wide
+    assert(sizeof(digest) == (128 / 8));
+    assert(sizeof(boost::uuids::detail::md5::digest_type) == 16);
+
+    pimpl_->md5_.get_digest(digest);
+
+    target.resize(32);
+    sprintf(&target[0], "%08x%08x%08x%08x",
+            digest[0],
+            digest[1],
+            digest[2],
+            digest[3]);
+
+#    else // BOOST version between 1066 and 1070: there was a byte order bug in these implementations (https://github.com/boostorg/uuid/pull/109)
+    unsigned int digest[4];
+
+    // Sanity check for the memory layout: A MD5 digest is 128 bits wide
+    assert(sizeof(digest) == (128 / 8));
+    assert(sizeof(boost::uuids::detail::md5::digest_type) == 16);
+
+    pimpl_->md5_.get_digest(digest);
+
+    target.resize(32);
+    sprintf(&target[0], "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x",
+            (digest[0] >> 0) & 0xFF, 
+            (digest[0] >> 8) & 0xFF,
+            (digest[0] >> 16) & 0xFF,
+            (digest[0] >> 24) & 0xFF,
+            (digest[1] >> 0) & 0xFF, 
+            (digest[1] >> 8) & 0xFF,
+            (digest[1] >> 16) & 0xFF,
+            (digest[1] >> 24) & 0xFF,
+            (digest[2] >> 0) & 0xFF, 
+            (digest[2] >> 8) & 0xFF,
+            (digest[2] >> 16) & 0xFF,
+            (digest[2] >> 24) & 0xFF,
+            (digest[3] >> 0) & 0xFF, 
+            (digest[3] >> 8) & 0xFF,
+            (digest[3] >> 16) & 0xFF,
+            (digest[3] >> 24) & 0xFF);
+
+#    endif //BOOST_VERSION >= 108600
+  }
+#  else // BOOST_VERSION >= 106600
   static char GetHexadecimalCharacter(uint8_t value)
   {
     assert(value < 16);
@@ -317,6 +427,8 @@
       target[2 * i + 1] = GetHexadecimalCharacter(static_cast<uint8_t>(actualHash[i] % 16));
     }
   }
+
+#  endif // BOOST_VERSION >= 106600
 #endif  /* ORTHANC_ENABLE_MD5 */
 
 
@@ -1947,6 +2059,9 @@
 
 #if (ORTHANC_ENABLE_ICU == 1)
     u_cleanup();
+#  if ORTHANC_STATIC_ICU == 1
+    globalIcuData_.clear();
+#  endif
 #endif
   }
 
@@ -2729,6 +2844,212 @@
     }
   }
 
+
+  bool Toolbox::IsValidUtf8(const std::string& s)
+  {
+    if (s.empty())
+    {
+      return true;
+    }
+
+    const unsigned char* bytes = reinterpret_cast<const unsigned char*>(s.data());
+    size_t len = s.size();
+
+    size_t i = 0;
+    while (i < len)
+    {
+      unsigned char b1 = bytes[i];
+
+      if (b1 <= 0x7fu)
+      {
+        // 1-byte (ASCII): 0xxxxxxx
+        i++;
+      }
+      else if ((b1 >> 5) == 0x06u)
+      {
+        // 2-byte sequence: 110xxxxx 10xxxxxx
+        if (i + 1 >= len)
+        {
+          return false;
+        }
+
+        unsigned char b2 = bytes[i + 1];
+        if ((b2 >> 6) != 0x02u)
+        {
+          return false;
+        }
+
+        uint32_t codepoint =
+          ((b1 & 0x1fu) << 6) |
+          (b2 & 0x3fu);
+
+        // Overlong encoding check
+        if (codepoint < 0x80u)
+        {
+          return false;
+        }
+
+        i += 2;
+      }
+      else if ((b1 >> 4) == 0x0eu)
+      {
+        // 3-byte sequence: 1110xxxx 10xxxxxx 10xxxxxx
+        if (i + 2 >= len)
+        {
+          return false;
+        }
+
+        unsigned char b2 = bytes[i + 1];
+        unsigned char b3 = bytes[i + 2];
+
+        if ((b2 >> 6) != 0x02u ||
+            (b3 >> 6) != 0x02u)
+        {
+          return false;
+        }
+
+        uint32_t codepoint =
+          ((b1 & 0x0fu) << 12) |
+          ((b2 & 0x3fu) << 6) |
+          (b3 & 0x3fu);
+
+        // Overlong encoding check
+        if (codepoint < 0x0800u)
+        {
+          return false;
+        }
+
+        // Surrogate range check (U+D800 to U+DFFF)
+        if (codepoint >= 0xd800u &&
+            codepoint <= 0xdfffu)
+        {
+          return false;
+        }
+
+        i += 3;
+      }
+      else if ((b1 >> 3) == 0x1eu)
+      {
+        // 4-byte sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
+        if (i + 3 >= len)
+        {
+          return false;
+        }
+
+        unsigned char b2 = bytes[i + 1];
+        unsigned char b3 = bytes[i + 2];
+        unsigned char b4 = bytes[i + 3];
+
+        if ((b2 >> 6) != 0x02u ||
+            (b3 >> 6) != 0x02u ||
+            (b4 >> 6) != 0x02u)
+        {
+          return false;
+        }
+
+        uint32_t codepoint =
+          ((b1 & 0x07u) << 18) |
+          ((b2 & 0x3fu) << 12) |
+          ((b3 & 0x3fu) << 6) |
+          (b4 & 0x3fu);
+
+        // Overlong encoding check
+        if (codepoint < 0x00010000u)
+        {
+          return false;
+        }
+
+        // Maximum valid Unicode code point
+        if (codepoint > 0x0010ffffu)
+        {
+          return false;
+        }
+
+        i += 4;
+      }
+      else
+      {
+        return false; // Invalid leading byte
+      }
+    }
+
+    return true;
+  }
+
+
+  std::string Toolbox::NormalizePath(const std::string& utf8,
+                                     bool allowUtf8,
+                                     bool allowSlashes)
+  {
+    std::string converted;
+
+    if (allowUtf8 &&
+        IsValidUtf8(utf8))
+    {
+      converted = utf8;
+    }
+    else
+    {
+      converted = Toolbox::ConvertToAscii(utf8);
+    }
+
+    std::string result;
+    result.reserve(converted.size());
+
+    for (size_t i = 0; i < converted.size(); i++)
+    {
+      unsigned char c = converted[i];
+      bool allowRepetitions = true;
+
+      if (isspace(c) ||
+          c <= 31 ||  // Control characters
+
+          // Remove characters that could break some filesystems
+          c == ':' ||
+          c == '*' ||
+          c == '?' ||
+          c == '"' ||
+          c == '<' ||
+          c == '>' ||
+          c == '|')
+      {
+        // This is a space character: Avoid their repetition
+        allowRepetitions = false;
+        c = ' ';
+      }
+      else if (c == '/' ||
+               c == '\\')
+      {
+        // This is a slash or backslash: Prevent their repetition
+        allowRepetitions = false;
+        if (allowSlashes)
+        {
+          c = '/';  // Normalize backslashes as forward slashes
+        }
+        else
+        {
+          c = ' ';  // Slashes are not allowed: Replace by spaces
+        }
+      }
+
+      if (allowRepetitions)
+      {
+        result.push_back(c);
+      }
+      else
+      {
+        if (result.empty() ||
+            result[result.size() - 1] != c)
+        {
+          result.push_back(c);
+        }
+      }
+    }
+
+    return Toolbox::StripSpaces(result);
+  }
+
+
   Toolbox::ElapsedTimer::ElapsedTimer()
   {
     Restart();
--- a/OrthancFramework/Sources/Toolbox.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/Sources/Toolbox.h	Thu Mar 19 12:31:58 2026 +0100
@@ -407,6 +407,12 @@
 
     static void RemoveSurroundingQuotes(std::string& value);
 
+    static bool IsValidUtf8(const std::string& s);
+
+    static std::string NormalizePath(const std::string& utf8,
+                                     bool allowUtf8,
+                                     bool allowSlashes);
+
     class ORTHANC_PUBLIC ElapsedTimer : public boost::noncopyable
     {
     private:
--- a/OrthancFramework/UnitTestsSources/FrameworkTests.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/UnitTestsSources/FrameworkTests.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -35,6 +35,7 @@
 
 #include <gtest/gtest.h>
 
+#include "../Sources/CompatibilityMath.h"
 #include "../Sources/DicomFormat/DicomTag.h"
 #include "../Sources/HttpServer/HttpToolbox.h"
 #include "../Sources/Logging.h"
@@ -51,6 +52,7 @@
 #endif
 
 #include <ctype.h>
+#include <limits>
 
 
 using namespace Orthanc;
@@ -511,6 +513,7 @@
   
   std::string s(reinterpret_cast<const char*>(&data[0]), 10);
   ASSERT_EQ("&abc", Toolbox::ConvertToAscii(s));
+  ASSERT_FALSE(Toolbox::IsValidUtf8(s));
 
   // Open in Emacs, then save with UTF-8 encoding, then "hexdump -C"
   std::string utf8 = Toolbox::ConvertToUtf8(s, Encoding_Latin1, false, false);
@@ -530,6 +533,7 @@
   ASSERT_EQ(0x62, static_cast<unsigned char>(utf8[12]));
   ASSERT_EQ(0x63, static_cast<unsigned char>(utf8[13]));
   ASSERT_EQ(0x00, static_cast<unsigned char>(utf8[14]));  // Null-terminated string
+  ASSERT_TRUE(Toolbox::IsValidUtf8(utf8));
 }
 
 
@@ -539,9 +543,12 @@
   const unsigned char latin1[] = { 0x63, 0x72, 0xe2, 0x6e, 0x65 };
 
   std::string s(reinterpret_cast<const char*>(&latin1[0]), sizeof(latin1) / sizeof(char));
+  ASSERT_FALSE(Toolbox::IsValidUtf8(s));
 
-  ASSERT_EQ(s, Toolbox::ConvertFromUtf8(Toolbox::ConvertToUtf8(s, Encoding_Latin1, false, false), Encoding_Latin1));
+  std::string utf8 = Toolbox::ConvertToUtf8(s, Encoding_Latin1, false, false);
+  ASSERT_EQ(s, Toolbox::ConvertFromUtf8(utf8, Encoding_Latin1));
   ASSERT_EQ("cre", Toolbox::ConvertToUtf8(s, Encoding_Utf8, false, false));
+  ASSERT_TRUE(Toolbox::IsValidUtf8(utf8));
 }
 
 
@@ -1864,3 +1871,46 @@
   ASSERT_TRUE(b.GetContent().isString());
   ASSERT_EQ("Hello", b.GetContent().asString());
 }
+
+
+TEST(Math, Rounding)
+{
+  ASSERT_EQ(42, Math::iround(42.005f));
+  ASSERT_EQ(42, Math::iround(41.995f));
+  ASSERT_EQ(-42, Math::iround(-42.005f));
+  ASSERT_EQ(-42, Math::iround(-41.995f));
+
+  ASSERT_EQ(43, Math::iround(43.005));
+  ASSERT_EQ(43, Math::iround(42.995));
+  ASSERT_EQ(-43, Math::iround(-43.005));
+  ASSERT_EQ(-43, Math::iround(-42.995));
+
+  ASSERT_EQ(44ll, Math::llround(44.005f));
+  ASSERT_EQ(44ll, Math::llround(43.995f));
+  ASSERT_EQ(-44ll, Math::llround(-44.005f));
+  ASSERT_EQ(-44ll, Math::llround(-43.995f));
+
+  ASSERT_EQ(45ll, Math::llround(45.005));
+  ASSERT_EQ(45ll, Math::llround(44.995));
+  ASSERT_EQ(-45ll, Math::llround(-45.005));
+  ASSERT_EQ(-45ll, Math::llround(-44.995));
+
+  long long a;
+  ASSERT_TRUE(Math::llround(a, 46.005f));  ASSERT_EQ(46ll, a);
+  ASSERT_TRUE(Math::llround(a, 45.995f));  ASSERT_EQ(46ll, a);
+  ASSERT_TRUE(Math::llround(a, -46.005f));  ASSERT_EQ(-46ll, a);
+  ASSERT_TRUE(Math::llround(a, -45.995f));  ASSERT_EQ(-46ll, a);
+
+  ASSERT_FALSE(Math::llround(a, std::numeric_limits<float>::quiet_NaN()));
+  ASSERT_FALSE(Math::llround(a, std::numeric_limits<float>::infinity()));
+  ASSERT_FALSE(Math::llround(a, 1e20f /* float too large */));
+
+  ASSERT_TRUE(Math::llround(a, 47.005));  ASSERT_EQ(47ll, a);
+  ASSERT_TRUE(Math::llround(a, 46.995));  ASSERT_EQ(47ll, a);
+  ASSERT_TRUE(Math::llround(a, -47.005));  ASSERT_EQ(-47ll, a);
+  ASSERT_TRUE(Math::llround(a, -46.995));  ASSERT_EQ(-47ll, a);
+
+  ASSERT_FALSE(Math::llround(a, std::numeric_limits<double>::quiet_NaN()));
+  ASSERT_FALSE(Math::llround(a, std::numeric_limits<double>::infinity()));
+  ASSERT_FALSE(Math::llround(a, 1e20 /* double too large */));
+}
--- a/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/UnitTestsSources/FromDcmtkTests.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -265,6 +265,7 @@
     std::string source(testEncodingsEncoded[i]);
     std::string expected(testEncodingsExpected[i]);
     std::string s = Toolbox::ConvertToUtf8(source, testEncodings[i], false, false);
+    ASSERT_TRUE(Toolbox::IsValidUtf8(s));
     //std::cout << EnumerationToString(testEncodings[i]) << std::endl;
     EXPECT_EQ(expected, s);
   }
@@ -335,6 +336,7 @@
       f.SetEncoding(testEncodings[i]);
 
       std::string s = Toolbox::ConvertToUtf8(testEncodingsEncoded[i], testEncodings[i], false, false);
+      ASSERT_TRUE(Toolbox::IsValidUtf8(s));
       f.Insert(DICOM_TAG_PATIENT_NAME, s, false, "");
       f.SaveToMemoryBuffer(dicom);
     }
@@ -1173,6 +1175,7 @@
         std::string encoded = Toolbox::ConvertFromUtf8(testEncodingsExpected[i], testEncodings[i]);
         ASSERT_STREQ(testEncodingsEncoded[i], encoded.c_str());
         std::string decoded = Toolbox::ConvertToUtf8(encoded, testEncodings[i], false, false);
+        ASSERT_TRUE(Toolbox::IsValidUtf8(decoded));
         ASSERT_STREQ(testEncodingsExpected[i], decoded.c_str());
 
         if (testEncodings[i] != Encoding_Chinese)
@@ -1182,6 +1185,7 @@
 
           const std::string tmp = Toolbox::ConvertToUtf8(
             Toolbox::ConvertFromUtf8(utf8, testEncodings[i]), testEncodings[i], false, false);
+          ASSERT_TRUE(Toolbox::IsValidUtf8(tmp));
           ASSERT_STREQ(testEncodingsExpected[i], tmp.c_str());
         }
       }
@@ -1612,7 +1616,8 @@
     0xea, 0xb8, 0xb8, 0xeb, 0x8f, 0x99
   };
 
-  std::string utf8(reinterpret_cast<const char*>(utf8raw), sizeof(utf8raw));
+  const std::string utf8(reinterpret_cast<const char*>(utf8raw), sizeof(utf8raw));
+  ASSERT_TRUE(Toolbox::IsValidUtf8(utf8));
 
   ParsedDicomFile dicom(false);
   dicom.ReplacePlainString(DICOM_TAG_SPECIFIC_CHARACTER_SET, "\\ISO 2022 IR 149");
@@ -1709,7 +1714,8 @@
     0x8d, 0xe3, 0x81, 0x86
   };
 
-  std::string utf8(reinterpret_cast<const char*>(utf8raw), sizeof(utf8raw));
+  const std::string utf8(reinterpret_cast<const char*>(utf8raw), sizeof(utf8raw));
+  ASSERT_TRUE(Toolbox::IsValidUtf8(utf8));
 
   ParsedDicomFile dicom(false);
   dicom.ReplacePlainString(DICOM_TAG_SPECIFIC_CHARACTER_SET, "\\ISO 2022 IR 87");
@@ -1914,7 +1920,10 @@
 
   std::string value;
   ASSERT_TRUE(dicom.GetTagValue(value, DICOM_TAG_PATIENT_NAME));
-  ASSERT_EQ(value, std::string(reinterpret_cast<const char*>(utf8), sizeof(utf8)));
+
+  const std::string tmp(reinterpret_cast<const char*>(utf8), sizeof(utf8));
+  ASSERT_EQ(value, tmp);
+  ASSERT_TRUE(Toolbox::IsValidUtf8(tmp));
 }
 
 
--- a/OrthancFramework/UnitTestsSources/ToolboxTests.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/UnitTestsSources/ToolboxTests.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -436,3 +436,26 @@
   s = Orthanc::Toolbox::ConvertDicomStringToUtf8("ORIGINAL\\PRIMARY", Encoding_Latin1, false, ValueRepresentation_ShortText);
   ASSERT_EQ("ORIGINAL\\PRIMARY", s);
 }
+
+TEST(Toolbox, IsValidUtf8)
+{
+  ASSERT_TRUE(Orthanc::Toolbox::IsValidUtf8("Hello, world!"));          // ASCII
+  ASSERT_TRUE(Orthanc::Toolbox::IsValidUtf8(""));                       // Empty string is valid
+
+  ASSERT_TRUE(Orthanc::Toolbox::IsValidUtf8("\xC2\xA2"));               // U+00A2 2-byte
+  ASSERT_TRUE(Orthanc::Toolbox::IsValidUtf8("\xE2\x82\xAC"));           // U+20AC 3-byte
+  ASSERT_TRUE(Orthanc::Toolbox::IsValidUtf8("\xF0\x9F\x8C\x8D"));       // U+1F30D 4-byte
+  ASSERT_TRUE(Orthanc::Toolbox::IsValidUtf8("Hello \xE2\x82\xAC \xF0\x9F\x8C\x8D"));   // Mixed
+  ASSERT_TRUE(Orthanc::Toolbox::IsValidUtf8("\xF4\x8F\xBF\xBF"));       // U+10FFFF (max valid)
+
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xC2\x20"));              // Invalid continuation
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xE2\x82"));              // Missing continuation
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\x80"));                  // Lone continuation byte
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xC0\xAF"));              // Overlong 2-byte
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xE0\x80\xAF"));          // Overlong 3-byte
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xED\xA0\x80"));          // Surrogate (U+D800)
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xF4\x90\x80\x80"));      // > U+10FFFF
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xF0\x9F\x8C"));          // Truncated 4-byte
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xFF"));                  // Invalid leading byte
+  ASSERT_FALSE(Orthanc::Toolbox::IsValidUtf8("\xF0\x28\x8C\x28"));
+}
--- a/OrthancFramework/UnitTestsSources/ZipTests.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancFramework/UnitTestsSources/ZipTests.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -36,12 +36,14 @@
 #include "../Sources/TemporaryFile.h"
 #include "../Sources/Toolbox.h"
 
+#include "../Resources/CodeGeneration/EncodingTests.h"
+
 
 using namespace Orthanc;
 
 TEST(ZipWriter, Basic)
 {
-  Orthanc::ZipWriter w;
+  ZipWriter w;
   w.SetOutputPath("UnitTestsResults/hello.zip");
   w.Open();
   w.OpenFile("world/hello");
@@ -51,7 +53,7 @@
 
 TEST(ZipWriter, Basic64)
 {
-  Orthanc::ZipWriter w;
+  ZipWriter w;
   w.SetOutputPath("UnitTestsResults/hello64.zip");
   w.SetZip64(true);
   w.Open();
@@ -62,18 +64,18 @@
 
 TEST(ZipWriter, Exceptions)
 {
-  Orthanc::ZipWriter w;
-  ASSERT_THROW(w.Open(), Orthanc::OrthancException);
+  ZipWriter w;
+  ASSERT_THROW(w.Open(), OrthancException);
   w.SetOutputPath("UnitTestsResults/hello3.zip");
   w.Open();
-  ASSERT_THROW(w.Write("hello world"), Orthanc::OrthancException);
+  ASSERT_THROW(w.Write("hello world"), OrthancException);
 }
 
 
 TEST(ZipWriter, Append)
 {
   {
-    Orthanc::ZipWriter w;
+    ZipWriter w;
     w.SetAppendToExisting(false);
     w.SetOutputPath("UnitTestsResults/append.zip");
     w.Open();
@@ -82,7 +84,7 @@
   }
 
   {
-    Orthanc::ZipWriter w;
+    ZipWriter w;
     w.SetAppendToExisting(true);
     w.SetOutputPath("UnitTestsResults/append.zip");
     w.Open();
@@ -103,40 +105,52 @@
   TEST(HierarchicalZipWriter, Index)
   {
     HierarchicalZipWriter::Index i;
-    ASSERT_EQ("hello", i.OpenFile("hello"));
-    ASSERT_EQ("hello-2", i.OpenFile("hello"));
-    ASSERT_EQ("coucou", i.OpenFile("coucou"));
-    ASSERT_EQ("hello-3", i.OpenFile("hello"));
+    ASSERT_EQ("hello", i.OpenFile("hello", true));
+    ASSERT_EQ("hello-2", i.OpenFile("hello", true));
+    ASSERT_EQ("coucou", i.OpenFile("coucou", true));
+    ASSERT_EQ("hello-3", i.OpenFile("hello", true));
+    ASSERT_EQ("trE hell", i.OpenFile("    ÊtrE hellô  ", false));
+    ASSERT_EQ("ÊtrE hellô", i.OpenFile("    ÊtrE hellô  ", true));
+    ASSERT_EQ("hel^lo", i.OpenFile("hel^lo", true));
+    ASSERT_EQ("hel lo", i.OpenFile("hel////lo", true));
+    ASSERT_EQ("hel lo-2", i.OpenFile("hel\\\\/\\/\\lo", true));
 
-    i.OpenDirectory("coucou");
+    i.OpenDirectory("coucou", true);
 
-    ASSERT_EQ("coucou-2/world", i.OpenFile("world"));
-    ASSERT_EQ("coucou-2/world-2", i.OpenFile("world"));
+    ASSERT_EQ("coucou-2/world", i.OpenFile("world", true));
+    ASSERT_EQ("coucou-2/world-2", i.OpenFile("world", true));
+
+    i.OpenDirectory("world", true);
+    i.OpenDirectory("hel///lo", true);
+    i.OpenDirectory("hel\\\\/\\/\\lo", true);
 
-    i.OpenDirectory("world");
-  
-    ASSERT_EQ("coucou-2/world-3/hello", i.OpenFile("hello"));
-    ASSERT_EQ("coucou-2/world-3/hello-2", i.OpenFile("hello"));
+    ASSERT_EQ("coucou-2/world-3/hel lo/hel lo/hello", i.OpenFile("hello", true));
+    ASSERT_EQ("coucou-2/world-3/hel lo/hel lo/hello-2", i.OpenFile("hello", true));
+
+    i.CloseDirectory();
+    i.CloseDirectory();
+    i.CloseDirectory();
+
+    ASSERT_EQ("coucou-2/world-4", i.OpenFile("world", true));
 
     i.CloseDirectory();
 
-    ASSERT_EQ("coucou-2/world-4", i.OpenFile("world"));
-
-    i.CloseDirectory();
-
-    ASSERT_EQ("coucou-3", i.OpenFile("coucou"));
+    ASSERT_EQ("coucou-3", i.OpenFile("coucou", true));
 
     ASSERT_THROW(i.CloseDirectory(), OrthancException);
   }
+}
 
 
-  TEST(HierarchicalZipWriter, Filenames)
-  {
-    ASSERT_EQ("trE hell", HierarchicalZipWriter::Index::KeepAlphanumeric("    ÊtrE hellô  "));
+TEST(HierarchicalZipWriter, Filenames)
+{
+  ASSERT_EQ("trE h/ell", Toolbox::NormalizePath("    ÊtrE h\\\\/\\/\\ellô  ", false, true));
+  ASSERT_EQ("ÊtrE h/ellô", Toolbox::NormalizePath("    ÊtrE h\\\\/\\/\\ellô  ", true, true));
+  ASSERT_EQ("H/el^^ ^ ^^lo world", Toolbox::NormalizePath("    H\\\\/\\/\\el^^   ^\r\n\t^^lo  \t  <world>  ", true, true));
 
-    // The "^" character is considered as a space in DICOM
-    ASSERT_EQ("Hel lo world", HierarchicalZipWriter::Index::KeepAlphanumeric("    Hel^^  ^\r\n\t^^lo  \t  <world>  "));
-  }
+  ASSERT_EQ("trE h ell", Toolbox::NormalizePath("    ÊtrE h\\\\/\\/\\ellô  ", false, false));
+  ASSERT_EQ("ÊtrE h ellô", Toolbox::NormalizePath("    ÊtrE h\\\\/\\/\\ellô  ", true, false));
+  ASSERT_EQ("H el^^ ^ ^^lo world", Toolbox::NormalizePath("    H\\\\/\\/\\el^^   ^\r\n\t^^lo  \t  <world>  ", true, false));
 }
 
 
@@ -191,7 +205,7 @@
   TemporaryFile f;
   
   {
-    Orthanc::ZipWriter w;
+    ZipWriter w;
     ASSERT_EQ(0u, w.GetArchiveSize());
 
     w.SetOutputPath(f.GetPath());
@@ -231,7 +245,7 @@
   for (int i = 0; i < 2; i++)
   {
     {
-      Orthanc::ZipWriter w;
+      ZipWriter w;
       w.SetMemoryOutput(memory, (i == 0) /* ZIP64? */);
       w.Open();
 
@@ -240,13 +254,13 @@
       w.CancelStream();
     }
 
-    ASSERT_THROW(ZipReader::CreateFromMemory(memory), Orthanc::OrthancException);
+    ASSERT_THROW(ZipReader::CreateFromMemory(memory), OrthancException);
 
     memory.clear();
     uint64_t archiveSize;
     
     {
-      Orthanc::ZipWriter w;
+      ZipWriter w;
       ASSERT_EQ(0u, w.GetArchiveSize());
       
       w.SetMemoryOutput(memory, (i == 0) /* ZIP64? */);
@@ -413,3 +427,48 @@
     ASSERT_EQ(0u, buffer.GetPosition());
   }
 }
+
+
+TEST(HierarchicalZipWriter, DISABLED_Utf8)
+{
+  (void) toUpperResult;
+  (void) toUpperSource;
+  (void) testEncodingsExpected;
+
+  static const std::string path = "UnitTestsResults/hello-utf8.zip";
+
+  {
+    ZipWriter w;
+    w.SetOutputPath(path);
+
+    for (unsigned int i = 0; i < testEncodingsCount; i++)
+    {
+      std::string source(testEncodingsEncoded[i]);
+      std::string s = Toolbox::ConvertToUtf8(source, testEncodings[i], false, false);
+      ASSERT_TRUE(Toolbox::IsValidUtf8(s));
+      printf("[%s]\n", s.c_str());
+      w.OpenFile(s);
+      w.Write("Hello");
+    }
+  }
+
+  {
+    std::unique_ptr<ZipReader> reader(ZipReader::CreateFromFile(path));
+
+    ASSERT_EQ(testEncodingsCount, reader->GetFilesCount());
+
+    std::set<std::string> files;
+    std::string filename, content;
+    while (reader->ReadNextFile(filename, content))
+    {
+      files.insert(filename);
+    }
+
+    for (unsigned int i = 0; i < testEncodingsCount; i++)
+    {
+      std::string source(testEncodingsEncoded[i]);
+      std::string s = Toolbox::ConvertToUtf8(source, testEncodings[i], false, false);
+      ASSERT_TRUE(files.find(s) != files.end());
+    }
+  }
+}
--- a/OrthancServer/CMakeLists.txt	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/CMakeLists.txt	Thu Mar 19 12:31:58 2026 +0100
@@ -109,6 +109,7 @@
   ${CMAKE_SOURCE_DIR}/Sources/Database/SQLiteDatabaseWrapper.cpp
   ${CMAKE_SOURCE_DIR}/Sources/Database/StatelessDatabaseOperations.cpp
   ${CMAKE_SOURCE_DIR}/Sources/Database/VoidDatabaseListener.cpp
+  ${CMAKE_SOURCE_DIR}/Sources/DicomInstanceDestination.cpp
   ${CMAKE_SOURCE_DIR}/Sources/DicomInstanceOrigin.cpp
   ${CMAKE_SOURCE_DIR}/Sources/DicomInstanceToStore.cpp
   ${CMAKE_SOURCE_DIR}/Sources/EmbeddedResourceHttpHandler.cpp
@@ -128,6 +129,7 @@
   ${CMAKE_SOURCE_DIR}/Sources/OrthancRestApi/OrthancRestResources.cpp
   ${CMAKE_SOURCE_DIR}/Sources/OrthancRestApi/OrthancRestSystem.cpp
   ${CMAKE_SOURCE_DIR}/Sources/OrthancWebDav.cpp
+  ${CMAKE_SOURCE_DIR}/Sources/OutgoingDicomInstance.cpp
   ${CMAKE_SOURCE_DIR}/Sources/QueryRetrieveHandler.cpp
   ${CMAKE_SOURCE_DIR}/Sources/ResourceFinder.cpp
   ${CMAKE_SOURCE_DIR}/Sources/Search/DatabaseDicomTagConstraint.cpp
--- a/OrthancServer/Plugins/Engine/OrthancPlugins.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Plugins/Engine/OrthancPlugins.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -57,8 +57,10 @@
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
 #include "../../../OrthancFramework/Sources/Toolbox.h"
 #include "../../Sources/Database/VoidDatabaseListener.h"
+#include "../../Sources/DicomInstanceToStore.h"
 #include "../../Sources/OrthancConfiguration.h"
 #include "../../Sources/OrthancFindRequestHandler.h"
+#include "../../Sources/OutgoingDicomInstance.h"
 #include "../../Sources/Search/HierarchicalMatcher.h"
 #include "../../Sources/ServerContext.h"
 #include "../../Sources/ServerToolbox.h"
@@ -69,7 +71,6 @@
 #include "PluginsEnumerations.h"
 #include "PluginsJob.h"
 
-#include <boost/math/special_functions/round.hpp>
 #include <boost/regex.hpp>
 #include <dcmtk/dcmdata/dcdicent.h>
 #include <dcmtk/dcmnet/dimse.h>
@@ -263,7 +264,11 @@
       {
         std::unique_ptr<File> f(new File(displayName));
         f->SetCreationTime(boost::posix_time::from_iso_string(creationTime));
-        f->SetContentLength(contentSize);
+
+        if (contentSize != ORTHANC_PLUGINS_WEBDAV_UNKNOWN_FILE_SIZE)
+        {
+          f->SetContentLength(contentSize);
+        }
 
         if (mimeType == NULL ||
             std::string(mimeType).empty())
@@ -2947,6 +2952,13 @@
   }
 
 
+  bool OrthancPlugins::FilterOutgoingCStoreInstance(const OutgoingDicomInstance& instance,
+                                                    const Json::Value& simplified)
+  {
+    // TODO
+    return true;
+  }
+
   OrthancPluginReceivedInstanceAction OrthancPlugins::ApplyReceivedInstanceCallbacks(PluginMemoryBuffer64& modified,
                                                                                      const void* receivedDicom,
                                                                                      size_t receivedDicomSize,
--- a/OrthancServer/Plugins/Engine/OrthancPlugins.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Plugins/Engine/OrthancPlugins.h	Thu Mar 19 12:31:58 2026 +0100
@@ -66,6 +66,7 @@
 {
   class HttpServer;
   class ServerContext;
+  class OutgoingDicomInstance;
 
   class OrthancPlugins : 
     public IHttpHandler, 
@@ -333,6 +334,10 @@
                                               const DicomInstanceToStore& instance,
                                               const Json::Value& simplified) ORTHANC_OVERRIDE;
 
+    // TODO
+    virtual bool FilterOutgoingCStoreInstance(const OutgoingDicomInstance& instance,
+                                              const Json::Value& simplified) ORTHANC_OVERRIDE;
+
     OrthancPluginReceivedInstanceAction ApplyReceivedInstanceCallbacks(PluginMemoryBuffer64& modified,
                                                                        const void* receivedDicomBuffer,
                                                                        size_t receivedDicomBufferSize,
--- a/OrthancServer/Plugins/Include/orthanc/OrthancCPlugin.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Plugins/Include/orthanc/OrthancCPlugin.h	Thu Mar 19 12:31:58 2026 +0100
@@ -222,6 +222,10 @@
  ** Definition of the Orthanc Plugin API.
  ********************************************************************/
 
+
+#define ORTHANC_PLUGINS_WEBDAV_UNKNOWN_FILE_SIZE ((uint64_t) -1)  /* New in Orthanc 1.12.11 */
+
+
 /** @{ */
 
 #ifdef __cplusplus
@@ -9498,11 +9502,17 @@
    * This function declares a file while returning the content of a
    * WebDAV folder.
    *
+   * Starting with Orthanc 1.12.11, if the file size is unknown
+   * because the file must be generated on-the-fly, you can set the
+   * size argument to the special value ORTHANC_PLUGINS_WEBDAV_UNKNOWN_FILE_SIZE.
+   * In this case, the WebDAV property "D:getcontentlength" will not be filled.
+   *
    * @param collection Context of the collection.
    * @param name Base name of the file.
    * @param dateTime The date and time of creation of the file.
    * Check out the documentation of OrthancPluginWebDavRetrieveFile() for more information.
-   * @param size Size of the file.
+   * @param size Size of the file. Can be set to ORTHANC_PLUGINS_WEBDAV_UNKNOWN_FILE_SIZE
+   * if unknown.
    * @param mimeType The MIME type of the file. If empty or set to `NULL`,
    * Orthanc will do a best guess depending on the file extension.
    * @return 0 if success, other value if error.
--- a/OrthancServer/Plugins/Samples/Common/OrthancPluginCppWrapper.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Plugins/Samples/Common/OrthancPluginCppWrapper.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -1544,11 +1544,25 @@
 
 #endif /* HAS_ORTHANC_PLUGIN_FIND_MATCHER == 1 */
 
+  static void CheckAnswerSizeIsLessThan4GB(const std::string& answer)
+  {
+    if (answer.size() > static_cast<size_t>(std::numeric_limits<uint32_t>::max()))
+    {
+  #if HAS_ORTHANC_EXCEPTION == 1
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange, "Cannot send HTTP response larger than 4GB");
+  #else
+      ORTHANC_PLUGINS_LOG_ERROR("Cannot send HTTP response larger than 4GB");
+      ORTHANC_PLUGINS_THROW_PLUGIN_ERROR_CODE(OrthancPluginErrorCode_ParameterOutOfRange);          
+  #endif
+    }
+  }
+
   void AnswerJson(const Json::Value& value,
                   OrthancPluginRestOutput* output)
   {
     std::string bodyString;
-    WriteStyledJson(bodyString, value);    
+    WriteStyledJson(bodyString, value);
+    CheckAnswerSizeIsLessThan4GB(bodyString);    
     OrthancPluginAnswerBuffer(GetGlobalContext(), output, bodyString.c_str(), bodyString.size(), "application/json");
   }
 
@@ -1556,6 +1570,7 @@
                     const char* mimeType,
                     OrthancPluginRestOutput* output)
   {
+    CheckAnswerSizeIsLessThan4GB(answer);
     OrthancPluginAnswerBuffer(GetGlobalContext(), output, answer.c_str(), answer.size(), mimeType);
   }
 
@@ -1564,6 +1579,26 @@
     OrthancPluginSendHttpStatusCode(GetGlobalContext(), output, httpError);
   }
 
+  void AnswerHttpError(uint16_t httpError,
+                       OrthancPluginRestOutput* output,
+                       const std::string& answer,
+                       const char* mimeType)
+  {
+    CheckAnswerSizeIsLessThan4GB(answer);
+
+    OrthancPluginSetHttpHeader(GetGlobalContext(),
+                               output,
+                               "content-type",
+                               mimeType);
+                               
+    OrthancPluginSendHttpStatus(GetGlobalContext(),
+                                output,
+                                httpError,
+                                answer.c_str(),
+                                static_cast<uint32_t>(answer.size()));
+  }
+
+
   void AnswerMethodNotAllowed(OrthancPluginRestOutput *output, const char* allowedMethods)
   {
     OrthancPluginSendMethodNotAllowed(GetGlobalContext(), output, allowedMethods);
--- a/OrthancServer/Plugins/Samples/Common/OrthancPluginCppWrapper.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Plugins/Samples/Common/OrthancPluginCppWrapper.h	Thu Mar 19 12:31:58 2026 +0100
@@ -690,6 +690,11 @@
   void AnswerHttpError(uint16_t httpError,
                        OrthancPluginRestOutput* output);
 
+  void AnswerHttpError(uint16_t httpError,
+                       OrthancPluginRestOutput* output,
+                       const std::string& answer,
+                       const char* mimeType);
+
   void AnswerMethodNotAllowed(OrthancPluginRestOutput* output, const char* allowedMethods);
 
 #if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 5, 0)
@@ -1026,6 +1031,13 @@
                                  value, OrthancPluginMetricsType_Default);
   }
 
+  inline void SetMetricsValue(const char* name,
+                              int64_t value)
+  {
+    OrthancPluginSetMetricsIntegerValue(GetGlobalContext(), name,
+                                        value, OrthancPluginMetricsType_Default);
+  }
+
   class MetricsTimer : public boost::noncopyable
   {
   private:
--- a/OrthancServer/Resources/Configuration.json	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Resources/Configuration.json	Thu Mar 19 12:31:58 2026 +0100
@@ -1098,6 +1098,22 @@
   // at any given time. A value of "0" indicates to use all the
   // available CPU logical cores. Prior to Orthanc 1.12.6, there were not limit.
   // (new in Orthanc 1.12.6)
-  "MaximumConcurrentDcmtkTranscoders" : 0
+  "MaximumConcurrentDcmtkTranscoders" : 0,
+
+  // Whether to use UTF-8 filenames in the ZIP archives generated by
+  // Orthanc. By default, it is set to "false" and only ASCII
+  // filenames are generated, which corresponds to the behavior of
+  // Orthanc <= 1.12.10. This default value can be overwritten per archive,
+  // by providing the "Utf8" field to the "{...}/archive" and
+  // "/tools/create-archives" routes. (new in Orthanc 1.12.11)
+  "ZipUseUtf8" : false,
 
+  // When set to false, this option disables all /patients routes and
+  // disables patients related sanity checks when performing resource
+  // modification.  This is required e.g when your Orthanc stores
+  // resources from multiple sources in which the same PatientID might
+  // be attached to different patients because there is no centralized
+  // patient id management. (new in Orthanc 1.12.11)
+  // As of 1.12.11, this is an experimental configuration.
+  "PatientLevelEnabled": true
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/DicomInstanceDestination.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,37 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "PrecompiledHeadersServer.h"
+#include "DicomInstanceDestination.h"
+
+
+namespace Orthanc
+{
+  void DicomInstanceDestination::Format(Json::Value& result) const
+  {
+    result = Json::objectValue;
+
+    result["RemoteIp"] = remoteIp_;
+    result["RemoteAet"] = remoteAet_;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/DicomInstanceDestination.h	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,61 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include <string>
+#include <json/value.h>
+
+
+namespace Orthanc
+{
+  class DicomInstanceDestination
+  {
+  private:
+    std::string   remoteIp_;
+    std::string   remoteAet_;
+
+  public:
+    DicomInstanceDestination(const std::string& remoteIp, const std::string& remoteAet) :
+      remoteIp_(remoteIp),
+      remoteAet_(remoteAet)
+    {
+    }
+
+    DicomInstanceDestination()
+    {
+    }
+
+    const std::string& GetRemoteIp() const
+    {
+      return remoteIp_;
+    }
+
+    const std::string& GetRemoteAet() const
+    {
+      return remoteAet_;
+    }
+
+    void Format(Json::Value& result) const;
+  };
+}
--- a/OrthancServer/Sources/IServerListener.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/IServerListener.h	Thu Mar 19 12:31:58 2026 +0100
@@ -23,7 +23,6 @@
 
 #pragma once
 
-#include "DicomInstanceToStore.h"
 #include "ServerIndexChange.h"
 #include "JobEvent.h"
 
@@ -31,6 +30,9 @@
 
 namespace Orthanc
 {
+  class DicomInstanceToStore;
+  class OutgoingDicomInstance;
+
   class IServerListener : public boost::noncopyable
   {
   public:
@@ -58,5 +60,12 @@
     virtual bool FilterIncomingCStoreInstance(uint16_t& dimseStatus,
                                               const DicomInstanceToStore& instance,
                                               const Json::Value& simplified) = 0;
+
+    /**
+     * Returns "false" if the DICOM instance must NOT be sent to the target modality.
+     */
+    virtual bool FilterOutgoingCStoreInstance(const OutgoingDicomInstance& instance,
+                                              const Json::Value& simplified) = 0;
+
   };
 }
--- a/OrthancServer/Sources/LuaScripting.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/LuaScripting.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -24,8 +24,10 @@
 #include "PrecompiledHeadersServer.h"
 #include "LuaScripting.h"
 
+#include "DicomInstanceToStore.h"
 #include "OrthancConfiguration.h"
 #include "OrthancRestApi/OrthancRestApi.h"
+#include "OutgoingDicomInstance.h"
 #include "ServerContext.h"
 
 #include "../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
@@ -1080,6 +1082,32 @@
   }
 
 
+  bool LuaScripting::FilterOutgoingCStoreInstance(const OutgoingDicomInstance& instance,
+                                                  const Json::Value& simplified)
+  {
+    static const char* NAME = "OutgoingCStoreInstanceFilter";
+
+    boost::recursive_mutex::scoped_lock lock(mutex_);
+
+    if (lua_.IsExistingFunction(NAME))
+    {
+      LuaFunctionCall call(lua_, NAME);
+      call.PushJson(simplified);
+
+      Json::Value destination;
+      instance.GetDestination().Format(destination);
+      call.PushJson(destination);
+
+      if (!call.ExecutePredicate())
+      {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+
   void LuaScripting::Execute(const std::string& command)
   {
     pendingEvents_.Enqueue(new ExecuteEvent(command));
--- a/OrthancServer/Sources/LuaScripting.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/LuaScripting.h	Thu Mar 19 12:31:58 2026 +0100
@@ -23,7 +23,6 @@
 
 #pragma once
 
-#include "DicomInstanceToStore.h"
 #include "ServerIndexChange.h"
 #include "JobEvent.h"
 #include "ServerJobs/LuaJobManager.h"
@@ -34,6 +33,8 @@
 namespace Orthanc
 {
   class ServerContext;
+  class OutgoingDicomInstance;
+  class DicomInstanceToStore;
 
   class LuaScripting : public boost::noncopyable
   {
@@ -129,6 +130,9 @@
                                       const DicomInstanceToStore& instance,
                                       const Json::Value& simplified);
 
+    bool FilterOutgoingCStoreInstance(const OutgoingDicomInstance& instance,
+                                      const Json::Value& simplified);
+
     void Execute(const std::string& command);
 
     void SignalJobEvent(const JobEvent& event);
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -27,6 +27,7 @@
 #include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
 #include "../../../OrthancFramework/Sources/Logging.h"
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
+#include "../DicomInstanceToStore.h"
 #include "../OrthancConfiguration.h"
 #include "../ServerContext.h"
 #include "../ServerJobs/MergeStudyJob.h"
@@ -432,10 +433,13 @@
            it = resources.begin(); it != resources.end(); ++it)
     {
       std::list<std::string> instances;
-      context.GetIndex().GetChildInstances(instances, *it);
+      ResourceType level;
+      context.GetIndex().LookupResourceType(level, *it);
+
+      context.GetIndex().GetChildInstances(instances, *it, level);
       job->AddInstances(instances);
 
-      job->AddParentResource(*it);
+      job->AddParentResource(*it, level);
     }
 
     job->PerformSanityChecks();
@@ -1305,18 +1309,22 @@
     Register("/instances/{id}/modify", ModifyInstance);
     Register("/series/{id}/modify", ModifyResource<ResourceType_Series>);
     Register("/studies/{id}/modify", ModifyResource<ResourceType_Study>);
-    Register("/patients/{id}/modify", ModifyResource<ResourceType_Patient>);
     Register("/tools/bulk-modify", BulkModify);
 
     Register("/instances/{id}/anonymize", AnonymizeInstance);
     Register("/series/{id}/anonymize", AnonymizeResource<ResourceType_Series>);
     Register("/studies/{id}/anonymize", AnonymizeResource<ResourceType_Study>);
-    Register("/patients/{id}/anonymize", AnonymizeResource<ResourceType_Patient>);
     Register("/tools/bulk-anonymize", BulkAnonymize);
 
     Register("/tools/create-dicom", CreateDicom);
 
     Register("/studies/{id}/split", SplitStudy);
     Register("/studies/{id}/merge", MergeStudy);
+
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/modify", ModifyResource<ResourceType_Patient>);
+      Register("/patients/{id}/anonymize", AnonymizeResource<ResourceType_Patient>);
+    }
   }
 }
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestApi.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestApi.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -30,6 +30,7 @@
 #include "../../../OrthancFramework/Sources/MetricsRegistry.h"
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
 #include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
+#include "../DicomInstanceToStore.h"
 #include "../OrthancConfiguration.h"
 #include "../ServerContext.h"
 
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestArchive.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestArchive.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -45,6 +45,7 @@
   static const char* const KEY_LOSSY_QUALITY = "LossyQuality";
   static const char* const KEY_FILENAME = "Filename";
   static const char* const KEY_USER_DATA = "UserData";
+  static const char* const KEY_ALLOW_UTF8 = "Utf8";
 
   static const char* const GET_TRANSCODE = "transcode";
   static const char* const GET_LOSSY_QUALITY = "lossy-quality";
@@ -52,6 +53,7 @@
   static const char* const GET_RESOURCES = "resources";
 
   static const char* const CONFIG_LOADER_THREADS = "ZipLoaderThreads";
+  static const char* const CONFIG_ALLOW_UTF8 = "ZipUseUtf8";
 
 
   static void AddResourcesOfInterestFromString(ArchiveJob& job,
@@ -126,9 +128,11 @@
                                unsigned int& loaderThreads,  /* out */
                                std::string& filename,        /* out */
                                Json::Value& userData,        /* out */
+                               bool& allowUtf8,              /* out (new in Orthanc 1.12.11) */
                                const Json::Value& body,      /* in */
                                const bool defaultExtended    /* in */,
-                               const std::string& defaultFilename /* in */)
+                               const std::string& defaultFilename /* in */,
+                               const bool defaultUtf8        /* in (new in Orthanc 1.12.11) */)
   {
     synchronous = OrthancRestApi::IsSynchronousJobRequest
       (true /* synchronous by default */, body);
@@ -186,7 +190,17 @@
       OrthancConfiguration::ReaderLock lock;
       loaderThreads = lock.GetConfiguration().GetUnsignedIntegerParameter(CONFIG_LOADER_THREADS, 0);  // New in Orthanc 1.10.0
     }
-   
+
+    // New in Orthanc 1.12.11
+    if (body.type() == Json::objectValue &&
+        body.isMember(KEY_ALLOW_UTF8))
+    {
+      allowUtf8 = SerializationToolbox::ReadBoolean(body, KEY_ALLOW_UTF8);
+    }
+    else
+    {
+      allowUtf8 = defaultUtf8;
+    }
   }
 
 
@@ -550,7 +564,7 @@
       .SetRequestField(KEY_LOSSY_QUALITY, RestApiCallDocumentation::Type_Number,
                         "If transcoding to a lossy transfer syntax, this entry defines the quality "
                         "as an integer between 1 and 100.  If not provided, the value is defined "
-                        "by the \"DicomLossyTranscodingQuality\" configuration. (new in v1.12.7)", false)
+                        "by the \"DicomLossyTranscodingQuality\" configuration. (new in 1.12.7)", false)
       .SetRequestField(KEY_FILENAME, RestApiCallDocumentation::Type_String,
                         "Filename to set in the \"Content-Disposition\" HTTP header "
                         "(including file extension)", false)
@@ -558,6 +572,11 @@
                        "In asynchronous mode, the priority of the job. The higher the value, the higher the priority.", false)
       .SetRequestField(KEY_USER_DATA, RestApiCallDocumentation::Type_JsonObject,
                        "In asynchronous mode, user data that will be attached to the job.", false)
+      .SetRequestField(KEY_ALLOW_UTF8, RestApiCallDocumentation::Type_Boolean, "If `true`, filenames will be encoded "
+                       "using UTF-8 in the ZIP archive, which may not be supported by your operating system or by your "
+                       "ZIP uncompression software. If `false`, filenames will be encoded using plain ASCII, which was "
+                       "the default in Orthanc <= 1.12.10. Default value is defined by the \"" +
+                       std::string(CONFIG_ALLOW_UTF8) + "\" configuration option. (new in 1.12.11)", false)
       .AddAnswerType(MimeType_Zip, "In synchronous mode, the ZIP file containing the archive")
       .AddAnswerType(MimeType_Json, "In asynchronous mode, information about the job that has been submitted to "
                      "generate the archive: https://orthanc.uclouvain.be/book/users/advanced-rest.html#jobs")
@@ -597,6 +616,13 @@
     Json::Value body;
     if (call.ParseJsonRequest(body))
     {
+      bool defaultUtf8;
+
+      {
+        OrthancConfiguration::ReaderLock lock;
+        defaultUtf8 = lock.GetConfiguration().GetBooleanParameter(CONFIG_ALLOW_UTF8, false);  // New in Orthanc 1.12.11
+      }
+
       bool synchronous, extended, transcode;
       DicomTransferSyntax transferSyntax;
       int priority;
@@ -604,9 +630,11 @@
       std::string filename;
       unsigned int lossyQuality;
       Json::Value userData;
+      bool allowUtf8;
 
       GetJobParameters(synchronous, extended, transcode, transferSyntax, lossyQuality,
-                       priority, loaderThreads, filename, userData, body, DEFAULT_IS_EXTENDED, "Archive.zip");
+                       priority, loaderThreads, filename, userData, allowUtf8,
+                       body, DEFAULT_IS_EXTENDED, "Archive.zip", defaultUtf8);
       
       std::unique_ptr<ArchiveJob> job(new ArchiveJob(context, IS_MEDIA, extended, ResourceType_Patient));
       AddResourcesOfInterest(*job, body);
@@ -618,6 +646,7 @@
       }
       
       job->SetLoaderThreads(loaderThreads);
+      job->SetAllowUtf8(allowUtf8);
       job->SetUserData(userData);
 
       SubmitJob(call.GetOutput(), context, job, priority, synchronous, filename);
@@ -789,6 +818,13 @@
     Json::Value body;
     if (call.ParseJsonRequest(body))
     {
+      bool defaultUtf8;
+
+      {
+        OrthancConfiguration::ReaderLock lock;
+        defaultUtf8 = lock.GetConfiguration().GetBooleanParameter(CONFIG_ALLOW_UTF8, false);  // New in Orthanc 1.12.11
+      }
+
       bool synchronous, extended, transcode;
       DicomTransferSyntax transferSyntax;
       int priority;
@@ -796,9 +832,11 @@
       std::string filename;
       unsigned int lossyQuality;
       Json::Value userData;
+      bool allowUtf8;
 
       GetJobParameters(synchronous, extended, transcode, transferSyntax, lossyQuality,
-                       priority, loaderThreads, filename, userData, body, false /* by default, not extented */, id + ".zip");
+                       priority, loaderThreads, filename, userData, allowUtf8,
+                       body, false /* by default, not extented */, id + ".zip", defaultUtf8);
       
       std::unique_ptr<ArchiveJob> job(new ArchiveJob(context, IS_MEDIA, extended, LEVEL));
       job->AddResource(id, true, LEVEL);
@@ -810,6 +848,7 @@
       }
 
       job->SetLoaderThreads(loaderThreads);
+      job->SetAllowUtf8(allowUtf8);
       job->SetUserData(userData);
 
       SubmitJob(call.GetOutput(), context, job, priority, synchronous, filename);
@@ -823,10 +862,14 @@
     
   void OrthancRestApi::RegisterArchive()
   {
-    Register("/patients/{id}/archive", CreateSingleGet<ResourceType_Patient, false /* ZIP */>);
-    Register("/patients/{id}/archive", CreateSinglePost<ResourceType_Patient, false /* ZIP */>);
-    Register("/patients/{id}/media",   CreateSingleGet<ResourceType_Patient, true /* media */>);
-    Register("/patients/{id}/media",   CreateSinglePost<ResourceType_Patient, true /* media */>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/archive", CreateSingleGet<ResourceType_Patient, false /* ZIP */>);
+      Register("/patients/{id}/archive", CreateSinglePost<ResourceType_Patient, false /* ZIP */>);
+      Register("/patients/{id}/media",   CreateSingleGet<ResourceType_Patient, true /* media */>);
+      Register("/patients/{id}/media",   CreateSinglePost<ResourceType_Patient, true /* media */>);
+    }
+
     Register("/series/{id}/archive",   CreateSingleGet<ResourceType_Series, false /* ZIP */>);
     Register("/series/{id}/archive",   CreateSinglePost<ResourceType_Series, false /* ZIP */>);
     Register("/series/{id}/media",     CreateSingleGet<ResourceType_Series, true /* media */>);
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -227,7 +227,7 @@
         .SetDescription("Trigger C-ECHO SCU command against the DICOM modality whose identifier is provided in URL: "
                         "https://orthanc.uclouvain.be/book/users/rest.html#performing-c-echo")
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-ECHO command, in seconds", false)
+                         "Timeout for the C-ECHO command, in seconds.  Orthanc will close the DICOM association if no C-ECHO answer is received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
@@ -684,7 +684,7 @@
                          "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                          "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-FIND command and subsequent C-MOVE retrievals, in seconds (new in Orthanc 1.9.1)", false)
+                         "Timeout for the C-FIND command and subsequent C-GET/C-MOVE retrievals, in seconds (new in Orthanc 1.9.1).  Orthanc will close the related DICOM associations if no DICOM messages are received within this time period.", false)
         .SetAnswerField("ID", RestApiCallDocumentation::Type_String,
                         "Identifier of the query, to be used with `/queries/{id}`")
         .SetAnswerField("Path", RestApiCallDocumentation::Type_String,
@@ -1032,7 +1032,7 @@
                        "AET of the target modality. By default, the AET of Orthanc is used, as defined in the "
                        "`DicomAet` configuration option.", false)
       .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                       "Timeout for the C-MOVE command, in seconds", false)
+                       "Timeout for the C-MOVE command, in seconds.  Orthanc will close the DICOM association if no DICOM messages (e.g., C-STORE sub-operations or responses) are received within this time period.", false)
       .SetRequestField(KEY_RETRIEVE_METHOD, RestApiCallDocumentation::Type_String,
                         "Force usage of C-MOVE or C-GET to retrieve the resource.  If note defined in the payload, "
                         "the retrieve method is defined in the DicomDefaultRetrieveMethod configuration or in "
@@ -1446,9 +1446,12 @@
                                "This string is not a valid Orthanc identifier: " + stripped);
       }
 
-      job.AddParentResource(stripped);  // New in Orthanc 1.5.7
+      ResourceType level;
+      context.GetIndex().LookupResourceType(level, stripped);
+
+      job.AddParentResource(stripped, level);  // New in Orthanc 1.5.7
       
-      context.AddChildInstances(job, stripped);
+      context.AddChildInstances(job, stripped, level);
 
       if (logExportedResources)
       {
@@ -1496,7 +1499,7 @@
                          "Whether to chain C-STORE with DICOM storage commitment to validate the success of the transmission: "
                          "https://orthanc.uclouvain.be/book/users/storage-commitment.html#chaining-c-store-with-storage-commitment", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-STORE command, in seconds", false)
+                         "Timeout for the C-STORE command, in seconds.  Orthanc will close the DICOM association if no DICOM messages (e.g., C-STORE responses) are received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
@@ -1655,7 +1658,7 @@
                          "Target AET that will be used by the remote DICOM modality as a target for its C-STORE SCU "
                          "commands, defaults to `DicomAet` configuration option in order to do a simple query/retrieve", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-MOVE command, in seconds", false)
+                         "Timeout for the C-MOVE command, in seconds.  Orthanc will close the DICOM association if no DICOM messages (e.g., C-STORE sub-operations or responses) are received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
@@ -1705,7 +1708,7 @@
                          "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                          "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-GET command, in seconds", false)
+                         "Timeout for the C-GET command, in seconds.  Orthanc will close the DICOM association if no DICOM messages are received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -38,6 +38,7 @@
 #include "../../../OrthancFramework/Sources/Logging.h"
 #include "../../../OrthancFramework/Sources/MultiThreading/Semaphore.h"
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
+#include "../../../OrthancFramework/Sources/CompatibilityMath.h"
 
 #include "../OrthancConfiguration.h"
 #include "../Search/DatabaseLookup.h"
@@ -47,7 +48,6 @@
 #include "../SliceOrdering.h"
 
 // This "include" is mandatory for Release builds using Linux Standard Base
-#include <boost/math/special_functions/round.hpp>
 #include <boost/shared_ptr.hpp>
 
 #include "../../../OrthancFramework/Sources/FileStorage/StorageAccessor.h"
@@ -1170,8 +1170,8 @@
             ratio = static_cast<float>(argHeight) / static_cast<float>(decoded->GetHeight());
           }
           
-          targetWidth = boost::math::iround(ratio * static_cast<float>(decoded->GetWidth()));
-          targetHeight = boost::math::iround(ratio * static_cast<float>(decoded->GetHeight()));
+          targetWidth = Math::iround(ratio * static_cast<float>(decoded->GetWidth()));
+          targetHeight = Math::iround(ratio * static_cast<float>(decoded->GetHeight()));
         }
         
         if (decoded->GetFormat() == PixelFormat_RGB24 || decoded->GetFormat() == PixelFormat_RGB48)
@@ -4283,14 +4283,20 @@
   void OrthancRestApi::RegisterResources()
   {
     Register("/instances", ListResources<ResourceType_Instance>);
-    Register("/patients", ListResources<ResourceType_Patient>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients", ListResources<ResourceType_Patient>);
+    }
     Register("/series", ListResources<ResourceType_Series>);
     Register("/studies", ListResources<ResourceType_Study>);
 
     if (!context_.IsReadOnly())
     {
       Register("/instances/{id}", DeleteSingleResource<ResourceType_Instance>);
-      Register("/patients/{id}", DeleteSingleResource<ResourceType_Patient>);
+      if (context_.IsPatientLevelEnabled())
+      {
+        Register("/patients/{id}", DeleteSingleResource<ResourceType_Patient>);
+      }
       Register("/series/{id}", DeleteSingleResource<ResourceType_Series>);
       Register("/studies/{id}", DeleteSingleResource<ResourceType_Study>);
 
@@ -4302,21 +4308,33 @@
     }
 
     Register("/instances/{id}", GetSingleResource<ResourceType_Instance>);
-    Register("/patients/{id}", GetSingleResource<ResourceType_Patient>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}", GetSingleResource<ResourceType_Patient>);
+    }
     Register("/series/{id}", GetSingleResource<ResourceType_Series>);
     Register("/studies/{id}", GetSingleResource<ResourceType_Study>);
 
     Register("/instances/{id}/statistics", GetResourceStatistics);
-    Register("/patients/{id}/statistics", GetResourceStatistics);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/statistics", GetResourceStatistics);
+    }
     Register("/studies/{id}/statistics", GetResourceStatistics);
     Register("/series/{id}/statistics", GetResourceStatistics);
 
-    Register("/patients/{id}/shared-tags", GetSharedTags);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/shared-tags", GetSharedTags);
+    }
     Register("/series/{id}/shared-tags", GetSharedTags);
     Register("/studies/{id}/shared-tags", GetSharedTags);
 
     Register("/instances/{id}/module", GetModule<ResourceType_Instance, DicomModule_Instance>);
-    Register("/patients/{id}/module", GetModule<ResourceType_Patient, DicomModule_Patient>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/module", GetModule<ResourceType_Patient, DicomModule_Patient>);
+    }
     Register("/series/{id}/module", GetModule<ResourceType_Series, DicomModule_Series>);
     Register("/studies/{id}/module", GetModule<ResourceType_Study, DicomModule_Study>);
     Register("/studies/{id}/module-patient", GetModule<ResourceType_Study, DicomModule_Patient>);
@@ -4347,11 +4365,17 @@
     Register("/instances/{id}/header", GetInstanceHeader);
     Register("/instances/{id}/numpy", GetNumpyInstance);  // New in Orthanc 1.10.0
 
-    Register("/patients/{id}/protected", IsProtectedPatient);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/protected", IsProtectedPatient);
+    }
   
     if (!context_.IsReadOnly())
     {
-      Register("/patients/{id}/protected", SetPatientProtection);
+      if (context_.IsPatientLevelEnabled())
+      {
+        Register("/patients/{id}/protected", SetPatientProtection);
+      }
     }
     else
     {
@@ -4367,6 +4391,11 @@
 
     for (size_t i = 0; i < resourceTypes.size(); i++)
     {
+      if (resourceTypes[i] == "patients" && !context_.IsPatientLevelEnabled())
+      {
+        continue;
+      }
+
       Register("/" + resourceTypes[i] + "/{id}/metadata", ListMetadata);
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", DeleteMetadata);
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", GetMetadata);
@@ -4418,9 +4447,12 @@
     Register("/tools/find", Find<FindType_Find>);
     Register("/tools/count-resources", Find<FindType_Count>);
 
-    Register("/patients/{id}/studies", GetChildResources<ResourceType_Patient, ResourceType_Study>);
-    Register("/patients/{id}/series", GetChildResources<ResourceType_Patient, ResourceType_Series>);
-    Register("/patients/{id}/instances", GetChildResources<ResourceType_Patient, ResourceType_Instance>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/studies", GetChildResources<ResourceType_Patient, ResourceType_Study>);
+      Register("/patients/{id}/series", GetChildResources<ResourceType_Patient, ResourceType_Series>);
+      Register("/patients/{id}/instances", GetChildResources<ResourceType_Patient, ResourceType_Instance>);
+    }
     Register("/studies/{id}/series", GetChildResources<ResourceType_Study, ResourceType_Series>);
     Register("/studies/{id}/instances", GetChildResources<ResourceType_Study, ResourceType_Instance>);
     Register("/series/{id}/instances", GetChildResources<ResourceType_Series, ResourceType_Instance>);
@@ -4432,7 +4464,10 @@
     Register("/instances/{id}/study", GetParentResource<ResourceType_Instance, ResourceType_Study>);
     Register("/instances/{id}/series", GetParentResource<ResourceType_Instance, ResourceType_Series>);
 
-    Register("/patients/{id}/instances-tags", GetChildInstancesTags);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/instances-tags", GetChildInstancesTags);
+    }
     Register("/studies/{id}/instances-tags", GetChildInstancesTags);
     Register("/series/{id}/instances-tags", GetChildInstancesTags);
 
@@ -4443,7 +4478,10 @@
 
     if (!context_.IsReadOnly())
     {
-      Register("/patients/{id}/reconstruct", ReconstructResource<ResourceType_Patient>);
+      if (context_.IsPatientLevelEnabled())
+      {
+        Register("/patients/{id}/reconstruct", ReconstructResource<ResourceType_Patient>);
+      }
       Register("/studies/{id}/reconstruct", ReconstructResource<ResourceType_Study>);
       Register("/series/{id}/reconstruct", ReconstructResource<ResourceType_Series>);
       Register("/instances/{id}/reconstruct", ReconstructResource<ResourceType_Instance>);
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -100,6 +100,7 @@
     static const char* const HAS_EXTENDED_FIND = "HasExtendedFind";
     static const char* const READ_ONLY = "ReadOnly";
     static const char* const HAS_RESERVE_QUEUE_VALUE = "HasReserveQueueValue";
+    static const char* const PATIENT_LEVEL_ENABLED = "PatientLevelEnabled";
 
     if (call.IsDocumentation())
     {
@@ -153,6 +154,8 @@
                         "and 'HasReserveQueueValue' (new in Orthanc 1.12.10)")
         .SetAnswerField(READ_ONLY, RestApiCallDocumentation::Type_Boolean,
                         "Whether Orthanc is running in read only mode (new in Orthanc 1.12.5)")
+        .SetAnswerField(PATIENT_LEVEL_ENABLED, RestApiCallDocumentation::Type_Boolean,
+                        "Whether Patient level routes and sanity checks are enabled (new in Orthanc 1.12.11)")
         .SetHttpGetSample("https://orthanc.uclouvain.be/demo/system", true);
       return;
     }
@@ -186,6 +189,7 @@
     result[STORAGE_AREA_PLUGIN] = Json::nullValue;
     result[DATABASE_BACKEND_PLUGIN] = Json::nullValue;
     result[READ_ONLY] = context.IsReadOnly();
+    result[PATIENT_LEVEL_ENABLED] = context.IsPatientLevelEnabled();
 
 #if ORTHANC_ENABLE_PLUGINS == 1
     result[PLUGINS_ENABLED] = true;
--- a/OrthancServer/Sources/OrthancWebDav.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/OrthancWebDav.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -30,6 +30,7 @@
 #include "../../OrthancFramework/Sources/Logging.h"
 #include "ResourceFinder.h"
 #include "Search/DatabaseLookup.h"
+#include "DicomInstanceToStore.h"
 #include "ServerContext.h"
 
 #include <boost/regex.hpp>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/OutgoingDicomInstance.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,62 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "PrecompiledHeadersServer.h"
+#include "OutgoingDicomInstance.h"
+
+#include "OrthancConfiguration.h"
+
+#include "../../OrthancFramework/Sources/DicomParsing/ParsedDicomFile.h"
+#include "../../OrthancFramework/Sources/OrthancException.h"
+#include "../../OrthancFramework/Sources/Toolbox.h"
+
+
+namespace Orthanc
+{
+  OutgoingDicomInstance* OutgoingDicomInstance::CreateFromBuffer(const std::string& buffer)
+  {
+    return CreateFromBuffer(buffer.empty() ? NULL : buffer.c_str(), buffer.size());
+  }
+
+  OutgoingDicomInstance* OutgoingDicomInstance::CreateFromBuffer(const void* buffer, size_t size)
+  {
+    return new OutgoingDicomInstance(new ParsedDicomFile(buffer, size));
+  }
+
+  void OutgoingDicomInstance::GetSimplifiedJson(Json::Value& simplifiedTags) const
+  {
+    if (dicom_.get() == NULL)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+
+    std::set<DicomTag> allMainDicomTags;
+    DicomMap::GetAllMainDicomTags(allMainDicomTags);
+
+    Json::Value dicomAsJson;
+    OrthancConfiguration::DefaultDicomDatasetToJson(dicomAsJson, *dicom_, allMainDicomTags); // don't crop any main dicom tags
+
+    Toolbox::SimplifyDicomAsJson(simplifiedTags, dicomAsJson, DicomToJsonFormat_Human);
+  }
+
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/Sources/OutgoingDicomInstance.h	Thu Mar 19 12:31:58 2026 +0100
@@ -0,0 +1,67 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2023 Osimis S.A., Belgium
+ * Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
+ * Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ * 
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "../../OrthancFramework/Sources/DicomFormat/DicomMap.h"
+#include "DicomInstanceDestination.h"
+#include "ServerEnumerations.h"
+
+#include <boost/shared_ptr.hpp>
+
+class DcmDataset;
+
+namespace Orthanc
+{
+  class ParsedDicomFile;
+
+  class OutgoingDicomInstance : public boost::noncopyable
+  {
+    DicomInstanceDestination            destination_;
+    std::unique_ptr<ParsedDicomFile>    dicom_;
+
+    OutgoingDicomInstance(ParsedDicomFile* dicom) :
+      dicom_(dicom)
+    {}
+
+  public:
+    // WARNING: The source in the factory methods is *not* copied and
+    // must *not* be deallocated as long as this wrapper object is alive
+    static OutgoingDicomInstance* CreateFromBuffer(const void* buffer,
+                                                   size_t size);
+
+    static OutgoingDicomInstance* CreateFromBuffer(const std::string& buffer);
+
+    void SetDestination(const DicomInstanceDestination& destination)
+    {
+      destination_ = destination;
+    }
+
+    const DicomInstanceDestination& GetDestination() const
+    {
+      return destination_;
+    } 
+    
+    void GetSimplifiedJson(Json::Value& dicomAsJson) const;
+  };
+}
--- a/OrthancServer/Sources/ServerContext.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerContext.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -40,6 +40,7 @@
 #include "../../OrthancFramework/Sources/MetricsRegistry.h"
 #include "../Plugins/Engine/OrthancPlugins.h"
 
+#include "DicomInstanceToStore.h"
 #include "OrthancConfiguration.h"
 #include "OrthancRestApi/OrthancRestApi.h"
 #include "ResourceFinder.h"
@@ -47,6 +48,7 @@
 #include "ServerJobs/OrthancJobUnserializer.h"
 #include "ServerToolbox.h"
 #include "StorageCommitmentReports.h"
+#include "OutgoingDicomInstance.h"
 
 #include <dcmtk/dcmdata/dcfilefo.h>
 #include <dcmtk/dcmnet/dimse.h>
@@ -324,21 +326,30 @@
   {
     if (saveJobs_)
     {
-      LOG(TRACE) << "Serializing the content of the jobs engine";
-    
-      try
-      {
-        Json::Value value;
-        jobsEngine_.GetRegistry().Serialize(value);
+      static boost::posix_time::ptime lastSerializedModification = boost::posix_time::neg_infin;
+      boost::posix_time::ptime lastModification = boost::posix_time::neg_infin;
+
+      jobsEngine_.GetRegistry().GetLastModificationTime(lastModification);
 
-        std::string serialized;
-        Toolbox::WriteFastJson(serialized, value);
+      if (lastModification > lastSerializedModification)
+      {
+        LOG(TRACE) << "Serializing the content of the jobs engine";
+      
+        try
+        {
+          Json::Value value;
+          jobsEngine_.GetRegistry().Serialize(value);
 
-        index_.SetGlobalProperty(GlobalProperty_JobsRegistry, false /* not shared */, serialized);
-      }
-      catch (OrthancException& e)
-      {
-        LOG(ERROR) << "Cannot serialize the jobs engine: " << e.What();
+          std::string serialized;
+          Toolbox::WriteFastJson(serialized, value);
+
+          index_.SetGlobalProperty(GlobalProperty_JobsRegistry, false /* not shared */, serialized);
+          lastSerializedModification = lastModification;
+        }
+        catch (OrthancException& e)
+        {
+          LOG(ERROR) << "Cannot serialize the jobs engine: " << e.What();
+        }
       }
     }
   }
@@ -389,6 +400,7 @@
     ingestTranscodingOfCompressed_(true),
     preferredTransferSyntax_(DicomTransferSyntax_LittleEndianExplicit),
     readOnly_(readOnly),
+    patientLevelEnabled_(true),
     deidentifyLogs_(false),
     serverStartTimeUtc_(boost::posix_time::second_clock::universal_time())
   {
@@ -613,6 +625,17 @@
   }
 
 
+  void ServerContext::SetPatientLevelEnabled(bool enabled)
+  {
+    if (enabled)
+      LOG(WARNING) << "Patient level is enabled";
+    else
+      LOG(WARNING) << "Patient level  is disabled";
+
+    patientLevelEnabled_ = enabled;
+  }
+
+
   void ServerContext::RemoveFile(const std::string& fileUuid,
                                  FileContentType type,
                                  const std::string& customData)
@@ -1787,10 +1810,11 @@
 
 
   void ServerContext::AddChildInstances(SetOfInstancesJob& job,
-                                        const std::string& publicId)
+                                        const std::string& publicId,
+                                        ResourceType level)
   {
     std::list<std::string> instances;
-    GetIndex().GetChildInstances(instances, publicId);
+    GetIndex().GetChildInstances(instances, publicId, level);
 
     job.Reserve(job.GetInstancesCount() + instances.size());
 
@@ -1985,6 +2009,36 @@
     const void* data = dicom.empty() ? NULL : dicom.c_str();
     const RemoteModalityParameters& modality = connection.GetParameters().GetRemoteModality();
 
+    // Filter out outgoing C-Store instances
+    {
+      boost::shared_lock<boost::shared_mutex> lock(listenersMutex_);
+
+      std::unique_ptr<OutgoingDicomInstance> outgoingInstance(OutgoingDicomInstance::CreateFromBuffer(dicom));
+      outgoingInstance->SetDestination(DicomInstanceDestination(connection.GetParameters().GetRemoteModality().GetHost(),
+                                                                connection.GetParameters().GetRemoteModality().GetApplicationEntityTitle()));
+
+      Json::Value simplifiedTags;
+      outgoingInstance->GetSimplifiedJson(simplifiedTags);
+
+      for (ServerListeners::iterator it = listeners_.begin(); it != listeners_.end(); ++it)
+      {
+        try
+        {
+          if (!it->GetListener().FilterOutgoingCStoreInstance(*outgoingInstance, simplifiedTags))
+          {
+            return;
+          }
+        }
+        catch (OrthancException& e)
+        {
+          LOG(ERROR) << "Error in the " << it->GetDescription() 
+                      << " callback while sending an instance: " << e.What()
+                      << " (code " << e.GetErrorCode() << ")";
+          throw;
+        }
+      }
+    }
+
     if (!transcodeDicomProtocol_ ||
         !modality.IsTranscodingAllowed())
     {
--- a/OrthancServer/Sources/ServerContext.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerContext.h	Thu Mar 19 12:31:58 2026 +0100
@@ -43,6 +43,7 @@
 namespace Orthanc
 {
   class DicomInstanceToStore;
+  class OutgoingDicomInstance;
   class IPluginStorageArea;
   class JobsEngine;
   class MetricsRegistry;
@@ -140,6 +141,13 @@
       {
         return context_.filterLua_.FilterIncomingCStoreInstance(dimseStatus, instance, simplified);
       }
+
+      virtual bool FilterOutgoingCStoreInstance(const OutgoingDicomInstance& instance,
+                                                const Json::Value& simplified) ORTHANC_OVERRIDE
+      {
+        return context_.filterLua_.FilterOutgoingCStoreInstance(instance, simplified);
+      }
+
     };
     
     class ServerListener
@@ -263,7 +271,8 @@
     std::set<DicomTransferSyntax>  acceptedTransferSyntaxes_;
     std::list<std::string>         acceptedSopClasses_;  // ordered; the most 120 common ones first
     bool readOnly_;
-
+    bool patientLevelEnabled_;
+    
     StoreResult StoreAfterTranscoding(std::string& resultPublicId,
                                       DicomInstanceToStore& dicom,
                                       StoreInstanceMode mode,
@@ -334,6 +343,13 @@
       return storageCache_.SetMaximumSize(size);
     }
 
+    void SetPatientLevelEnabled(bool enabled);
+
+    bool IsPatientLevelEnabled() const
+    {
+      return patientLevelEnabled_;
+    }
+
     void SetCompressionEnabled(bool enabled);
 
     bool IsCompressionEnabled() const
@@ -498,7 +514,8 @@
     bool HasPlugins() const;
 
     void AddChildInstances(SetOfInstancesJob& job,
-                           const std::string& publicId);
+                           const std::string& publicId,
+                           ResourceType level);
 
     void SignalUpdatedModalities();
 
--- a/OrthancServer/Sources/ServerJobs/ArchiveJob.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ArchiveJob.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -55,6 +55,7 @@
 static const char* const KEY_UNCOMPRESSED_SIZE = "UncompressedSize";
 static const char* const KEY_ARCHIVE_SIZE = "ArchiveSize";
 static const char* const KEY_TRANSCODE = "Transcode";
+static const char* const KEY_ALLOW_UTF8 = "Utf8";
 
 static boost::mutex loaderThreadsCounterMutex;
 static uint32_t loaderThreadsCounter = 0;
@@ -1020,7 +1021,7 @@
         }
       }
 
-      path = Toolbox::StripSpaces(Toolbox::ConvertToAscii(path));
+      path = Toolbox::StripSpaces(path);
 
       if (path.empty() 
           || (static_cast<size_t>(boost::count(path, '^')) == path.size()))  // this happens with non ASCII patient names: only the '^' remains and this is not a valid zip folder name
@@ -1095,18 +1096,21 @@
     std::unique_ptr<DicomDirWriter>         dicomDir_;
     bool                                    isMedia_;
     bool                                    isStream_;
+    bool                                    allowUtf8_;
 
   public:
     ZipWriterIterator(ServerContext& context,
                       InstanceLoader& instanceLoader,
                       ArchiveIndex& archive,
                       bool isMedia,
-                      bool enableExtendedSopClass) :
+                      bool enableExtendedSopClass,
+                      bool allowUtf8) :
       context_(context),
       instanceLoader_(instanceLoader),
       commands_(instanceLoader),
       isMedia_(isMedia),
-      isStream_(false)
+      isStream_(false),
+      allowUtf8_(allowUtf8)
     {
       if (isMedia)
       {
@@ -1134,6 +1138,7 @@
       {
         zip_.reset(new HierarchicalZipWriter(path));
         zip_->SetZip64(commands_.IsZip64());
+        zip_->SetAllowUtf8(allowUtf8_);
         isStream_ = false;
       }
       else
@@ -1149,6 +1154,7 @@
       if (zip_.get() == NULL)
       {
         zip_.reset(new HierarchicalZipWriter(protection.release(), commands_.IsZip64()));
+        zip_->SetAllowUtf8(allowUtf8_);
         isStream_ = true;
       }
       else
@@ -1267,7 +1273,8 @@
     transcode_(false),
     transferSyntax_(DicomTransferSyntax_LittleEndianImplicit),
     lossyQuality_(100),
-    loaderThreads_(0)
+    loaderThreads_(0),
+    allowUtf8_(false)
   {
   }
 
@@ -1393,6 +1400,19 @@
   }
 
 
+  void ArchiveJob::SetAllowUtf8(bool allowUtf8)
+  {
+    if (writer_.get() != NULL)   // Already started
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      allowUtf8_ = allowUtf8;
+    }
+  }
+
+
   void ArchiveJob::Reset()
   {
     throw OrthancException(ErrorCode_BadSequenceOfCalls,
@@ -1433,7 +1453,7 @@
           assert(asynchronousTarget_.get() != NULL);
           asynchronousTarget_->Touch();  // Make sure we can write to the temporary file
           
-          writer_.reset(new ZipWriterIterator(context_, *instanceLoader_, *archive_, isMedia_, enableExtendedSopClass_));
+          writer_.reset(new ZipWriterIterator(context_, *instanceLoader_, *archive_, isMedia_, enableExtendedSopClass_, allowUtf8_));
           writer_->SetOutputFile(asynchronousTarget_->GetPath());
         }
       }
@@ -1441,7 +1461,7 @@
       {
         assert(synchronousTarget_.get() != NULL);
     
-        writer_.reset(new ZipWriterIterator(context_, *instanceLoader_, *archive_, isMedia_, enableExtendedSopClass_));
+        writer_.reset(new ZipWriterIterator(context_, *instanceLoader_, *archive_, isMedia_, enableExtendedSopClass_, allowUtf8_));
         writer_->AcquireOutputStream(synchronousTarget_.release());
       }
 
@@ -1612,6 +1632,9 @@
     {
       value[KEY_TRANSCODE] = GetTransferSyntaxUid(transferSyntax_);
     }
+
+    // New in Orthanc 1.12.11
+    value[KEY_ALLOW_UTF8] = allowUtf8_;
   }
 
 
@@ -1649,18 +1672,16 @@
     if (key == "archive" &&
         !mediaArchiveId_.empty())
     {
-      SharedArchive::Accessor accessor(context_.GetMediaArchive(), mediaArchiveId_);
+      bool found;
 
-      if (accessor.IsValid())
       {
-        context_.GetMediaArchive().Remove(mediaArchiveId_);
-        return true;
+        SharedArchive::Accessor accessor(context_.GetMediaArchive(), mediaArchiveId_);
+        found = accessor.IsValid();
       }
-      else
-      {
-        return false;
-      }
-    }    
+
+      context_.GetMediaArchive().Remove(mediaArchiveId_);
+      return found;
+    }
     else
     {
       return false;
--- a/OrthancServer/Sources/ServerJobs/ArchiveJob.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ArchiveJob.h	Thu Mar 19 12:31:58 2026 +0100
@@ -75,6 +75,9 @@
     // New in Orthanc 1.10.0
     unsigned int         loaderThreads_;
 
+    // New in Orthanc 1.12.11
+    bool                 allowUtf8_;
+
     void FinalizeTarget();
     
   public:
@@ -111,6 +114,8 @@
 
     void SetLoaderThreads(unsigned int loaderThreads);
 
+    void SetAllowUtf8(bool allowUtf8);
+
     virtual void Reset() ORTHANC_OVERRIDE;
 
     virtual void Start() ORTHANC_OVERRIDE;
--- a/OrthancServer/Sources/ServerJobs/DicomGetScuJob.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/DicomGetScuJob.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -25,6 +25,7 @@
 
 #include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
+#include "../DicomInstanceToStore.h"
 #include "../ServerContext.h"
 #include <dcmtk/dcmnet/dimse.h>
 #include <algorithm>
--- a/OrthancServer/Sources/ServerJobs/MergeStudyJob.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/MergeStudyJob.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -26,6 +26,7 @@
 #include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
 #include "../../../OrthancFramework/Sources/Logging.h"
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
+#include "../DicomInstanceToStore.h"
 #include "../OrthancConfiguration.h"
 #include "../ServerContext.h"
 
--- a/OrthancServer/Sources/ServerJobs/Operations/ModifyInstanceOperation.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/Operations/ModifyInstanceOperation.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -25,6 +25,7 @@
 #include "ModifyInstanceOperation.h"
 
 #include "DicomInstanceOperationValue.h"
+#include "../../DicomInstanceToStore.h"
 #include "../../ServerContext.h"
 
 #include "../../../../OrthancFramework/Sources/Logging.h"
--- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -26,6 +26,7 @@
 
 #include "../../../OrthancFramework/Sources/Logging.h"
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
+#include "../DicomInstanceToStore.h"
 #include "../ServerContext.h"
 
 #include <dcmtk/dcmdata/dcfilefo.h>
@@ -726,9 +727,9 @@
       }
     }
 
-    if (modificationLevel == ResourceType_Study && replacePatientMainDicomTags)
+    if (GetContext().IsPatientLevelEnabled() && modificationLevel == ResourceType_Study && replacePatientMainDicomTags)
     {
-      for (std::set<std::string>::const_iterator studyId = parentResources_.begin(); studyId != parentResources_.end(); ++studyId)
+      for (std::map<std::string, ResourceType>::const_iterator it = parentResources_.begin(); it != parentResources_.end(); ++it)
       {
         // When modifying a study, you may not modify patient tags as you wish.
         // - If this is the patient's only study, you may modify all patient tags. This could be performed in 2 steps (modify the patient and then, the study) but, 
@@ -736,6 +737,7 @@
         // - If the patient already has other studies, you may only 'attach' the study to an existing patient by modifying 
         //   all patient tags from the study to match those of the target patient.
         // - Otherwise, you can't modify the patient tags
+        const std::string& studyId = it->first;
         
         std::string targetPatientId;
         if (modification_->IsReplaced(DICOM_TAG_PATIENT_ID))
@@ -745,7 +747,7 @@
         else
         {
           FindRequest request(ResourceType_Study);
-          request.SetOrthancStudyId(*studyId);
+          request.SetOrthancStudyId(studyId);
           request.SetRetrieveMainDicomTags(true);
 
           FindResponse response;
@@ -787,7 +789,7 @@
             bool targetPatientHasOtherStudies = childrenIds.size() > 1;
             if (childrenIds.size() == 1)
             {
-              targetPatientHasOtherStudies = (childrenIds.find(*studyId) == childrenIds.end());  // if the patient has one study that is not the one being modified
+              targetPatientHasOtherStudies = (childrenIds.find(studyId) == childrenIds.end());  // if the patient has one study that is not the one being modified
             }
 
             if (targetPatientHasOtherStudies)
@@ -806,12 +808,21 @@
                    mainPatientTag != mainPatientTags.end(); ++mainPatientTag)
               {
                 if (targetPatientTags.HasTag(*mainPatientTag) &&
-                    (!modification_->IsReplaced(*mainPatientTag) ||
+                    ((!modification_->IsReplaced(*mainPatientTag) && targetPatientTags.GetStringValue(*mainPatientTag, "", false).size() > 0) ||
                      modification_->GetReplacementAsString(*mainPatientTag) != targetPatientTags.GetStringValue(*mainPatientTag, "", false)))
                 {
-                  throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  " 
-                    "The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags "
-                    "and you should specify all Patient MainDicomTags in your query.  Try using /patients/../modify instead to modify the patient. Failing tag: ") + mainPatientTag->Format());
+                  if (!modification_->IsReplaced(*mainPatientTag))
+                  {
+                    throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  " 
+                      "The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags "
+                      "and you should specify all Patient MainDicomTags in your query.  Try using /patients/../modify instead to modify the patient. Missing tag in the 'Replace' tags: ") + mainPatientTag->Format());
+                  }
+                  else
+                  {
+                    throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  " 
+                      "The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags "
+                      "and you should specify all Patient MainDicomTags in your query.  Try using /patients/../modify instead to modify the patient. Failing tag: ") + mainPatientTag->Format());
+                  }
                 }
                 else if (!targetPatientTags.HasTag(*mainPatientTag) && modification_->IsReplaced(*mainPatientTag) )
                 {
--- a/OrthancServer/Sources/ServerJobs/SplitStudyJob.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/SplitStudyJob.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -26,6 +26,7 @@
 #include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
 #include "../../../OrthancFramework/Sources/Logging.h"
 #include "../../../OrthancFramework/Sources/SerializationToolbox.h"
+#include "../DicomInstanceToStore.h"
 #include "../ServerContext.h"
 
 
--- a/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -371,6 +371,7 @@
 
   static const char* KEY_FAILED_INSTANCES = "FailedInstances";
   static const char* KEY_PARENT_RESOURCES = "ParentResources";
+  static const char* KEY_RESOURCES = "Resources";
   static const char* KEY_DESCRIPTION = "Description";
   static const char* KEY_PERMISSIVE = "Permissive";
   static const char* KEY_USER_DATA = "UserData";
@@ -382,6 +383,26 @@
   static const char* KEY_KEEP_SOURCE = "KeepSource";
   static const char* KEY_WORKERS_COUNT = "WorkersCount";
 
+  static void SerializeResources(Json::Value& target, const std::map<std::string, ResourceType>& parentResources, bool includeParentResourcesField)
+  {
+    if (!parentResources.empty())
+    {
+      target[KEY_RESOURCES] = Json::arrayValue;
+      SerializationToolbox::WriteMapOfResourcesAndTypes(target[KEY_RESOURCES], parentResources);
+
+      if (includeParentResourcesField)
+      {
+        std::set<std::string> keys;
+        for (std::map<std::string, ResourceType>::const_iterator it = parentResources.begin(); it != parentResources.end(); ++it) 
+        {
+          keys.insert(it->first);
+        }
+
+        SerializationToolbox::WriteSetOfStrings(target, keys, KEY_PARENT_RESOURCES);
+      }
+    }
+  }
+
 
   void ThreadedSetOfInstancesJob::GetPublicContent(Json::Value& target) const
   {
@@ -391,10 +412,7 @@
     target[KEY_INSTANCES_COUNT] = static_cast<uint32_t>(GetInstancesCount());
     target[KEY_FAILED_INSTANCES_COUNT] = static_cast<uint32_t>(failedInstances_.size());
 
-    if (!parentResources_.empty())
-    {
-      SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
-    }
+    SerializeResources(target, parentResources_, true);
   }
 
 
@@ -417,7 +435,7 @@
     
     SerializationToolbox::WriteSetOfStrings(target, instancesToProcess_, KEY_INSTANCES);
     SerializationToolbox::WriteSetOfStrings(target, failedInstances_, KEY_FAILED_INSTANCES);
-    SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
+    SerializeResources(target, parentResources_, false);
 
     return true;
   }
@@ -439,10 +457,29 @@
   {
     SerializationToolbox::ReadSetOfStrings(failedInstances_, source, KEY_FAILED_INSTANCES);
 
-    if (source.isMember(KEY_PARENT_RESOURCES))
+    if (source.isMember(KEY_PARENT_RESOURCES) && !source.isMember(KEY_RESOURCES))
     {
-      // Backward compatibility with Orthanc <= 1.5.6
-      SerializationToolbox::ReadSetOfStrings(parentResources_, source, KEY_PARENT_RESOURCES);
+      // Backward compatibility with Orthanc <= 1.12.11 (a KEY_PARENT_RESOURCES field with the resources ids)
+      std::set<std::string> parentResources;
+      SerializationToolbox::ReadSetOfStrings(parentResources, source, KEY_PARENT_RESOURCES);
+
+      for (std::set<std::string>::const_iterator it = parentResources.begin(); it != parentResources.end(); ++it)
+      {
+        try
+        {
+          ResourceType level;
+          context.GetIndex().LookupResourceType(level, *it);
+          parentResources_[*it] = level;
+        }
+        catch(...)
+        {
+          // ignore errors, the resource might have disappear 
+        }
+      }
+    }
+    else if (source.isMember(KEY_RESOURCES) && source[KEY_RESOURCES].isArray())
+    {
+      SerializationToolbox::ReadMapOfResourcesAndTypes(parentResources_, source, KEY_RESOURCES);
     }
     
     if (source.isMember(KEY_KEEP_SOURCE))
@@ -635,11 +672,11 @@
   }
 
 
-  void ThreadedSetOfInstancesJob::AddParentResource(const std::string &resource)
+  void ThreadedSetOfInstancesJob::AddParentResource(const std::string &resource, ResourceType level)
   {
     boost::recursive_mutex::scoped_lock lock(mutex_);
 
-    parentResources_.insert(resource);
+    parentResources_[resource] = level;
   }
 
 }
--- a/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.h	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.h	Thu Mar 19 12:31:58 2026 +0100
@@ -71,7 +71,7 @@
   
   protected:
     mutable boost::recursive_mutex      mutex_;
-    std::set<std::string>               parentResources_;
+    std::map<std::string, ResourceType> parentResources_;
 
   public:
     ThreadedSetOfInstancesJob(ServerContext& context,
@@ -129,7 +129,7 @@
 
     void AddInstances(const std::list<std::string>& instances);
 
-    void AddParentResource(const std::string &resource);
+    void AddParentResource(const std::string &resource, ResourceType level);
 
     bool IsPermissive() const;
 
--- a/OrthancServer/Sources/ServerToolbox.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/ServerToolbox.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -31,6 +31,7 @@
 #include "../../OrthancFramework/Sources/OrthancException.h"
 #include "Database/IDatabaseWrapper.h"
 #include "Database/ResourcesContent.h"
+#include "DicomInstanceToStore.h"
 #include "OrthancConfiguration.h"
 #include "ServerContext.h"
 
--- a/OrthancServer/Sources/main.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/Sources/main.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -37,6 +37,7 @@
 #include "../../OrthancFramework/Sources/Lua/LuaFunctionCall.h"
 #include "../Plugins/Engine/OrthancPlugins.h"
 #include "Database/SQLiteDatabaseWrapper.h"
+#include "DicomInstanceToStore.h"
 #include "EmbeddedResourceHttpHandler.h"
 #include "OrthancConfiguration.h"
 #include "OrthancFindRequestHandler.h"
@@ -322,7 +323,7 @@
 
     if (alwaysAllowMove_)
     {
-      LOG(WARNING) << "Security risk in DICOM SCP: C-MOOVE requests are always allowed, even from unknown modalities";
+      LOG(WARNING) << "Security risk in DICOM SCP: C-MOVE requests are always allowed, even from unknown modalities";
     }
   }
 
@@ -1678,6 +1679,8 @@
   {
     OrthancConfiguration::ReaderLock lock;
 
+    context.SetPatientLevelEnabled(lock.GetConfiguration().GetBooleanParameter("PatientLevelEnabled", true));
+
     if (context.IsReadOnly())
     {
       LOG(WARNING) << "READ-ONLY SYSTEM: ignoring these configurations: StorageCompression, StoreMD5ForAttachments, OverwriteInstances, MaximumPatientCount, MaximumStorageSize, MaximumStorageMode, SaveJobs"; 
@@ -1690,9 +1693,17 @@
       // New option in Orthanc 1.4.2
       context.SetOverwriteInstances(lock.GetConfiguration().GetBooleanParameter("OverwriteInstances", false));
 
+      unsigned int maximumPatientCount = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumPatientCount", 0);
+      unsigned int maximumStorageSize = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumStorageSize", 0);
+
+      if (!context.IsPatientLevelEnabled() && (maximumPatientCount != 0 || maximumStorageSize != 0))
+      {
+        LOG(ERROR) << "Can not set MaximumPatientCount or MaximumStorageSize when PatientLevelEnabled is set to false since these options require patient recycling.";
+      }
+
       try
       {
-        context.GetIndex().SetMaximumPatientCount(lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumPatientCount", 0));
+        context.GetIndex().SetMaximumPatientCount(maximumPatientCount);
       }
       catch (...)
       {
@@ -1701,7 +1712,7 @@
 
       try
       {
-        uint64_t size = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumStorageSize", 0);
+        uint64_t size = maximumStorageSize;
         context.GetIndex().SetMaximumStorageSize(size * 1024 * 1024);
       }
       catch (...)
--- a/OrthancServer/UnitTestsSources/ServerIndexTests.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/UnitTestsSources/ServerIndexTests.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -32,6 +32,7 @@
 #include "../../OrthancFramework/Sources/Logging.h"
 
 #include "../Sources/Database/SQLiteDatabaseWrapper.h"
+#include "../Sources/DicomInstanceToStore.h"
 #include "../Sources/OrthancConfiguration.h"
 #include "../Sources/Search/DatabaseLookup.h"
 #include "../Sources/ServerContext.h"
--- a/OrthancServer/UnitTestsSources/ServerJobsTests.cpp	Tue Jan 20 16:50:25 2026 +0100
+++ b/OrthancServer/UnitTestsSources/ServerJobsTests.cpp	Thu Mar 19 12:31:58 2026 +0100
@@ -32,6 +32,7 @@
 #include "../../OrthancFramework/Sources/SerializationToolbox.h"
 
 #include "../Sources/Database/SQLiteDatabaseWrapper.h"
+#include "../Sources/DicomInstanceToStore.h"
 #include "../Sources/ServerContext.h"
 #include "../Sources/ServerJobs/LuaJobManager.h"
 #include "../Sources/ServerJobs/OrthancJobUnserializer.h"
@@ -782,6 +783,14 @@
   {
     ArchiveJob job(GetContext(), false, false, ResourceType_Patient);
     ASSERT_FALSE(job.Serialize(s));  // Cannot serialize this
+
+    Json::Value content;
+    job.GetPublicContent(content);
+    ASSERT_FALSE(content["Utf8"].asBool());
+
+    job.SetAllowUtf8(true);
+    job.GetPublicContent(content);
+    ASSERT_TRUE(content["Utf8"].asBool());
   }
 
   // DicomModalityStoreJob
--- a/TODO	Tue Jan 20 16:50:25 2026 +0100
+++ b/TODO	Thu Mar 19 12:31:58 2026 +0100
@@ -22,7 +22,6 @@
 * Support retry counter in Orthanc::HttpClient
 * Option to enable DNS lookups in DICOM:
   https://orthanc.uclouvain.be/hg/orthanc/file/Orthanc-1.9.3/OrthancFramework/Sources/OrthancFramework.cpp#l88
-* Toolbox::ComputeMD5() fails on files larger than 4GB
 * Logging: add more specific information to contextualize the logs.
   For a DICOM Transfer, that would be nice to include the modality in the context + a study identifier or a job id.
   Note that the thread id greatly helps already wrt this topic.
@@ -74,8 +73,6 @@
       "Hashing": "sha1"} }
 * Reuse the ThreadedInstanceLoader from the ArchiveJob in C-Store, peer/store ... to optimize loading
   time when working with an object storage.
-* Implement a SharedMessageQueue with a max size -> that would greatly simplify the flow control in the 
-  ThreadedInstanceLoader (also used in DicomWeb).
   
 
 ============================
@@ -288,6 +285,8 @@
     Would be interesting to log these errors and count them in the prometheus metrics.
   Note: I'm (maybe naively) thinking that you only need the beginning of the file to get the DICOM tags to be stored 
   in the database, so maybe the rest of the file could be "streamed" directly to disk and not kept in memory?
+  Refs: 
+  - https://discourse.orthanc-server.org/t/high-memory-consumption-during-ingestion-of-large-1-5-gb-multi-frame-instances/6408
 * To investigate: usage of mapped_file (not only in the indexer plugin): 
   https://discourse.orthanc-server.org/t/patch-for-orthanc-indexer-plugin-crashing-on-big-non-dicom-files/3849/7