2012/01/07

相対参照を適当に考慮したHTMLファイルの移動

1月2日の続きで、自分用ひとこと日記支援システム(?)的なものをだらだら作っています。

ちなみに前回は、「HTML ファイルを生成する仕組みを作ろう」という、なんともややこしい話をしていたのでした。
※ ちなみにあの後、くろねこさんからも非常に有益なアドバイスを頂きました。記事になるところまでできたらここに書きたいと思います。
 せっかくですから、ひとこと日記を単に書き溜めるだけでなく、月別のアーカイブページなども生成できれば、情報が整理できて閲覧性も高まるのではないかと考えました(というか、至って平凡な思いつきですが)。

さて、ここで問題になるのが HTML に記述された相対参照の扱いです。

もしも HTML ファイル中に  
<img src="baka/aho/img.png" /> 
とか、 
<a href="../../hoge/piyo.html" /> 
みたいな相対参照が仕組まれていた場合、何も考えずに階層の異なる別ディレクトリ移動・複製すると、リンク切れを起こしてしまうのです。

どうにかしたいですよね。

しかし、この問題を完全に解決しようとすると果てしなく面倒なので、実用上それほど問題が出ないレベルで適当に対処しようというのが本日のテーマです。



【テキストファイルの文字コード判定】

相対リンク云々以前に、テキストファイルを扱う際にまず立ちはだかる第一の関門が文字コードです。テキストに使用される文字コードを適切に判別しないと日本語が化けてしまうのですが、使用されている文字コードを厳密に判断する完全な手段が存在しないのです(たとえば、半角カナのみを特定の配列で使用した場合など)。

ここではとりあえずテキストを読んでみて、それを基に文字コードを判定し、それから改めて文字コードを指定してファイルを読みなおす※※…という無駄な事をやります。
※ テキストの量が多いほど、精度よく文字コードを判定できるため。
※※ 初回の読み込みでは改行文字を無視していましたが、2回目の読み込みでは考慮します。

文字コードを判定するメソッドに関しては、[Javaレビュー]レビューで鍛えるJavaコーディング力 その7(文字コードチェック)に有用なサンプルがありましたので、そちらを参考にしました。

String getCharSet(StringBuffer text) 
    throws UnsupportedEncodingException {
  String[] charCodeList = {"SJIS", "EUC-JP", "UTF-8"};
  String str = text.toString();
  for(String code : charCodeList) {
    byte[] bytes = str.getBytes(code);
    if(str.equals(new String(bytes, code))) return code;
  }
  throw new UnsupportedEncodingException();
}

今回は割とメジャーな 3 つの文字コード(Shift JIS、EUC-JP、UTF-8)をサポートします。

上記の getCharSet() メソッドを用いて、文字コードを考慮した HTML ファイルを読み込む処理を書くと、以下のようになります(コードでは省略していますが、IOException が投げられる可能性があります)。

// HTMLファイルを取り敢えず読み込み
File file = new File("hoge.html");
if(!file.exists()) throw new FileNotFoundException();

BufferedReader br   = new BufferedReader(new FileReader(file));
String         line = br.readLine();
StringBuffer   src  = new StringBuffer("");

while(line != null) {
  src.append(line);
  line = br.readLine();
}

// 文字コードを取得
String charset;
try {
  charset = getCharSet(src);
} catch(UnsupportedEncodingException ex) { 
  charset = "UTF-8"; 
}

// 文字コードを指定して再度読み込み
br = new BufferedReader(
       new InputStreamReader(
         new FileInputStream(file), charset));

line = br.readLine();
src.delete(0, src.length());
while(line != null) {
  src.append(line + "\n");
  line = br.readLine();
}
System.out.println(src);

これを走らせると、StringBuffer オブジェクト src に HTML ファイルが読み込まれます。



【HTML から相対参照の抜き出し】

次に、先ほど読み込んだ HTML から相対パスを抽出する処理を考えます。

これも厳密に考えようとするとどうにも面倒なので、正規表現を用いて簡易的に抜く事にしましょう。

