diff --git a/CMakeLists.txt b/CMakeLists.txt
index 37a4f0c55c74..dc2edaad1a27 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -159,7 +159,6 @@ if(WIN32)
set(USE_COLORD OFF)
set(USE_KWALLET OFF)
set(BUILD_CMSTEST OFF)
- set(BUILD_PRINT OFF)
set(TESTBUILD_OPENCL_PROGRAMS OFF)
if(BUILD_MSYS2_INSTALL)
add_definitions(-DMSYS2_INSTALL)
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index e2d15b5b16cc..ff3b40652976 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -913,24 +913,39 @@ if(USE_COLORD)
endif(USE_COLORD)
if(BUILD_PRINT)
- find_package(Cups)
- if(CUPS_FOUND)
- include_directories(SYSTEM ${CUPS_INCLUDE_DIR})
+ if(WIN32)
+ # Windows-native print backend using GDI / WinSpool
FILE(GLOB SOURCE_FILES_PRINT
"common/cups_print.h"
- "common/cups_print.c"
+ "common/win_print.c"
"common/printprof.c"
"common/printing.h"
"common/printing.c"
)
set(SOURCES ${SOURCES} ${SOURCE_FILES_PRINT})
- list(APPEND LIBS ${CUPS_LIBRARIES})
+ list(APPEND LIBS winspool gdi32 shell32)
add_definitions("-DHAVE_PRINT")
- message(STATUS "Print mode: enabled")
- else(CUPS_FOUND)
- set(BUILD_PRINT OFF)
- message(STATUS "Print mode: disabled, please install CUPS dev package")
- endif(CUPS_FOUND)
+ message(STATUS "Print mode: enabled (Windows native)")
+ else(WIN32)
+ find_package(Cups)
+ if(CUPS_FOUND)
+ include_directories(SYSTEM ${CUPS_INCLUDE_DIR})
+ FILE(GLOB SOURCE_FILES_PRINT
+ "common/cups_print.h"
+ "common/cups_print.c"
+ "common/printprof.c"
+ "common/printing.h"
+ "common/printing.c"
+ )
+ set(SOURCES ${SOURCES} ${SOURCE_FILES_PRINT})
+ list(APPEND LIBS ${CUPS_LIBRARIES})
+ add_definitions("-DHAVE_PRINT")
+ message(STATUS "Print mode: enabled")
+ else(CUPS_FOUND)
+ set(BUILD_PRINT OFF)
+ message(STATUS "Print mode: disabled, please install CUPS dev package")
+ endif(CUPS_FOUND)
+ endif(WIN32)
else(BUILD_PRINT)
message(STATUS "Print mode: disabled")
endif(BUILD_PRINT)
diff --git a/src/common/printing.c b/src/common/printing.c
index ea14b0c56e66..08398e88b4be 100644
--- a/src/common/printing.c
+++ b/src/common/printing.c
@@ -329,7 +329,7 @@ void dt_printing_setup_image(dt_images_box *imgs,
box->print.width = box->pos.width * imgs->page_width;
box->print.height = box->pos.height * imgs->page_height;
- dt_image_pos pos;
+ dt_image_pos pos = { 0 };
_align_pos(&box->print, box->alignment, box->exp_width, box->exp_height, &pos);
box->print.x = pos.x;
diff --git a/src/common/win_print.c b/src/common/win_print.c
new file mode 100644
index 000000000000..4343dfe4aca0
--- /dev/null
+++ b/src/common/win_print.c
@@ -0,0 +1,488 @@
+/*
+ This file is part of darktable,
+ Copyright (C) 2026 darktable developers.
+
+ darktable 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.
+
+ darktable 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 darktable. If not, see .
+*/
+
+/* Windows native print backend, implementing the same API surface
+ as cups_print.c but using GDI / WinSpool instead of CUPS.
+ Color management defaults to sRGB (see issue #19856). */
+
+#ifdef _WIN32
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "common/file_location.h"
+#include "common/image.h"
+#include "common/image_cache.h"
+#include "common/mipmap_cache.h"
+#include "common/pdf.h"
+#include "control/jobs/control_jobs.h"
+#include "cups_print.h"
+
+typedef struct dt_prtctl_t
+{
+ void (*cb)(dt_printer_info_t *, void *);
+ void *user_data;
+} dt_prtctl_t;
+
+/* helper: convert a UTF-16 wide string to a UTF-8 gchar*.
+ The caller must g_free() the result. */
+static gchar *_wchar_to_utf8(const wchar_t *wstr)
+{
+ if(!wstr) return g_strdup("");
+ return g_utf16_to_utf8((const gunichar2 *)wstr, -1, NULL, NULL, NULL);
+}
+
+/* helper: convert a UTF-8 string to a newly-allocated wide string.
+ The caller must g_free() the result. */
+static wchar_t *_utf8_to_wchar(const char *utf8)
+{
+ if(!utf8) return NULL;
+ return (wchar_t *)g_utf8_to_utf16(utf8, -1, NULL, NULL, NULL);
+}
+
+// initialize the pinfo structure
+void dt_init_print_info(dt_print_info_t *pinfo)
+{
+ memset(&pinfo->printer, 0, sizeof(dt_printer_info_t));
+ memset(&pinfo->page, 0, sizeof(dt_page_setup_t));
+ memset(&pinfo->paper, 0, sizeof(dt_paper_info_t));
+ pinfo->printer.intent = DT_INTENT_PERCEPTUAL;
+ pinfo->printer.is_turboprint = FALSE;
+ *pinfo->printer.profile = '\0';
+ pinfo->num_printers = 0;
+}
+
+void dt_get_printer_info(const char *printer_name,
+ dt_printer_info_t *pinfo)
+{
+ g_strlcpy(pinfo->name, printer_name, MAX_NAME);
+ pinfo->is_turboprint = FALSE;
+
+ // default resolution
+ pinfo->resolution = 300;
+
+ // try to get hardware margins via a printer DC
+ // Use "WINSPOOL" as driver name for proper printer DC creation
+ wchar_t *wprinter = _utf8_to_wchar(printer_name);
+ if(!wprinter) return;
+
+ HDC hdc = CreateDCW(L"WINSPOOL", wprinter, NULL, NULL);
+ g_free(wprinter);
+
+ if(hdc)
+ {
+ // get physical page size and printable area (in device units)
+ const int phys_w = GetDeviceCaps(hdc, PHYSICALWIDTH);
+ const int phys_h = GetDeviceCaps(hdc, PHYSICALHEIGHT);
+ const int ofs_x = GetDeviceCaps(hdc, PHYSICALOFFSETX);
+ const int ofs_y = GetDeviceCaps(hdc, PHYSICALOFFSETY);
+ const int print_w = GetDeviceCaps(hdc, HORZRES);
+ const int print_h = GetDeviceCaps(hdc, VERTRES);
+ const int dpi_x = GetDeviceCaps(hdc, LOGPIXELSX);
+ const int dpi_y = GetDeviceCaps(hdc, LOGPIXELSY);
+
+ if(dpi_x > 0 && dpi_y > 0)
+ {
+ pinfo->resolution = dpi_x;
+ while(pinfo->resolution > 360)
+ pinfo->resolution /= 2;
+
+ // margins in mm (25.4 mm per inch)
+ pinfo->hw_margin_left = (ofs_x * 25.4) / dpi_x;
+ pinfo->hw_margin_top = (ofs_y * 25.4) / dpi_y;
+ pinfo->hw_margin_right = ((phys_w - ofs_x - print_w) * 25.4) / dpi_x;
+ pinfo->hw_margin_bottom = ((phys_h - ofs_y - print_h) * 25.4) / dpi_y;
+ }
+
+ DeleteDC(hdc);
+ }
+}
+
+static volatile int _cancel = 0;
+
+static int _detect_printers_callback(dt_job_t *job)
+{
+ dt_prtctl_t *pctl = dt_control_job_get_params(job);
+
+ DWORD needed = 0, returned = 0;
+
+ // first call to find out how much memory we need
+ EnumPrintersW(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
+ NULL, 2, NULL, 0, &needed, &returned);
+
+ if(needed == 0)
+ {
+ darktable.control->cups_started = TRUE;
+ return 0;
+ }
+
+ BYTE *buffer = (BYTE *)g_malloc(needed);
+ if(!buffer)
+ {
+ darktable.control->cups_started = TRUE;
+ return 1;
+ }
+
+ if(EnumPrintersW(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS,
+ NULL, 2, buffer, needed, &needed, &returned))
+ {
+ PRINTER_INFO_2W *pi = (PRINTER_INFO_2W *)buffer;
+
+ for(DWORD i = 0; i < returned; i++)
+ {
+ // check for cancellation
+ if(_cancel) break;
+
+ // skip printers that are paused, offline, or have errors
+ const DWORD bad_status = PRINTER_STATUS_ERROR
+ | PRINTER_STATUS_PAUSED
+ | PRINTER_STATUS_OFFLINE
+ | PRINTER_STATUS_NOT_AVAILABLE;
+
+ if(pi[i].Status & bad_status)
+ {
+ gchar *name_utf8 = _wchar_to_utf8(pi[i].pPrinterName);
+ if(name_utf8)
+ {
+ dt_print(DT_DEBUG_PRINT, "[print] skip printer %s (status=%lu)",
+ name_utf8, (unsigned long)pi[i].Status);
+ g_free(name_utf8);
+ }
+ continue;
+ }
+
+ gchar *name_utf8 = _wchar_to_utf8(pi[i].pPrinterName);
+ if(!name_utf8) continue;
+
+ dt_printer_info_t pr;
+ memset(&pr, 0, sizeof(pr));
+ dt_get_printer_info(name_utf8, &pr);
+ if(pctl->cb) pctl->cb(&pr, pctl->user_data);
+ dt_print(DT_DEBUG_PRINT, "[print] new printer %s found", name_utf8);
+
+ g_free(name_utf8);
+ }
+ }
+
+ g_free(buffer);
+ darktable.control->cups_started = TRUE;
+ return 0;
+}
+
+void dt_printers_abort_discovery(void)
+{
+ _cancel = 1;
+}
+
+void dt_printers_discovery(void (*cb)(dt_printer_info_t *pr, void *user_data),
+ void *user_data)
+{
+ _cancel = 0;
+
+ // asynchronously checks for available printers
+ dt_job_t *job = dt_control_job_create(&_detect_printers_callback, "detect connected printers");
+ if(job)
+ {
+ dt_prtctl_t *prtctl = g_malloc0(sizeof(dt_prtctl_t));
+
+ prtctl->cb = cb;
+ prtctl->user_data = user_data;
+
+ dt_control_job_set_params(job, prtctl, g_free);
+ dt_control_add_job(DT_JOB_QUEUE_SYSTEM_BG, job);
+ }
+}
+
+dt_paper_info_t *dt_get_paper(GList *papers,
+ const char *name)
+{
+ dt_paper_info_t *result = NULL;
+
+ for(GList *p = papers; p; p = g_list_next(p))
+ {
+ dt_paper_info_t *pi = (dt_paper_info_t *)p->data;
+ if(!strcmp(pi->name, name) || !strcmp(pi->common_name, name))
+ {
+ result = pi;
+ break;
+ }
+ }
+ return result;
+}
+
+static gint
+sort_papers(gconstpointer p1, gconstpointer p2)
+{
+ const dt_paper_info_t *n1 = (dt_paper_info_t *)p1;
+ const dt_paper_info_t *n2 = (dt_paper_info_t *)p2;
+ const int l1 = strlen(n1->common_name);
+ const int l2 = strlen(n2->common_name);
+ return l1 == l2 ? strcmp(n1->common_name, n2->common_name) : (l1 < l2 ? -1 : +1);
+}
+
+GList *dt_get_papers(const dt_printer_info_t *printer)
+{
+ GList *result = NULL;
+
+ wchar_t *wprinter = _utf8_to_wchar(printer->name);
+ if(!wprinter) return NULL;
+
+ HANDLE hPrinter = NULL;
+ if(!OpenPrinterW(wprinter, &hPrinter, NULL))
+ {
+ g_free(wprinter);
+ return NULL;
+ }
+
+ // get the number of paper names
+ const DWORD count = DeviceCapabilitiesW(wprinter, NULL, DC_PAPERNAMES, NULL, NULL);
+ if(count == 0 || count == (DWORD)-1)
+ {
+ ClosePrinter(hPrinter);
+ g_free(wprinter);
+ return NULL;
+ }
+
+ // paper names are fixed 64-wchar_t blocks
+ wchar_t *names = (wchar_t *)g_malloc0(count * 64 * sizeof(wchar_t));
+ // paper sizes in tenths of mm
+ POINT *sizes = (POINT *)g_malloc0(count * sizeof(POINT));
+
+ DeviceCapabilitiesW(wprinter, NULL, DC_PAPERNAMES, (LPWSTR)names, NULL);
+ DeviceCapabilitiesW(wprinter, NULL, DC_PAPERSIZE, (LPWSTR)sizes, NULL);
+
+ for(DWORD k = 0; k < count; k++)
+ {
+ const wchar_t *wname = names + k * 64;
+
+ // skip papers with zero dimension
+ if(sizes[k].x == 0 || sizes[k].y == 0)
+ continue;
+
+ gchar *paper_name_utf8 = _wchar_to_utf8(wname);
+ if(!paper_name_utf8) continue;
+
+ dt_paper_info_t *paper = g_malloc0(sizeof(dt_paper_info_t));
+ g_strlcpy(paper->name, paper_name_utf8, MAX_NAME);
+ g_strlcpy(paper->common_name, paper_name_utf8, MAX_NAME);
+ paper->width = (double)sizes[k].x / 10.0; // convert tenths of mm to mm
+ paper->height = (double)sizes[k].y / 10.0;
+
+ result = g_list_append(result, paper);
+
+ dt_print(DT_DEBUG_PRINT,
+ "[print] new paper %4lu %6.2f x %6.2f (%s)",
+ (unsigned long)k, paper->width, paper->height, paper->name);
+
+ g_free(paper_name_utf8);
+ }
+
+ g_free(names);
+ g_free(sizes);
+ g_free(wprinter);
+ ClosePrinter(hPrinter);
+
+ result = g_list_sort(result, sort_papers);
+ return result;
+}
+
+GList *dt_get_media_type(const dt_printer_info_t *printer)
+{
+ // Windows does not expose media type the same way CUPS/PPD does.
+ // Return an empty list — the media type combo will be hidden or
+ // show only "default".
+ return NULL;
+}
+
+dt_medium_info_t *dt_get_medium(GList *media,
+ const char *name)
+{
+ dt_medium_info_t *result = NULL;
+
+ for(GList *m = media; m; m = g_list_next(m))
+ {
+ dt_medium_info_t *mi = (dt_medium_info_t *)m->data;
+ if(!strcmp(mi->name, name) || !strcmp(mi->common_name, name))
+ {
+ result = mi;
+ break;
+ }
+ }
+ return result;
+}
+
+void dt_print_file(const dt_imgid_t imgid,
+ const char *filename,
+ const char *job_title,
+ const dt_print_info_t *pinfo)
+{
+ // first for safety check that filename exists and is readable
+
+ if(!g_file_test(filename, G_FILE_TEST_IS_REGULAR))
+ {
+ dt_control_log(_("file `%s' to print not found for image %d on `%s'"),
+ filename, imgid, pinfo->printer.name);
+ return;
+ }
+
+ // Use the "printto" verb so we can target the specific printer
+ // selected in the darktable UI, not just the system default.
+ // Syntax: ShellExecute(NULL, "printto", file, "PrinterName", ...)
+
+ wchar_t *wfilename = _utf8_to_wchar(filename);
+ wchar_t *wprinter = _utf8_to_wchar(pinfo->printer.name);
+
+ if(!wfilename || !wprinter)
+ {
+ dt_control_log(_("error while printing `%s' on `%s'"), job_title, pinfo->printer.name);
+ g_free(wfilename);
+ g_free(wprinter);
+ return;
+ }
+
+ const HINSTANCE result = ShellExecuteW(NULL, L"printto", wfilename,
+ wprinter, NULL, SW_HIDE);
+
+ if((intptr_t)result <= 32)
+ {
+ dt_control_log(_("error while printing `%s' on `%s'"), job_title, pinfo->printer.name);
+ dt_print(DT_DEBUG_ALWAYS,
+ "[print] ShellExecuteW('printto') failed for %s on %s, code %d",
+ filename, pinfo->printer.name, (int)(intptr_t)result);
+ }
+ else
+ dt_control_log(_("printing `%s' on `%s'"), job_title, pinfo->printer.name);
+
+ g_free(wfilename);
+ g_free(wprinter);
+}
+
+void dt_get_print_layout(const dt_print_info_t *prt,
+ const int32_t area_width,
+ const int32_t area_height,
+ float *px,
+ float *py,
+ float *pwidth,
+ float *pheight,
+ float *ax,
+ float *ay,
+ float *awidth,
+ float *aheight,
+ gboolean *borderless)
+{
+ /* this is where the layout is done for the display and for the
+ print too. So this routine is one of the most critical for the
+ print circuitry. */
+
+ // page w/h
+ float pg_width = prt->paper.width;
+ float pg_height = prt->paper.height;
+
+ /* here, width and height correspond to the area for the picture */
+
+ // non-printable
+ float np_top = prt->printer.hw_margin_top;
+ float np_left = prt->printer.hw_margin_left;
+ float np_right = prt->printer.hw_margin_right;
+ float np_bottom = prt->printer.hw_margin_bottom;
+
+ /* do some arrangements for the landscape mode. */
+
+ if(prt->page.landscape)
+ {
+ float tmp = pg_width;
+ pg_width = pg_height;
+ pg_height = tmp;
+
+ // rotate the non-printable margins
+ tmp = np_top;
+ np_top = np_right;
+ np_right = np_bottom;
+ np_bottom = np_left;
+ np_left = tmp;
+ }
+
+ // the image area aspect
+ const float a_aspect = (float)area_width / (float)area_height;
+
+ // page aspect
+ const float pg_aspect = pg_width / pg_height;
+
+ // display page
+ float p_bottom, p_right;
+
+ if(a_aspect > pg_aspect)
+ {
+ *px = (area_width - (area_height * pg_aspect)) / 2.0f;
+ *py = 0;
+ p_bottom = area_height;
+ p_right = area_width - *px;
+ }
+ else
+ {
+ *px = 0;
+ *py = (area_height - (area_width / pg_aspect)) / 2.0f;
+ p_right = area_width;
+ p_bottom = area_height - *py;
+ }
+
+ *pwidth = p_right - *px;
+ *pheight = p_bottom - *py;
+
+ // page margins, note that we do not want to change those values for
+ // the landscape mode. these margins are those set by the user from
+ // the GUI, and the top margin is *always* at the top of the screen.
+
+ const float border_top = prt->page.margin_top;
+ const float border_left = prt->page.margin_left;
+ const float border_right = prt->page.margin_right;
+ const float border_bottom = prt->page.margin_bottom;
+
+ // display picture area, that is removing the non printable areas
+ // and user's margins
+
+ const float bx = *px + (border_left / pg_width) * (*pwidth);
+ const float by = *py + (border_top / pg_height) * (*pheight);
+ const float bb = p_bottom - (border_bottom / pg_height) * (*pheight);
+ const float br = p_right - (border_right / pg_width) * (*pwidth);
+
+ *borderless = border_left < np_left
+ || border_right < np_right
+ || border_top < np_top
+ || border_bottom < np_bottom;
+
+ // now we have the printable area (ax, ay) -> (ax + awidth, ay + aheight)
+
+ *ax = bx;
+ *ay = by;
+ *awidth = br - bx;
+ *aheight = bb - by;
+}
+
+#endif /* _WIN32 */
+
+// clang-format off
+// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
+// vim: shiftwidth=2 expandtab tabstop=2 cindent
+// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
+// clang-format on
diff --git a/src/libs/CMakeLists.txt b/src/libs/CMakeLists.txt
index b9b57d1c9a2a..e7ff54848e46 100644
--- a/src/libs/CMakeLists.txt
+++ b/src/libs/CMakeLists.txt
@@ -108,10 +108,10 @@ add_library(geotagging MODULE "geotagging.c")
set(MODULES ${MODULES} geotagging)
# the module specific to print mode
-if(CUPS_FOUND)
+if(BUILD_PRINT)
add_library(print_settings MODULE "print_settings.c")
set(MODULES ${MODULES} print_settings)
-endif(CUPS_FOUND)
+endif(BUILD_PRINT)
# AI neural restore module
if(USE_AI)
diff --git a/src/views/CMakeLists.txt b/src/views/CMakeLists.txt
index e13a6f1af1d9..0e1df0137778 100644
--- a/src/views/CMakeLists.txt
+++ b/src/views/CMakeLists.txt
@@ -22,10 +22,10 @@ if(Gphoto2_FOUND)
set(MODULES ${MODULES} tethering)
endif(Gphoto2_FOUND)
-if(CUPS_FOUND)
+if(BUILD_PRINT)
add_library(print MODULE "print.c")
set(MODULES ${MODULES} print)
-endif(CUPS_FOUND)
+endif(BUILD_PRINT)
foreach(module ${MODULES})
target_link_libraries(${module} lib_darktable)