/*
 *  $Id: gwyfile.c 30151 2026-06-25 13:17:45Z yeti-dn $
 *  Copyright (C) 2003-2026 David Necas (Yeti), Petr Klapetek.
 *  E-mail: yeti@gwyddion.net, klapetek@gwyddion.net.
 *
 *  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 2 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, write to the
 *  Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 */

/**
 * [FILE-MAGIC-FREEDESKTOP]
 * <mime-type type="application/x-gwyddion-spm">
 *   <comment>Gwyddion SPM data</comment>
 *   <magic priority="100">
 *     <match type="string" offset="0" value="GWYOGwyContainer"/>
 *     <match type="string" offset="0" value="GWYPGwyContainer"/>
 *     <match type="string" offset="0" value="GWYQGwyFile"/>
 *   </magic>
 *   <glob pattern="*.gwy"/>
 *   <glob pattern="*.GWY"/>
 *   <glob pattern="*.gwy2"/>
 *   <glob pattern="*.GWY2"/>
 *   <glob pattern="*.gwy3"/>
 *   <glob pattern="*.GWY3"/>
 * </mime-type>
 **/

/**
 * [FILE-MAGIC-FILEMAGIC]
 * # Gwyddion native formats (serialised data containers),
 * # see http://gwyddion.net/
 * 0 string GWYOGwyContainer\0 Gwyddion SPM data version 1
 * 0 string GWYPGwyContainer\0 Gwyddion SPM data version 2
 * 0 string GWYQGwyFile\0 Gwyddion SPM data version 3
 **/

/**
 * [FILE-MAGIC-USERGUIDE]
 * Gwyddion native data
 * .gwy, .gwy2, .gwy3
 * Read Save SPS Volume XYZ
 **/

#include "config.h"
#include <glib/gi18n-lib.h>
#include <string.h>
#include <errno.h>
#include <stdlib.h>
#include <stdarg.h>
#ifdef HAVE_UNISTD_H
#include <unistd.h>
#endif
#include <glib/gstdio.h>
#include <gwy.h>

#include "err.h"

#define EXTENSION ".gwy"
#define EXTENSION1 ".gwy1"
#define EXTENSION2 ".gwy2"
#define EXTENSION3 ".gwy3"

#define MAGIC_COMMON "GWY"
#define MAGIC_COMMON_SIZE (sizeof(MAGIC_COMMON)-1)
#define MAGIC_SIZE (MAGIC_COMMON_SIZE+1)
#define MAGIC1 "OGwyContainer\0"
#define MAGIC1_SIZE (sizeof(MAGIC1)-1)
#define MAGIC2 "PGwyContainer\0"
#define MAGIC2_SIZE (sizeof(MAGIC2)-2)
#define MAGIC3 "QGwyFile\0"
#define MAGIC3_SIZE (sizeof(MAGIC3)-3)

typedef struct {
    const gchar *oldname;
    const gchar *newname;
} NameTranslation;

typedef struct {
    gint id;
    gint n;
    gint *expand_to;
} IdExpansion;

typedef struct {
    gchar *filename_utf8;
    gchar *filename_orig_utf8;
    GQuark key;
    gboolean restore_filename;
} SelfFilename;

#define intern_key(s) g_quark_to_string(g_quark_from_string(s))

static gboolean              module_register            (void);
static gint                  detect_file1               (const GwyFileDetectInfo *fileinfo,
                                                         gboolean only_name);
static gint                  detect_file2               (const GwyFileDetectInfo *fileinfo,
                                                         gboolean only_name);
static gint                  detect_file3               (const GwyFileDetectInfo *fileinfo,
                                                         gboolean only_name);
static GwyFile*              load_file1                 (const gchar *filename,
                                                         GwyRunModeFlags mode,
                                                         GError **error);
static GwyFile*              load_file2                 (const gchar *filename,
                                                         GwyRunModeFlags mode,
                                                         GError **error);
static GwyFile*              load_file3                 (const gchar *filename,
                                                         GwyRunModeFlags mode,
                                                         GError **error);
static gboolean              save_file2                 (GwyFile *data,
                                                         const gchar *filename,
                                                         GwyRunModeFlags mode,
                                                         GError **error);
static gboolean              save_file3                 (GwyFile *data,
                                                         const gchar *filename,
                                                         GwyRunModeFlags mode,
                                                         GError **error);
static void                  translate_object_group_2to3(GwySerializableGroup *group);
static void                  pack_metadata              (GwyDict *dict);
static void                  remove_v1_cruft            (GwyDict *dict);
static GwySerializableGroup* deserialize_v1_toplevel    (const guchar *buffer,
                                                         gsize size,
                                                         gsize *position);

static const NameTranslation object_rename_2to3[] = {
    { "GwyDataField",           "GwyField",         },
    { "GwyDataLine",            "GwyLine",          },
    { "GwyContainer",           "GwyDict",          },
    { "GwySIUnit",              "GwyUnit",          },
    { "GwySelectionProjective", "GwySelectionQuad", },
    { "Gwy3DLabel",             "GwyGLLabel",       },
    { "Gwy3DSetup",             "GwyGLSetup",       },
    { "GwySurface",             "GwyScatter",       },
};

static const NameTranslation item_rename_2to3[] = {
    { "si_unit_x",       "unit_x",       },
    { "si_unit_y",       "unit_y",       },
    { "si_unit_z",       "unit_z",       },
    { "si_unit_w",       "unit_w",       },
    { "si_unit_xy",      "unit_xy",      },
    { "si_units_curves", "units_curves", },
};

static const NameTranslation gcm_item_rename_2to3[] = {
    { "type",       "mode",       },
    { "point_type", "point-type", },
    { "point_size", "point-size", },
    { "line_size",  "line-width", },
    { "line_style", "line-style", },
};

static GwyModuleInfo module_info = {
    GWY_MODULE_ABI_VERSION,
    &module_register,
    N_("Loads and saves Gwyddion native data files (serialized objects)."),
    "Yeti <yeti@gwyddion.net>",
    "0.24",
    "David Nečas (Yeti) & Petr Klapetek",
    "2003",
};

GWY_MODULE_QUERY2(module_info, gwyfile)

static gboolean
module_register(void)
{
    gwy_file_func_register("gwy1file",
                           N_("Gwyddion native format v1 (.gwy)"),
                           detect_file1,
                           load_file1,
                           NULL,
                           NULL);
    gwy_file_func_register("gwy2file",
                           N_("Gwyddion native format v2 (.gwy, .gwy2)"),
                           detect_file2,
                           load_file2,
                           save_file2,
                           NULL);
    gwy_file_func_register("gwy3file",
                           N_("Gwyddion native format v3 (.gwy, .gwy3)"),
                           detect_file3,
                           load_file3,
                           save_file3,
                           NULL);

    return TRUE;
}

static gboolean
detect_gwy(const gchar *buf, gsize size,
           const gchar *vermagic, gsize vermagicsize)
{
    if (size <= MAGIC_COMMON_SIZE || memcmp(buf, MAGIC_COMMON, MAGIC_COMMON_SIZE))
        return FALSE;
    return size >= MAGIC_COMMON_SIZE + vermagicsize && !memcmp(buf + MAGIC_COMMON_SIZE, vermagic, vermagicsize);
}

static gint
detect_file3(const GwyFileDetectInfo *fileinfo, gboolean only_name)
{
    if (only_name) {
        if (g_str_has_suffix(fileinfo->name_lowercase, EXTENSION3)
            || g_str_has_suffix(fileinfo->name_lowercase, EXTENSION))
            return 20;
        return 0;
    }
    return detect_gwy(fileinfo->head, fileinfo->buffer_len, MAGIC3, MAGIC3_SIZE) ? 100 : 0;
}

static gint
detect_file2(const GwyFileDetectInfo *fileinfo, gboolean only_name)
{
    if (only_name)
        return g_str_has_suffix(fileinfo->name_lowercase, EXTENSION2) ? 20 : 0;
    return detect_gwy(fileinfo->head, fileinfo->buffer_len, MAGIC2, MAGIC2_SIZE) ? 100 : 0;
}

static gint
detect_file1(const GwyFileDetectInfo *fileinfo, gboolean only_name)
{
    if (only_name)
        return g_str_has_suffix(fileinfo->name_lowercase, EXTENSION1) ? 20 : 0;
    return detect_gwy(fileinfo->head, fileinfo->buffer_len, MAGIC1, MAGIC1_SIZE) ? 100 : 0;
}

static void
err_DESERIALIZATION(GError **error)
{
    g_set_error(error, GWY_MODULE_FILE_ERROR, GWY_MODULE_FILE_ERROR_DATA,
                _("Data deserialization failed."));
}

static GwyFile*
check_toplevel(GObject *object, GError **error)
{
    if (!object) {
        err_DESERIALIZATION(error);
        return NULL;
    }
    if (!GWY_IS_FILE(object)) {
        g_set_error(error, GWY_MODULE_FILE_ERROR, GWY_MODULE_FILE_ERROR_DATA,
                    _("Unexpected top-level object %s."),
                    g_type_name(G_TYPE_FROM_INSTANCE(object)));
        g_object_unref(object);
        return NULL;
    }
    return GWY_FILE(object);
}

static GwyFile*
load_file3(const gchar *filename,
           G_GNUC_UNUSED GwyRunModeFlags mode,
           GError **error)
{
    GError *err = NULL;
    const guchar *buffer = NULL;
    gsize size = 0, pos = 0;

    if (!gwy_map_file_contents(filename, &buffer, &size, &err)) {
        err_GET_FILE_CONTENTS(error, &err);
        return NULL;
    }
    if (!detect_gwy(buffer, size, MAGIC3, MAGIC3_SIZE)) {
        err_FILE_TYPE(error, "Gwyddion3");
        gwy_unmap_file_contents(buffer);
        return NULL;
    }

    GObject *object = gwy_deserialize_memory(buffer + MAGIC_SIZE, size - MAGIC_SIZE, &pos, NULL);
    if (pos != size - MAGIC_SIZE)
        g_warning("Trailing data (%lu bytes) remain after deserialization.", (gulong)(size - pos));
    gwy_unmap_file_contents(buffer);

    GwyFile *file;
    if (!(file = check_toplevel(object, error)))
        return NULL;

    /* Make sure that if there is filename, it is set by the app. */
    gwy_dict_remove(GWY_DICT(file), gwy_file_key_filename());

    return file;
}

static gboolean
is_id_number(const gchar *s, gint *value)
{
    if (!*s)
        return FALSE;

    for (guint i = 0; s[i]; i++) {
        if (!g_ascii_isdigit(s[i]) || i == 8)
            return FALSE;
    }

    *value = atol(s);
    return TRUE;
}

static gboolean
components_match(const GwySerializableItem *item, GPtrArray *components,
                 GwySerializableCType ctype, ...)
{
    if (item->ctype != ctype)
        return FALSE;

    va_list ap;
    va_start(ap, ctype);
    const gchar **comp = (const gchar**)components->pdata;
    guint ncomp = components->len;
    gboolean ok = FALSE;

    for (guint i = 0; i < ncomp; i++) {
        const gchar *name = va_arg(ap, const gchar*);
        // Too many components
        if (!name)
            goto end;
        // Bad path component
        if (!gwy_strequal(comp[i], name))
            goto end;
    }
    // Too few components
    if (va_arg(ap, const gchar*))
        goto end;

    ok = TRUE;

end:
    va_end(ap);
    return ok;
}