とりあえず、img タグなどの src 属性、a タグの href 属性、そして object タグの data 属性に対応するために、以下の正規表現を書きました。
((?:src|href|data)[\\s]*=[\\s]*\")([^\"]+)(\")
この表現は、上記の各属性に対して href="../../hoge/piyo.html" のようにマッチします。

ただし、このままでは絶対パスで表記された URI も拾ってしまうため、さらに除外用のパターンが必要です。
^\\/|^(?:https?|ftp):\\/\\/
これは、http://www.baka.com/hoge.html という文字列のうち、水色で着色した部分にマッチします。また、 /script/piyo.js のような、サーバ名を省略した絶対参照にもマッチします。

本来は、一つのパターンで的確に相対パスのみを抜く事ができればよいのですが、先ほど紹介した
  • HTML からパスを抜き出すパターン
  • 抜き出したパスから絶対参照を除外するパターン
―― のいずれもが簡易的なものであり、完全に意図した通りには機能しない事を考えると、2 段階に分割しておいた方が後々の修正もラクになる気がします。
※ 不完全というのは聞き捨てならんかも知れませんが、よしんば完全に機能するものを作っても、HTML の仕様変更に追従できなければ意味がありません。修正のしやすさを優先しました。
加えて、HTML 特有の問題として、ページ内リンクの扱いを適切に処理する必要があります。たとえば、foo.html 内に、以下のようなタグが含まれていたとしましょう。
<a href="#20120107" />
この場合は、先ほどの正規表現で抜き出したパス(#20120107)の前方に、当該 HTML ファイル名(foo.html)を挿し込み、パス情報を foo.html#20120107 に修正します。

なお、相対パスを一旦絶対パス表記に直しておくと、後々都合がよくなります。

ここで、もしも foo.html の所在が判っていれば、参照先の絶対パス表記は以下のように得る事ができます。
[foo.html の親ディレクトリの絶対パス] + [区切り文字] + [foo.html 内の相対パス]
つまり、foo.html が
 C:\Users\Tercel\Documents\html\foo.html
―― に存在して、そこから ..\img\bg.png を相対参照している場合、bg.png の絶対パスは
C:\Users\Tercel\Documents\html\..\img\bg.png
―― となります。"html\..\" のあたりがちょっと冗長ですが、これは一時的なものですのでこのままにしておいても構いません。

ソースから相対パスを抜き出して、新たな基準ディレクトリからのパスに変換する Java コードは以下のようになります。

// ========================================

// HTMLテキストからURIパスを抽出するための正規表現
final String pathRegex = "((?:src|href|data)[\\s]*=[\\s]*\")([^\"]+)(\")";

// URIが絶対パスかどうかを(割と適当に)調べる正規表現
final String absoluteURIRegex = "^\\/|^(?:https?|ftp):\\/\\/";

final Matcher pathMatcher = Pattern.compile(pathRegex, 
               Pattern.CASE_INSENSITIVE).matcher(src);
final Pattern absoluteURLPattern = Pattern.compile(absoluteURIRegex);

// HTMLファイルの相対パスを抽出
while(pathMatcher.find()) {
            
 //String header = pathMatcher.group(1);
 String path  = pathMatcher.group(2);
 //String footer = pathMatcher.group(3);
 if(!absoluteURLPattern.matcher(path).find()) {
  // もし相対パスだったら出力
  System.out.print(path);  
 }
}

ちなみに上記コードには、pathMatcher.group(x); という意味深なコードがありますが、これは正規表現中の丸括弧 ( ) でグループ化された要素を取得するための構文です。

たとえば先ほどの正規表現 ――
((?:src|href|data)[\\s]*=[\\s]*\")([^\"]+)(\")
―― 全体にマッチした文字列のうち、((?:src|href|data)[\\s]*=[\\s]*\") にマッチする部分文字列がグループ1、([^\"]+) にマッチする部分文字列がグループ2、残りの (\") がグループ3となります。

この性質を使によって、以下のようにパスのみを巧く加工する事ができるのです。
String newStr = matcher.group(1) +
                hoge(matcher.group(2)) +
                matcher.group(3);
あとは、抽出に成功したパスを適切に変換するのみとなりました。



【相対パスの変換】

最後に、任意の基点から見たファイルの相対パスを得る方法を考えます。

ここでは例として、基準ディレクトリ C:\Users\Tercel\Documents\html\aaa\bbb から見た C:\Users\Tercel\Documents\html\xxx\yyy\zzz\index.html の相対パスを得る手続きを一つひとつ確認していく事にします。

まず、両者を並べます。
C:\Users\Tercel\Documents\html\aaa\bbb
C:\Users\Tercel\Documents\html\xxx\yyy\zzz\index.html
次に、名前区切り文字(Windows 環境では "\" 記号、Unix 環境では "/" 記号)で各ファイルパスを分割します。
C:\Users\Tercel\Documents\html\aaa\bbb
C:\Users\Tercel\Documents\html\xxx\yyy\zzz\index.html
区切られたパスを先頭から比較し、共通部分を除去します。
C:\Users\Tercel\Documents\html\aaa\bbb
C:\Users\Tercel\Documents\html\xxx\yyy\zzz\index.html
この時点で残った基点の階層の深さは 2(aaabbb)なので、相対パスには親ディレクトリを参照する ".." を 2 つ並べて、対象の残ったパスを続けます。
../../xxx/yy/zzz/index.html
あとは、各階層を Unix デフォルトの区切り文字 "/" で区切れば完成。
../../xxx/yy/zzz/index.html
これをメソッド化すると、以下のようなコードになります。

// ある場所から見たファイルの相対パスを返すよ
//
// 引数1 basisPath  ... 相対パスの基準
// 引数2 targetPath ... 対象ファイルのパス
String getRelativePath(String basisPath, String targetPath) {
  
  File basisFile  = new File(basisPath).getAbsoluteFile();
  File targetFile = new File(targetPath).getAbsoluteFile();
  
  // Java実行環境が使用するファイルの区切り文字の取得
  String separator = File.separator;
  
  // セパレータの正規表現(Windows環境の場合は"\"記号をエスケープ)
  String separatorRegex = separator.replaceAll("\\\\", "\\\\\\\\");
  
  
  // 相対パス表現の基準となるディレクトリ
  File basisDir = basisFile.isDirectory() ? 
    basisFile : basisFile.getParentFile();
  String basisDirPath = basisDir.getAbsolutePath();
  
  // ターゲットとなるディレクトリ
  File targetDir;
  String targetFileName;
  if(targetFile.isDirectory()) {
    targetDir      = targetFile;
    targetFileName = "";
  } else {
    targetDir = targetFile.getParentFile();
    targetFileName = targetFile.getName();
  }
  String targetDirPath  = targetDir.getAbsolutePath();

  // ディレクトリ階層を格納
  String[] basis  = basisDirPath.split(separatorRegex);
  String[] target = targetDirPath.split(separatorRegex);
  
  // 相対パスを生成
  StringBuffer path = new StringBuffer("");
  
  int cIndex;
  int limit = Math.min(target.length, basis.length);
  
  for(cIndex = 0; cIndex < limit; ++cIndex)
    if(!target[cIndex].equals(basis[cIndex])) break;

  for(int i = cIndex; i < basis.length; ++i)
    path.append("../");

  for(int i = cIndex; i < target.length; ++i)
    path.append(target[i] + "/");

  path.append(targetFileName);
  
  return path.toString();
}

このメソッドは、だいたい正しく動作します。

例外的に、
  • 基準となるディレクトリや対象のファイルが存在しない場合
  • 相対パスの基準と対象のファイルがそれぞれ異なるドライブである場合
―― は、期待した結果を得る事ができない場合があります。

また、注意すべき点として、引数に相対パスを与えた場合、(相対参照の)基準はデフォルトで Java 仮想マシンの呼び出し元になります。何を言っているか分からない場合は(ごめんなさい)、両者ともに絶対パスを与えた方が無難でしょう。

問題を知っていて放置するのは気が引けるので、一応この 2 点に関する回避策を述べておきます。

まず前者の場合、基準・あるいは対象ファイルが存在する事を事前に調べる事ができます。なければ一時的にディレクトリを作り、パスを得た後でディレクトリを削除するといった方法で対処できます(ちなみに今回の場合は、基準と対象はどちらも実在のディレクトリであるため、この問題に関してはあまり考える必要はないと思います)。

後者の場合、異なるドライブに対して相対参照する事はできないので、適当な例外を投げて終わるくらいが妥当なのでしょう。それ以上のお節介処理は、「相対パスを得る」という名前から想像できない挙動なので、好ましいとは言えません。



というわけで、HTML ファイルから相対パス抜き、さらにそれを別の基点から見た相対パスに変換するための道具が一応揃いました。

めでたし。

0 件のコメント:

コメントを投稿

ひとことどうぞφ(・ω・,,)