PHP : してログ

SJISのテキストファイルなどで機種依存文字(①,②,③,Ⅰ,Ⅱ,Ⅲ,㈱,㎝ など)を含むデータを読み込むときに、これらの文字が「?」に化けないようにします。例えば、Windowsで作成されたCSVファイルですが、次のように変換してしまうと「?」に化けてしまいますので要注意です。

mb_convert_encoding($str, 'UTF8', 'SJIS');

機種依存文字が無い場合はこれでもうまく行きますが、それが含まれる場合は次のように変換します。

mb_convert_encoding($str, 'UTF8', 'SJIS-win');

もしくは、

mb_convert_encoding($str, 'UTF8', 'CP932');

当たり前だが、UTF-8以外に変換すると化けるし、「~」などダメな場合もあり、オールマイティじゃないので注意です。そうした場合は、予め置換しておくなどの対処が必要かと思います。

XYZ 方式のタイルレイヤで、用意されていないズームレベルのタイルを、それより低い解像度のタイルで代用したいときがあります。地理院地図で細かい地図を表示したくないときや、容量やレンダリング負荷を減らしたい場合に有効なテクニックです。OpenLayers2 は不可能だと思って PHP で対応していたのですが、今回可能だということが分かったので紹介します。

OpenLayers2

serverResolutions にサーバーでサポートする解像度を配列で指定しておくと、それ以外のズームレベルで拡大表示が行われます。解像度は1ピクセル当たりのメートル単位になっており、下記ソースコードの配列で0~18の順で並んでいます。拡大表示を確認するには、下側の解像度をいくつかコメントアウトしてみてください。

var map = new OpenLayers.Map({
	div: "map",
	projection: "EPSG:3857",
	displayProjection: "EPSG:4326",
	numZoomLevels: 19,
	layers: [
		new OpenLayers.Layer.XYZ(
			"GSI Maps", 
			"https://cyberjapandata.gsi.go.jp/xyz/std/${z}/${x}/${y}.png",
			{
				serverResolutions: [
					156543.03390625,
					78271.516953125,
					39135.7584765625,
					19567.87923828125,
					9783.939619140625,
					4891.9698095703125,
					2445.9849047851562,
					1222.9924523925781,
					611.4962261962891,
					305.74811309814453,
					152.87405654907226,
					76.43702827453613,
					38.218514137268066
					19.109257068634033,
					9.554628534317017,
					4.777314267158508,
					2.388657133579254
					1.194328566789627,
					0.5971642833948135
				]
			}
		)
	],
	center: new OpenLayers.LonLat(139, 37).transform("EPSG:4326", "EPSG:3857"),
	zoom: 5
});
OpenLayers3

maxZoom にサーバーでサポートするズームレベルを指定すると、それ以外のズームレベルで拡大表示が行われます。下記ソースコードでは、ズームレベル11以上は10の拡大表示になります。

var map = new ol.Map({
	target: 'map',
	layers: [
		new ol.layer.Tile({
			source: new ol.source.XYZ({
				url: "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png",
				maxZoom: 10
			})
		})
	],
	view: new ol.View({
		center: ol.proj.fromLonLat([139, 37]),
		zoom: 5
	})
});
Leaflet

Leaflet では maxNativeZoom を指定することで、同様の拡大表示を実現可能です。

PHP で無理やり対応(おまけ)

OpenLayers2 にはそういった機能が無いと思って、PHP で動的に拡大カットするラッパーを組んでしまいました。必要性が無くなったものの、他にも応用が効くコードかと思いますので公開しておきます。

  • シンプルにするために、以下のコードでは入力パラメータをチェックしていませんのでご注意ください
  • $max_zoom にはタイルレイヤで用意されている最大ズームレベルを入れてください
$z = $_REQUEST['z'];
$x = $_REQUEST['x'];
$y = $_REQUEST['y'];

$max_zoom = 12;