static inline const gchar*
form_key(GwyDataKind data_kind, gint id, GwyFilePiece piece, const gchar *suffix)
{
    GwyFileKeyParsed parsed = { .data_kind = data_kind, .id = id, .piece = piece, .suffix = suffix };
    return gwy_form_string_file_key(&parsed);
}

// Not all normally actually exist in v2 files for all data kinds, but if they do for whatever reason, we happily
// accept them.
static gboolean
translate_common_item_2to3(GwySerializableItem *item, GPtrArray *components, gint id,
                           GwyDataKind data_kind)
{
    if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, NULL))
        item->name = form_key(data_kind, id, GWY_FILE_PIECE_NONE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "meta", NULL))
        item->name = form_key(data_kind, id, GWY_FILE_PIECE_META, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "log", NULL))
        item->name = form_key(data_kind, id, GWY_FILE_PIECE_LOG, NULL);
    else
        return FALSE;
    return TRUE;
}

static gint
translate_image_item_2to3(GwySerializableItem *item, GPtrArray *components, GHashTable *masks, GHashTable *links)
{
    const gchar **comp = (const gchar**)components->pdata;
    guint ncomp = components->len;
    gint id;

    if (ncomp < 2 || !is_id_number(comp[0], &id) || gwy_strequal(comp[1], "graph"))
        return 0;

    // TODO: 3D view? More?
    // NB: Cannot use translate_common_item_2to3() because images have keys oddly scattered.
    g_ptr_array_remove_index(components, 0);
    ncomp = components->len;
    comp = (const gchar**)components->pdata;
    if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "data", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_NONE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "data", "log", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_LOG, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "meta", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_META, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "data", "title", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_TITLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "data", "visible", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_VISIBLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "data", "realsquare", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_REAL_SQUARE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "base", "palette", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_PALETTE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_INT32, "base", "range-type", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_COLOR_MAPPING, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "base", "min", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_RANGE, "min");
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "base", "max", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_RANGE, "max");
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "data", "view", "scale", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_VIEW, "scale");
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "data", "view", "relative-size", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_VIEW, "relative-size");
    else if (components_match(item, components, GWY_SERIALIZABLE_INT32, "data", "sps-id", NULL)) {
        const gchar *link_target = form_key(GWY_FILE_SPECTRA, item->value.v_int32, GWY_FILE_PIECE_NONE, NULL);
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_REF, "spectra");
        item->ctype = GWY_SERIALIZABLE_STRING;
        item->value.v_string = g_strdup(link_target);
        g_hash_table_insert(links, (gpointer)item->name, (gpointer)link_target);
    }
    else if (ncomp == 2 && item->ctype == GWY_SERIALIZABLE_OBJECT && gwy_strequal(comp[0], "select"))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_SELECTION, comp[1]);
    else if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "mask", NULL)) {
        g_hash_table_insert(masks, GINT_TO_POINTER(id), GINT_TO_POINTER(TRUE));
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_MASK, NULL);
    }
    else if (ncomp == 2 && item->ctype == GWY_SERIALIZABLE_DOUBLE && gwy_strequal(comp[0], "mask")) {
        if (gwy_stramong(comp[1], "red", "green", "blue", "alpha", NULL)) {
            g_hash_table_insert(masks, GINT_TO_POINTER(id), GINT_TO_POINTER(TRUE));
            item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_MASK, comp[1]);
        }
        else
            return -1;
    }
    else if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "show", NULL))
        item->name = form_key(GWY_FILE_IMAGE, id, GWY_FILE_PIECE_PICTURE, NULL);
    else {
        // Return -1 when we want to claim the prefix (i.e. nothing else should claim it), but did not recognise the
        // particular item.
        return -1;
    }
    return 1;
}

static gint
translate_graph_item_2to3(GwySerializableItem *item, GPtrArray *components)
{
    const gchar **comp = (const gchar**)components->pdata;
    guint ncomp = components->len;
    gint id;

    if (ncomp < 4
        || !gwy_strequal(comp[0], "0")
        || !gwy_strequal(comp[1], "graph")
        || !gwy_strequal(comp[2], "graph")
        || !is_id_number(comp[3], &id))
        return 0;

    g_ptr_array_remove_range(components, 0, 4);
    if (translate_common_item_2to3(item, components, id, GWY_FILE_GRAPH))
        return 1;
    if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "visible", NULL))
        item->name = form_key(GWY_FILE_GRAPH, id, GWY_FILE_PIECE_VISIBLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "view", "relative-size", NULL))
        item->name = form_key(GWY_FILE_GRAPH, id, GWY_FILE_PIECE_VIEW, "relative-size");
    else if (components_match(item, components, GWY_SERIALIZABLE_INT32, "view", "width", NULL))
        item->name = form_key(GWY_FILE_GRAPH, id, GWY_FILE_PIECE_VIEW, "width");
    else if (components_match(item, components, GWY_SERIALIZABLE_INT32, "view", "height", NULL))
        item->name = form_key(GWY_FILE_GRAPH, id, GWY_FILE_PIECE_VIEW, "height");
    else {
        // Return -1 when we want to claim the prefix (i.e. nothing else should claim it), but did not recognise the
        // particular item.
        return -1;
    }
    return 1;
}

static gint
translate_spectra_item_2to3(GwySerializableItem *item, GPtrArray *components)
{
    const gchar **comp = (const gchar**)components->pdata;
    guint ncomp = components->len;
    gint id;

    if (ncomp < 2 || !gwy_strequal(comp[0], "sps") || !is_id_number(comp[1], &id))
        return 0;

    g_ptr_array_remove_range(components, 0, 2);
    if (translate_common_item_2to3(item, components, id, GWY_FILE_SPECTRA))
        return 1;
    // Return -1 when we want to claim the prefix (i.e. nothing else should claim it), but did not recognise the
    // particular item.
    return -1;
}

static gint
translate_volume_item_2to3(GwySerializableItem *item, GPtrArray *components)
{
    const gchar **comp = (const gchar**)components->pdata;
    guint ncomp = components->len;
    gint id;

    if (ncomp < 2 || !gwy_strequal(comp[0], "brick") || !is_id_number(comp[1], &id))
        return 0;

    g_ptr_array_remove_range(components, 0, 2);
    if (translate_common_item_2to3(item, components, id, GWY_FILE_VOLUME))
        return 1;
    if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "preview", NULL))
        item->name = form_key(GWY_FILE_VOLUME, id, GWY_FILE_PIECE_PICTURE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "visible", NULL))
        item->name = form_key(GWY_FILE_VOLUME, id, GWY_FILE_PIECE_VISIBLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "preview", "palette", NULL))
        item->name = form_key(GWY_FILE_VOLUME, id, GWY_FILE_PIECE_PALETTE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "realsquare", NULL))
        item->name = form_key(GWY_FILE_VOLUME, id, GWY_FILE_PIECE_REAL_SQUARE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "title", NULL))
        item->name = form_key(GWY_FILE_VOLUME, id, GWY_FILE_PIECE_TITLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "preview", "view", "relative-size", NULL))
        item->name = form_key(GWY_FILE_VOLUME, id, GWY_FILE_PIECE_VIEW, "relative-size");
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "preview", "view", "scale", NULL))
        item->name = form_key(GWY_FILE_VOLUME, id, GWY_FILE_PIECE_VIEW, "scale");
    else {
        // Return -1 when we want to claim the prefix (i.e. nothing else should claim it), but did not recognise the
        // particular item.
        return -1;
    }
    return 1;
}

static gint
translate_xyz_item_2to3(GwySerializableItem *item, GPtrArray *components)
{
    const gchar **comp = (const gchar**)components->pdata;
    guint ncomp = components->len;
    gint id;

    if (ncomp < 2 || !gwy_strequal(comp[0], "surface") || !is_id_number(comp[1], &id))
        return 0;

    g_ptr_array_remove_range(components, 0, 2);
    if (translate_common_item_2to3(item, components, id, GWY_FILE_XYZ))
        return 1;
    if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "preview", NULL))
        item->name = form_key(GWY_FILE_XYZ, id, GWY_FILE_PIECE_PICTURE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "visible", NULL))
        item->name = form_key(GWY_FILE_XYZ, id, GWY_FILE_PIECE_VISIBLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "preview", "palette", NULL))
        item->name = form_key(GWY_FILE_XYZ, id, GWY_FILE_PIECE_PALETTE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "title", NULL))
        item->name = form_key(GWY_FILE_XYZ, id, GWY_FILE_PIECE_TITLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_INT32, "preview", "view", "width", NULL))
        item->name = form_key(GWY_FILE_XYZ, id, GWY_FILE_PIECE_VIEW, "width");
    else if (components_match(item, components, GWY_SERIALIZABLE_INT32, "preview", "view", "height", NULL))
        item->name = form_key(GWY_FILE_XYZ, id, GWY_FILE_PIECE_VIEW, "height");
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "preview", "view", "relative-size", NULL))
        item->name = form_key(GWY_FILE_XYZ, id, GWY_FILE_PIECE_VIEW, "relative-size");
    else {
        // Return -1 when we want to claim the prefix (i.e. nothing else should claim it), but did not recognise the
        // particular item.
        return -1;
    }
    return 1;
}

static gint
translate_cmap_item_2to3(GwySerializableItem *item, GPtrArray *components)
{
    const gchar **comp = (const gchar**)components->pdata;
    guint ncomp = components->len;
    gint id;

    if (ncomp < 2 || !gwy_strequal(comp[0], "lawn") || !is_id_number(comp[1], &id))
        return 0;

    g_ptr_array_remove_range(components, 0, 2);
    if (translate_common_item_2to3(item, components, id, GWY_FILE_CMAP))
        return 1;
    else if (components_match(item, components, GWY_SERIALIZABLE_OBJECT, "preview", NULL))
        item->name = form_key(GWY_FILE_CMAP, id, GWY_FILE_PIECE_PICTURE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "visible", NULL))
        item->name = form_key(GWY_FILE_CMAP, id, GWY_FILE_PIECE_VISIBLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "palette", NULL))
        item->name = form_key(GWY_FILE_CMAP, id, GWY_FILE_PIECE_PALETTE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_BOOLEAN, "realsquare", NULL))
        item->name = form_key(GWY_FILE_CMAP, id, GWY_FILE_PIECE_REAL_SQUARE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_STRING, "title", NULL))
        item->name = form_key(GWY_FILE_CMAP, id, GWY_FILE_PIECE_TITLE, NULL);
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "preview", "view", "relative-size", NULL))
        item->name = form_key(GWY_FILE_CMAP, id, GWY_FILE_PIECE_VIEW, "relative-size");
    else if (components_match(item, components, GWY_SERIALIZABLE_DOUBLE, "preview", "view", "scale", NULL))
        item->name = form_key(GWY_FILE_CMAP, id, GWY_FILE_PIECE_VIEW, "scale");
    else {
        // Return -1 when we want to claim the prefix (i.e. nothing else should claim it), but did not recognise the
        // particular item.
        return -1;
    }
    return 1;
}

