Showing posts with label xsltproc. Show all posts
Showing posts with label xsltproc. Show all posts

Wednesday, February 13, 2013

How to XML-ify a tab separated text file with xsltproc (revisited)

My last post described an XSLT 1.0 solution how to transform a plain tab separated text file into XML.

Here I present a solution which uses some features available in extensions (and which are available in XSLT 2 as language features), namely tokenize and node-set. tokenize will allow me to split a string into tokens at once rather than having to call substring-before and substring-after repeatedly. In a certain way it is a contrast to the template thinking of XSLT but of course useful. node-set is a mighty tool since it allows me to transform variables into node sets and with that comes the ability to use proper XPATH functions on the nodes.
The xsltproc version on my Mac contains some EXSTL extensions (visible via xsltproc --dumpextensions) so here are the required namespaces which need to be declared at the beginning of the script

namespace
tokenizexmlns:strings="http://exslt.org/strings"
node-setxmlns:common="http://exslt.org/common"

And here is how to use them:

Usage
tokenizeI use tokeinze in a for-each loop to split $someText delimited by $newline
<xsl:for-each select="strings:tokenize($someText,$newline)" >
...
</xsl:for-each>
node-setTransform the contents of a variable $lines into a node-set $lineNodes
<xsl:variable name="lineNodes" select="common:node-set($lines)" />

All the work is being done in the parseDelimited template and it follows pretty much old style programming conventions. There is one loop which splits the complete input by newline. The first line is split by delimiter into the names of the headers All other lines are then split by delimiter into their individual fields. Everything is wrapped into elements as follows and and put into a variable. The pseudo-code is already close to its implementation.

element "data"
  for each line tokenize the line by delimiter
    element "row"
      for each field n
        element "header n"
          content of field n
        end of element "header n"
      end for
    end of element "row"
  end for
end of element "data"

Here is the complete code.

<?xml version="1.0" encoding="ISO-8859-1"?>
<xsl:stylesheet version="1.0"
        xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
        xmlns:strings="http://exslt.org/strings"
        xmlns:common="http://exslt.org/common"
        >
<!-- From strings we use:  tokenize
     From common we use:  node-set
-->

<!-- Define delimiter and newline -->
<xsl:variable name="delim"   select="'&#x9;'" />
<xsl:variable name="newline" select="'&#xA;'" />

<!-- Define node1 and node2 for the output -->
<xsl:variable name="node1"   select="'data'" />
<xsl:variable name="node2"   select="'row'" />


<xsl:template match="/">
  <!-- Take whatever input is coming, don't care about 'fakeroot' -->
  <xsl:call-template name="root"/>
</xsl:template>

<xsl:template name="root">
    <!-- Call the line parser with the whole content of the file -->
    <xsl:call-template name="parseDelimited">
      <xsl:with-param name="delimitedText" select="." />
    </xsl:call-template>
</xsl:template>