if ($z > $max_zoom) {

	// $max_zoom のタイルを切り取って拡大する

	$pw = pow(2,$z - $max_zoom);

	$zz = $max_zoom;
	$xx = floor($x / $pw);
	$yy = floor($y / $pw);

	$dw = 256;
	$sw = floor($dw / $pw);
	$sx = $x % $pw * $sw;
	$sy = $y % $pw * $sw;

	$path = "./layers/tile/{$zz}/{$xx}/{$yy}.png";

	if (file_exists($path)) {

		$src = imagecreatefrompng($path);

		$dst = imagecreatetruecolor($dw,$dw);
		imageAlphaBlending($dst,false);
		imageSaveAlpha($dst,true);
		$transparent = imageColorAllocateAlpha($dst, 0,0,0, 0);
		imageFill($dst, 0,0, $transparent);

		imagecopyresampled($dst, $src, 0,0, $sx,$sy, $dw,$dw, $sw,$sw);

		imagepng($dst);
		exit;

	} else {

		header("HTTP/1.1 404 Not Found");
		exit;
	}

} else {

	// 実ファイルにリダイレクト

	$url = "./layers/tile/{$z}/{$x}/{$y}.png";

	header('Location: '.$url);
	exit;

}
関連ページ

連想配列の配列をループしながら、分類項目でキーブレイクして階層的な HTML に出力したい場合、前後のタグを挟む条件分岐や最終部分の閉じタグの書き方が煩雑になりがちです。こういった処理は度々書いていますが、もう少し簡潔に書けないものかとマニュアルを読んでいたところ、currentnext などの関数が使えそうでしたので試してみました。

例として、都道府県で市町村をグループ化して、セレクトボックスの HTML を出力するコードを書きました。まだ、$opt の初期化が2箇所にあるのが気に入りませんが、タグでラップする部分は分かりやすいと思います。

$dataset = array(
	array('pref'=>'新潟県','city'=>'新潟市'),
	array('pref'=>'新潟県','city'=>'長岡市'),
	array('pref'=>'新潟県','city'=>'上越市'),
	array('pref'=>'新潟県','city'=>'村上市'),
	array('pref'=>'長野県','city'=>'長野市'),
	array('pref'=>'長野県','city'=>'松本市'),
	array('pref'=>'長野県','city'=>'上田市'),
	array('pref'=>'長野県','city'=>'岡谷市'),
	array('pref'=>'石川県','city'=>'金沢市'),
	array('pref'=>'石川県','city'=>'七尾市'),
	array('pref'=>'石川県','city'=>'小松市'),
	array('pref'=>'石川県','city'=>'輪島市'),
);


$code = $opts = '';

while ($row = current($dataset)) {

	$opts.= "<option>{$row['city']}</option>";

	if (!($ner = next($dataset)) || $row['pref'] != $ner['pref']) {
		$code.= "<optgroup label='{$row['pref']}'>{$opts}</optgroup>";
		$opts = '';
	}

}

echo "<select>{$code}</select>";

相対 URL を絶対 URL に解消する関数を探したのですが、ぴったりのものが見つからないので自分で作ってみました。ただし、簡単そうにみえて難しい部類なので、あらゆるパターンで完璧かどうかは保証できません。なお、HTML を処理すると言っても、<a>、<img> の2つのみが処理対象になっています。他のタグを含めるには、正規表現のパターンを追加してください。

ソースコード
function html_absolute_url($base,$html) {

	return preg_replace_callback(

		'/(<a\s+.*?href=")(.+?)(".*?>)'.
		'|(<a\s+.*?href=\')(.+?)(\'.*?>)'.
		'|(<img\s+.*?src=")(.+?)(".*?>)'.
		'|(<img\s+.*?src=\')(.+?)(\'.*?>)/',

		create_function(
			'$matches',
			'$base = unserialize(\''.serialize($base).'\');'.
			'$n = (count($matches)-1)/3-1;'.
			'$url = $matches[$n*3+2];'.
			'$url = absolute_url($base,$url);'.
			'return $matches[$n*3+1].$url.$matches[$n*3+3];'
		),

		$html
	);

}