static void
pack_rgba_to_record(GwySerializableGroup *group,
                    const gchar *srcprefix, const gchar *target,
                    gboolean has_alpha, GArray *gather_indices)
{
    gsize nitems, pfxlen = strlen(srcprefix);
    GwySerializableItem *items = gwy_serializable_group_items(group, &nitems);
    gsize indices[4] = { G_MAXSIZE, G_MAXSIZE, G_MAXSIZE, G_MAXSIZE };

    for (gsize i = 0; i < nitems; i++) {
        GwySerializableItem *item = items + i;
        if (item->ctype != GWY_SERIALIZABLE_DOUBLE || strncmp(item->name, srcprefix, pfxlen))
            continue;
        if (gwy_strequal(item->name + pfxlen, "red"))
            indices[0] = i;
        else if (gwy_strequal(item->name + pfxlen, "green"))
            indices[1] = i;
        else if (gwy_strequal(item->name + pfxlen, "blue"))
            indices[2] = i;
        else if (has_alpha && gwy_strequal(item->name + pfxlen, "alpha"))
            indices[3] = i;
    }

    if (indices[0] == G_MAXSIZE || indices[1] == G_MAXSIZE || indices[2] == G_MAXSIZE)
        return;
    if (has_alpha && indices[3] == G_MAXSIZE)
        return;

    GwyRGBA rgba = {
        .r = items[indices[0]].value.v_double,
        .g = items[indices[1]].value.v_double,
        .b = items[indices[2]].value.v_double,
        .a = has_alpha ? items[indices[3]].value.v_double : 1.0
    };

    if (gather_indices)
        g_array_append_vals(gather_indices, indices, has_alpha ? 4 : 3);
    else
        gwy_serializable_group_discard(group, indices, has_alpha ? 4 : 3, TRUE);

    GwySerializableItem model = {
        .ctype = GWY_SERIALIZABLE_BOXED,
        .name = target,
        .aux.boxed_type = GWY_TYPE_RGBA,
        .array_size = 0,
        .flags = 0,
    };
    gwy_serializable_group_append_rgba(group, &model, &rgba);

    // We are not done because we need the record itemised to components, not an already constructed GwyRGBA.
    items = gwy_serializable_group_items(group, &nitems);
    GwySerializableItem *item = items + nitems-1;
    g_assert(gwy_strequal(item->name, target));
    item->value.v_group = gwy_serializable_boxed_itemize(item->aux.boxed_type, item->value.v_boxed);
    item->flags |= GWY_SERIALIZABLE_IS_GROUP;
}

static void
enforce_utf8_dict_keys(GwySerializableGroup *group)
{
    if (!gwy_strequal(gwy_serializable_group_name(group), "GwyDict")) {
        g_warning("Metadata object is %s instead of GwyDict.", gwy_serializable_group_name(group));
        return;
    }

    gsize nitems;
    GwySerializableItem *items = gwy_serializable_group_items(group, &nitems);
    for (gsize i = 0; i < nitems; i++) {
        GwySerializableItem *item = items + i;
        guint len = strlen(item->name);

        if (!g_utf8_validate(item->name, len, NULL)) {
            gchar *valid_name = gwy_convert_to_utf8(item->name, len, "ISO-8859-1");
            item->name = intern_key(valid_name);
            g_free(valid_name);
        }
    }
}

static void
translate_object_group_2to3(GwySerializableGroup *group)
{
    const gchar *name = gwy_serializable_group_name(group);
    // This one is kind of unidirectional.
    if (gwy_strequal(name, "GwySelectionCross"))
        gwy_serializable_group_rename(group, "GwySelectionPoint");
    else {
        for (guint i = 0; i < G_N_ELEMENTS(object_rename_2to3); i++) {
            const NameTranslation *ntr = object_rename_2to3 + i;
            if (gwy_strequal(name, ntr->oldname)) {
                gwy_serializable_group_rename(group, ntr->newname);
                break;
            }
        }
    }

    gsize nitems;
    GwySerializableItem *items = gwy_serializable_group_items(group, &nitems);
    GArray *toremove = NULL;
    for (gsize i = 0; i < nitems; i++) {
        GwySerializableItem *item = items + i;

        for (guint j = 0; j < G_N_ELEMENTS(item_rename_2to3); j++) {
            const NameTranslation *ntr = item_rename_2to3 + j;
            if (gwy_strequal(item->name, ntr->oldname)) {
                item->name = ntr->newname;
                break;
            }
        }

        gboolean remove_item = FALSE;
        if (gwy_strequal(name, "GwyGraphCurveModel")) {
            for (guint j = 0; j < G_N_ELEMENTS(gcm_item_rename_2to3); j++) {
                const NameTranslation *ntr = gcm_item_rename_2to3 + j;
                if (gwy_strequal(item->name, ntr->oldname)) {
                    item->name = ntr->newname;
                    break;
                }
            }
            if (item->ctype == GWY_SERIALIZABLE_INT32 && gwy_stramong(item->name, "line-width", "point-size", NULL)) {
                gdouble v = item->value.v_int32;
                item->ctype = GWY_SERIALIZABLE_DOUBLE;
                item->value.v_double = v;
            }
            pack_rgba_to_record(group, "color.", "color", FALSE, NULL);
        }
        else if (gwy_strequal(name, "GwySelectionAxis")
                 && gwy_strequal(item->name, "orientation")
                 && item->ctype == GWY_SERIALIZABLE_INT32) {
            item->value.v_int32 = !item->value.v_int32;
        }
        else if (gwy_strequal(name, "GwyDataField")
                 && (gwy_strequal(item->name, "cache_data") || gwy_strequal(item->name, "cache_bits")))
            remove_item = TRUE;
        else if (gwy_strequal(name, "GwyGraphModel")
                 && (gwy_strequal(item->name, "has_x_unit") || gwy_strequal(item->name, "has_y_unit")
                     || gwy_strequal(item->name, "x_reqmin") || gwy_strequal(item->name, "y_reqmin")
                     || gwy_strequal(item->name, "x_reqmax") || gwy_strequal(item->name, "y_reqmax")))
            remove_item = TRUE;
        else if (gwy_strequal(name, "GwySurface")) {
            // Units have alrerady been renamed from si_unit_foo to unit_foo by the generic renaming.
            if (gwy_strequal(item->name, "unit_z") && item->ctype == GWY_SERIALIZABLE_OBJECT) {
                item->name = "units_data";
                item->ctype = GWY_SERIALIZABLE_OBJECT_ARRAY;
                item->array_size = 1;
                GwySerializableGroup *item_group = item->value.v_group;
                item->value.v_group_array = g_new(GwySerializableGroup*, 1);
                item->value.v_group_array[0] = item_group;
            }
            if (gwy_strequal(item->name, "data") && item->ctype == GWY_SERIALIZABLE_DOUBLE_ARRAY) {
                // This can throw away some values when "data" does not have a multiple of 3 items. In such case, who
                // cares – it is absolutely botched anyway.
                gsize n = item->array_size/3;
                gdouble *xy = g_new(gdouble, 2*n), *z = g_new(gdouble, n);

                GwySerializableItem toadd[2];
                gwy_clear(toadd, 2);
                toadd[0].ctype = GWY_SERIALIZABLE_INT32;
                toadd[0].name = "nvalues";
                toadd[0].value.v_int32 = 1;
                toadd[1].ctype = GWY_SERIALIZABLE_DOUBLE_ARRAY;
                toadd[1].name = "xy";
                toadd[1].array_size = 2*n;
                toadd[1].value.v_double_array = xy;

                gdouble *xyz = item->value.v_double_array;
                for (gsize k = 0; k < n; k++) {
                    xy[2*k] = xyz[3*k];
                    xy[2*k + 1] = xyz[3*k + 1];
                    z[k] = xyz[3*k + 2];
                }
                g_free(item->value.v_double_array);
                item->array_size = n;
                item->value.v_double_array = z;

                gwy_serializable_group_append(group, toadd, 2);
            }
        }

        if (remove_item) {
            // Silently discard old items.
            if (!toremove)
                toremove = g_array_new(FALSE, FALSE, sizeof(gsize));
            g_array_append_val(toremove, i);
            continue;
        }

        if (item->ctype == GWY_SERIALIZABLE_OBJECT)
            translate_object_group_2to3(item->value.v_group);
        else if (item->ctype == GWY_SERIALIZABLE_OBJECT_ARRAY) {
            for (gsize j = 0; j < item->array_size; j++)
                translate_object_group_2to3(item->value.v_group_array[j]);
        }
    }

    if (toremove) {
        gwy_serializable_group_discard(group, &g_array_index(toremove, gsize, 0), toremove->len, TRUE);
        g_array_free(toremove, TRUE);
    }
}

static void
translate_toplevel_2to3(GwySerializableGroup *group, GHashTable *masks, GHashTable *links)
{
    // Caller checks the group is a GwyContainer. We generally do not report errors because if something fails to
    // translate it will show up at a later stage. We also first create a GwyDict, not GwyFile. The latter is too
    // smart and can complain when we put weird things to unexpected places. Once everything in the GwyDict is as it
    // should, we move it to a new GwyFile object.
    gwy_serializable_group_rename(group, "GwyDict");

    GString *path = g_string_new(NULL);
    GPtrArray *components = g_ptr_array_new();
    GArray *toremove = g_array_new(FALSE, FALSE, sizeof(gsize));

    gsize nitems;
    GwySerializableItem *items = gwy_serializable_group_items(group, &nitems);
    for (gsize i = 0; i < nitems; i++) {
        GwySerializableItem *item = items + i;
        if (item->name[0] != '/')
            continue;

        if (gwy_strequal(item->name, "/0/graph/lastid")
            || gwy_strequal(item->name, "/filename")
            || gwy_strequal(item->name, "/filename/untitled")) {
            g_array_append_val(toremove, i);
            continue;
        }

        g_string_assign(path, item->name);
        g_ptr_array_set_size(components, 0);
        gchar *s = path->str + 1;
        while (s) {
            gchar *sep = strchr(s, '/');
            if (sep)
                *sep = '\0';
            g_ptr_array_add(components, s);
            s = sep ? sep+1 : NULL;
        }

        if (components_match(item, components, GWY_SERIALIZABLE_STRING, "filename", "title", NULL))
            item->name = form_key(GWY_FILE_IMAGE, 0, GWY_FILE_PIECE_TITLE, NULL);
        else if (translate_image_item_2to3(item, components, masks, links)
                 || translate_graph_item_2to3(item, components)
                 || translate_spectra_item_2to3(item, components)
                 || translate_volume_item_2to3(item, components)
                 || translate_xyz_item_2to3(item, components)
                 || translate_cmap_item_2to3(item, components)) {
            /* OK */
        }
        else
            continue;

        if (item->ctype == GWY_SERIALIZABLE_OBJECT) {
            translate_object_group_2to3(item->value.v_group);
            if (g_str_has_suffix(item->name, "/meta"))
                enforce_utf8_dict_keys(item->value.v_group);
        }
        else if (item->ctype == GWY_SERIALIZABLE_OBJECT_ARRAY) {
            for (gsize j = 0; j < item->array_size; j++)
                translate_object_group_2to3(item->value.v_group_array[j]);
        }
    }

    gwy_serializable_group_discard(group, &g_array_index(toremove, gsize, 0), toremove->len, TRUE);

    g_array_free(toremove, TRUE);
    g_ptr_array_free(components, TRUE);
    g_string_free(path, TRUE);
}

