Manipuler des données géographiques avec GeoTools

Dans le monde des Systèmes d'Informations Géographique (SIG), il est assez courant de devoir manipuler des données au format Shapefile (ensemble de fichiers standardisés pour la représentation de cartographie) afin de présenter différents types d'informations.

Pour ce faire, le plus simple est encore d'utiliser comme intermédiaire une base Postgre dédiée, autrement nommée PostGIS, spécifique au traitement de ce type de données.

La solution la plus courante est d'utiliser les outils en ligne de commande shp2pgsql ou ogr2ogr qui permettent de créer un fichier SQL à partir des Shapefile et éventuellement de le jouer directement en base. Cependant, le but de ce billet est de présenter comment il est possible en Java d'extraire des informations et/ou ré-organiser un lot de fichiers Shapefile. L'utilisation d'une base de données intermédiaire a pour vocation de résoudre des problèmes de performance liés à la manipulation directe des fichiers.

Voici un exemple de code à utiliser :

public class GeoToPostGISClient {

 private static final String POSTGIS_TABLENAME = "MY_TABLE";

 private static GeoProperties props = GeoProperties.getInstance();

 private static ShapefileDataStoreFactory shpFactory = new ShapefileDataStoreFactory();

 private static FeatureTypeFactoryImpl factory = new FeatureTypeFactoryImpl();

 private static JDBCDataStore pgStore;

 private SimpleFeatureType schema;

 public GeoToPostGISClient() throws IOException {
  // Ouvrir une connexion vers la base PostGIS
  if (pgStore == null) {
   PostgisNGDataStoreFactory pgFactory = new PostgisNGDataStoreFactory();
   Map<String, String> jdbcparams = new HashMap<String, String>();
   jdbcparams.put(PostgisNGDataStoreFactory.DBTYPE.key, "postgis");
   jdbcparams.put(PostgisNGDataStoreFactory.HOST.key, props.getProperty(GeoProperties.DB_HOST));
   jdbcparams.put(PostgisNGDataStoreFactory.PORT.key, props.getProperty(GeoProperties.DB_PORT));
   jdbcparams.put(PostgisNGDataStoreFactory.SCHEMA.key, props.getProperty(GeoProperties.DB_SCHEMA));
   jdbcparams.put(PostgisNGDataStoreFactory.DATABASE.key, props.getProperty(GeoProperties.DB_NAME));
   jdbcparams.put(PostgisNGDataStoreFactory.USER.key, props.getProperty(GeoProperties.DB_USER));
   jdbcparams.put(PostgisNGDataStoreFactory.PASSWD.key, props.getProperty(GeoProperties.DB_PWD));
   pgStore = pgFactory.createDataStore(jdbcparams);
  }
 }

 /**
  * Insert all specified shapefiles in Postgre
  * 
  * @param shapefilePaths files
  * @throws IOException all
  */
 public void insertShpIntoDb(List<String> shapefilePaths) throws IOException {
  Iterator<String> iterator = shapefilePaths.iterator();
  String path = null;
  while (iterator.hasNext()) {
   path = iterator.next();

   Map<String, Object> shpparams = new HashMap<String, Object>();
   shpparams.put("url", "file://" + path);
   // create indexes only for last file (performance issue)
   FileDataStore shpStore = (FileDataStore) shpFactory.createDataStore(shpparams);
   SimpleFeatureCollection features = shpStore.getFeatureSource().getFeatures();

   if (schema == null) {
    // Copy schema and change name in order to refer to the same
    // global schema for all files
    SimpleFeatureType originalSchema = shpStore.getSchema();
    Name originalName = originalSchema.getName();
    NameImpl theName = new NameImpl(originalName.getNamespaceURI(), originalName.getSeparator(), POSTGIS_TABLENAME);
    schema = factory.createSimpleFeatureType(theName, originalSchema.getAttributeDescriptors(), originalSchema.getGeometryDescriptor(),
      originalSchema.isAbstract(), originalSchema.getRestrictions(), originalSchema.getSuper(), originalSchema.getDescription());
    pgStore.createSchema(schema);
   }
   SimpleFeatureStore featureStore = (SimpleFeatureStore) pgStore.getFeatureSource(POSTGIS_TABLENAME);

   // Ajout des objets du shapefile dans la table PostGIS
   DefaultTransaction transaction = new DefaultTransaction("create");
   featureStore.setTransaction(transaction);
   try {
    featureStore.addFeatures(features);
    transaction.commit();
   } catch (Exception problem) {
    LOGGER.error(problem.getMessage(), problem);
    transaction.rollback();
   } finally {
    transaction.close();
   }
   shpStore.dispose();
  }
  extractFromDb();
 }

