lucidiot's cybrecluster

Well-Known Binary standard avec Kaitai Struct, seconde partie

Lucidiot Informatique 2022-01-30
Je n'ai jamais dit qu'on ne se contenterait que de YAML…


On a précédemment bien entamé l'écriture d'une spécification pour le Well-Known Binary « standard » avec Kaitai Struct. Sur les sept types à prendre en charge, on supporte pour l'instant les points, lignes brisées et polygones, et pas les quatre autres. Les quatres types restants sont appelés des entités complexes.

Les entités complexes, dans le jargon géospatial, sont les entités qui contiennent d'autres entités. Dans le WKB standard, ce sont les ensembles de points (multi_point), ensembles de lignes brisées (multi_line_string), ensembles de polygones (multi_polygon) et les collections de géométries (geometry_collection).

Ces ensembles sont définis de façon assez différente des listes de points ou listes de lignes brisées qu'on a vu précédemment pour les lignes brisées et les polygones : au lieu de seulement contenir des coordonnées supplémentaires, les éléments de la liste sont des géométries à part entière. Autrement dit, on répète le boutisme et on répète le type de géométrie, à chaque élément de liste.

Voici par exemple un ensemble de points qu'on définirait en WKT par MULTIPOINT (1 2, 3 4) :

01 04 00 00 00 02 00 00 00 Point WKB 1 Point WKB 2 Boutisme Type Nombre de points Points Point 1 : 01 01 00 00 00 00 00 00 00 00 00 F0 3F 00 00 00 00 00 00 00 40 Boutisme Type X Y Point 2 : 01 01 00 00 00 00 00 00 00 00 00 08 40 00 00 00 00 00 00 10 40 Boutisme Type X Y Coordonné e Coordonné e Coordonné e Coordonné e

On a trois boutismes, qui peuvent être différents, et deux types : le type 4 correspond à l'ensemble de points et le type 1 correspond à un seul point. En fait, les ensembles sont tous définis comme des versions plus strictes de la collection de géométries, qui peut contenir n'importe quel type de géométrie en même temps. Les ensembles de points, lignes brisées et polygones ne peuvent contenir qu'un seul type de géométrie mais sont définis de la même manière.

On peut aussi observer comment est formatée une collection de géométries vide, c'est-à-dire une GEOMETRYCOLLECTION EMPTY :

01 07 00 00 00 00 00 00 00 Boutisme Type Nombre de omé tries

Ce n'est rien de plus qu'une collection avec zéro géométries dedans. Cela explique aussi pourquoi un point seul ne peut jamais être EMPTY en WKT, mais une ligne brisée, un anneau linéaire dans un polygone, ou un polygone entier peuvent l'être : il n'y a pas de moyen d'indiquer en WKB qu'un point peut être vide, mais on pourrait déclarer une ligne à zéro points ou un polygone à zéro anneaux.

Gérer les collections de géométries

Pour pouvoir gérer les entités complexes, il va d'abord falloir envelopper toute notre séquence principale dans un nouveau type générique, geometry :

seq:
  - id: geometry
    type: geometry
types:
  geometry:
    seq:
      - id: endianness
        type: u1
      - id: body
        type: geometry_body
  geometry_body:
    seq:
      - id: type
        type: u4
        enum: geometry_type
      - id: data
        type:
          switch-on: type
          cases: ...

On va pouvoir profiter de ce nouveau type pour vite gérer les collections de géométries. On ajoute au cases une nouvelle entrée pour les collections :

cases:
  geometry_type::point: point
  geometry_type::line_string: line_string
  geometry_type::polygon: polygon
  geometry_type::geometry_collection: geometry_collection

Et on peut déclarer un nouveau type pour la collection, qui contiendra un nombre de géométries suivi desdites géométries :

types:
  # ...
  geometry_collection:
    seq:
      - id: count
        type: u4
      - id: geometries
        type: geometry
        repeat: expr
        repeat-expr: count

On gère ainsi immédiatement des collections de n'importe quelle géométrie, y compris des collections de collections de géométries, ou si on est assez fou, des collections de collections de collections de collections de collections de … vous avez compris.

Gérer les ensembles