static void
translate_mask_2to3(gpointer hkey,
                    G_GNUC_UNUSED gpointer hvalue,
                    gpointer user_data)
{
    gint id = GPOINTER_TO_INT(hkey);
    GwyDict *dict = (GwyDict*)user_data;
    GwyField *field;

    GQuark key = gwy_file_key_image_mask(id);
    if (gwy_dict_gis_object(dict, key, &field)) {
        GwyNield *nield = gwy_field_new_nield_alike(field);
        gwy_nield_mark_by_threshold(nield, field, 0.5, TRUE);
        gwy_nield_number_contiguous(nield);
        gwy_dict_pass_object(dict, key, nield);
    }
    const gchar *strkey = g_quark_to_string(key);
    GwyRGBA rgba;
    if (gwy_rgba_get_from_dict(&rgba, dict, strkey))
        gwy_dict_set_boxed(dict, GWY_TYPE_RGBA, gwy_file_key_image_mask_color(id), &rgba);
    gwy_rgba_remove_from_dict(dict, strkey);
}

static void
set_item_link(gpointer hkey,
              gpointer hvalue,
              gpointer user_data)
{
    gwy_file_set_link((GwyFile*)user_data, (const gchar*)hkey, (const gchar*)hvalue);
}

static GwyFile*
deserialize_v1or2_from_group(GwySerializableGroup *group, GError **error)
{
    GHashTable *masks = g_hash_table_new(g_direct_hash, g_direct_equal);
    GHashTable *links = g_hash_table_new(g_str_hash, g_str_equal);

    translate_toplevel_2to3(group, masks, links);

    GwyErrorList *errlist = NULL;
    GwyDict *workspace = (GwyDict*)gwy_deserialize_construct(group, &errlist);
    for (GwyErrorList *l = errlist; l; l = g_slist_next(l)) {
        g_warning("%s", ((GError*)l->data)->message);
    }
    gwy_error_list_clear(&errlist);

    if (!workspace || !GWY_IS_DICT(workspace)) {
        err_DESERIALIZATION(error);
        g_hash_table_destroy(masks);
        g_hash_table_destroy(links);
        return NULL;
    }

    g_hash_table_foreach(masks, translate_mask_2to3, workspace);
    g_hash_table_destroy(masks);

    remove_v1_cruft(workspace);
    pack_metadata(workspace);
    // FIXME: If we want to find sets of compatible XYZ data and stack them always to a single object, here would be
    // the place to do it. However, it is a pretty niche case.

    GwyFile *file = gwy_file_new_in_construction();
    gwy_dict_transfer(workspace, GWY_DICT(file), NULL, NULL, FALSE, FALSE);
    g_clear_object(&workspace);

    g_hash_table_foreach(links, set_item_link, file);
    g_hash_table_destroy(links);

    return file;
}

static GwyFile*
load_file2(const gchar *filename,
           G_GNUC_UNUSED GwyRunModeFlags mode,
           GError **error)
{
    GError *err = NULL;
    const guchar *buffer = NULL;
    gsize size = 0;

    if (!gwy_map_file_contents(filename, &buffer, &size, &err)) {
        err_GET_FILE_CONTENTS(error, &err);
        return NULL;
    }
    if (!detect_gwy(buffer, size, MAGIC2, MAGIC2_SIZE)) {
        err_FILE_TYPE(error, "Gwyddion2");
        gwy_unmap_file_contents(buffer);
        return NULL;
    }

    GwyErrorList *errlist = NULL;
    // TODO: Do something more useful with the error list in general.
    GwySerializableGroup *group = gwy_deserialize_group_memory(buffer + MAGIC_SIZE, size - MAGIC_SIZE,
                                                               GWY_SERIALIZE_SIZE_32BIT, &errlist);
    for (GwyErrorList *l = errlist; l; l = g_slist_next(l)) {
        g_warning("%s", ((GError*)l->data)->message);
    }
    gwy_error_list_clear(&errlist);

    GwyFile *file = NULL;
    if (!group)
        err_DESERIALIZATION(error);
    else if (!gwy_strequal(gwy_serializable_group_name(group), "GwyContainer")) {
        g_set_error(error, GWY_MODULE_FILE_ERROR, GWY_MODULE_FILE_ERROR_DATA,
                    _("Unexpected top-level object %s."),
                    gwy_serializable_group_name(group));
    }
    else
        file = deserialize_v1or2_from_group(group, error);

    gwy_unmap_file_contents(buffer);

    return file;
}

static GwyFile*
load_file1(const gchar *filename,
           G_GNUC_UNUSED GwyRunModeFlags mode,
           GError **error)
{
    GError *err = NULL;
    const guchar *buffer = NULL;
    gsize size = 0, pos = 0;

    if (!gwy_map_file_contents(filename, &buffer, &size, &err)) {
        err_GET_FILE_CONTENTS(error, &err);
        return NULL;
    }
    if (!detect_gwy(buffer, size, MAGIC1, MAGIC1_SIZE)) {
        err_FILE_TYPE(error, "Gwyddion1");
        gwy_unmap_file_contents(buffer);
        return NULL;
    }

    GwySerializableGroup *group = deserialize_v1_toplevel(buffer + MAGIC_SIZE, size - MAGIC_SIZE, &pos);
    // TODO: Warn if not all data consumed.

    GwyFile *file = NULL;
    if (!group)
        err_DESERIALIZATION(error);
    else
        file = deserialize_v1or2_from_group(group, error);

    gwy_unmap_file_contents(buffer);

    return file;
}

/* Ensure the saved file contains its own name under "/filename". Meaning we need to set it before saving it. */
static void
presage_filename(SelfFilename *sf, GwyFile *file, const gchar *filename)
{
    gwy_clear(sf, 1);

    sf->restore_filename = TRUE;
    sf->key = gwy_file_key_filename();
    GwyDict *dict = GWY_DICT(file);

    const gchar *s = NULL;
    gwy_dict_gis_string(dict, sf->key, &s);
    sf->filename_orig_utf8 = s ? g_strdup(s) : NULL;

    sf->filename_utf8 = g_filename_to_utf8(filename, -1, NULL, NULL, NULL);
    if (!sf->filename_utf8)
        gwy_dict_remove(dict, sf->key);
    else if (sf->filename_orig_utf8 && gwy_strequal(sf->filename_orig_utf8, sf->filename_utf8))
        sf->restore_filename = FALSE;
    else {
        gwy_dict_pass_string(dict, sf->key, sf->filename_utf8);
        sf->filename_utf8 = NULL;
    }
}

static void
clean_up_filename(SelfFilename *sf, GwyFile *file, gboolean save_ok)
{
    /* Restore filename if save failed */
    if (!save_ok && sf->restore_filename) {
        if (sf->filename_orig_utf8)
            gwy_dict_pass_string(GWY_DICT(file), sf->key, sf->filename_orig_utf8);
        else
            gwy_dict_remove(GWY_DICT(file), sf->key);
        sf->filename_orig_utf8 = NULL;
    }
    g_free(sf->filename_orig_utf8);
    g_free(sf->filename_utf8);
}

static gboolean
save_file3(GwyFile *data,
           const gchar *filename,
           G_GNUC_UNUSED GwyRunModeFlags mode,
           GError **error)
{
    SelfFilename sf;
    presage_filename(&sf, data, filename);

    GError *err = NULL;
    GFile *gfile = g_file_new_for_path(filename);
    GFileOutputStream *stream;

    gboolean ok = TRUE;
    if (!(stream = g_file_replace(gfile, NULL, FALSE, 0, NULL, &err))) {
        err_OPEN_WRITE_GERROR(error, &err);
        ok = FALSE;
    }
    if (ok) {
        g_output_stream_write(G_OUTPUT_STREAM(stream), MAGIC_COMMON, MAGIC_COMMON_SIZE, NULL, NULL);
        g_output_stream_write(G_OUTPUT_STREAM(stream), "Q", 1, NULL, NULL);
        if (!gwy_serialize_gio(GWY_SERIALIZABLE(data), G_OUTPUT_STREAM(stream), &err)) {
            g_set_error(error, GWY_MODULE_FILE_ERROR, GWY_MODULE_FILE_ERROR_IO,
                        _("Cannot write to file: %s."), err->message);
            g_clear_error(&err);
            ok = FALSE;
        }
    }
    g_clear_object(&stream);
    g_object_unref(gfile);
    clean_up_filename(&sf, data, ok);

    return ok;
}

static gboolean
translate_boolean(GwyDict *source, GQuark srcquark,
                 GwyDict *dest, const gchar *destkey_template, gint id,
                 GString *str)
{
    gboolean b;
    if (!gwy_dict_gis_boolean(source, srcquark, &b))
        return FALSE;
    if (id < 0)
        gwy_dict_set_boolean_by_name(dest, destkey_template, b);
    else {
        g_string_printf(str, destkey_template, id);
        gwy_dict_set_boolean_by_name(dest, str->str, b);
    }
    return TRUE;
}

static gboolean
translate_int(GwyDict *source, GQuark srcquark,
              GwyDict *dest, const gchar *destkey_template, gint id,
              GString *str)
{
    gint32 i;
    if (!gwy_dict_gis_int32(source, srcquark, &i))
        return FALSE;
    if (id < 0)
        gwy_dict_set_int32_by_name(dest, destkey_template, i);
    else {
        g_string_printf(str, destkey_template, id);
        gwy_dict_set_int32_by_name(dest, str->str, i);
    }
    return TRUE;
}

static gboolean
translate_link_to_id(GwyDict *source, GQuark srcquark,
                     GwyDict *dest, const gchar *destkey_template, gint id,
                     GString *str)
{
    const gchar *s;
    if (!gwy_file_value_is_link(GWY_FILE(source), g_quark_to_string(srcquark))
        || !gwy_dict_gis_string(source, srcquark, &s))
        return FALSE;
    GwyFileKeyParsed parsed;
    // Version 2 links are just numbers; the data kind is implicit. Extract the id from the parsed key.
    if (!gwy_parse_file_key(s, &parsed) || parsed.id < 0)
        return FALSE;

    g_string_printf(str, destkey_template, id);
    gwy_dict_set_int32_by_name(dest, str->str, parsed.id);
    return TRUE;
}

static gboolean
translate_double(GwyDict *source, GQuark srcquark,
                 GwyDict *dest, const gchar *destkey_template, gint id,
                 GString *str)
{
    gdouble d;
    if (!gwy_dict_gis_double(source, srcquark, &d))
        return FALSE;
    if (id < 0)
        gwy_dict_set_double_by_name(dest, destkey_template, d);
    else {
        g_string_printf(str, destkey_template, id);
        gwy_dict_set_double_by_name(dest, str->str, d);
    }
    return TRUE;
}

static gboolean
translate_string(GwyDict *source, GQuark srcquark,
                 GwyDict *dest, const gchar *destkey_template, gint id,
                 GString *str)
{
    const gchar *s;
    if (!gwy_dict_gis_string(source, srcquark, &s))
        return FALSE;
    if (id < 0)
        gwy_dict_set_string_by_name(dest, destkey_template, s);
    else {
        g_string_printf(str, destkey_template, id);
        gwy_dict_set_string_by_name(dest, str->str, s);
    }
    return TRUE;
}

static gboolean
translate_boxed(GwyDict *source, GType boxed_type, GQuark srcquark,
                GwyDict *dest, const gchar *destkey_template, gint id,
                GString *str)
{
    GType gtype = gwy_dict_value_type(source, srcquark);
    if (!gtype || !g_type_is_a(gtype, boxed_type))
        return FALSE;

    gpointer boxed = gwy_dict_get_boxed(source, boxed_type, srcquark);
    if (id < 0)
        gwy_dict_set_boxed_by_name(dest, boxed_type, destkey_template, boxed);
    else {
        g_string_printf(str, destkey_template, id);
        gwy_dict_set_boxed_by_name(dest, boxed_type, str->str, boxed);
    }
    return TRUE;
}