 /**
  * Extracts local data from postgis DB
  * 
  * @throws IOException all
  */
 public void extractFromDb() throws IOException {
  // Faire une requête spatiale dans la base
  ContentFeatureCollection filteredFeatures = null;

  String destFolder = "/shp/";

  for (Object dep : ReferentielDepartement.getDepartements()) {
   try {
    filteredFeatures = pgStore.getFeatureSource(POSTGIS_TABLENAME).getFeatures(CQL.toFilter("DPT_NUM = '" + dep + "'"));
   } catch (CQLException e) {
    LOGGER.error(e.getMessage(), e);
   }
   if (filteredFeatures != null && filteredFeatures.size() > 0) {
    // Écrire le résultat dans un fichier shapefile
    Map<String, String> destshpparams = new HashMap<String, String>();
    SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMdd");
    String destinationSchemaName = "MySchema_" + dep;
    destshpparams.put("url", "file://" + destFolder + destinationSchemaName + "_" + formatter.format(new Date()) + ".shp");
    DataStore destShpStore = shpFactory.createNewDataStore(destshpparams);

    // duplicate existing schema to create destination's one
    Name originalName = schema.getName();
    NameImpl theName = new NameImpl(originalName.getNamespaceURI(), originalName.getSeparator(), destinationSchemaName);
    SimpleFeatureType destschema = factory.createSimpleFeatureType(theName, schema.getAttributeDescriptors(),
      schema.getGeometryDescriptor(), schema.isAbstract(), schema.getRestrictions(), schema.getSuper(), schema.getDescription());
    destShpStore.createSchema(destschema);

    SimpleFeatureStore destFeatureStore = (SimpleFeatureStore) destShpStore.getFeatureSource(destinationSchemaName);
    destFeatureStore.addFeatures(filteredFeatures);

    // Fermer les connections et les fichiers
    destShpStore.dispose();
   }
  }
 }
}

Avec ce type de code, il est possible d'extraire une nouvelle cartographie spécifique (découpage selon la variable DPT_NUM) à partir d'un lot de données source.

Pour une mise en place plus rapide, voici les dépendances nécessaires (pom.xml) :

...
<repositories>
 <repository>
  <id>osgeo</id>
  <name>Open Source Geospatial Foundation Repository</name>
  <url>http://download.osgeo.org/webdav/geotools/</url>
 </repository>
</repositories>
...
<dependencies>
 <!-- Geo Tools -->
 <dependency>
  <groupId>org.geotools</groupId>
  <artifactId>gt-shapefile</artifactId>
  <version>8.0-M4</version>
 </dependency>
 <dependency>
  <groupId>org.geotools.jdbc</groupId>
  <artifactId>gt-jdbc-postgis</artifactId>
  <version>8.0-M4</version>
 </dependency>
 <dependency>
  <groupId>org.geotools</groupId>
  <artifactId>gt-cql</artifactId>
  <version>8.0-M4</version>
 </dependency>
</dependencies>

Voilà tout, bon courage!
HTH


Fichier(s) joint(s) :



Apache Camel par l'exemple

Avec cet article j'ai décidé d'entrer directement dans le vif du sujet...

S'il fallait présenter rapidement Camel, on pourrait dire qu'il s'agit d'une plateforme d'intégration d'application, basée sur un système d'échange de messages et dont le but est de fournir une implémentation des grands patrons d'intégrations en entreprise (facilitant la communication inter-applications). Pour ne pas plagier ou paraphraser, voici deux articles intéressants présentant ces patrons : le premier sur le site de Novedia et le second, plus détaillé chez Soat. Pour continuer sur une présentation plus spécifique de Camel, voici un premier billet écrit sur le blog d'Octo en enfin une présentation complète par un des co-auteurs du livre "Camel In Action", Jonathan Anstey.

Mon but ici est donc de fournir un exemple de mise en place d'un "bus" Camel pour créer un flux applicatif.

Le scénario est le suivant : on doit récupérer une archive zippée sur un serveur FTP distant, la décompresser et traiter son contenu en fonction de son type : les fichiers CSV doivent être segmentés selon une règle métier puis re-zippés unitairement et les autres types de fichiers sont envoyés à un script shell. Entre-temps, les données sont triées et validées. Celles qui sont invalides sont déposées séparément dans un répertoire spécifique.

De manière plus illustrée :

En jaune sont représentés les composants intégrés à Camel : FTP, ZIP, EXEC et CSV
En rouge sont illustrés les patrons d'intégration implémentés : Split, Enricher, Router, Filter, Sort, Recipient list, Validate.
En blanc sont indiqués les beans/services personnalisés ajoutés.

Et maintenant le plus intéressant, le code pour la mise en place des routes (les commentaires décrivent tout son fonctionnement) :

import java.util.Comparator;
import java.util.List;

import org.apache.camel.Exchange;
import org.apache.camel.Processor;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.dataformat.csv.CsvDataFormat;
import org.apache.camel.language.bean.BeanLanguage;
import org.apache.camel.processor.validation.PredicateValidationException;

