Thymeleafで名前空間修飾付きの属性をレンダリングする
テンプレート・エンジンThymeleafはその文法自体をXMLの構文ルールに従わせることで「そのままブラウザで参照可能なHTMLやXHTMLのファイルとしてテンプレート・ファイルをつくる」ことを可能にしています。
これはこのテンプレート・エンジンを非常に便利なものとしているポイントです。しかし一方で同じ理由から、名前空間の修飾が付いた属性のレンダリングが(標準では)できないという欠点も生まれます。私がこの問題に直面したのはSVG画像ファイルの処理をThymeleafで行おうとしたときのことです。
Thymeleafは標準語(Standard Dialect)としてHTML向けに多くの機能を提供しています。HTMLテンプレートでは多くのHTML属性と同じ名前の属性がth:*
形式で提供されており、それらの属性を通じて対応するHTMLの属性の値を設定できます。当然のことながらSVGも含む他のXMLサブセットにはそのような便宜は提供されていません。
また名前空間の問題もあります。一例として、SVGではAタグとXLink(XMLリンク付け言語)のhref
属性を使ってハイパーリンクを表現しますが、XLinkはSVGとは別の名前空間を持つ独立した仕様のため、<a xlink:href="...">...</a>
という構文で記述をしなくてはなりません。一方でThymeleafも名前空間を使用して各種の機能を提供しているため問題が発生します。
パターン1:HTMLにも同名の属性がある場合
一応「問題なし」のパターン。TemplateEngine#setTemplateMode(String)
メソッドでモードとして"XML"
を指定していても、width
やheight
などHTML・XHTMLにも同じ名前の属性が存在する場合はth:*="..."
形式で値(式)を指定できます。おかげでSVGのwidth
やheight
をレンダリングするのは簡単です。ただしこの挙動は将来的に変更される可能性/リスクがあると思います。
パターン2:HTMLに同名の属性がない場合
「問題なし」ただし煩雑になってしまうパターン。fill
などHTML・XHTMLに同じ名前の属性が存在しない場合はth:*="..."
形式で記述しても無視され、レンダリング結果にそのまま出力されてしまいます。Thymeleaf標準語でこれらの属性の値(式)を指定するにはth:attr
属性を使用する必要があります。例えばth:attr="fill=${...}"
というふうにします。少々煩雑です。ともあれこの記法で済ませられるならそうしておくべきでしょう。
パターン3:名前空間の修飾が必要な属性である場合
「問題あり」のパターン。xlink:href
のように元々名前空間による修飾が付いている場合th:attr
属性も使用できません。th:attr="xlink:href=..."
という記述をすると構文エラーになってしまいます。したがってこうした属性を処理するにはThymeleaf標準語を拡張した独自のディアレクトを作成して、テンプレート・エンジンに設定してやる必要があります。
(ご察しの通り、これは正確に言えばXML名前空間云々の話ではなく、th:attr
属性のパース・ロジックの問題です。将来的にはThymeleaf標準語のパース・ロジックが変更になって、コロンを含む属性アサイン式を柔軟に処理できるようになる可能性はあります。パターン3について一番最初に考えた解決策も当該ロジックを何らかの方法で置き換えることはできないかということでした。しかし、Thymeleafのコードは良くも悪くもおびただしい数の抽象クラスからなる継承関係を持っており、当該のロジックを特定するのもその差し替えを行うのも容易でなさそうでした。よって当座は独自のディアレクトをつくるというのが現実的な選択肢であると判断しました。)
パターン3の問題を解決するためThymeleaf標準語を拡張する
公式ドキュメントにもあるとおり、Thymeleaf標準語を拡張するにはいくつかのインターフェースを実装してやる必要があります。今回はIDialect
とIProcessor
という2つのインターフェースを実装します。もっともThymeleaf標準語をちょっぴり拡張するのが目的なので、カスタムメイドのコードもそう多くはなりません。
IDialect
インターフェースの実装:
/** * カスタム・ディアレクトを定義. * ただしほとんどすべての処理と実装を標準語(Standard Dialect)に委譲する。 */ public class MyDialect implements IDialect { /** * Thymeleaf標準語のディアレクト. */ private final IDialect inner = new StandardDialect(); @Override public Set<IDocTypeResolutionEntry> getDocTypeResolutionEntries() { // 標準語に委譲 return inner.getDocTypeResolutionEntries(); } @Override public Set<IDocTypeTranslation> getDocTypeTranslations() { // 標準語に委譲 return inner.getDocTypeTranslations(); } @Override public Map<String, Object> getExecutionAttributes() { // 標準語に委譲 return inner.getExecutionAttributes(); } @Override public String getPrefix() { // 標準語に委譲 return inner.getPrefix(); } @Override public Set<IProcessor> getProcessors() { // プロセッサにはカスタムメイドのものを追加するのでまずセットを新規作成 final Set<IProcessor> mySet = new HashSet<IProcessor>(); // 標準語のプロセッサ群をそっくりそのまま取り込む mySet.addAll(inner.getProcessors()); // 加えてカスタムメイドのプロセッサも追加する mySet.add(new MyProcessor()); // 呼び出し元に返す return mySet; } }
ご覧のとおり、ほとんどすべての処理はStandardDialect
クラスに委譲しています。
IProcessor
インターフェースの実装:
/** * カスタムメイドのプロセッサ. * {@code "xlink:href"}属性の処理を担当する。 * テンプレートで{@code "th:xlink-href=..."}という形式で指定された値(式)を読み取り、 * 他のThymeleaf標準語がサポートする属性と同じく評価した結果を使って、 * {@code "xlink:href=..."}という形式でレンダリングを行う。 * テンプレート上で元々記述されていた{@code "xlink:href"}属性の値は上書きされる。 */ public class MyProcessor extends AbstractStandardSingleAttributeModifierAttrProcessor { /** * このプロセッサが処理を担当する属性の名称(加工前のもの). * {@code "xlink"}と{@code "href"}の区切り文字はコロン * (テンプレートでは{@code "th:xlink:href=..."}という記述になる)ではXMLとして不正。 * Thymeleafテンプレート・エンジンで処理する分には問題ないのだが、 * テンプレートをWebブラウザ等で表示すると構文エラーを指摘されてしまう。 * したがってコロンを何かしらの文字で置き換えて代替させる必要がある。 * */ private static final String attributeName = "xlink-href"; protected MyProcessor() { // 親クラスのコンストラクタに渡す属性名は加工前のもの super(attributeName); } @Override protected String getTargetAttributeName(Arguments arg0, Element arg1, String arg2) { // このメソッドが返す属性名は加工後のもの(レンダリングに使用される) return "xlink:href"; } @Override protected ModificationType getModificationType(Arguments arg0, Element arg1, String arg2, String arg3) { // 既存の属性を置き換えるよう指定する return ModificationType.SUBSTITUTION; } @Override protected boolean removeAttributeIfEmpty(Arguments arg0, Element arg1, String arg2, String arg3) { // 値なしでは意味がない属性なのでtrueを返す return true; } @Override public int getPrecedence() { // 優先順位はどうでもいいので0を返す return 0; } }
このクラスもほとんどすべての処理を親クラスに任せています。このクラス独自の機能はgetTargetAttributeName(Arguments, Element, String)
メソッドによる属性名の置換くらいのものです。そしてこれこそが重要なポイントです。
あとはこのカスタムメイドのディアレクトを使ってテンプレート・エンジンを初期化し、テンプレート・ファイルを処理させるだけです。
ExtendingDialect
クラス:
public final class ExtendingDialect { public void execute(final String[] args) throws Exception { final TemplateEngine engine = initializeTemplateEngine(); final Context ctx = makeContext(); ctx.setVariable("imageWidth", 500); ctx.setVariable("imageHeight", 500); ctx.setVariable("circleFill", "green"); ctx.setVariable("pathToReferredContent", "path/to/referred/content.html"); final Writer writer = makeWriter("image.svg"); engine.process("image", ctx, writer); writer.close(); } private TemplateEngine initializeTemplateEngine() { // エンジンをインスタンス化 final TemplateEngine engine = new TemplateEngine(); engine.setDialect(new MyDialect()); // テンプレート解決子をインスタンス化 final TemplateResolver resolver = new ClassLoaderTemplateResolver(); // 種々の設定を行う resolver.setPrefix("templates/"); resolver.setSuffix(".svg"); resolver.setTemplateMode("XML"); resolver.setCharacterEncoding("utf-8"); engine.setTemplateResolver(resolver); return engine; } private Context makeContext() { final Context ctx = new Context(); return ctx; } private Writer makeWriter(final String out) throws FileNotFoundException { return new BufferedWriter( new OutputStreamWriter( new FileOutputStream(out), Charset.forName("utf-8"))); } public static void main(String[] args) throws Exception { new ExtendingDialect().execute(args); } }
テンプレート・エンジンの初期化に際して先ほど定義したカスタムメイドのディアレクトを使用している点に注意してください。続いてテンプレート・ファイルをつくります。
image.svg
テンプレート・ファイル:
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:th="http://www.thymeleaf.org" contentScriptType="text/ecmascript" zoomAndPan="magnify" contentStyleType="text/css" preserveAspectRatio="xMidYMid meet" version="1.0" width="400" height="400" th:width="${imageWidth}" th:height="${imageHeight}"> <a xlink:href="#" th:xlink-href="${pathToReferredContent}"> <circle fill="blue" th:attr="fill=${circleFill}" r="100.0" cx="200" cy="200"/> </a> </svg>
SVG画像ファイルのテンプレートのなかでは前述の属性値指定の3パターンがすべて登場します。パターン3に該当するものとしてはth:xlink-href="..."
という記述になっています。th:xlink:href="..."
ではNGです。これではXMLとして不正な記述になってしまいます。詳細はIProcessor
のドキュメンテーション・コメントを参照してください。