Pour gérer les ensembles de géométries spécifiques, le principe est similaire que pour les collections. Cependant, nous allons devoir restreindre le type de géométrie autorisé. Il n'y a pas vraiment de moyen élégant de faire ça avec Kaitai, le plus simple sera de définir un nouveau type qui représente des géométries ne pouvant avoir comme type que le point par exemple.

types:
  # ...
  wkb_point:
    seq:
      - id: endianness
        type: u1
      - id: body
        type: wkb_point_body
  wkb_point_body:
    seq:
      - id: type
        type: u4
        enum: geometry_type
      - id: data
        type: point
    meta:
      endian:
        switch-on: _parent.endianness
        cases:
          0: be
          1: le
  multi_point:
    seq:
      - id: count
        type: u4
      - id: points
        type: wkb_point
        repeat: expr
        repeat-expr: count

Je vous laisse imaginer une répétition de ce code pour le multi_line_string et le multi_polygon.

Métamétaprogrammation

La répétition de code est généralement peu recommendable, et si vous avez lu mes précédents articles sur le Well-Known Text, vous savez probablement que j'aime éviter la duplication du code par des méthodes farfelues qui me permettent ensuite de faire des choses encore plus complexes. Une fois le traitement de cette variante du WKB terminé, il y en aura encore d'autres, donc on pourra probablement construire des choses par dessus ce genre de métaprogrammation. Mais dans le contexte actuel, ce n'est pas seulement de la métaprogrammation : Kaitai Struct génère du code, donc le fichier YAML est déjà de la métaprogrammation. Nous faisons donc de la métamétaprogrammation, et bien entendu il y a un XKCD pertinent.

Pour accomplir cette mission, j'avais initialement envisagé de beaucoup souffrir et d'utiliser m4, qu'on a déjà vu auparavant. Ce serait techniquement possible, puisque le YAML est un format texte, mais la gestion de l'indentation serait assez infernale. J'ai donc envisagé un autre langage que j'ai déjà utilisé précédemment pour travailler avec du XML et du JSON, jq. J'ai déjà mentionné cet interpréteur dans des articles précédents ; il est capable d'effectuer des opérations de façon assez performante sur du JSON, un format qui n'est que très rarement performant. Mais si jq génère du JSON, comment va-t-on obtenir du YAML ?

Le format YAML est en fait un sur-ensemble de JSON, c'est-à-dire qu'un fichier JSON est déjà un fichier YAML valide. La question ne se poserait donc même pas. Cependant, on peut si on le souhaite utiliser oq, un programme qui enrobe jq pour y rajouter le support des formats XML et YAML, pour obtenir un fichier YAML plus propre et qui ne ressemble plus à du JSON.

Avec ce nouveau fichier, on pourra donc exécuter soit jq soit oq pour obtenir notre schéma Kaitai Struct :

jq --null-input -f wkb.jq > wkb.ksy
oq --null-input -f wkb.jq -o simpleyaml > wkb.ksy

Définitions de base

On peut commencer notre nouveau script jq avec les définitions assez simples qui ne nécessitent pas de traitement particulier ; les métadonnées, les références à la spécification et l'énumération des types de géométrie. On peut aussi tout de suite inclure notre séquence principale qui contient juste le type geometry.

{
  meta: {
    id: "wkb_std",
    title: "Standard Well-Known Binary",
    license: "AGPL-3.0-or-later",
    xref: {
      iso: "19125-1:2004"
    }
  },
  "doc-ref": [
    "https://www.ogc.org/standards/sfa",
    "http://libgeos.org/specifications/wkb/"
  ],
  seq: [
    {
      id: "geometry",
      type: "geometry"
    }
  ],
  enums: {
    geometry_type: {
      "0": "geometry",
      "1": "point",
      "2": "line_string",
      "3": "polygon",
      "4": "multi_point",
      "5": "multi_line_string",
      "6": "multi_polygon",
      "7": "geometry_collection"
    }
  }
}

Redéfinir les entités simples

On peut commencer par directement définir le point, le type de base :

{
  types: {
    point: {
      seq: [
        {
          id: "x",
          type: "f8"
        },
        {
          id: "y",
          type: "f8"
        }
      ]
    }
  }
}

Pour la ligne brisée et le polygone, on remarquera que les deux utilisent le même système pour gérer la liste d'éléments qu'ils contiennent : un nombre d'éléments, suivi de tous les éléments. On peut donc définir une fonction pour factoriser cette fonctionnalité :