<xsl:template name="parseDelimited">
  <xsl:param name="delimitedText" />

  <!-- Split the file content by newline -->
  <xsl:variable name="lines">
    <xsl:for-each select="strings:tokenize($delimitedText,$newline)" >
      <line>
      <xsl:value-of select='.' />
      </line>
    </xsl:for-each>
  </xsl:variable>
  <!-- Create a node-set out of the previous 'lines'
       in order to be able to use them as an XPATH var -->
  <xsl:variable name="lineNodes" select="common:node-set($lines)" />

  <!-- The first line containing the header fields -->
  <xsl:variable name="first" select='$lineNodes/line[1]' />
  <xsl:variable name="headers" >
    <xsl:for-each select="strings:tokenize($first,$delim)" >
      <head>
      <xsl:value-of select='.' />
      </head>
    </xsl:for-each>
  </xsl:variable>
  <!-- Create a node-set out of the previous 'headers'
       in order to be able to use them as an XPATH var -->
  <xsl:variable name="headerNodes" select="common:node-set($headers)" />

  <!-- Loop through all lines, we can do this since it is a node set.
       This creates the actual XML content -->
  <xsl:variable name="output" >
    <!-- Start tag <data> -->
    <xsl:element name="{$node1}">
    <xsl:value-of select='$newline' />

    <xsl:for-each select="$lineNodes/line">
      <!-- Skip the first line of course -->
      <xsl:if test="position() > 1">

        <!-- Start tag <row> -->
        <xsl:element name="{$node2}">

        <!-- Split the line by 'delim'
             and create an element for each entry.
             The element name is coming from the header line -->
        <xsl:for-each select="strings:tokenize(.,$delim)" >
          <xsl:variable name="p" select="position()" />
          <xsl:element name="{$headerNodes/head[$p]}">
            <!-- Print the actual content , phew! -->
            <xsl:value-of select="." />
          </xsl:element>
        </xsl:for-each>
        <!-- End tag <row> -->
        </xsl:element>
        <xsl:value-of select='$newline' />

      </xsl:if>
    </xsl:for-each>

    <!-- End tag </data> -->
    </xsl:element>
    <xsl:value-of select='$newline' />

  </xsl:variable>

  <xsl:variable name="all" select="common:node-set($output)" />
  <!-- Output of nodified elements -->
  <xsl:copy-of select="($all)/*" />

  <!-- With a node-set one can now use its advantages
       e.g. sum up all Num values -->
  <xsl:value-of select='$newline' />
  <xsl:element name="Sum_Num">
  <xsl:value-of disable-output-escaping="yes"  select="sum(common:node-set($output)/data/row/Num)"/>
  </xsl:element>

</xsl:template>