import CamelProperties;
import ReferentielDepartement;

/**
 * Main class to creates Camel routes
 * 
 * @author pe.faidherbe
 *
 */
public class MyRoutesBuilder extends RouteBuilder {
 
 // Stores archive's file name retrieved from remote server
 private static String downloadedFileName;
 
 // Indicates Csv column's name used to sort datas
 private static String csvIdentityColumn;
 
 // Indicates Csv column's number used to sort datas
 private static Integer csvIdentityColumnNumber;
 
 // Indicates length of sorting data used as data identifier
 private static int csvIdentityLength;
 
 // General properties
 private static final CamelProperties camelProps = CamelProperties.getInstance();
 
 // Csv data delimiter
 private static final String csvDelimiter = camelProps.getProperty(CamelProperties.CAMEL_DATA_SEP);
 
 // Prefix of data file (used for routing)
 private static final String NAT_FILE_PREFIX = camelProps.getProperty(CamelProperties.CAMEL_DATA_FILE_NAT_PREFIX);
 
 // Prefix of data file (used for routing)
 private static final String S2_FILE_PREFIX = camelProps.getProperty(CamelProperties.CAMEL_DATA_FILE_S2_PREFIX);
 
 // Prefix of data file (used for routing)
 private static final String ILOT_FILE_PREFIX = camelProps.getProperty(CamelProperties.CAMEL_DATA_FILE_ILOT_PREFIX);
 
 /**
  * Getter used by Camel
  * @return remote File Name
  */
 public String getDownloadedFileName() {
  return downloadedFileName;
 }
 
 /**
  * Getter used by Camel
  * @return csv identity column
  */
 public String getCsvIdentColumn() {
  return csvIdentityColumn;
 }
 
 /**
  * Getter used by Camel
  * @return csv identity column num
  */
 public Integer getCsvIdentityColumnNumber() {
  return csvIdentityColumnNumber;
 }
 
 /**
  * Getter used by Camel
  * @return csv identity data length
  */
 public int getCsvIdentLength() {
  return csvIdentityLength;
 }
 
 /**
  * Getter used by Camel
  * @return csv delimiter to use
  */
 public String getCsvDelimiter() {
  return csvDelimiter;
 }
 
 /**
  * Used by first Camel route to "persist" informations about remote file
  * later passed as parameters for second route
  */
 private void setMyContext(String downloadedArchive) {
  downloadedFileName = downloadedArchive.substring(0, downloadedArchive.indexOf("."));
  if(downloadedFileName.startsWith(S2_FILE_PREFIX)) {
   csvIdentityColumn = camelProps.getProperty(CamelProperties.CAMEL_DATA_IDENT_COL_S2);
   csvIdentityLength = Integer.parseInt(camelProps.getProperty(CamelProperties.CAMEL_DATA_IDENT_S2_LENGTH));
   csvIdentityColumnNumber = Integer.parseInt(camelProps.getProperty(CamelProperties.CAMEL_DATA_IDENT_COL_S2_NUM));
  } else if(downloadedFileName.startsWith(NAT_FILE_PREFIX)) {
   csvIdentityColumn = camelProps.getProperty(CamelProperties.CAMEL_DATA_IDENT_COL_NAT);
   csvIdentityLength = Integer.parseInt(camelProps.getProperty(CamelProperties.CAMEL_DATA_IDENT_NAT_LENGTH));
   csvIdentityColumnNumber = Integer.parseInt(camelProps.getProperty(CamelProperties.CAMEL_DATA_IDENT_COL_NAT_NUM));
  }
 }