def counted: {
  seq: [
    {
      id: "count",
      type: "u4"
    },
    . + {
      repeat: "expr",
      "repeat-expr": "count"
    }
  ]
};

Cette fonction sera à placer au tout début du fichier, puisqu'elle doit être définie avant qu'on ne construise le document final. On donnera à cette fonction l'ID et le type de l'attribut qui est censé être répété, et elle générera un type entier qui commencera par un nombre d'éléments et répètera l'attribut qu'on a fourni :

{
  types: {
    line_string: ({ id: "points", type: "point" } | counted),
    polygon: ({ id: "rings", type: "line_string" } | counted)
  }
}

Redéfinir les enfants des entités complexes

Les enfants des entités complexes sont comme des géométries, avec leur boutisme et leur type. Je n'avais pas encore défini le type geometry parce que je comptais factoriser les quatre définitions à l'aide d'une seule fonction. La seule différence entre ces quatre définitions sont le contenu qui suit le boutisme et le type. Avec la géométrie, qu'elle soit à la racine du fichier ou dans une collection de géométries, on pourra avoir n'importe quel type. Avec les ensembles de points, lignes brisées ou polygones, on n'aura que des points, lignes brisées ou polygones. Le seul paramètre de notre fonction est donc le type du corps de l'entité.

def wkb_seq: [
  {
    id: "type",
    type: "u4",
    enum: "geometry_type"
  },
  {
    id: "data",
    type: .
  }
];

Cette fonction construit une séquence pour n'importe quel type. On ne va cette fois-ci pas générer un type entier, car la géométrie a besoin de plus que juste une séquence et ce sera donc plus simple à gérer comme ça. On peut utiliser cette fonction, en combinaison avec la précédente, pour définir les enfants des entités complexes :

{
  types: {
    wkb_point: { seq: ("point" | wkb_seq) }
    wkb_line_string: { seq: ("line_string" | wkb_seq) }
    wkb_polygon: { seq: ("polygon" | wkb_seq) }
  }
}

Redéfinir les entités complexes

Les entités complexes vont contenir une liste des géométries que nous venons de redéfinir, donc on peut déjà utiliser counted pour les définir :

{
  types: {
    multi_point: ({ id: "points", type: "wkb_point" } | counted),
    multi_line_string: ({ id: "line_strings", type: "wkb_line_string" } | counted),
    multi_polygon: ({ id: "polygons", type: "wkb_polygon" } | counted),
    geometry_collection: ({ id: "geometries", type: "geometry" } | counted)
  }
}

Il ne nous reste plus qu'à gérer le boutisme pour pouvoir les définir entièrement, et aussi définir la géométrie elle-même. Pour gérer le boutisme, on avait à chaque fois entouré le type par un autre type qui contient la valeur de l'attribut de boutisme, et permet au type d'origine de voir son boutisme être influencé par cette valeur. On peut donc avoir une fonction qui reçoit un type entier, l'entoure par l'attribut endianness, et modifie le type pour influencer son boutisme :

def endianize: {
  seq: [
    {
      id: "endianness",
      type: "u1"
    },
    {
      id: "body",
      type: "body"
    }
  ],
  types: {
    "body": (. + {
      meta: {
        endian: {
          "switch-on": "_parent.endianness",
          cases: {
            "0": "be",
            "1": "le"
          }
        }
      }
    })
  }
};

On peut utiliser ça pour redéfinir les enfants des entités complexes pour qu'ils gèrent le boutisme :

{
  types: {
    wkb_point: ({ seq: ("point" | wkb_seq) } | endianize),
    wkb_line_string: ({ seq: ("line_string" | wkb_seq) } | endianize),
    wkb_polygon: ({ seq: ("polygon" | wkb_seq) } | endianize)
  }
}

Redéfinir la géométrie

On peut désormais encapsuler tous ces types dans un seul type geometry. Ce type utilisera endianize pour le boutisme, et utilisera wkb_seq pour construire sa séquence ; le type ne sera cette fois pas seulement une chaîne de caractères, mais le block switch-on que l'on a utilisé pour relier l'enumération aux différents types qu'on a défini.