</xsl:stylesheet>
There are two interesting pieces here.
  • How to get the header names into the game? The for-each loop in bold tokenizes a line. Each field has an index which you can get via position() in XSLT. An element is created and it gets the name of the header field using this exact index (this works since the header line has the same number of fields than every other line). <xsl:element name="{$headerNodes/head[$p]}"> (the creation of variable 'p' to store the position is actually superfluous but it makes the code more readable).
  • At the end there are two more lines in bold which show how to use the XPATH function sum to get the total of the Num fields.

    This script, call it data.xsl, needs to be fed by the same wrapped input as before, here is the script which I omitted last time.

    #!/bin/sh
           
    # A shell wrapper for non-xml parsing with xslt
    
    FILE=data.txt
    FAKEROOT=fakeroot   # Important for XML completeness but will be skipped by XSLT
    
    (
    echo "<?xml version=\"1.0\"?>"
    printf "<$FAKEROOT>"
    cat $FILE
    echo "</$FAKEROOT>"
    )  |
    xsltproc data.xsl -
    

    The result is as follows. Note the 71 in the last line which is the sum of Num (this makes the output non-XML, it's just there to show the possibilities).

    <?xml version="1.0"?>
    <data>
    <row><Date>20120415</Date><Num>13</Num><Duration>2310</Duration></row>
    <row><Date>20120510</Date><Num>9</Num><Duration>1470</Duration></row>
    <row><Date>20120526</Date><Num>16</Num><Duration>3817</Duration></row>
    <row><Date>20120701</Date><Num>5</Num><Duration>2269</Duration></row>
    <row><Date>20120831</Date><Num>28</Num><Duration>4505</Duration></row>
    </data>
    <Sum_Num>71</Sum_Num>
    
  • Tuesday, February 5, 2013

    How to XML-ify a tab separated text file with xsltproc

    In the past I have done a lot with plain - somehow delimited - text files, CSV where the comma can stand for any kind of character to play the delimiter.

    I used the ususal UNIX tools or Perl or what and I always wondered whether the file manipulation couldn't be done with XSLT as well. I never took the time to really look into it since my normal toolset worked so well and I didn't see the need though the curiosity persisted.

    So assume you have this file which consists of a header line and tab separated data (it is not really important what the entries mean, and you can see one of the well-known issues of human-readability: the column headers do not sit exactly on top of their columns in contrast to fixed field files.)

    Date    Num     Duration
    20120415        13      2310
    20120510        9       1470
    20120526        16      3817
    20120701        5       2269
    20120831        28      4505
    
    and you want to transform it into an XML file (something I would describe as XML-ifying though I'm not sure this term exists elsewhere) like this
    <?xml version="1.0"?>
    <data>
    <row><Date>20120415</Date><Num>13</Num><Duration>2310</Duration></row>
    <row><Date>20120510</Date><Num>9</Num><Duration>1470</Duration></row>
    <row><Date>20120526</Date><Num>16</Num><Duration>3817</Duration></row>
    <row><Date>20120701</Date><Num>5</Num><Duration>2269</Duration></row>
    <row><Date>20120831</Date><Num>28</Num><Duration>4505</Duration></row>
    </data>
    
    You can see that I want the header fields in the first line to become the enclosing tags for the data. For lack of something definite the root is called <data> and the various entries are <row>.

    The first important consideration when working with XSLT is the question of platform and tool which will determine the XSLT version and probable extensions.
    In my case that was MacOS 10.5.8 and its standarad xsltproc was on libs with XSLT 1.0.

    The second important issue is of course that XSLT requires XML input which my delimited text isn't at all.

    So can I be successful and what do I need?
    In XSLT terms all I have is a big string. XSLT will happily recognize it if it is wrapped a little to make it look like XML. So putting the following around the file (one can use whatever tool fits, I used simple shell echo/printf) will get XSLT started. The node name 'fakeroot' is not important, the script will not check for it.

    <?xml version="1.0"?>
    <fakeroot>
    ...
    </fakeroot>
    

    Now XSLT needs to parse the string properly. XLST 2.0 or extensions of 1.0 (e.g. EXSLT 'common') would have provided nice string functions like tokenize or node-set which would easily dissect the string and allow its chunks to be used in various ways. I wanted a pure XSLT 1.0 solution though. And this meant to handcraft the tokenization and also somehow manage the header line whose fields should become the node names for each line.

    The solution takes into account that the big input string can be split by newlines (they are part of the content) into lines and each line can then further be split by the delimiting character. Aside from the first 'match="/"' template I need three templates.

  • the first template 'match="fakeroot"' takes the whole input, chops off the first line and passes the remaining lines to the next template
  • the second template 'parseDelimited' does the split by newline: it chops off one line and feeds it to the next template and then calls itself recursively with one line less
  • the third template 'parseLine' does the split by delimiter: it chops off the content before the first delimiter and slso the header line before the delimiter,puts out the <tag>...</tag> entries and calls itself recursively with the remainder of the line after the delimiter

    The functions substring-before() and substring-after() and the recursive call of templates are the main ingredients in this whole process.

    <?xml version="1.0" encoding="ISO-8859-1"?>
    <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
    
    <!-- Define delimiter, newline and root -->  
    <xsl:variable name="delim"   select="'&#x9;'" />
    <xsl:variable name="newline" select="'&#xA;'" />
    
    <!-- Define node1 and node2 for the output -->  
    <xsl:variable name="node1"   select="'data'" />
    <xsl:variable name="node2"   select="'row'" />
    
          
    <xsl:template match="/">
      <!-- Take whatever input is coming, don't care about 'fakeroot' -->
      <xsl:call-template name="root"/>
    </xsl:template>
    
    <xsl:template name="root">
      <xsl:value-of disable-output-escaping="yes" select="concat('&lt;',$node1,'&gt;')" />
      <xsl:value-of select="$newline" />
      <xsl:call-template name="parseDelimited">
        <!-- Chop off the first line with the header fields
             It will be passed as a parameter to all other templates -->
        <xsl:with-param name="headerLine" select="substring-before(.,$newline)" />
        <xsl:with-param name="delimitedText" select="substring-after(.,$newline)" />
      </xsl:call-template>
      <xsl:value-of disable-output-escaping="yes" select="concat('&lt;/',$node1,'&gt;')" />
    </xsl:template>
      
        
    <xsl:template name="parseDelimited">   
      <xsl:param name="headerLine" />
      <xsl:param name="delimitedText" />
    
      <xsl:variable name="line" select="substring-before($delimitedText,$newline)" />
      <xsl:variable name="remaining" select="substring-after($delimitedText,$newline)" />
         
      <!-- Handle one line which has been chopped off -->
      <xsl:if test="string-length($line) > 0">
        <xsl:value-of disable-output-escaping="yes" select="concat('&lt;',$node2,'&gt;')" />
    
        <xsl:call-template name="parseLine">
          <xsl:with-param name="headerLine" select="concat($headerLine,$delim)" />
          <xsl:with-param name="line" select="concat($line,$delim)" />
        </xsl:call-template>
    
        <xsl:value-of disable-output-escaping="yes" select="concat('&lt;/',$node2,'&gt;')" />
        <xsl:value-of select="$newline" />
      </xsl:if>
    
      <!-- Call the template recursively with the remaining lines -->
      <xsl:if test="string-length($remaining) > 0">
        <xsl:call-template name="parseDelimited">
          <xsl:with-param name="headerLine" select="$headerLine" />
          <xsl:with-param name="delimitedText" select="$remaining" />    
        </xsl:call-template>
      </xsl:if>
    
    </xsl:template>
    
    
    <xsl:template name="parseLine">
      <xsl:param name="headerLine" />
      <xsl:param name="line" />
    
      <!-- Retrieve the fields before the delimiter -->
      <xsl:variable name="fieldName" select="substring-before($headerLine,$delim)" />
      <xsl:variable name="field" select="substring-before($line,$delim)" />
    
      <xsl:if test="string-length($fieldName) > 0">
        <!-- This is the actual output -->
        <xsl:value-of disable-output-escaping="yes" select="concat('&lt;',$fieldName,'&gt;',$field,'&lt;/',$fieldName,'&gt;')" />
    
        <!-- Call the template recursively with the remaining fields -->
        <xsl:call-template name="parseLine">
          <xsl:with-param name="headerLine" select="substring-after($headerLine,$delim)" />
          <xsl:with-param name="line" select="substring-after($line,$delim)" />
        </xsl:call-template>
      </xsl:if>
    
    </xsl:template>
    
    </xsl:stylesheet>
    

    There are a few tricks being used here.

  • the header line is always passed as a parameter to the templates because it cannot be stored in a global variable or an array (that is XSLT after all, not an imperative programming language)
  • in order to catch the last field I simply append a delimiter at the end of each line. This ensures that substring-before will always catch something. Otherwise I would have had to use some if-else-logic to handle the case of the last field

    Of course one can add newlines, indents etc. to change the look but that is not important for this exercise as well as parameterize the script: the delimiter could be passed as --stringparam to xsltproc in order to make it more flexible and also the output node names 'data' and 'row' could be coming from command line parameters in a true production script.


    Now looking at this of course I could have achieved the XML-ifying much simpler with this little awk script which took me less than 10 minutes to write. The XSLT exercise was nice but since this was eventually all about string handling one is probably still better off with the traditional tools.
    BEGIN { FS="TAB"; }      # Put in the real TAB character here
    NR==1 { split($0,header,FS); printf "<data>\n"}
    NR>1  {
            printf "<row>";
            for(i=1;i<=NF;i++)  printf "<%s>%s</%s>", header[i],$i,header[i];
            printf "</row>\n";
    }
    END   { printf "</data>\n" }
    
    which will result too in
    <data>
    <row><Date>20120415</Date><Num>13</Num><Duration>2310</Duration></row>
    <row><Date>20120510</Date><Num>9</Num><Duration>1470</Duration></row>
    <row><Date>20120526</Date><Num>16</Num><Duration>3817</Duration></row>
    <row><Date>20120701</Date><Num>5</Num><Duration>2269</Duration></row>
    <row><Date>20120831</Date><Num>28</Num><Duration>4505</Duration></row>
    </data>