 /**
  * @see org.apache.camel.builder.RouteBuilder#configure()
  */
 @Override
 public void configure() throws Exception {
  // Props
  String camelWorkDir = camelProps.getProperty(CamelProperties.CAMEL_WORK_DIR);
  
  // Csv comparator
  CsvSorter sorter = new CsvSorter();
  
  // dead Letter Channel
  errorHandler(deadLetterChannel("log:camel"));
  
  // not validated messages go to particular error folder
  onException(PredicateValidationException.class).handled(true)
   .to("file://C:/test2/csverror?fileName=${header:downloadedFileName}_${header:territoire}_${date:now:yyyyMMddHHmmss}.csv")
   .log("Validation Error : ${exception.message}").end(); 
  
  /*
   * Download
   */
  from("ftp://"+camelProps.getProperty(CamelProperties.FTP_USER_PROP)
    + "@"
    + camelProps.getProperty(CamelProperties.FTP_HOST)
    + "?password="
    + camelProps.getProperty(CamelProperties.FTP_USER_PWD)
    + "&binary=true&noop=true&disconnect=true"
    // Poll every X sec
    + "&consumer.delay=" + camelProps.getProperty(CamelProperties.FTP_POLL_TIME_MS)
    // Specify temp destination for performance issue (not loaded in memory)
    + "&localWorkDirectory="+camelWorkDir)
   .log("Unzipping : ${file:name}")
   // Read as zip file
   .marshal().zip()
   // Unzip in memory 
   .unmarshal().zip()
   // Send to bean to extract entries
   .split().method("ZipService","unzipFile")
    .log("Extracted : ${header:entryName}")
   // Write each file
   .to("file://"+camelWorkDir+"?fileName=${header:entryName}")
   .process(new Processor() {
    @Override
    public void process(Exchange e) throws Exception {
     // Set informations on how to treat latter data
     setMyContext((String) e.getIn().getHeader("CamelFileName"));
    }
   })
  .end();
  
  /*
   *  ROUTER
   */
  from("file://"+camelWorkDir).id("routerRoute").log("Routing start")
   // No autostart to avoid polling "undesired" file (not previously retrieved from ftp)
   //.noAutoStartup()
   // Enrich file polling with context informations
   .enrich("direct:contextEnricher")
   .choice()
    .when(header("downloadedFileName").startsWith(S2_FILE_PREFIX))
     // CSV data, going to split
     .to("direct:surfaces")
    .when(header("downloadedFileName").startsWith(ILOT_FILE_PREFIX))
     // Geo data, go to DB
     .to("direct:ilots")
    .when(header("downloadedFileName").startsWith(NAT_FILE_PREFIX))
     // CSV data, going to split
     .to("direct:national")
    .otherwise()
     .log("Fichier ${file:name} non pris en charge!")
    .end();
  
  /*
   * Content enricher
   */
  from("direct:contextEnricher")
   .setHeader("downloadedFileName", BeanLanguage.bean(getClass(), "getDownloadedFileName"))
   .setHeader("csvIdentityColumn", BeanLanguage.bean(getClass(), "getCsvIdentColumn"))
   .setHeader("csvIdentityLength", BeanLanguage.bean(getClass(), "getCsvIdentLength"))
   .setHeader("csvDelimiter", BeanLanguage.bean(getClass(), "getCsvDelimiter"));
  
  /*
   * Manage geo data
   */
  from("direct:ilots")
   // Manage only SHP files
   .filter(header("entryName").endsWith("shp"))
   .to("file://C:/test2?fileName=${header:entryName}")
   // RecipientList is needed because route is computed at runtime (because of dynamic parameters)
   .recipientList(simple("exec:C:/test2/cmd/ogr2ogr.bat?args=${header:entryName}&workingDir=C:/test2/cmd/&useStderrOnEmptyStdout=true"))
    // Convert to String because cmd return is InputStream
    .convertBodyTo(String.class)
    .log("Command return : ${body} , error : ${header:exec_stderr}")
   .end();
  
  /*
   * Split CSV
   */

  // Used to customized CSV separator
  CsvDataFormat csvFormat = new CsvDataFormat();
  csvFormat.setDelimiter(csvDelimiter);
  
  from("direct:surfaces").convertBodyTo(String.class).unmarshal(csvFormat)
   .sort(body(), sorter)
   // Split CSV content
   // Bean uses StringBuilder and writes CSV content in order to get better performance than
   // creating a lot of List<Map<String, Object>> handled by camel's csv marshaler
   .split().method("CsvService","splitDatas")
   // Business check : is "territoire" a valid data?
   .validate(header("territoire").in(ReferentielDepartement.getDepartements()))
   // Write each data to a proper file
   .to("file://C:/test2/splitted?fileName=Territorial_${header:territoire}_${date:now:yyyyMMdd}.csv")
   .log("Written CSV file for : ${header:territoire}");
  
  from("direct:national").convertBodyTo(String.class).unmarshal(csvFormat)
   .sort(body(), sorter)
   // Split CSV content
   .split().method("CsvService","splitDatas")
   .validate(header("territoire").in(ReferentielDepartement.getDepartements()))
   // Write each data to a proper file
   .to("file://C:/test2/splitted?fileName=${file:onlyname.noext}_${header:territoire}_${date:now:yyyyMMddHHmmss}.csv")
   .log("Written CSV file for : ${header:territoire}");
  
  
  /*
   * Zip final files
   */
  // To handle transformation from camel's DeflaterOutputStream to traditional ZipOutputStream
  CustomizedZipDataFormat zipFormat = new CustomizedZipDataFormat();
  from("file://C:/test2/splitted").marshal(zipFormat)
   .to("file://C:/test2?fileName=${file:onlyname.noext}.zip")
   .log("Zipped : ${file:onlyname.noext}");
 }
 
 class CsvSorter implements Comparator<List<String>> {
  @Override
  public int compare(List<String> o1, List<String> o2) {
   int result = 0;
   // Do not treat first line (headers)
   if(!o1.contains(csvIdentityColumn) && !o2.contains(csvIdentityColumn)) {
    String ccom1 = o1.get(csvIdentityColumnNumber).substring(0, csvIdentityLength);
    String ccom2 = o2.get(csvIdentityColumnNumber).substring(0, csvIdentityLength);
    result = ccom1.compareTo(ccom2);
   }
   return result;
  }
 }
}