{
  geometry: ({
    seq: ({
      "switch-on": "type",
      cases: {
        "geometry_type::point": "point",
        "geometry_type::line_string": "line_string",
        "geometry_type::polygon": "polygon",
        "geometry_type::multi_point": "multi_point",
        "geometry_type::multi_line_string": "multi_line_string",
        "geometry_type::multi_polygon": "multi_polygon",
        "geometry_type::geometry_collection": "geometry_collection"
      }
    } | wkb_seq),
    types: {
      # Tous les types définis précédemment
    }
  } | endianize)
}

Conclusion

Nous avons maintenant pu gérer tous les types d'entités du Well-Known Binary standard avec Kaitai Struct, et avons factorisé le schéma de sorte à le rendre parfaitement illisible mais surtout à le rendre plus extensible pour gérer les deux prochaines variantes. Voici le script final :

Script jq générant un schéma Kaitai Struct pour le Well-Known Binary standard

#!/usr/bin/jq
# Generates a Kaitai Struct-compatible JSON structure to parse
# the standard Well-Known Binary.
# Usage: oq -n -o simpleyaml -f wkb.jq  wkb.ksy

def endianize: {
  seq: [
    {
      id: "endianness",
      type: "u1"
    },
    {
      id: "body",
      type: "body"
    }
  ],
  types: {
    "body": (. + {
      meta: {
        endian: {
          "switch-on": "_parent.endianness",
          cases: {
            "0": "be",
            "1": "le"
          }
        }
      }
    })
  }
};

def counted: {
  seq: [
    {
      id: "count",
      type: "u4"
    },
    . + {
      repeat: "expr",
      "repeat-expr": "count"
    }
  ]
};

def wkb_seq: [
  {
    id: "type",
    type: "u4",
    enum: "geometry_type"
  },
  {
    id: "data",
    type: .
  }
];

{
  meta: {
    id: "wkb_std",
    title: "Standard Well-Known Binary",
    license: "AGPL-3.0-or-later",
    xref: {
      iso: "19125-1:2004"
    }
  },
  "doc-ref": [
    "https://www.ogc.org/standards/sfa",
    "http://libgeos.org/specifications/wkb/"
  ],
  seq: [
    {
      id: "geometry",
      type: "geometry"
    }
  ],
  enums: {
    geometry_type: {
      "0": "geometry",
      "1": "point",
      "2": "line_string",
      "3": "polygon",
      "4": "multi_point",
      "5": "multi_line_string",
      "6": "multi_polygon",
      "7": "geometry_collection"
    }
  },
  types: {
    geometry: ({
      seq: ({
        "switch-on": "type",
        cases: {
          "geometry_type::point": "point",
          "geometry_type::line_string": "line_string",
          "geometry_type::polygon": "polygon",
          "geometry_type::multi_point": "multi_point",
          "geometry_type::multi_line_string": "multi_line_string",
          "geometry_type::multi_polygon": "multi_polygon",
          "geometry_type::geometry_collection": "geometry_collection"
        }
      } | wkb_seq),
      types: {
        point: {
          seq: [
            {
              id: "x",
              type: "f8"
            },
            {
              id: "y",
              type: "f8"
            }
          ]
        },
        line_string: ({ id: "points", type: "point" } | counted),
        polygon: ({ id: "rings", type: "line_string" } | counted),
        wkb_point: ({ seq: ("point" | wkb_seq) } | endianize),
        wkb_line_string: ({ seq: ("line_string" | wkb_seq) } | endianize),
        wkb_polygon: ({ seq: ("polygon" | wkb_seq) } | endianize),
        multi_point: ({ id: "points", type: "wkb_point" } | counted),
        multi_line_string: ({ id: "line_strings", type: "wkb_line_string" } | counted),
        multi_polygon: ({ id: "polygons", type: "wkb_polygon" } | counted),
        geometry_collection: ({ id: "geometries", type: "geometry" } | counted)
      }
    } | endianize)
  }
}

La prochaine fois, nous affronterons le WKB ISO, qui introduira la gestion de dimensions multiples ainsi que de nouveaux types de géométries. On va s'amuser à rajouter encore plus de fonctions, de parenthèses et d'accolades…


Commentaires

Il n'y a pour l'instant aucun commentaire. Soyez le premier !