function absolute_url($base,$url) {

	if (substr($base,-1)!='/') $base.='/';

	$pbase = parse_url($base);

	$purl = parse_url($url);

	if (!isset($purl['scheme'])) {
		if (!isset($purl['host'])) {
			// スキームとホストが省略されている
			if (substr($url,0,1)=='/') {
				// 絶対URL
				$url = $pbase['scheme'].'://'.$pbase['host'].$url;
			} else {
				// 相対URLと判定(./と../の解消は行わない)
				$url = $base.$url;
			}
		} else {
			// スキームのみ省略されている
			$url = $pbase['scheme'].':'.$url;
		}
	} else {
		// 完全なURLと判定
	}

	return $url;
}

html_absolute_url が HTML を処理する関数で、absolute_url が URL を個別に処理する関数です。PHP5.3 未満に対応するために、preg_replace_callback のコールバック関数でクロージャを使用しておらず、変数の値を無理やり展開して渡しています。また、正規表現の中でシングルとダブルクオーテーションをそれぞれ記述していますが、まとめてうまく表現できないものかと思います。もっとスマートに書ける人がいたら、教えて欲しいです。

PHP ソースコードの標準的なエンコーディングは UTF-8 になっており、そのまま記述すればどのような記号も問題ないはずです。しかし、使っているエディタによっては文字化けしたりしますので、一般的な記号以外はあまりお勧めしません。こういった文字は「\uXXXX」のようなコードポイント数値表現を json_decode でデコードすることで記述できます。

「♥」を記述したいとき
json_decode('"\u2665"')

PCRE 修飾子で「s」を指定した場合「ドットメタ文字は改行を含むすべての文字にマッチします」、「 これを設定しない場合は、改行にはマッチしません」とあります。例えば「/^(.+)$/m」のように書けば、複数行テキストを配列に取ることができるはずなのですが、実際やってみると改行を含むテキストが返ってきます。対処法で trim を掛けたりしていましたが、せっかくなので調べてみました。

改行の意味するもの

改行の種類として「LF」「CR+LF」「CR」の3種類があると思いますが、実際にテストコードを書いてみたところ、ドットメタ文字は「CR」にマッチし「LF」にマッチしませんでした。マニュアルには単に「改行」と書かれているので、これらのすべてに対応しているものと思い込んでおりましたが、違うようです。従って、上の例で改行に見えていたのは「CR+LF」の「CR」が残っていたものと思われます。

改行を配列に取る正しい例

原因が分かれば話は簡単で、文字クラスの否定を使って「CR」及び「LF」を指定すれば良いだけです。

preg_match_all('/^([^\r\n]+)$/m', $text, $match);
まとめ

マニュアルなどで単に「改行」と言った場合は「LF」のみを指すと思って良さそうです。プログラム書くときも特に意識しないで "\n" を使ってますし、UNIX 系では「LF」なのだから当然と言えば当然でした。毎回悩むのもアレなので、改行テキストを綺麗な配列に取る関数を自作しておくと楽かも知れません。

要注意なのは、HTML の改行コードが「CR+LF」で規定されている点です。このため、textarea で POST されたデータは「CR+LF」になっており、取り扱う際に注意しなければなりません。また、PHP などで HTML を出力する際の改行コードを「LF」のみとしてしまうのは、本来間違いということになります(今まで結構やってたなあ)。多くの場合、「LF」で出力しても、「CR+LF」「LF」が混在していてもブラウザの表示には影響しませんが、気になる方は HTML は「CR+LF」と覚えておくといいかも知れません。