Pour ce qui est du service permettant de segmenter les informations CSV, voici son squelette :

public class CsvService {
 
 /**
  * Receives csv informations and split it out to multiple messages
  * Received datas are pre-ordered
  * @param headers in-message headers
  * @param body in-message body (unmarshalled csv content)
  * @return messages containing csv info to be written
  */
 public List<Message> splitDatas(@Headers Map<String, Object> headers,
   @Body List<List<String>> body) {
  List<Message> answer = new ArrayList<Message>();
  
  // headers
  List<String> csvHeaders = body.get(0);
  
  String csvDelimiter = (String) headers.get("csvDelimiter");
  
  // data
  List<List<String>> datas = body.subList(1, body.size());

  (... sort routine ...)

  return answer;
 }
}

Pour ce qui est du service permettant de dézipper l'archive :

public class ZipService {

 /**
  * Splits in message to multiple messages for entries
  * @param headers in headers
  * @param body in body
  * @return one message per zip entry
  */
 public List<Message> unzipFile(@Headers Map<String, Object> headers,
   @Body Object body) {
  List<Message> answer = new ArrayList<Message>();
  try {
   ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(
     (byte[]) body));
   ZipEntry ze = null;
   String entryName = "";
   String unzippedFiles = "";
   while ((ze = zis.getNextEntry()) != null) {
    entryName = ze.getName();
    unzippedFiles += entryName + ",";
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    for (int c = zis.read(); c != -1; c = zis.read()) {
     out.write(c);
    }
    zis.closeEntry();
    out.close();
    DefaultMessage message = new DefaultMessage();
    Map<String, Object> newHeaders = new CaseInsensitiveMap(headers);
    newHeaders.put("entryName", entryName);
    newHeaders.put("unzippedFiles", unzippedFiles);
    message.setHeaders(newHeaders);
    message.setBody(out.toByteArray());
    answer.add(message);
   }
   zis.close();
  } catch (Throwable e) {
   e.printStackTrace();
  }
  return answer;
 }
}

Enfin, le code utilisé pour créer des archives zip utilisables (via CustomizedZipDataFormat) provient de cette page.

J'espère que tout ce code ne paraît pas trop indigeste, mais en y regardant de plus près, on s'aperçoit que Camel permet assez facilement de mettre en place ce genre de flux de données, en peu de lignes de code et surtout de manière plutôt lisible. La documentation est d'ailleurs incroyablement bien faite pour ce qui concerne la description des patrons et des composants natifs.

Le seul bémol que j'ajouterai est la gestion des formats ZIP. En effet, par défaut, Camel crée des DeflaterOutputStream : je ne sais pas trop d'où provient ce format, mais en tout cas il ne permet pas directement de créer des archives lisibles. Il faut donc explicitement les convertir en ZipOutputStream classique.

N'hésitez pas à m'indiquer si vous avez déjà utilisé cet outil et surtout si vous voyez des façons d'améliorer ce que j'ai présenté!

Sources :


Fichier(s) joint(s) :



XSLT et modification de namespace

Cet article a pour but d'éclaircir l'utilisation des namespaces au sein des feuilles de transformations XSL.

Imaginons qu'il faille changer le namespace global du xml suivant :

<?xml version="1.0" encoding="utf-8"?>
<pef:OPS xmlns:pef="http://www.fooldomain.fr">
  <pef:DAT TM="CU">
    ...
  </pef:DAT>
</pef:OPS>

En celui-ci :

<?xml version="1.0" encoding="utf-8"?>
<pef:OPS xmlns:pef="http://developpef.blogspot.com">
  <pef:DAT TM="CU">
    ...
  </pef:DAT>
</pef:OPS>

A première vue, le template suivant devrait faire l'affaire :

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" 
  xmlns:pef="http://www.fooldomain.fr"
  version="1.0">
  
 <xsl:template match="pef:OPS">
  <pef:OPS xmlns:pef="http://developpef.blogspot.com">
   <pef:DAT TM="CU">
   ...
   </pef:DAT>
  </pef:OPS>
 </xsl:template>
  
</xsl:stylesheet>

Or, voici ce qui sera produit :

<?xml version="1.0" encoding="utf-8"?>
<pef:OPS xmlns:pef="http://developpef.blogspot.com">
  <pef:DAT xmlns:pef="http://www.fooldomain.fr" TM="CU">
    ...
  </pef:DAT>
</pef:OPS>

