Thursday, October 27, 2005

1371.aspx

How to replace multiple strings in XSL

It has been a while since I have done some serious xsl coding so I had some fun today when I was asked how to replace multiple strings in XSL. This is a piece of cake if you use a Microsoft XML parser (MSXML or .NET version) as you can call JavaScript using the proprietary msxml:script tag in xsl. Just write a simple function like this:


<?xml version="1.0" encoding="ISO-8859-1"?>


<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:msxsl="urn:schemas-microsoft-com:xslt" xmlns:egilh="http://egilh.com/" version="1.0">


 <msxsl:script language="JScript" implements-prefix="egilh">


 function replaceString(source, find, replaceWith) {


  var result = source;


  while (result.indexOf(find) >= 0){


   result = result.replace(find, replaceWith);


  }


  return result;


 }


 </msxsl:script>


 


 <xsl:template match="/">


   Test: <xsl:value-of select="egilh:replaceString('abcd', 'b', ' CoolOrWhat ')"/>


 </xsl:template>


</xsl:stylesheet>


 


 


Xpath 2.0 solves the problem as you use the replace function but in this case they were using a Java XSL parser...


I have tried to implement a generic and configurable solution. It uses an external XML file that contains the search and replace strings. You only have to include the replace.xsl in your stylesheet and call the template to replace as many strings as you want.


Step 1: Include the xsl
Add the following line after the <xsl:stylesheet> declaration but before the first <xsl:template>:


    <xsl:include href="replace.xsl"/>


Note: You must put the replace.xsl file in the same directory as your stylesheet or modify the href


Step 2: call the "replace  routine"
Call the replace routine which is implemented as a template. You have to pass it the following arguments:



  • the text to search in

  • the XML nodes with find/replace elements

Example:
<!-- Replace strings found in replace.xml --> 
<xsl:call-template name="replaceStrings">
  <xsl:with-param name="inputText" select="."/>
  <xsl:with-param name="search" select="document('replace.xml')/SearchAndReplace/search"/>
</xsl:call-template>


The example above workes on the current node (.). It gets the list of items to search and replace from the external file 'replace.xml'. You must put the replace.xml file in the same directory as your stylesheet or modify the path.


Step 3: configure the text to search and replace
The replace.xml file defines what to search for and the text to replace it with. It has the following format


<SearchAndReplace>


 <search>


  <find>Test1</find>


  <replace>Did you say 1?</replace> 


 </search>


</SearchAndReplace>



How it works
Replace.xsl uses two recursive templates to do its work:



  • replaceStrings (inputText, search):


    • Loops on the search nodes. It calls the replaceString() for the first element in the list, then it calls itself with the remaining elements in the list

  • replaceString(sourceText, findText, replaceText):


    • Loops on itself as long as findText is found in the sourceText.

    • It replaces the first occurrence it finds using substring, then it calls itself again. 

    • NB!: You can get never ending loops that bomb with a out of stack space errors if the findText is contained in the replaceText. Replacing "a" with "abc" for example will forever replace the first element with "abc". It is possible to avoid this by passing an offset to replaceString.


Replace.xsl


<?xml version="1.0" encoding="ISO-8859-1"?>


<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:fo="http://www.w3.org/1999/XSL/Format">  


           


      <xsl:template name="replaceStrings">


        <xsl:param name="inputText" />


        <xsl:param name="search" />


       


      <!-- Debug -->


      <!--


      [inputText: <xsl:value-of select="$inputText"/>] 


      [find: <xsl:value-of select="$search[1]/find"/>] 


      [replace: <xsl:value-of select="$search[1]/replace"/>]  


      -->


        <!-- Replace the FIRST string in the replace list -->


        <xsl:variable name="replaced_text">


            <xsl:call-template name="replaceString">


              <xsl:with-param name="sourceText" select="$inputText" />


              <xsl:with-param name="findText" select="$search[1]/find" />


              <xsl:with-param name="replaceText" select="$search[1]/replace" />


            </xsl:call-template>


        </xsl:variable>


        <xsl:choose>


            <!-- If there is (at least) one more element -->


            <xsl:when test="$search[2]">


              <!-- Call ourself with the remaining list of elements


                    (removing the first one -->


              <xsl:call-template name="replaceStrings">


                  <xsl:with-param name="inputText" select="$replaced_text" />


                  <xsl:with-param name="search" select="$search[position() > 1]" />


              </xsl:call-template>


     


            </xsl:when>


            <xsl:otherwise>


              <xsl:value-of select="$replaced_text" />


            </xsl:otherwise>


        </xsl:choose>


      </xsl:template>


     


     


      <xsl:template name="replaceString">


        <xsl:param name="sourceText"/>


        <xsl:param name="findText"/>


        <xsl:param name="replaceText"/>


        <!-- Debug statements -->


        <!--     


          [sourceText: <xsl:value-of select="$sourceText"/>]


            [findText: <xsl:value-of select="$findText"/>]


            [replaceText: <xsl:value-of select="$replaceText"/>]


         -->


        <xsl:choose>         


            <!-- If the output contains the text we are looking for -->


            <xsl:when test="contains($sourceText,$findText)"> 


              <!-- Replace using substring before + new text + substring after -->


              <xsl:value-of select="concat(substring-before($sourceText,$findText),$replaceText)"/>


              <!-- Apply template again to replace the next occurence -->


              <xsl:call-template name="replaceString">


                  <xsl:with-param name="sourceText" select="substring-after($sourceText,$findText)"/>


                  <xsl:with-param name="findText" select="$findText"/>


                  <xsl:with-param name="replaceText" select="$replaceText"/>


              </xsl:call-template>


            </xsl:when>


            <xsl:otherwise>


              <xsl:value-of select="$sourceText"/>


            </xsl:otherwise>


        </xsl:choose>


      </xsl:template>


</xsl:stylesheet>



Example Replace.xml


<SearchAndReplace>


 <search>


  <find>Test1</find>


  <replace>Did you say 1?</replace> 


 </search>


 <search>


  <find>Test2</find>


  <replace>OK!</replace>


 </search>


</SearchAndReplace>



I based my implementation on the hints I found in the following articles:


1 comment:

  1. Thanks for this. One gotcha I found with using the JScript function was if you call it with a selected node then the 'type' isn't a string.



    I got around this by converting to a string first, though I think my approach is a bit hacky (where 'comment' is a child element):



    <xsl:value-of select="ns:myFunction(concat('', comment))" disable-output-escaping="yes"/>



    Also, I wanted my function to return HTML, so I had to disable output escaping, as shown.



    Hope this helps someone, and that someone else can provide a better way of getting the element's value as a string. I'm not really an XSL guru.



    Drew Noakes.

    ReplyDelete