XSLTで効率的にインデントする方法

前にもどこかで書いたが、正規表現などによるXML文章の直接操作は危険なのでやめた方が無難だ。XML文章をいじりたいならばDOMなどのAPIを叩くべきである。というのも、例えばCDATAセクション内の、本来はいじりたくない部分まで置換されてしまったりと、細かいところで問題が起こる可能性があるからだ。XMLは微妙に複雑なので出来るだけ文字列としての扱いは避け、パースしてから扱ったほうが良いといえるだろう。

といわけで、せっかくXSLTを使うなら、もう1度インデントの為のXSL変換を行うの方法をお勧めする。メインの処理はインデント無しで行い、最後にインデントだけ行うXSL文章で整形する事により、かなり自由度の高いインデントが可能になる。参考までにHIMMMELでインデント用に使っているXSL文章は次のような感じだ。

<?xml version="1.0" encoding="UTF-8"?>

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

<xsl:output omit-xml-declaration="no" version="1.0" method="xml" indent="no" encoding="UTF-8"/>

<xsl:param name="spacer" select="'  '"/>



<xsl:template match="*[ancestor::*[./@xml:space][1]/@xml:space = 'preserve']
                     | *[ancestor::xh:pre]" priority="15">
    <xsl:element name="{local-name()}" namespace="{namespace-uri(.)}">
        <xsl:copy-of select="@*"/>
        <xsl:apply-templates/>
    </xsl:element>
</xsl:template>

<xsl:template match="*[./@xml:space = 'preserve']
                     | xh:pre" priority="14">
    <xsl:call-template name="space"/>
    <xsl:element name="{local-name()}" namespace="{namespace-uri(.)}">
        <xsl:copy-of select="@*"/>
        <xsl:apply-templates/>
    </xsl:element>
    <xsl:text>
</xsl:text>
</xsl:template>

<xsl:template match="*[following-sibling::text() or preceding-sibling::text() or ancestor::*[following-sibling::text() or preceding-sibling::text()]]" priority="13">
    <xsl:element name="{local-name()}" namespace="{namespace-uri(.)}">
        <xsl:copy-of select="@*"/>
        <xsl:apply-templates/>
    </xsl:element>
</xsl:template>

<xsl:template match="*[not(child::node())]" priority="12">
    <xsl:call-template name="space"/>
    <xsl:element name="{local-name()}" namespace="{namespace-uri(.)}">
        <xsl:copy-of select="@*"/>
        <xsl:apply-templates/>
    </xsl:element>
    <xsl:text>
</xsl:text>
</xsl:template>

<xsl:template match="*[child::text()]" priority="11">
    <xsl:call-template name="space"/>
    <xsl:element name="{local-name()}" namespace="{namespace-uri(.)}">
        <xsl:copy-of select="@*"/>
        <xsl:apply-templates/>
    </xsl:element>
    <xsl:text>
</xsl:text>
</xsl:template>

<xsl:template match="*[not(child::text())]" priority="10">
    <xsl:call-template name="space"/>
    <xsl:element name="{local-name()}" namespace="{namespace-uri(.)}">
        <xsl:copy-of select="@*"/><xsl:text>
</xsl:text>
        <xsl:apply-templates/>
        <xsl:call-template name="space"/>
    </xsl:element>
    <xsl:text>
</xsl:text>
</xsl:template>


<xsl:template match="comment()[ancestor::comment()[./@xml:space][1]/@xml:space = 'preserve']
                     | comment()[ancestor::xh:pre]" priority="7">
    <xsl:copy-of select="."/>
</xsl:template>

<xsl:template match="comment()[following-sibling::text() or preceding-sibling::text() or ancestor::*[following-sibling::text() or preceding-sibling::text()]]" priority="6">
    <xsl:copy-of select="."/>
</xsl:template>

<xsl:template match="comment()" priority="5">
    <xsl:call-template name="space"/>
    <xsl:copy-of select="."/>
    <xsl:text>
</xsl:text>
</xsl:template>


<xsl:template name="space">
    <xsl:for-each select="ancestor::*">
        <xsl:value-of select="$spacer"/>
    </xsl:for-each>
</xsl:template>

</xsl:stylesheet>

気合があれば全てのブロック要素とインライン要素をリストアップすることも出来るが、XHTML1.1の場合、これで大体思ったとおりにインデントできる。このソース自体はどの語彙でも使えるが、XHTMLだけはちょっと贔屓して、pre要素を明示的にxml:space="preserve"として扱っている。また、諸事情により変換後の要素名が{local-name()}になっているが、名前空間接頭辞を保持したいならば{name()}に変えればいいはずだ。

1つ問題点を挙げるとしたら、次のようなテキストノートを兄弟に持つブロックレベル要素が現れるとそのブロックレベル要素の中身がインデントされない。

<ul>
    <li>foo</li>
    <li>bar
        <ul><!-- ul要素の兄弟にテキストノードが! -->
            <li>baz</li>
        </ul>
    </li>
</ul>

このようなソースは次のように変換される。

<ul>
    <li>foo</li>
    <li>bar<ul><li>baz</li></ul></li>
</ul>

要素を全てリストアップしてブロックレベル要素とインライン要素に分類すれば解決できるが、さすがに面倒な上、XHTML1.1ではul要素かobject要素以外ではこのような状況になることが有り得ないので放置していいと思う。XHTML2.0だとp要素に色々なブロックレベルが含まれるようになるので適切にインデントしたいならば手直しが必要になる。