Le processeur XSL ajoutera l'ancien namespace au premier élément qui n'en porte pas... Pourquoi donc? Tout d'abord, la déclaration du namespace telle que faite sur l'élément OPS n'affecte en aucun cas les namespaces utilisés par le processeur. Lui ne connait que ceux qui sont déclarés en en-tête du document. Il va donc naturellement rajouter le namespace qu'il utilise au premier élément considéré. En réalité, dans ce cas de figure, il faut explicitement indiquer au processeur les deux namespace à utiliser : le premier lui permettant de lire le XML entrant et le second décrivant le XML sortant. Voici donc à quoi doit ressemble notre XSLT :

<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" 
  xmlns:peffool="http://www.fooldomain.fr"
  xmlns:pef="http://developpef.blogspot.com"
  exclude-result-prefixes="peffool"
  version="1.0">
  
 <xsl:template match="peffool:OPS">
  <pef:OPS>
   <pef:DAT TM="CU">
   ...
   </pef:DAT>
  </pef:OPS>
 </xsl:template>
  
</xsl:stylesheet>

Ainsi, le template travaillera bien sur les éléments basés sur l'ancien namespace peffool:OPS pour produire le nouveau XML basé sur le bon namespace xmlns:pef="http://developpef.blogspot.com"

Hope this helps! :)


Fichier(s) joint(s) :

XSLT et transcodage