static gboolean
translate_object(GwyDict *source, GQuark srcquark,
                 GwyDict *dest, const gchar *destkey_template, gint id,
                 GString *str)
{
    GObject *object;
    if (!gwy_dict_gis_object(source, srcquark, &object))
        return FALSE;
    if (id < 0)
        gwy_dict_set_object_by_name(dest, destkey_template, object);
    else {
        g_string_printf(str, destkey_template, id);
        gwy_dict_set_object_by_name(dest, str->str, object);
    }
    return TRUE;
}

static void
translate_mask_3to2(gpointer hkey,
                    G_GNUC_UNUSED gpointer hvalue,
                    gpointer user_data)
{
    gint id = GPOINTER_TO_INT(hkey);
    GwyDict *dict = (GwyDict*)user_data;
    gchar *strkey;

    strkey = g_strdup_printf("/%d/mask", id);
    GQuark maskkey = g_quark_from_string(strkey);
    g_free(strkey);

    strkey = g_strdup_printf("/%d/data", id);
    GQuark imagekey = g_quark_from_string(strkey);
    g_free(strkey);

    GwyNield *mask;
    if (gwy_dict_gis_object(dict, maskkey, &mask)) {
        GwyField *mainfield;
        if (gwy_dict_gis_object(dict, imagekey, &mainfield)) {
            GwyField *field = gwy_field_new_alike(mainfield, FALSE);
            gwy_unit_clear(gwy_field_get_unit_xy(field));
            gwy_field_fill_mask(field, mask, 0.0, 1.0);
            gwy_dict_pass_object(dict, maskkey, field);
        }
        else
            g_warning("Mask %s has no main data field.", g_quark_to_string(maskkey));
    }

    strkey = g_strdup_printf("/%d/mask/color", id);
    GwyRGBA rgba;
    if (gwy_dict_gis_boxed_by_name(dict, GWY_TYPE_RGBA, strkey, &rgba)) {
        gwy_dict_remove_by_name(dict, strkey);
        g_free(strkey);
        strkey = g_strdup_printf("/%d/mask", id);
        gwy_rgba_store_to_dict(&rgba, dict, strkey);
    }
    g_free(strkey);
}

