From dbc1c55de42ea9cd8d8e37162e69c5b4f2e40087 Mon Sep 17 00:00:00 2001 From: Alx Sa Date: Thu, 12 Feb 2026 01:49:47 +0000 Subject: [PATCH] core, dialog: Add Procreate swatches import support Procreate swatches are zipped JSON files. They contain an object array of HSB color palette values. Newer versions of the format also support different color models, spaces, and color profiles. This patch adds support for importing the palette name, colors, and associated color profile in the original HSB format. --- app/core/gimppalette-import.c | 4 + app/core/gimppalette-load.c | 270 +++++++++++++++++++++++++++- app/core/gimppalette-load.h | 7 +- app/dialogs/palette-import-dialog.c | 6 + 4 files changed, 282 insertions(+), 5 deletions(-) diff --git a/app/core/gimppalette-import.c b/app/core/gimppalette-import.c index 5940251020..87fac5abe8 100644 --- a/app/core/gimppalette-import.c +++ b/app/core/gimppalette-import.c @@ -567,6 +567,10 @@ gimp_palette_import_from_file (GimpContext *context, palette_list = gimp_palette_load_sbz (context, file, input, error); break; + case GIMP_PALETTE_FILE_FORMAT_PROCREATE: + palette_list = gimp_palette_load_procreate (context, file, input, error); + break; + default: g_set_error (error, GIMP_DATA_ERROR, GIMP_DATA_ERROR_READ, diff --git a/app/core/gimppalette-load.c b/app/core/gimppalette-load.c index f7a5be85ce..9870822dcf 100644 --- a/app/core/gimppalette-load.c +++ b/app/core/gimppalette-load.c @@ -17,6 +17,8 @@ #include "config.h" +#include + #include #include @@ -38,11 +40,12 @@ #include "gimp-intl.h" +/* Used by SwatchBooker and Procreate color profiles */ typedef struct { GimpColorProfile *profile; gchar *id; -} SwatchBookerColorProfile; +} PaletteColorProfile; typedef struct { @@ -1424,10 +1427,10 @@ gimp_palette_load_sbz (GimpContext *context, if (profile) { - SwatchBookerColorProfile sbz_profile; + PaletteColorProfile sbz_profile; sbz_profile.profile = profile; - sbz_profile.id = g_strdup (archive_entry_pathname (entry)); + sbz_profile.id = g_strdup (archive_entry_pathname (entry)); sbz_data.embedded_profiles = g_list_append (sbz_data.embedded_profiles, &sbz_profile); @@ -1466,6 +1469,261 @@ gimp_palette_load_sbz (GimpContext *context, return g_list_prepend (NULL, sbz_data.palette); } +GList * +gimp_palette_load_procreate (GimpContext *context, + GFile *file, + GInputStream *input, + GError **error) +{ + GimpPalette *palette = NULL; + gchar *json_data = NULL; + struct archive *a; + struct archive_entry *entry; + size_t entry_size; + int r; + + g_return_val_if_fail (G_IS_FILE (file), NULL); + g_return_val_if_fail (G_IS_INPUT_STREAM (input), NULL); + g_return_val_if_fail (error == NULL || *error == NULL, NULL); + + if ((a = archive_read_new ())) + { + const gchar *name = gimp_file_get_utf8_name (file); + + archive_read_support_format_all (a); + r = archive_read_open_filename (a, name, 10240); + if (r != ARCHIVE_OK) + { + archive_read_free (a); + + /* TODO: Translate after string freeze */ + g_set_error (error, GIMP_DATA_ERROR, GIMP_DATA_ERROR_READ, + "Unable to read Procreate swatches file"); + return NULL; + } + + while (archive_read_next_header (a, &entry) == ARCHIVE_OK) + { + const gchar *lower = g_ascii_strdown (archive_entry_pathname (entry), -1); + + if (g_str_has_suffix (lower, ".json")) + { + entry_size = archive_entry_size (entry); + json_data = (gchar *) g_malloc (entry_size); + + r = archive_read_data (a, json_data, entry_size); + } + } + + if (json_data) + { + JsonParser *parser; + JsonReader *reader; + GList *profiles = NULL; + + parser = json_parser_new (); + if (! json_parser_load_from_data (parser, json_data, entry_size, + error)) + { + g_set_error (error, GIMP_DATA_ERROR, GIMP_DATA_ERROR_READ, + _("Could not read header from palette file '%s': "), + gimp_file_get_utf8_name (file)); + + g_free (json_data); + g_clear_object (&parser); + return NULL; + } + + reader = json_reader_new (json_parser_get_root (parser)); + + if (json_reader_read_member (reader, "name") && + json_reader_is_value (reader)) + { + const gchar *name = json_reader_get_string_value (reader); + + palette = GIMP_PALETTE (gimp_palette_new (context, name)); + json_reader_end_member (reader); + } + else + { + g_set_error (error, GIMP_DATA_ERROR, GIMP_DATA_ERROR_READ, + _("Could not read header from palette file '%s': "), + gimp_file_get_utf8_name (file)); + + g_free (json_data); + g_clear_object (&reader); + g_clear_object (&parser); + return NULL; + } + + /* Procreate shows columns in rows of 10 */ + gimp_palette_set_columns (palette, 10); + + if (json_reader_read_member (reader, "colorProfiles") && + json_reader_is_array (reader)) + { + guint array_length = json_reader_count_elements (reader); + + for (gint i = 0; i < array_length; i++) + { + PaletteColorProfile *data; + GError *test = NULL; + + data = g_malloc0 (sizeof (PaletteColorProfile)); + data->id = NULL; + data->profile = NULL; + + json_reader_read_element (reader, i); + if (json_reader_read_member (reader, "hash") && + json_reader_is_value (reader)) + { + const gchar *hash = + json_reader_get_string_value (reader); + + data->id = g_strdup (hash); + } + json_reader_end_member (reader); + + if (json_reader_read_member (reader, "iccData") && + json_reader_is_value (reader)) + { + GimpColorProfile *profile = NULL; + const gchar *icc_data = NULL; + guchar *decoded = NULL; + gsize icc_size; + + icc_data = json_reader_get_string_value (reader); + decoded = g_base64_decode (icc_data, &icc_size); + + if (decoded) + { + profile = + gimp_color_profile_new_from_icc_profile ((const guint8 *) decoded, + icc_size, + &test); + + g_free (decoded); + } + + if (profile) + data->profile = profile; + } + json_reader_end_member (reader); + + if (data->id && data->profile) + profiles = g_list_append (profiles, data); + + json_reader_end_element (reader); + } + + } + json_reader_end_member (reader); + + if (json_reader_read_member (reader, "swatches") && + json_reader_is_array (reader)) + { + guint array_length = json_reader_count_elements (reader); + const gchar *labels[3] = {"hue", "saturation", "brightness"}; + guint position = 0; + + for (gint i = 0; i < array_length; i++) + { + gfloat hsva[4] = { 0, 0, 0, 1 }; + gint valid = 0; + + json_reader_read_element (reader, i); + for (gint j = 0; j < 3; j++) + { + if (json_reader_read_member (reader, labels[j]) && + json_reader_is_value (reader)) + { + hsva[j] = + (gfloat) json_reader_get_double_value (reader); + + valid++; + } + json_reader_end_member (reader); + } + if (json_reader_read_member (reader, "alpha") && + json_reader_is_value (reader)) + hsva[3] = (gfloat) json_reader_get_double_value (reader); + json_reader_end_member (reader); + + if (valid >= 3) + { + GeglColor *color = gegl_color_new (NULL); + const Babl *space = NULL; + + if (profiles && + json_reader_read_member (reader, "colorProfile") && + json_reader_is_value (reader)) + { + GList *profile_list; + const gchar *hash = + json_reader_get_string_value (reader); + + for (profile_list = profiles; profile_list; + profile_list = g_list_next (profile_list)) + { + PaletteColorProfile *icc = profile_list->data; + + if (! strcmp (icc->id, hash)) + { + space = gimp_color_profile_get_space (icc->profile, + GIMP_COLOR_RENDERING_INTENT_RELATIVE_COLORIMETRIC, + NULL); + break; + } + } + } + json_reader_end_member (reader); + + gegl_color_set_pixel (color, + babl_format_with_space ("HSV float", space), + hsva); + + if (! gimp_palette_find_entry (palette, color, NULL)) + gimp_palette_add_entry (palette, position, NULL, color); + + if (json_reader_read_member (reader, "name") && + json_reader_is_value (reader)) + { + const gchar *name = + json_reader_get_string_value (reader); + + gimp_palette_set_entry_name (palette, position, name); + } + json_reader_end_member (reader); + position++; + + g_object_unref (color); + } + json_reader_end_element (reader); + } + } + json_reader_end_member (reader); + + if (profiles) + g_list_free (profiles); + + g_free (json_data); + g_clear_object (&reader); + g_clear_object (&parser); + } + + r = archive_read_free (a); + } + else + { + /* TODO: Translate after string freeze */ + g_set_error (error, GIMP_DATA_ERROR, GIMP_DATA_ERROR_READ, + "Unable to read Procreate swatches file"); + return NULL; + } + + return g_list_prepend (NULL, palette); +} + static void swatchbooker_load_start_element (GMarkupParseContext *context, const gchar *element_name, @@ -1624,7 +1882,7 @@ swatchbooker_load_text (GMarkupParseContext *context, profile_list; profile_list = g_list_next (profile_list)) { - SwatchBookerColorProfile *icc = profile_list->data; + PaletteColorProfile *icc = profile_list->data; if (! strcmp (sbz_data->color_space, icc->id)) { @@ -1753,6 +2011,10 @@ gimp_palette_load_detect_format (GFile *file, { format = GIMP_PALETTE_FILE_FORMAT_SBZ; } + else if (g_str_has_suffix (lower, ".swatches")) + { + format = GIMP_PALETTE_FILE_FORMAT_PROCREATE; + } g_free (lower); } diff --git a/app/core/gimppalette-load.h b/app/core/gimppalette-load.h index e00aefef97..0ef3ec5b01 100644 --- a/app/core/gimppalette-load.h +++ b/app/core/gimppalette-load.h @@ -32,7 +32,8 @@ typedef enum GIMP_PALETTE_FILE_FORMAT_ACB, /* Photoshop ACB color book */ GIMP_PALETTE_FILE_FORMAT_ASE, /* Photoshop ASE color palette */ GIMP_PALETTE_FILE_FORMAT_CSS, /* Cascaded Stylesheet file (CSS) */ - GIMP_PALETTE_FILE_FORMAT_SBZ /* Swatchbooker SBZ file */ + GIMP_PALETTE_FILE_FORMAT_SBZ, /* Swatchbooker SBZ file */ + GIMP_PALETTE_FILE_FORMAT_PROCREATE /* Procreate .swatches file */ } GimpPaletteFileFormat; @@ -72,6 +73,10 @@ GList * gimp_palette_load_sbz (GimpContext *context, GFile *file, GInputStream *input, GError **error); +GList * gimp_palette_load_procreate (GimpContext *context, + GFile *file, + GInputStream *input, + GError **error); GimpPaletteFileFormat gimp_palette_load_detect_format (GFile *file, GInputStream *input); diff --git a/app/dialogs/palette-import-dialog.c b/app/dialogs/palette-import-dialog.c index d192c96e39..488f00be51 100644 --- a/app/dialogs/palette-import-dialog.c +++ b/app/dialogs/palette-import-dialog.c @@ -948,6 +948,12 @@ palette_import_file_set_filters (GtkFileChooser *file_chooser) gtk_file_filter_add_pattern (filter, "*.PAL"); gtk_file_chooser_add_filter (file_chooser, filter); + filter = gtk_file_filter_new (); + gtk_file_filter_set_name (filter, "Procreate (*.swatches)"); + gtk_file_filter_add_pattern (filter, "*.swatches"); + gtk_file_filter_add_pattern (filter, "*.SWATCHES"); + gtk_file_chooser_add_filter (file_chooser, filter); + filter = gtk_file_filter_new (); gtk_file_filter_set_name (filter, _("SwatchBooker (*.sbz)")); gtk_file_filter_add_pattern (filter, "*.sbz");