Lors de la transformation d'un fichier XML, il peut être nécessaire d'effectuer un transcodage sur certains éléments (migration d'une ancienne valeur vers une nouvelle). Les choses se compliquent si en plus ceci doit se faire à partir d'un référentiel externe contenant le mapping des données à utiliser.

Concrètement, voici un exemple de fichier XML initial :

<?xml version="1.0"?>
<OPS AD="FRA" FR="FRA">
      <LOG RC="FHZW" MA="VIAUD">
          <SPE SN="ANE" WT="5000.00">
            <PRO FF="A" PS="FRE" PR="WHL" TY="BOX" CF="1.00"></PRO>
            <RAS SR="23E6"></RAS>
          </SPE>
          <SPE SN="ANE" WT="5000.00">
            <PRO FF="A" PS="FRE" PR="WHL" TY="BOX" CF="1.00"></PRO>
            <RAS SR="24F8"></RAS>
         </SPE>
     </LOG>
</OPS>

Qui doit être convertit pour obtenir ceci (on change la valeur de l'attribut SR de l'élement RAS) :

<?xml version="1.0"?>
<OPS AD="FRA" FR="FRA">
      <LOG RC="FHZW" MA="VIAUD">
          <SPE SN="ANE" WT="5000.00">
            <PRO FF="A" PS="FRE" PR="WHL" TY="BOX" CF="1.00"></PRO>
            <RAS SR="FR23E6"></RAS>
          </SPE>
          <SPE SN="ANE" WT="5000.00">
            <PRO FF="A" PS="FRE" PR="WHL" TY="BOX" CF="1.00"></PRO>
            <RAS SR="FR24F8"></RAS>
         </SPE>
     </LOG>
</OPS>

La table de transcodage utilisée est située dans le document 'transcodage.xml' :

<mapping>
    <data>
        <item v1="23E6" v3="FR23E6" />
        <item v1="24F8" v3="FR24F8" />
    </data>
</mapping>

Pour arriver à nos fins, XSLT met à notre disposition l'élément xsl:key qui permet de définir une clé de recherche dans un document. Voici comment l'utiliser :

<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
 
<xsl:variable name="map" select="document('transcodage.xml')"/>
<xsl:key name="mapPorts" match="/mapping/data/item" use="@v1"/>
 
<xsl:template match="@* | node()">
    <xsl:copy>
        <xsl:apply-templates select="@* | node()"/>
    </xsl:copy>
</xsl:template>
 
<xsl:template match="@SR[parent::RAS]">
    <xsl:variable name="codeV1" select="."/>
    <xsl:for-each select="$map">
        <xsl:attribute name="SR">
            <xsl:value-of select="key('mapPorts', $codeV1)/@v3"/>
        </xsl:attribute>
    </xsl:for-each>
</xsl:template>
 
</xsl:stylesheet>

Voici en détail le déroulement des opérations :

  • La variable map contient la table de transcodage.
  • La clé mapPorts permet d'indiquer : "je recherche tous les éléments correspondant à /mapping/data/item et je teste leur attribut v1
  • Dans le template gérant l'attribut SR du noeud RAS, on parcourt les éléments de la table de transcodage (en changeant temporairement de noeud racine grâce à xsl:for-each) afin de récupérer la valeur de l'attribut v3 de l'élément dont l'attribut v1 porte la valeur actuelle. En effet, l'appel à la fonction key() retourne l'élément qui a répondu aux conditions passées en paramètres (chemin décrit par la clé et valeur d'attribut)

Ainsi, chaque ancienne valeur sera remplacée par la valeur correspondante issue du document de mapping. Une autre syntaxe est possible, sans utiliser de clé :

<xsl:template match="@SR[parent::RAS]">
    <xsl:attribute name="SR">
       <xsl:value-of select="$map/mapping/data/item[@v1 = current()]/@v3"/>
    </xsl:attribute>
</xsl:template>

Cette solution est tout de même à modérer dans le cas de volume important de données.

Mon prochain article sera dédié au changement de namespace lors d'une transformation XSL.

Bon courage!


Fichier(s) joint(s) :

Exporter un document Calc en XML

Encore une fois, les choses ne sont pas si simples qu'il n'y paraît...

Ici le besoin consiste à exporter un classeur OpenOffice Calc en XML, selon une structure personnalisée. Le logiciel offre déjà la possibilité d'exporter sous forme d'XML à la sauce Micro$oft : autant dire que le résultat n'est pas glorieux :

Le document Calc :

Le XML produit (Fichier > Enregistrer sous... > Microsoft Excel 2003 XML) :

<?xml version="1.0" encoding="utf-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns:c="urn:schemas-microsoft-com:office:component:spreadsheet" xmlns:html="http://www.w3.org/TR/REC-html40" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:x2="http://schemas.microsoft.com/office/excel/2003/xml" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:x="urn:schemas-microsoft-com:office:excel">
  <OfficeDocumentSettings xmlns="urn:schemas-microsoft-com:office:office">
    <Colors>
      <Color>
        <Index>3</Index>
        <RGB>#c0c0c0</RGB>
      </Color>
      <Color>
        <Index>4</Index>
        <RGB>#ff0000</RGB>
      </Color>
    </Colors>
  </OfficeDocumentSettings>
  <ExcelWorkbook xmlns="urn:schemas-microsoft-com:office:excel">
    <WindowHeight>9000</WindowHeight>
    <WindowWidth>13860</WindowWidth>
    <WindowTopX>240</WindowTopX>
    <WindowTopY>75</WindowTopY>
    <ProtectStructure>False</ProtectStructure>
    <ProtectWindows>False</ProtectWindows>
  </ExcelWorkbook>
  <Styles>
    <Style ss:ID="Default" ss:Name="Default" />
    <Style ss:ID="Result" ss:Name="Result">
      <Font ss:Bold="1" ss:Italic="1" ss:Underline="Single" />
    </Style>
    <Style ss:ID="Result2" ss:Name="Result2">
      <Font ss:Bold="1" ss:Italic="1" ss:Underline="Single" />
      <NumberFormat ss:Format="Euro Currency" />
    </Style>
    <Style ss:ID="Heading" ss:Name="Heading">
      <Alignment ss:Horizontal="Center" />
      <Font ss:Bold="1" ss:Italic="1" ss:Size="16" />
    </Style>
    <Style ss:ID="Heading1" ss:Name="Heading1">
      <Alignment ss:Horizontal="Center" ss:Rotate="90" />
      <Font ss:Bold="1" ss:Italic="1" ss:Size="16" />
    </Style>
    <Style ss:ID="co1" />
    <Style ss:ID="ta1" />
    <Style ss:ID="ta_extref" />
  </Styles>
  <ss:Worksheet ss:Name="Exemple1">
    <Table ss:StyleID="ta1">
      <Column ss:Span="1" ss:Width="64.2614" />
      <Row ss:Height="12.8409">
        <Cell>
          <Data ss:Type="String">Donnée 1</Data>
        </Cell>
        <Cell>
          <Data ss:Type="String">Donnée 1.1</Data>
        </Cell>
      </Row>
      <Row ss:Height="12.8409">
        <Cell>
          <Data ss:Type="String">Donnée 2</Data>
        </Cell>
        <Cell>
          <Data ss:Type="String">Donnée 1.2</Data>
        </Cell>
      </Row>
    </Table>
    <x:WorksheetOptions />
  </ss:Worksheet>
  <ss:Worksheet ss:Name="Exemple2">
    <Table ss:StyleID="ta1">
      <Column ss:Span="1" ss:Width="64.2614" />
      <Row ss:Height="12.8409">
        <Cell>
          <Data ss:Type="String">Feuille 2 test</Data>
        </Cell>
        <Cell>
          <Data ss:Type="String">Autre valeur</Data>
        </Cell>
      </Row>
    </Table>
    <x:WorksheetOptions />
  </ss:Worksheet>
</Workbook>

Pas très lisible tout ça...

Avant de continuer, il faut savoir que les fichiers Calc (.ods) ne sont en fait que des fichiers zip contenant des XML. La structure de base du classeur, tel que lu par OpenOffice est la suivante :

  • content.xml
  • meta.xml
  • mimetype.xml
  • settings.xml
  • styles.xml

Tous ces fichiers décrivent le contenu, la présentation et les métas informations constituant le classeur. Le plus intéressant est le premier. En voici un extrait :

  <office:body>
    <office:spreadsheet>
      <table:table table:name="Exemple1" table:style-name="ta1" table:print="false">
        <office:forms form:automatic-focus="false" form:apply-design-mode="false" />
        <table:table-column table:style-name="co1" table:number-columns-repeated="2" table:default-cell-style-name="Default" />
        <table:table-row table:style-name="ro1">
          <table:table-cell office:value-type="string">
            <text:p>Donnée 1</text:p>
          </table:table-cell>
          <table:table-cell office:value-type="string">
            <text:p>Donnée 1.1</text:p>
          </table:table-cell>
        </table:table-row>
        <table:table-row table:style-name="ro1">
          <table:table-cell office:value-type="string">
            <text:p>Donnée 2</text:p>
          </table:table-cell>
          <table:table-cell office:value-type="string">
            <text:p>Donnée 1.2</text:p>
          </table:table-cell>
        </table:table-row>
      </table:table>
      <table:table table:name="Exemple2" table:style-name="ta1" table:print="false">
        <table:table-column table:style-name="co1" table:number-columns-repeated="2" table:default-cell-style-name="Default" />
        <table:table-row table:style-name="ro1">
          <table:table-cell office:value-type="string">
            <text:p>Feuille 2 test</text:p>
          </table:table-cell>
          <table:table-cell office:value-type="string">
            <text:p>Autre valeur</text:p>
          </table:table-cell>
        </table:table-row>
      </table:table>
    </office:spreadsheet>
  </office:body>

On retrouve ici les informations contenues dans les cellules de chaque feuille.

Ainsi, puisque la structure des documents Calc est basée sur du XML, si l'on désire l'exporter sous un autre format, il faudra passer par... XSLT!

Admettons que nous voulions exporter le document présenté plus haut sous la forme :

<mapping>
 <Exemple1>
  <data col1="Donnée 1" col2="Donnée 1.1" />
  <data col1="Donnée 2" col2="Donnée 1.2" />
 </Exemple1>
 <Exemple2>
 ...
</mapping>

Il faudra donc trouver la feuille de transformation XSL convenable. Par exemple, celle-ci (le code est perfectible mais est présenté à titre d'exemple) :

<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
 xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
 xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
 exclude-result-prefixes="office table">
 
<xsl:output method="xml" indent="yes" encoding="UTF-8" omit-xml-declaration="yes"/>
 
<!-- To avoid annoying header data -->
<xsl:template match="office:meta" />
<xsl:template match="office:settings" />
<xsl:template match="office:styles" />
<xsl:template match="office:master-styles" />
<xsl:template match="office:automatic-styles" />
<xsl:template match="office:font-face-decls" />
<xsl:template match="office:scripts" />
<xsl:template match="office:font-face-decls" />
 
<!-- parse document -->
<xsl:template match="office:spreadsheet">
 <xsl:element name="mapping">
  <xsl:for-each select="table:table">
   <!-- take only sheets that have real data (multiple columns) -->
   <xsl:if test="table:table-column[@table:number-columns-repeated]">
    <xsl:variable name="tablename" select="@table:name" />
    <xsl:element name="{$tablename}">
     <xsl:for-each select="table:table-row">
      <xsl:element name="item">
       <xsl:for-each select="table:table-cell">
        <xsl:variable name="attrNameV1" select="'col1'" />
        <xsl:variable name="attrNameV3" select="'col2'" />
        <xsl:if test="position() = 1">
         <xsl:attribute name="{$attrNameV1}">
          <xsl:value-of select="text:p" />
         </xsl:attribute>
        </xsl:if>
        <xsl:if test="position() = 2">
         <xsl:attribute name="{$attrNameV3}">
          <xsl:value-of select="text:p" />
         </xsl:attribute>
        </xsl:if>
       </xsl:for-each>
      </xsl:element>
     </xsl:for-each>
    </xsl:element>
   </xsl:if>
  </xsl:for-each>
 </xsl:element>
</xsl:template>
</xsl:stylesheet>

En détails, voici comment elle fonctionne :

  • Les namespaces déclarés correspondent à ceux attendus par OpenOffice pour présenter ses propres balises.
  • Le premier bloc de templates supprime le traitement de tout ce qui est méta-infos, styles et autres scripts éventuellement contenus dans le classeur.
  • L'élement table:table décrit chaque feuille du classeur
  • Le test xsl:if permet de ne pas traiter les feuilles ne contenant aucune donnée (chaque feuille contenant par défault toujours au moins une cellule vide)
  • Enfin, on parcour chaque ligne (table:table-row) et chaque cellule (table:table-cell) pour en extraire le contenu (text:p)

Pour rendre cet export disponible dans OpenOffice, il faut le configurer. Pour ce faire : Outils > Paramétrages du filtre XML... > Nouveau... Et renseigner le chemin vers notre XSLT dans la section "XSLT pour export"

Notre export sera finalement réalisable depuis le menu Fichier > Exporter...

Pourquoi faire simple quand on peut faire compliqué!


Fichier(s) joint(s) :