static gboolean
translate_image_item_3to2(GwyDict *dict, GQuark quark, GwyDict *workspace, const GwyFileKeyParsed *parsed,
                          GHashTable *masks, GString *str)
{
    if (parsed->piece == GWY_FILE_PIECE_NONE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/%d/data", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_MASK && !parsed->suffix) {
        g_hash_table_insert(masks, GINT_TO_POINTER(parsed->id), GINT_TO_POINTER(TRUE));
        return translate_object(dict, quark, workspace, "/%d/mask", parsed->id, str);
    }
    if (parsed->piece == GWY_FILE_PIECE_MASK_COLOR && !parsed->suffix) {
        g_hash_table_insert(masks, GINT_TO_POINTER(parsed->id), GINT_TO_POINTER(TRUE));
        return translate_boxed(dict, GWY_TYPE_RGBA, quark, workspace, "/%d/mask/color", parsed->id, str);
    }
    if (parsed->piece == GWY_FILE_PIECE_TITLE && !parsed->suffix)
        return translate_string(dict, quark, workspace, "/%d/data/title", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_PICTURE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/%d/show", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_META && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/%d/meta", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_LOG && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/%d/data/log", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VISIBLE && !parsed->suffix)
        return translate_boolean(dict, quark, workspace, "/%d/data/visible", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_PALETTE && !parsed->suffix)
        return translate_string(dict, quark, workspace, "/%d/base/palette", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_COLOR_MAPPING && !parsed->suffix)
        return translate_int(dict, quark, workspace, "/%d/base/range-type", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_REAL_SQUARE && !parsed->suffix)
        return translate_boolean(dict, quark, workspace, "/%d/data/realsquare", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_REF && gwy_strequal(parsed->suffix, "spectra"))
        return translate_link_to_id(dict, quark, workspace, "/%d/data/sps-id", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_RANGE) {
        if (gwy_strequal(parsed->suffix, "min"))
            return translate_double(dict, quark, workspace, "/%d/base/min", parsed->id, str);
        if (gwy_strequal(parsed->suffix, "max"))
            return translate_double(dict, quark, workspace, "/%d/base/max", parsed->id, str);
        return FALSE;
    }
    if (parsed->piece == GWY_FILE_PIECE_VIEW) {
        if (gwy_strequal(parsed->suffix, "scale"))
            return translate_double(dict, quark, workspace, "/%d/data/view/scale", parsed->id, str);
        if (gwy_strequal(parsed->suffix, "relative-size"))
            return translate_double(dict, quark, workspace, "/%d/data/view/relative-size", parsed->id, str);
        return FALSE;
    }
    if (parsed->piece == GWY_FILE_PIECE_SELECTION) {
        if (gwy_stramong(parsed->suffix,
                         "pointer", "point", "rectangle", "line", "ellipse", "cross", "path", "lattice",
                         "projective", NULL)) {
            g_string_printf(str, "/%d/select/%s", parsed->id, parsed->suffix);
            return translate_object(dict, quark, workspace, str->str, -1, NULL);
        }
        return FALSE;
    }
    return FALSE;
}

static gboolean
translate_graph_item_3to2(GwyDict *dict, GQuark quark, GwyDict *workspace, const GwyFileKeyParsed *parsed,
                          GString *str)
{
    if (parsed->piece == GWY_FILE_PIECE_NONE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/0/graph/graph/%d", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VISIBLE && !parsed->suffix)
        return translate_boolean(dict, quark, workspace, "/0/graph/graph/%d/visible", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VIEW) {
        if (gwy_strequal(parsed->suffix, "width"))
            return translate_int(dict, quark, workspace, "/0/graph/graph/%d/view/width", parsed->id, str);
        if (gwy_strequal(parsed->suffix, "height"))
            return translate_int(dict, quark, workspace, "/0/graph/graph/%d/view/height", parsed->id, str);
        if (gwy_strequal(parsed->suffix, "relative-size"))
            return translate_double(dict, quark, workspace, "/0/graph/graph/%d/view/relative-size", parsed->id, str);
        return FALSE;
    }
    if ((parsed->piece == GWY_FILE_PIECE_META || parsed->piece == GWY_FILE_PIECE_LOG) && !parsed->suffix) {
        // Skip silently, not in 2.x.
        return TRUE;
    }
    return FALSE;
}

static gboolean
translate_spectra_item_3to2(GwyDict *dict, GQuark quark, GwyDict *workspace, const GwyFileKeyParsed *parsed,
                            GString *str)
{
    if (parsed->piece == GWY_FILE_PIECE_NONE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/sps/%d", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VISIBLE && !parsed->suffix)
        return translate_boolean(dict, quark, workspace, "/sps/%d/visible", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VIEW) {
        // Skip silently, not in 2.x.
        return TRUE;
    }
    if ((parsed->piece == GWY_FILE_PIECE_META || parsed->piece == GWY_FILE_PIECE_LOG) && !parsed->suffix) {
        // Skip silently, not in 2.x.
        return TRUE;
    }
    return FALSE;
}

static gboolean
translate_volume_item_3to2(GwyDict *dict, GQuark quark, GwyDict *workspace, const GwyFileKeyParsed *parsed,
                           GString *str)
{
    if (parsed->piece == GWY_FILE_PIECE_NONE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/brick/%d", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_PICTURE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/brick/%d/preview", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VISIBLE && !parsed->suffix)
        return translate_boolean(dict, quark, workspace, "/brick/%d/visible", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_PALETTE && !parsed->suffix)
        return translate_string(dict, quark, workspace, "/brick/%d/preview/palette", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_META && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/brick/%d/meta", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_LOG && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/brick/%d/log", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_TITLE && !parsed->suffix)
        return translate_string(dict, quark, workspace, "/brick/%d/title", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_REAL_SQUARE && !parsed->suffix) {
        // Skip silently, not in 2.x.
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_VIEW) {
        if (gwy_strequal(parsed->suffix, "relative-size"))
            return translate_double(dict, quark, workspace, "/brick/%d/preview/view/relative-size", parsed->id, str);
        if (gwy_strequal(parsed->suffix, "scale"))
            return translate_double(dict, quark, workspace, "/brick/%d/preview/view/scale", parsed->id, str);
        return FALSE;
    }
    return FALSE;
}

static GwyScatter*
extract_one_scatter_value(GwyScatter *scatter, gint i)
{
    gsize n = gwy_scatter_get_n_points(scatter);
    GwyScatter *extracted = gwy_scatter_new_sized(1, n);
    gwy_unit_assign(gwy_scatter_get_unit_xy(extracted), gwy_scatter_get_unit_xy(scatter));
    gwy_unit_assign(gwy_scatter_get_unit_data(extracted, 0), gwy_scatter_get_unit_data(scatter, i));
    gwy_assign(gwy_scatter_get_xy(extracted), gwy_scatter_get_xy_const(scatter), n);
    gwy_assign(gwy_scatter_get_data(extracted, 0), gwy_scatter_get_data_const(scatter, i), n);
    const gchar *label = gwy_scatter_get_data_label(scatter, i);
    if (label)
        gwy_scatter_set_data_label(extracted, 0, label);
    return extracted;
}

static gboolean
translate_xyz_item_3to2(GwyDict *dict, GQuark quark, GwyDict *workspace, const GwyFileKeyParsed *parsed,
                        GArray *expansions, GString *str)
{
    gint origid = parsed->id;
    IdExpansion idexp = { .id = origid, .n = 1, .expand_to = &origid };

    if (expansions) {
        for (guint i = 0; i < expansions->len; i++) {
            if (parsed->id == g_array_index(expansions, IdExpansion, i).id) {
                idexp = g_array_index(expansions, IdExpansion, i);
                // Pure XY data with no Z cannot be represented in v2. Omit any related item and consider it a success.
                if (!idexp.n)
                    return TRUE;
                break;
            }
        }
    }

    // Do the usual translation, but possibly replicating the item to all target ids. We assume that if the first
    // translations succeeds all the other will succeed too because there is no reason they should differ. The
    // multiple references are fine for everything except the main object because we are just going to serialise stuff
    // and forget it. However, the main object may need to be split physically to single-valued XYZ data.
    if (parsed->piece == GWY_FILE_PIECE_NONE && !parsed->suffix) {
        if (!translate_object(dict, quark, workspace, "/surface/%d", parsed->id, str))
            return FALSE;
        if (idexp.n == 1)
            return TRUE;
        // We moved the unsplit object at the first id, but if we need to split, it too must be replaced. Unlike all
        // the other replication cycles, start from 0 here.
        GwyScatter *scatter = gwy_dict_get_object_by_name(workspace, str->str);
        g_return_val_if_fail(GWY_IS_SCATTER(scatter), FALSE);
        for (gint j = 0; j < idexp.n; j++) {
            GwyScatter *singlevalued = extract_one_scatter_value(scatter, j);
            g_string_printf(str, "/surface/%d", idexp.expand_to[j]);
            gwy_dict_pass_object_by_name(workspace, str->str, singlevalued);
        }
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_PICTURE && !parsed->suffix) {
        // XXX: Odd because only one of all the previews is for the matching channel (which may not even be the first
        // one). Oh, well…
        if (!translate_object(dict, quark, workspace, "/surface/%d/preview", parsed->id, str))
            return FALSE;
        for (gint j = 1; j < idexp.n; j++)
            translate_object(dict, quark, workspace, "/surface/%d/preview", idexp.expand_to[j], str);
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_VISIBLE && !parsed->suffix) {
        if (!translate_boolean(dict, quark, workspace, "/surface/%d/visible", parsed->id, str))
            return FALSE;
        // XXX: Do we want to make *everything* visible when the multi-channel object was visible?
        for (gint j = 1; j < idexp.n; j++)
            translate_boolean(dict, quark, workspace, "/surface/%d/visible", idexp.expand_to[j], str);
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_PALETTE && !parsed->suffix) {
        if (!translate_string(dict, quark, workspace, "/surface/%d/preview/palette", parsed->id, str))
            return FALSE;
        for (gint j = 1; j < idexp.n; j++)
            translate_string(dict, quark, workspace, "/surface/%d/preview/palette", idexp.expand_to[j], str);
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_META && !parsed->suffix) {
        if (!translate_object(dict, quark, workspace, "/surface/%d/meta", parsed->id, str))
            return FALSE;
        for (gint j = 1; j < idexp.n; j++)
            translate_object(dict, quark, workspace, "/surface/%d/meta", idexp.expand_to[j], str);
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_LOG && !parsed->suffix) {
        if (!translate_object(dict, quark, workspace, "/surface/%d/log", parsed->id, str))
            return FALSE;
        for (gint j = 1; j < idexp.n; j++)
            translate_object(dict, quark, workspace, "/surface/%d/log", idexp.expand_to[j], str);
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_TITLE && !parsed->suffix) {
        // XXX: Unhelpful. Try to use value labels?
        if (!translate_string(dict, quark, workspace, "/surface/%d/title", parsed->id, str))
            return FALSE;
        for (gint j = 1; j < idexp.n; j++)
            translate_string(dict, quark, workspace, "/surface/%d/title", idexp.expand_to[j], str);
        return TRUE;
    }
    if (parsed->piece == GWY_FILE_PIECE_VIEW) {
        if (gwy_strequal(parsed->suffix, "relative-size")) {
            if (!translate_double(dict, quark, workspace, "/surface/%d/preview/view/relative-size", parsed->id, str))
                return FALSE;
            for (gint j = 1; j < idexp.n; j++) {
                translate_double(dict, quark, workspace,
                                 "/surface/%d/preview/view/relative-size", idexp.expand_to[j], str);
            }
            return TRUE;
        }
        if (gwy_strequal(parsed->suffix, "width")) {
            if (!translate_int(dict, quark, workspace, "/surface/%d/preview/view/width", parsed->id, str))
                return FALSE;
            for (gint j = 1; j < idexp.n; j++)
                translate_int(dict, quark, workspace, "/surface/%d/preview/view/width", idexp.expand_to[j], str);
            return TRUE;
        }
        if (gwy_strequal(parsed->suffix, "height")) {
            if (!translate_int(dict, quark, workspace, "/surface/%d/preview/view/height", parsed->id, str))
                return FALSE;
            for (gint j = 1; j < idexp.n; j++)
                translate_int(dict, quark, workspace, "/surface/%d/preview/view/height", idexp.expand_to[j], str);
            return TRUE;
        }
        return FALSE;
    }
    return FALSE;
}

static gboolean
translate_cmap_item_3to2(GwyDict *dict, GQuark quark, GwyDict *workspace, const GwyFileKeyParsed *parsed,
                         GString *str)
{
    if (parsed->piece == GWY_FILE_PIECE_NONE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/lawn/%d", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_PICTURE && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/lawn/%d/preview", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VISIBLE && !parsed->suffix)
        return translate_boolean(dict, quark, workspace, "/lawn/%d/visible", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_PALETTE && !parsed->suffix)
        return translate_string(dict, quark, workspace, "/lawn/%d/preview/palette", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_META && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/lawn/%d/meta", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_LOG && !parsed->suffix)
        return translate_object(dict, quark, workspace, "/lawn/%d/log", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_TITLE && !parsed->suffix)
        return translate_string(dict, quark, workspace, "/lawn/%d/title", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_REAL_SQUARE && !parsed->suffix)
        return translate_boolean(dict, quark, workspace, "/lawn/%d/preview/realsquare", parsed->id, str);
    if (parsed->piece == GWY_FILE_PIECE_VIEW) {
        if (gwy_strequal(parsed->suffix, "relative-size"))
            return translate_double(dict, quark, workspace, "/lawn/%d/preview/view/relative-size", parsed->id, str);
        if (gwy_strequal(parsed->suffix, "scale"))
            return translate_double(dict, quark, workspace, "/lawn/%d/preview/view/scale", parsed->id, str);
        return FALSE;
    }
    return FALSE;
}

static GArray*
analyse_xyz_3to2(GwyFile *file)
{
    gint *ids = gwy_file_get_ids(file, GWY_FILE_XYZ);
    gint nxyz = 0;
    while (ids[nxyz] >= 0)
        nxyz++;
    if (!nxyz) {
        g_free(ids);
        return NULL;
    }

    GArray *expansions = NULL;
    gint m = ids[nxyz-1];
    for (gint i = 0; i < nxyz; i++) {
        GwyScatter *scatter = gwy_file_get_xyz(file, ids[i]);
        gint nvalues = gwy_scatter_get_n_values(scatter);
        if (nvalues == 1)
            continue;

        if (!expansions)
            expansions = g_array_new(FALSE, FALSE, sizeof(IdExpansion));

        IdExpansion idexp = { .id = ids[i], .n = nvalues };
        if (nvalues) {
            idexp.expand_to = g_new(gint, nvalues);
            idexp.expand_to[0] = ids[i];
            for (gint j = 1; j < nvalues; j++)
                idexp.expand_to[j] = ++m;
        }
        else
            idexp.expand_to = NULL;

        g_array_append_val(expansions, idexp);
    }
    g_free(ids);

    return expansions;
}

static GwyDict*
translate_file_3to2(GwyFile *file)
{
    GwyDict *dict = GWY_DICT(file);
    GwyDict *workspace = gwy_dict_new_in_construction();
    GQuark *quarks = gwy_dict_keys(dict);
    GString *destkey = g_string_new(NULL);
    GHashTable *masks = g_hash_table_new(g_direct_hash, g_direct_equal);

    GArray *xyz_expansions = analyse_xyz_3to2(file);

    for (gsize i = 0; quarks[i]; i++) {
        const gchar *strkey = g_quark_to_string(quarks[i]);
        GwyFileKeyParsed parsed;
        if (!gwy_parse_file_key(strkey, &parsed)) {
            g_warning("Cannot parse key %s.", strkey);
            continue;
        }
        GwyDataKind data_kind = parsed.data_kind;

        gboolean handled = FALSE;
        if (data_kind == GWY_FILE_NONE) {
            if (parsed.piece == GWY_FILE_PIECE_FILENAME && !parsed.suffix)
                handled = translate_string(dict, quarks[i], workspace, "/filename", -1, NULL);
        }
        else if (data_kind == GWY_FILE_IMAGE)
            handled = translate_image_item_3to2(dict, quarks[i], workspace, &parsed, masks, destkey);
        else if (data_kind == GWY_FILE_GRAPH)
            handled = translate_graph_item_3to2(dict, quarks[i], workspace, &parsed, destkey);
        else if (data_kind == GWY_FILE_GRAPH)
            handled = translate_spectra_item_3to2(dict, quarks[i], workspace, &parsed, destkey);
        else if (data_kind == GWY_FILE_VOLUME)
            handled = translate_volume_item_3to2(dict, quarks[i], workspace, &parsed, destkey);
        else if (data_kind == GWY_FILE_XYZ)
            handled = translate_xyz_item_3to2(dict, quarks[i], workspace, &parsed, xyz_expansions, destkey);
        else if (data_kind == GWY_FILE_CMAP)
            handled = translate_cmap_item_3to2(dict, quarks[i], workspace, &parsed, destkey);

        if (!handled)
            g_warning("Unhandled key %s.", strkey);
    }

    if (xyz_expansions) {
        for (guint j = 0; j < xyz_expansions->len; j++)
            g_free(g_array_index(xyz_expansions, IdExpansion, j).expand_to);
        g_array_free(xyz_expansions, TRUE);
    }
    g_string_free(destkey, TRUE);
    g_free(quarks);

    g_hash_table_foreach(masks, translate_mask_3to2, workspace);
    g_hash_table_destroy(masks);

    return workspace;
}

static void
expand_rgba_to_components(GwySerializableGroup *group, gsize i,
                          const gchar *target_prefix, gboolean has_alpha)
{
    static const gchar *rgba_compnames[] = { "r", "g", "b", "a" };
    static const gchar *expanded_compnames[] = { "red", "green", "blue", "alpha" };
    GwySerializableItem *item = gwy_serializable_group_items(group, NULL) + i;

    g_return_if_fail(item->ctype == GWY_SERIALIZABLE_BOXED
                     && (item->flags & GWY_SERIALIZABLE_IS_GROUP)
                     && item->aux.boxed_type == GWY_TYPE_RGBA);

    gsize ncomp = 0;
    GwySerializableItem *components = gwy_serializable_group_items(item->value.v_group, &ncomp);

    GwySerializableItem expanded[4];
    gwy_clear(expanded, 4);
    for (guint j = 0; j < 4; j++) {
        gchar *s = g_strconcat(target_prefix, expanded_compnames[j], NULL);
        expanded[j].name = intern_key(s);
        g_free(s);
        expanded[j].ctype = GWY_SERIALIZABLE_DOUBLE;

        for (guint k = 0; k < ncomp; k++) {
            if (gwy_strequal(components[k].name, rgba_compnames[j])) {
                expanded[j].value.v_double = components[k].value.v_double;
                break;
            }
        }
    }

    // NB: This possibly reallocates items.
    gwy_serializable_group_append(group, expanded, has_alpha ? 4 : 3);
}

static void
translate_object_group_3to2(GwySerializableGroup *group, GPtrArray *tofree)
{
    const gchar *name = gwy_serializable_group_name(group);
    for (guint i = 0; i < G_N_ELEMENTS(object_rename_2to3); i++) {
        const NameTranslation *ntr = object_rename_2to3 + i;
        if (gwy_strequal(name, ntr->newname)) {
            gwy_serializable_group_rename(group, ntr->oldname);
            name = ntr->oldname;
            break;
        }
    }

    gsize nitems;
    GwySerializableItem *items = gwy_serializable_group_items(group, &nitems);
    GArray *toremove = NULL;
    GwySerializableItem *surface_xy = NULL, *surface_data = NULL;
    for (gsize i = 0; i < nitems; i++) {
        GwySerializableItem *item = items + i;

        for (guint j = 0; j < G_N_ELEMENTS(item_rename_2to3); j++) {
            const NameTranslation *ntr = item_rename_2to3 + j;
            if (gwy_strequal(item->name, ntr->newname)) {
                item->name = ntr->oldname;
                break;
            }
        }

        gboolean remove_item = FALSE;
        if (gwy_strequal(name, "GwyGraphCurveModel")) {
            for (guint j = 0; j < G_N_ELEMENTS(gcm_item_rename_2to3); j++) {
                const NameTranslation *ntr = gcm_item_rename_2to3 + j;
                if (gwy_strequal(item->name, ntr->newname)) {
                    item->name = ntr->oldname;
                    break;
                }
            }
            if (item->ctype == GWY_SERIALIZABLE_DOUBLE && gwy_stramong(item->name, "line_size", "point_size", NULL)) {
                gdouble v = item->value.v_double;
                item->ctype = GWY_SERIALIZABLE_INT32;
                item->value.v_int32 = GWY_ROUND(v);
            }
            else if (item->ctype == GWY_SERIALIZABLE_INT32 && gwy_strequal(item->name, "line_style"))
                item->value.v_int32 = CLAMP(item->value.v_int32, 0, 2);
            else if (item->ctype == GWY_SERIALIZABLE_BOXED
                     && item->aux.boxed_type == GWY_TYPE_RGBA
                     && gwy_strequal(item->name, "color")) {
                expand_rgba_to_components(group, i, "color.", FALSE);
                remove_item = TRUE;

                // Refresh items[] which could be reallocated.
                items = gwy_serializable_group_items(group, NULL);
                item = items + i;
            }
        }
        else if (gwy_strequal(name, "GwySelectionAxis")
                 && gwy_strequal(item->name, "orientation")
                 && item->ctype == GWY_SERIALIZABLE_INT32) {
            item->value.v_int32 = !item->value.v_int32;
        }
        else if (gwy_strequal(name, "GwySurface")) {
            if (gwy_strequal(item->name, "nvalues") && item->ctype == GWY_SERIALIZABLE_INT32)
                remove_item = TRUE;
            else if (gwy_strequal(item->name, "data_labels") && item->ctype == GWY_SERIALIZABLE_STRING_ARRAY)
                remove_item = TRUE;
            else if (gwy_strequal(item->name, "units_data") && item->ctype == GWY_SERIALIZABLE_OBJECT_ARRAY) {
                g_assert(item->array_size == 1);
                g_assert(item->flags & GWY_SERIALIZABLE_IS_GROUP);
                GwySerializableGroup *unit_group = item->value.v_group_array[0];
                item->name = "si_unit_z";
                g_free(item->value.v_group_array);
                item->value.v_group = unit_group;
                item->array_size = 0;
                item->ctype = GWY_SERIALIZABLE_OBJECT;
            }
            else if (gwy_strequal(item->name, "xy") && item->ctype == GWY_SERIALIZABLE_DOUBLE_ARRAY) {
                surface_xy = item;
                remove_item = TRUE;
            }
            else if (gwy_strequal(item->name, "data") && item->ctype == GWY_SERIALIZABLE_DOUBLE_ARRAY)
                surface_data = item;

            // Once we have both XY and Z, synthetise the XYZ data.
            if (surface_xy && surface_data) {
                g_assert(2*surface_data->array_size == surface_xy->array_size);
                gsize npoints = surface_data->array_size;
                const gdouble *xy = surface_xy->value.v_double_array;
                const gdouble *z = surface_data->value.v_double_array;
                gdouble *xyz = surface_data->value.v_double_array = g_new(gdouble, 3*npoints);
                for (gsize k = 0; k < npoints; k++) {
                    xyz[3*k + 0] = xy[2*k + 0];
                    xyz[3*k + 1] = xy[2*k + 1];
                    xyz[3*k + 2] = z[k];
                }
                surface_data->array_size = 3*npoints;
                // The synthetic xyz array is not owned by any object and done() will not free it. Need to keep track
                // separately.
                g_ptr_array_add(tofree, xyz);
                surface_xy = surface_data = NULL;
            }
        }

        if (remove_item) {
            // Silently discard old items.
            if (!toremove)
                toremove = g_array_new(FALSE, FALSE, sizeof(gsize));
            g_array_append_val(toremove, i);
            continue;
        }

        if (item->ctype == GWY_SERIALIZABLE_OBJECT)
            translate_object_group_3to2(item->value.v_group, tofree);
        else if (item->ctype == GWY_SERIALIZABLE_OBJECT_ARRAY) {
            for (gsize j = 0; j < item->array_size; j++)
                translate_object_group_3to2(item->value.v_group_array[j], tofree);
        }
    }

    if (toremove) {
        gwy_serializable_group_discard(group, &g_array_index(toremove, gsize, 0), toremove->len, FALSE);
        g_array_free(toremove, TRUE);
    }
}

static void
translate_toplevel_group_3to2(GwySerializableGroup *group, GPtrArray *tofree)
{
    gwy_serializable_group_rename(group, "GwyContainer");

    gsize nitems;
    GwySerializableItem *items = gwy_serializable_group_items(group, &nitems);
    for (gsize i = 0; i < nitems; i++) {
        GwySerializableItem *item = items + i;

        if (item->ctype == GWY_SERIALIZABLE_OBJECT) {
            translate_object_group_3to2(item->value.v_group, tofree);
            if (g_str_has_suffix(item->name, "/select/cross")) {
                // We want to change SelectionPoint to SelectionCross, but only for ‘cross’ selection in v2 files.
                GwySerializableGroup *itemgroup = item->value.v_group;
                if (gwy_strequal(gwy_serializable_group_name(itemgroup), "GwySelectionPoint"))
                    gwy_serializable_group_rename(itemgroup, "GwySelectionCross");
            }
        }
        else if (item->ctype == GWY_SERIALIZABLE_OBJECT_ARRAY) {
            for (gsize j = 0; j < item->array_size; j++)
                translate_object_group_3to2(item->value.v_group_array[j], tofree);
        }
    }
}

static gboolean
save_file2(GwyFile *data,
           const gchar *filename,
           G_GNUC_UNUSED GwyRunModeFlags mode,
           GError **error)
{
    SelfFilename sf;
    presage_filename(&sf, data, filename);

    GwyDict *workspace = translate_file_3to2(data);
    GwySerializableGroup *group = gwy_serializable_itemize(GWY_SERIALIZABLE(workspace));
    GPtrArray *tofree = g_ptr_array_new_full(0, g_free);
    translate_toplevel_group_3to2(group, tofree);

    GError *err = NULL;
    GFile *gfile = g_file_new_for_path(filename);
    GFileOutputStream *stream;

    gboolean ok = TRUE;
    if (!(stream = g_file_replace(gfile, NULL, FALSE, G_FILE_CREATE_NONE, NULL, &err))) {
        err_OPEN_WRITE_GERROR(error, &err);
        ok = FALSE;
    }
    if (ok) {
        g_output_stream_write(G_OUTPUT_STREAM(stream), MAGIC_COMMON, MAGIC_COMMON_SIZE, NULL, NULL);
        g_output_stream_write(G_OUTPUT_STREAM(stream), "P", 1, NULL, NULL);
        if (!gwy_serialize_group_gio(group, GWY_SERIALIZE_SIZE_32BIT, G_OUTPUT_STREAM(stream), &err)) {
            g_set_error(error, GWY_MODULE_FILE_ERROR, GWY_MODULE_FILE_ERROR_IO,
                        _("Cannot write to file: %s."), err->message);
            g_clear_error(&err);
            ok = FALSE;
        }
    }

    gwy_serializable_group_done(group);
    gwy_serializable_done(GWY_SERIALIZABLE(workspace));
    gwy_serializable_group_free(group, FALSE, NULL);
    g_object_unref(workspace);
    g_ptr_array_free(tofree, TRUE);

    g_clear_object(&stream);
    g_object_unref(gfile);
    clean_up_filename(&sf, data, ok);

    return ok;
}

static void
gather_one_meta(GQuark quark,
                        GValue *value,
                        GwyDict *meta,
                        const gchar *prefix,
                        guint prefix_len)
{
    const gchar *strkey;

    strkey = g_quark_to_string(quark);
    g_return_if_fail(g_str_has_prefix(strkey, prefix));
    strkey += prefix_len;

    if (strkey[0] != '/' || !strkey[1] || !G_VALUE_HOLDS_STRING(value))
        return;

    gwy_dict_pass_string_by_name(meta, strkey+1, g_value_dup_string(value));
}

static void
gather_meta(GQuark quark, GValue *gvalue, gpointer user_data)
{
    gather_one_meta(quark, gvalue, (GwyDict*)user_data, "/meta", sizeof("/meta")-1);
}

static void
gather_0_data_meta(GQuark quark, GValue *gvalue, gpointer user_data)
{
    gather_one_meta(quark, gvalue, (GwyDict*)user_data, "/0/data/meta", sizeof("/0/data/meta")-1);
}

/**
 * pack_metadata:
 * @data: A data container.
 *
 * Pack scattered metadata under "/meta" to a container at "/0/meta" and metadata scattered under "/0/data/meta" to
 * a container at "/0/meta".
 **/
static void
pack_metadata(GwyDict *dict)
{
    GwyDict *meta = gwy_dict_new();

    /* Mindlessly run one packing after another.  Losing some metadata is not a tragedy and the file has to be borken
     * in the first place to have metadata conflicts. */
    gwy_dict_foreach(dict, "/meta", &gather_meta, meta);
    gwy_dict_foreach(dict, "/0/data/meta", &gather_0_data_meta, meta);
    if (!gwy_dict_get_n_items(meta)) {
        g_object_unref(meta);
        return;
    }

    gwy_dict_remove_by_prefix(dict, "/meta");
    gwy_dict_remove_by_prefix(dict, "/0/data/meta");
    gwy_file_pass_meta(GWY_FILE(dict), GWY_FILE_IMAGE, 0, meta);
}

static gboolean
gather_v1_selection(GwyDict *dict, GString *prefix,
                    gdouble *coords,
                    ...)
{
    va_list ap;
    va_start(ap, coords);
    const gchar *suffix;
    guint len = prefix->len, i = 0;;
    while ((suffix = va_arg(ap, const gchar*))) {
        g_string_append(prefix, suffix);
        if (!gwy_dict_gis_double_by_name(dict, prefix->str, coords + i)) {
            va_end(ap);
            return FALSE;
        }
        g_string_truncate(prefix, len);
        i++;
    }

    va_end(ap);
    return TRUE;
}

static GwySelection*
gather_v1_rect_selection(GwyDict *dict)
{
    gboolean selected;
    if (!gwy_dict_gis_boolean_by_name(dict, "/0/select/rect/selected", &selected) || !selected)
        return NULL;

    GString *key = g_string_new("/0/select/rect/");
    gdouble xy[4];
    gboolean ok = gather_v1_selection(dict, key, xy, "x0", "y0", "x1", "y1", NULL);
    g_string_free(key, TRUE);
    GwySelection *sel = NULL;
    if (ok) {
        sel = gwy_selection_rectangle_new();
        gwy_selection_set_max_objects(sel, 1);
        gwy_selection_set_object(sel, 0, xy);
    }

    return sel;
}

static GwySelection*
gather_v1_point_selection(GwyDict *dict)
{
    gint nselected;
    if (!gwy_dict_gis_int32_by_name(dict, "/0/select/points/nselected", &nselected))
        return NULL;

    nselected = CLAMP(nselected, 0, 16);
    if (!nselected)
        return NULL;

    GString *key = g_string_new(NULL);
    GwySelection *sel = gwy_selection_point_new();
    gwy_selection_set_max_objects(sel, nselected);
    for (gint i = 0; i < nselected; i++) {
        gdouble xy[2];
        g_string_printf(key, "/0/select/points/%d/", i);
        if (gather_v1_selection(dict, key, xy, "x", "y", NULL))
            gwy_selection_set_object(sel, -1, xy);
    }
    g_string_free(key, TRUE);

    if (!gwy_selection_get_n_objects(sel))
        g_clear_object(&sel);

    return sel;
}

static GwySelection*
gather_v1_line_selection(GwyDict *dict)
{
    gint nselected;
    if (!gwy_dict_gis_int32_by_name(dict, "/0/select/lines/nselected", &nselected))
        return NULL;

    nselected = CLAMP(nselected, 0, 16);
    if (!nselected)
        return NULL;

    GString *key = g_string_new(NULL);
    GwySelection *sel = gwy_selection_line_new();
    gwy_selection_set_max_objects(sel, nselected);
    for (gint i = 0; i < nselected; i++) {
        gdouble xy[4];
        g_string_printf(key, "/0/select/lines/%d/", i);
        if (gather_v1_selection(dict, key, xy, "x0", "y0", "x1", "y1", NULL))
            gwy_selection_set_object(sel, -1, xy);
    }
    g_string_free(key, TRUE);

    if (!gwy_selection_get_n_objects(sel))
        g_clear_object(&sel);

    return sel;
}

static void
remove_v1_cruft(GwyDict *dict)
{
    /* Selections */
    GwySelection *rect = gather_v1_rect_selection(dict);
    GwySelection *point = gather_v1_point_selection(dict);
    GwySelection *line = gather_v1_line_selection(dict);

    gwy_dict_remove_by_prefix(dict, "/0/select");
    if (rect)
        gwy_dict_pass_object(dict, gwy_file_key_image_selection(0, "rectangle"), rect);
    if (point)
        gwy_dict_pass_object(dict, gwy_file_key_image_selection(0, "point"), point);
    if (line)
        gwy_dict_pass_object(dict, gwy_file_key_image_selection(0, "line"), line);

    /* 3D */
    gwy_dict_remove_by_prefix(dict, "/0/3d/labels");
    gwy_dict_remove_by_name(dict, "/0/3d/rot_x");
    gwy_dict_remove_by_name(dict, "/0/3d/rot_y");
    gwy_dict_remove_by_name(dict, "/0/3d/view_scale");
    gwy_dict_remove_by_name(dict, "/0/3d/deformation_z");
    gwy_dict_remove_by_name(dict, "/0/3d/light_z");
    gwy_dict_remove_by_name(dict, "/0/3d/light_y");
}

static inline gboolean
unpack_boolean(const guchar *buffer,
               gsize size,
               gsize *position,
               gboolean *value)
{
    gwy_debug("buf = %p, size = %" G_GSIZE_FORMAT ", pos = %" G_GSIZE_FORMAT, buffer, size, *position);
    g_return_val_if_fail(buffer, FALSE);
    g_return_val_if_fail(position, FALSE);
    if (*position + sizeof(gchar) > size)
        return FALSE;
    *value = !!buffer[*position];
    *position += sizeof(gchar);

    gwy_debug("value = <%s>", *value ? "TRUE" : "FALSE");
    return TRUE;
}

static inline gboolean
unpack_uchar(const guchar *buffer,
             gsize size,
             gsize *position,
             guchar *value)
{
    gwy_debug("buf = %p, size = %" G_GSIZE_FORMAT ", pos = %" G_GSIZE_FORMAT, buffer, size, *position);
    g_return_val_if_fail(buffer, FALSE);
    g_return_val_if_fail(position, FALSE);
    if (*position + sizeof(guchar) > size)
        return FALSE;
    *value = buffer[*position];
    *position += sizeof(guchar);

    gwy_debug("value = <%c>", *value);
    return TRUE;
}

static inline gboolean
unpack_size(const guchar *buffer,
            gsize size,
            gsize *position,
            gsize *value)
{
    gwy_debug("buf = %p, size = %" G_GSIZE_FORMAT ", pos = %" G_GSIZE_FORMAT, buffer, size, *position);
    g_return_val_if_fail(buffer && position && value, FALSE);
    if (*position + sizeof(guint32) > size)
        return FALSE;

    guint32 sz32;
    memcpy(&sz32, buffer + *position, sizeof(guint32));
    *value = GUINT32_FROM_LE(sz32);
    *position += sizeof(guint32);

    gwy_debug("value = <%zu>", *value);
    return TRUE;
}

static inline gboolean
unpack_int32(const guchar *buffer,
             gsize size,
             gsize *position,
             gint32 *value)
{
    gwy_debug("buf = %p, size = %" G_GSIZE_FORMAT ", pos = %" G_GSIZE_FORMAT, buffer, size, *position);
    g_return_val_if_fail(buffer && position && value, FALSE);
    if (*position + sizeof(gint32) > size)
        return FALSE;
    memcpy(value, buffer + *position, sizeof(gint32));
    *value = GINT32_FROM_LE(*value);
    *position += sizeof(gint32);

    gwy_debug("value = <%d>", *value);
    return TRUE;
}

static inline gboolean
unpack_int64(const guchar *buffer,
             gsize size,
             gsize *position,
             gint64 *value)
{
    gwy_debug("buf = %p, size = %" G_GSIZE_FORMAT ", pos = %" G_GSIZE_FORMAT, buffer, size, *position);
    g_return_val_if_fail(buffer && position && value, FALSE);
    if (*position + sizeof(gint64) > size)
        return FALSE;
    memcpy(value, buffer + *position, sizeof(gint64));
    *value = GINT64_FROM_LE(*value);
    *position += sizeof(gint64);

    gwy_debug("value = <%" G_GINT64_FORMAT ">", *value);
    return TRUE;
}

static inline gboolean
unpack_double(const guchar *buffer,
              gsize size,
              gsize *position,
              gdouble *value)
{
    gwy_debug("buf = %p, size = %" G_GSIZE_FORMAT ", pos = %" G_GSIZE_FORMAT, buffer, size, *position);
    g_return_val_if_fail(buffer && position && value, FALSE);
    if (*position + sizeof(gdouble) > size)
        return FALSE;
#if (G_BYTE_ORDER == G_LITTLE_ENDIAN)
    memcpy(value, buffer + *position, sizeof(gdouble));
#else
    gwy_memcpy_byte_swap(buffer + *position, (guint8*)value, sizeof(gdouble), 1, sizeof(gdouble) - 1);
#endif
    *position += sizeof(gdouble);

    gwy_debug("value = <%g>", *value);
    return TRUE;
}

static inline gboolean
unpack_string(const guchar *buffer,
              gsize size,
              gsize *position,
              gchar **value)
{
    gwy_debug("buf = %p, size = %" G_GSIZE_FORMAT ", pos = %" G_GSIZE_FORMAT, buffer, size, *position);
    g_return_val_if_fail(buffer && position && value, FALSE);
    if (*position >= size)
        return FALSE;

    const guchar *start = buffer + *position;
    const guchar *p = memchr(start, 0, size - *position);
    if (!p)
        return FALSE;
    gsize len_with_0 = p - start + 1;
    *value = g_memdup2(start, len_with_0);
    *position += len_with_0;

    gwy_debug("value = <%s>", *value);
    return TRUE;
}

static inline gsize
check_string(const guchar *buffer,
             gsize size,
             gsize position,
             const guchar *compare_to)
{
    gwy_debug("<%s> buf = %p, size = %" G_GSIZE_FORMAT ", pos = %"
              G_GSIZE_FORMAT,
              compare_to ? compare_to : (const guchar*)"(null)",
              buffer, size, position);
    g_return_val_if_fail(buffer, 0);
    if (!size || position >= size)
        return 0;

    const guchar *start = buffer + position;
    const guchar *p = (guchar*)memchr(start, 0, size - position);
    if (!p || (compare_to && !gwy_strequal(start, compare_to)))
        return 0;

    return (p - start) + 1;
}

// For mysterious reasons, v1 file GwyContainer does not have the standard (ctype, name, data) serialisation.
// Preprocess it to the GwySerializableGroup intermediate representation in order to mostly handle it like a v2 file.
static GwySerializableGroup*
deserialize_v1_toplevel(const guchar *buffer,
                        gsize size,
                        gsize *position)
{
    g_return_val_if_fail(buffer, NULL);

    gsize pos = check_string(buffer, size, *position, "GwyContainer");
    if (!pos)
        return NULL;
    *position += pos;

    gsize mysize;
    if (!unpack_size(buffer, size, position, &mysize))
        return NULL;
    const guchar *buf = buffer + *position;
    pos = 0;

    GwySerializableGroup *toplevel = gwy_serializable_group_new("GwyContainer", 32);
    while (pos < mysize) {
        gsize type;
        gchar *name;
        if (!unpack_size(buf, mysize, &pos, &type) || !unpack_string(buf, mysize, &pos, &name))
            goto fail;
        gwy_debug("deserializing %s => %s", name, g_type_name(type));

        GwySerializableItem item;
        gwy_clear1(item);
        item.name = intern_key(name);
        g_free(name);

        if (type == G_TYPE_OBJECT) {
            GwyErrorList *errlist = NULL;
            // NB: Here we do not expect to deserialize everything, so GWY_DESERIALIZE_ERROR_PADDING is not an error.
            GwySerializableGroup *group = gwy_deserialize_group_memory(buf + pos, mysize - pos,
                                                                       GWY_SERIALIZE_SIZE_32BIT, &errlist);
            for (GwyErrorList *l = errlist; l; l = g_slist_next(l)) {
                GError *err = (GError*)l->data;
                if (err->code != GWY_DESERIALIZE_ERROR_PADDING)
                    g_warning("%s", err->message);
            }
            gwy_error_list_clear(&errlist);
            // We clearly got out of sync. Just give up.
            if (!group)
                goto fail;

            pos += gwy_serializable_group_size(group);
            item.ctype = GWY_SERIALIZABLE_OBJECT;
            item.flags |= GWY_SERIALIZABLE_IS_GROUP;
            item.value.v_group = group;
        }
        else if (type == G_TYPE_BOOLEAN) {
            if (!unpack_boolean(buf, mysize, &pos, &item.value.v_boolean))
                goto fail;
            item.ctype = GWY_SERIALIZABLE_BOOLEAN;
        }
        else if (type == G_TYPE_UCHAR) {
            if (!unpack_uchar(buf, mysize, &pos, &item.value.v_uint8))
                goto fail;
            item.ctype = GWY_SERIALIZABLE_INT8;
        }
        else if (type == G_TYPE_INT) {
            if (!unpack_int32(buf, mysize, &pos, &item.value.v_int32))
                goto fail;
            item.ctype = GWY_SERIALIZABLE_INT32;
        }
        else if (type == G_TYPE_INT64) {
            if (!unpack_int64(buf, mysize, &pos, &item.value.v_int64))
                goto fail;
            item.ctype = GWY_SERIALIZABLE_INT64;
        }
        else if (type == G_TYPE_DOUBLE) {
            if (!unpack_double(buf, mysize, &pos, &item.value.v_double))
                goto fail;
            item.ctype = GWY_SERIALIZABLE_DOUBLE;
        }
        else if (type == G_TYPE_STRING) {
            if (!unpack_string(buf, mysize, &pos, &item.value.v_string))
                goto fail;
            item.ctype = GWY_SERIALIZABLE_STRING;
        }
        else {
            g_warning("Invalid type #%lu in top-level GwyContainer.", (gulong)type);
            goto fail;
        }
        gwy_serializable_group_append(toplevel, &item, 1);
    }
    *position += mysize;

    return toplevel;

fail:
    gwy_serializable_group_free(toplevel, TRUE, NULL);
    return NULL;
}

/* vim: set cin columns=120 tw=118 et ts=4 sw=4 cino=>1s,e0,n0,f0,{0,}0,^0,\:1s,=0,g1s,h0,t0,+1s,c3,(0,u0 : */