EXIFでは少数を表すのに、RATIONAL型という分数を使います。 PHPのEXIF関数で取り出し可能ですが、正しく取り出せない場合があるので注意が必要です。 RATIONAL型は、分子と分母で2つの符号なし32bit整数だということですが、PHPのEXIF関数では符号付き32bit整数で取り出してしまうようです。 通常は符号のありなしが、問題になるような桁が出てくるのは稀で、単に分数として計算すれば良いはずです。 しかし、稀にGPSの緯度経度情報でマイナス値が取得されることがあり、対策をしておいた方が良いようです。 (符号なし整数の大きな値は、符号あり整数ではマイナス値となります)

ちなみにPHPの整数値は、環境に依存しており PHP_INT_SIZE で取得(バイト数)できます。 しかし、64bit OS(PHP_INT_SIZE=8 の環境)でもEXIF関数はマイナス値を返しました。 マニュアルを見ると「PHP 7 より前のバージョンにおける Windows は例外で、Windows で PHP 7 より前のバージョンを使う場合はは常に 32 ビットとなります」とあり、それなら 64bit Linux はどうかと試してみましたが、やっぱりだめでした。 この件は、EXIF関数のバグだと思われます。

原因が特定できたので、対策は割りと簡単にできます。 4バイト符号付きを符号なしに読み替えれば良いので、まず16進数に直してから、下位4バイトを残せばいいだけなので、下記のようになります。

hexdec( substr( dechex( $v ), -8 ) );

このコードですが、不思議な事に 32bit 環境でもうまく行きます。 理由は、hexdex 関数のマニュアルに「この関数は、プラットフォームの integer 型に収まらない大きな数も変換できます。 その場合、結果は float で返します。」とあり納得しました。 というか型付けができないので、dechex('FFFFFFFFFFFFFFFF') としても -1 にならず、大きな浮動小数点数に変換されます。

話をEXIFに戻すと、気をつけなければならないのは、LONG型とRATIONAL型です。 LONG型は符号なし32bit整数であり、RATIONAL型はLONG型のペアで表される分数です。 従って、これらの数値にマイナスが入ることは無く、その場合のみ前述の読み替えをすれば良いのです。 これとは別に、SIGNED LONG型とSIGNED RATIONAL型もあるので注意が必要です。 言うまでもなく、これらの型は符号ありなため、負数を許容しています。

これらを踏まえて、LONG型、RATIONAL型、及び緯度経度を正しい値で読み込む関数を作成しました。 実際は、レンズの焦点距離(RATIONAL型)や画像幅(LONG型)では問題になることは無く、そのまま使用して差し支えないと思います。 しかし、緯度経度では実際にそのようなデータを確認したことがあるため、対策をしておいた方が良いでしょう。

// LONG型を修正
function exif_long($v) {
	if ($v<0) {
		return hexdec(substr(dechex($v),-8));
	} else {
		return $v;
	}
}

// RATIONAL型を修正
function exif_rational($v) {
	list($num,$den) = explode('/', $v);
	$num = exif_long($num);
	$den = exif_long($den);
	return $num/$den;
}

// GPS緯度経度を十進に変換(西経と南緯の場合は負数にする処理が必要)
function exif_dms($dms) {
	$d = 0;
	for ($i=0;$i<3;$i++) {
		$v = $dms[$i];
		$d += exif_rational($v)/pow(60,$i);
	}
	return $d;
}

ZipArchive クラスで zip 圧縮したとき、作業ファイルは作成されているようだが、最終的に zip ファイルにリネームされずに消滅してしまうことがありました。 これは圧縮後のファイルサイズが 2GB を超えたためでしたので、適当なサイズになるように分割して解決しました。 ただし、圧縮後のファイルサイズを推計できないので、場合によっては厄介な問題かも知れません。

そのほか、filesize をはじめ PHP の関数では 2GB の壁がありますので、大きなサイズのファイルを扱って期待通りに動作しない場合は、まず疑ってみたほうが良いかも知れません。