はじめに
Webアプリケーション開発で、ユーザーが入力したテキストの改行をHTMLで正しく表示したいとき、どのように処理していますか?PHPのnl2br
関数は、テキスト内の改行文字(\n、\r\n、\r)を HTML の<br>
タグに変換する非常に便利な関数です。
この記事では、nl2br
関数の基本的な使い方から実践的な応用例まで、詳しく解説していきます。
nl2br関数とは?
nl2br
は「New Line to Break」の略で、テキスト内の改行文字を HTML の<br>
タグまたは<br />
タグに変換する関数です。主にユーザー入力のテキストをWebページで表示する際に使用されます。
基本的な構文
string nl2br(string $string, bool $use_xhtml = true)
パラメータ
- $string: 変換するテキスト文字列
- $use_xhtml: XHTML準拠の
<br />
を使用するか(デフォルト: true)
戻り値
- 改行文字が
<br>
または<br />
タグに変換された文字列
基本的な使い方
1. シンプルな改行変換
<?php
$text = "こんにちは\nこれは2行目です\r\nこれは3行目です";
echo "変換前:\n";
echo $text;
echo "\n\n変換後:\n";
echo nl2br($text);
/*
変換前:
こんにちは
これは2行目です
これは3行目です
変換後:
こんにちは<br />
これは2行目です<br />
これは3行目です
*/
?>
2. XHTML形式とHTML形式
<?php
$text = "1行目\n2行目\n3行目";
// XHTML形式(デフォルト)
echo "XHTML形式:\n";
echo nl2br($text, true);
echo "\n\n";
// HTML形式
echo "HTML形式:\n";
echo nl2br($text, false);
/*
XHTML形式:
1行目<br />
2行目<br />
3行目
HTML形式:
1行目<br>
2行目<br>
3行目
*/
?>
3. 異なる改行文字の処理
<?php
// 様々な改行文字を含むテキスト
$text = "Unix改行\nWindows改行\r\nMac改行\r混合改行";
echo "元のテキスト(改行文字を可視化):\n";
echo str_replace(["\n", "\r\n", "\r"], ["\\n", "\\r\\n", "\\r"], $text);
echo "\n\n";
echo "nl2br変換後:\n";
echo nl2br($text);
/*
元のテキスト(改行文字を可視化):
Unix改行\nWindows改行\r\nMac改行\r混合改行
nl2br変換後:
Unix改行<br />
Windows改行<br />
Mac改行<br />
混合改行
*/
?>
実践的な使用例
1. ユーザー入力の表示
<?php
class TextDisplayer {
public static function displayUserText($text, $options = []) {
$options = array_merge([
'escape_html' => true,
'convert_newlines' => true,
'use_xhtml' => true,
'max_length' => null,
'add_paragraphs' => false
], $options);
// HTMLエスケープ
if ($options['escape_html']) {
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
// 長さ制限
if ($options['max_length'] && mb_strlen($text) > $options['max_length']) {
$text = mb_substr($text, 0, $options['max_length']) . '...';
}
// 改行変換
if ($options['convert_newlines']) {
if ($options['add_paragraphs']) {
// 連続する改行を段落に変換
$text = self::convertToParagraphs($text);
} else {
// 単純にbrタグに変換
$text = nl2br($text, $options['use_xhtml']);
}
}
return $text;
}
private static function convertToParagraphs($text) {
// 連続する改行で段落を分割
$paragraphs = preg_split('/\n\s*\n/', trim($text));
$result = '';
foreach ($paragraphs as $paragraph) {
$paragraph = trim($paragraph);
if ($paragraph) {
// 段落内の単一改行はbrタグに変換
$paragraph = nl2br($paragraph, true);
$result .= '<p>' . $paragraph . '</p>' . "\n";
}
}
return $result;
}
}
// 使用例
$userInput = "これは最初の段落です。\n改行を含んでいます。\n\nこれは2つ目の段落です。\nこちらも改行があります。";
echo "=== 基本表示 ===\n";
echo TextDisplayer::displayUserText($userInput);
echo "\n\n=== 段落付き表示 ===\n";
echo TextDisplayer::displayUserText($userInput, ['add_paragraphs' => true]);
echo "\n\n=== HTML形式 ===\n";
echo TextDisplayer::displayUserText($userInput, ['use_xhtml' => false]);
?>
2. フォーム処理とバリデーション
<?php
class FormProcessor {
private $errors = [];
public function processTextArea($name, $input, $rules = []) {
$rules = array_merge([
'required' => false,
'max_length' => 1000,
'min_lines' => null,
'max_lines' => null,
'allow_html' => false,
'preserve_formatting' => true
], $rules);
// 必須チェック
if ($rules['required'] && empty(trim($input))) {
$this->errors[$name] = 'この項目は必須です';
return null;
}
if (empty(trim($input))) {
return '';
}
// 長さチェック
if ($rules['max_length'] && mb_strlen($input) > $rules['max_length']) {
$this->errors[$name] = "最大{$rules['max_length']}文字まで入力できます";
return null;
}
// 行数チェック
$lines = explode("\n", $input);
$lineCount = count($lines);
if ($rules['min_lines'] && $lineCount < $rules['min_lines']) {
$this->errors[$name] = "最低{$rules['min_lines']}行必要です";
return null;
}
if ($rules['max_lines'] && $lineCount > $rules['max_lines']) {
$this->errors[$name] = "最大{$rules['max_lines']}行まで入力できます";
return null;
}
// HTMLエスケープとフォーマット処理
$processed = $input;
if (!$rules['allow_html']) {
$processed = htmlspecialchars($processed, ENT_QUOTES, 'UTF-8');
}
if ($rules['preserve_formatting']) {
$processed = nl2br($processed);
}
return $processed;
}
public function getErrors() {
return $this->errors;
}
public function hasErrors() {
return !empty($this->errors);
}
public function getFormattedErrors() {
if (empty($this->errors)) {
return '';
}
$html = '<div class="errors"><ul>';
foreach ($this->errors as $field => $error) {
$html .= '<li><strong>' . htmlspecialchars($field) . ':</strong> ' . htmlspecialchars($error) . '</li>';
}
$html .= '</ul></div>';
return $html;
}
}
// 使用例
$processor = new FormProcessor();
// サンプルフォームデータ
$formData = [
'name' => '田中太郎',
'message' => "こんにちは!\n\nお問い合わせがあります。\n詳細は以下の通りです。\n\n- 項目1\n- 項目2\n- 項目3",
'comment' => "短いコメント"
];
echo "=== フォーム処理結果 ===\n";
$processedName = $processor->processTextArea('name', $formData['name'], [
'required' => true,
'max_length' => 50,
'preserve_formatting' => false
]);
$processedMessage = $processor->processTextArea('message', $formData['message'], [
'required' => true,
'max_length' => 500,
'min_lines' => 3,
'max_lines' => 20
]);
$processedComment = $processor->processTextArea('comment', $formData['comment'], [
'required' => false,
'max_length' => 100
]);
if ($processor->hasErrors()) {
echo "エラーが発生しました:\n";
print_r($processor->getErrors());
} else {
echo "名前: $processedName\n\n";
echo "メッセージ:\n$processedMessage\n\n";
echo "コメント: $processedComment\n";
}
?>
3. メール送信での改行処理
<?php
class EmailFormatter {
public static function formatTextEmail($subject, $body, $options = []) {
$options = array_merge([
'line_length' => 72,
'convert_html_breaks' => true,
'add_signature' => true,
'encoding' => 'UTF-8'
], $options);
// HTMLのbrタグを改行文字に戻す
if ($options['convert_html_breaks']) {
$body = self::br2nl($body);
}
// 行の長さを調整
if ($options['line_length']) {
$body = self::wordWrap($body, $options['line_length']);
}
// 署名を追加
if ($options['add_signature']) {
$body .= "\n\n--\n送信元: システム自動送信";
}
return [
'subject' => $subject,
'body' => $body,
'headers' => [
'Content-Type' => 'text/plain; charset=' . $options['encoding'],
'Content-Transfer-Encoding' => '8bit'
]
];
}
public static function formatHtmlEmail($subject, $body, $options = []) {
$options = array_merge([
'convert_newlines' => true,
'add_css' => true,
'encoding' => 'UTF-8'
], $options);
// 改行をbrタグに変換
if ($options['convert_newlines']) {
$body = nl2br(htmlspecialchars($body, ENT_QUOTES, $options['encoding']));
}
// CSSスタイルを追加
if ($options['add_css']) {
$body = self::addEmailCSS($body);
}
// HTMLテンプレートで包む
$htmlBody = self::wrapInHtmlTemplate($body, $subject);
return [
'subject' => $subject,
'body' => $htmlBody,
'headers' => [
'Content-Type' => 'text/html; charset=' . $options['encoding'],
'Content-Transfer-Encoding' => '8bit'
]
];
}
private static function br2nl($text) {
return preg_replace('/<br\s*\/?>/i', "\n", $text);
}
private static function wordWrap($text, $length) {
$lines = explode("\n", $text);
$wrappedLines = [];
foreach ($lines as $line) {
if (mb_strlen($line) <= $length) {
$wrappedLines[] = $line;
} else {
$words = explode(' ', $line);
$currentLine = '';
foreach ($words as $word) {
if (mb_strlen($currentLine . ' ' . $word) <= $length) {
$currentLine .= ($currentLine ? ' ' : '') . $word;
} else {
if ($currentLine) {
$wrappedLines[] = $currentLine;
}
$currentLine = $word;
}
}
if ($currentLine) {
$wrappedLines[] = $currentLine;
}
}
}
return implode("\n", $wrappedLines);
}
private static function addEmailCSS($body) {
return '<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">' .
$body .
'</div>';
}
private static function wrapInHtmlTemplate($body, $title) {
return '<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>' . htmlspecialchars($title) . '</title>
</head>
<body style="margin: 0; padding: 20px; background-color: #f5f5f5;">
<div style="max-width: 600px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px;">
' . $body . '
</div>
</body>
</html>';
}
}
// 使用例
$emailContent = "お客様へ\n\n平素よりお世話になっております。\n\nご注文の件でご連絡いたします。\n詳細は以下の通りです:\n\n- 商品名: サンプル商品\n- 数量: 2個\n- 金額: 3,000円\n\nご確認のほどよろしくお願いいたします。";
echo "=== テキストメール ===\n";
$textEmail = EmailFormatter::formatTextEmail('ご注文確認', $emailContent);
echo "件名: " . $textEmail['subject'] . "\n";
echo "本文:\n" . $textEmail['body'] . "\n\n";
echo "=== HTMLメール ===\n";
$htmlEmail = EmailFormatter::formatHtmlEmail('ご注文確認', $emailContent);
echo "件名: " . $htmlEmail['subject'] . "\n";
echo "本文:\n" . $htmlEmail['body'] . "\n";
?>
高度な使用例とパターン
1. マークダウン風テキスト処理
<?php
class SimpleMarkdownProcessor {
public static function process($text, $options = []) {
$options = array_merge([
'convert_newlines' => true,
'process_bold' => true,
'process_italic' => true,
'process_links' => true,
'process_lists' => true,
'escape_html' => true
], $options);
if ($options['escape_html']) {
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
// 太字処理: **text** -> <strong>text</strong>
if ($options['process_bold']) {
$text = preg_replace('/\*\*(.*?)\*\*/', '<strong>$1</strong>', $text);
}
// イタリック処理: *text* -> <em>text</em>
if ($options['process_italic']) {
$text = preg_replace('/\*(.*?)\*/', '<em>$1</em>', $text);
}
// リンク処理: [text](url) -> <a href="url">text</a>
if ($options['process_links']) {
$text = preg_replace('/\[([^\]]+)\]\(([^)]+)\)/', '<a href="$2">$1</a>', $text);
}
// リスト処理
if ($options['process_lists']) {
$text = self::processLists($text);
}
// 改行をbrタグに変換(最後に実行)
if ($options['convert_newlines']) {
$text = nl2br($text);
}
return $text;
}
private static function processLists($text) {
$lines = explode("\n", $text);
$result = [];
$inList = false;
foreach ($lines as $line) {
$trimmed = trim($line);
if (preg_match('/^[*\-+]\s+(.+)/', $trimmed, $matches)) {
if (!$inList) {
$result[] = '<ul>';
$inList = true;
}
$result[] = '<li>' . $matches[1] . '</li>';
} else {
if ($inList) {
$result[] = '</ul>';
$inList = false;
}
$result[] = $line;
}
}
if ($inList) {
$result[] = '</ul>';
}
return implode("\n", $result);
}
}
// 使用例
$markdownText = "# タイトル
これは**重要**な*テキスト*です。
詳細については[こちら](https://example.com)をご覧ください。
リストは以下の通りです:
* 項目1
* 項目2
* 項目3
以上です。";
echo "=== マークダウン処理結果 ===\n";
echo SimpleMarkdownProcessor::process($markdownText);
?>
2. コメントシステム
<?php
class CommentSystem {
private $comments = [];
public function addComment($author, $content, $parentId = null) {
$commentId = uniqid();
$comment = [
'id' => $commentId,
'author' => htmlspecialchars($author, ENT_QUOTES, 'UTF-8'),
'content' => $this->processContent($content),
'parent_id' => $parentId,
'created_at' => date('Y-m-d H:i:s'),
'replies' => []
];
if ($parentId && isset($this->comments[$parentId])) {
$this->comments[$parentId]['replies'][$commentId] = $comment;
} else {
$this->comments[$commentId] = $comment;
}
return $commentId;
}
private function processContent($content) {
// 不適切な内容をフィルタリング
$content = $this->filterContent($content);
// HTMLエスケープ
$content = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
// 改行をbrタグに変換
$content = nl2br($content);
// URLを自動リンク化
$content = $this->autoLinkUrls($content);
// @メンション処理
$content = $this->processMentions($content);
return $content;
}
private function filterContent($content) {
// 禁止用語のフィルタリング(実際にはより高度な処理が必要)
$bannedWords = ['spam', '不適切', '禁止用語'];
foreach ($bannedWords as $word) {
$content = str_ireplace($word, str_repeat('*', mb_strlen($word)), $content);
}
return $content;
}
private function autoLinkUrls($content) {
$pattern = '/https?:\/\/[^\s<>"\']+/';
return preg_replace_callback($pattern, function($matches) {
$url = $matches[0];
$displayUrl = mb_strlen($url) > 50 ? mb_substr($url, 0, 47) . '...' : $url;
return '<a href="' . $url . '" target="_blank" rel="noopener">' . $displayUrl . '</a>';
}, $content);
}
private function processMentions($content) {
return preg_replace('/@(\w+)/', '<span class="mention">@$1</span>', $content);
}
public function displayComments() {
foreach ($this->comments as $comment) {
$this->displayComment($comment);
}
}
private function displayComment($comment, $level = 0) {
$indent = str_repeat(' ', $level);
$replyClass = $level > 0 ? ' reply' : '';
echo $indent . '<div class="comment' . $replyClass . '">' . "\n";
echo $indent . ' <div class="comment-header">' . "\n";
echo $indent . ' <strong>' . $comment['author'] . '</strong> ';
echo '<span class="comment-date">' . $comment['created_at'] . '</span>' . "\n";
echo $indent . ' </div>' . "\n";
echo $indent . ' <div class="comment-content">' . "\n";
echo $indent . ' ' . $comment['content'] . "\n";
echo $indent . ' </div>' . "\n";
// 返信を表示
foreach ($comment['replies'] as $reply) {
$this->displayComment($reply, $level + 1);
}
echo $indent . '</div>' . "\n";
}
public function getCommentsAsJson() {
return json_encode($this->comments, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
}
}
// 使用例
$commentSystem = new CommentSystem();
// コメントを追加
$comment1 = $commentSystem->addComment('田中太郎', "とても良い記事ですね!\n\n特に最後の部分が参考になりました。\n\n@佐藤さん はどう思いますか?");
$comment2 = $commentSystem->addComment('佐藤花子', "私も同感です。\n詳細は https://example.com/detail で確認できます。");
$reply1 = $commentSystem->addComment('山田次郎', "お二人とも、貴重なご意見ありがとうございます。\n\n追加の情報があれば教えてください。", $comment1);
echo "=== コメント表示 ===\n";
$commentSystem->displayComments();
echo "\n=== JSON出力 ===\n";
echo $commentSystem->getCommentsAsJson();
?>
3. ログ表示システム
<?php
class LogDisplaySystem {
private $logEntries = [];
public function addLogEntry($level, $message, $context = []) {
$this->logEntries[] = [
'timestamp' => microtime(true),
'level' => $level,
'message' => $message,
'context' => $context,
'formatted_time' => date('Y-m-d H:i:s')
];
}
public function displayLogs($options = []) {
$options = array_merge([
'format' => 'html', // html, text, json
'level_filter' => null,
'limit' => null,
'reverse_order' => true,
'show_context' => true,
'convert_newlines' => true
], $options);
$logs = $this->logEntries;
// レベルフィルタ
if ($options['level_filter']) {
$logs = array_filter($logs, function($log) use ($options) {
return $log['level'] === $options['level_filter'];
});
}
// 順序
if ($options['reverse_order']) {
$logs = array_reverse($logs);
}
// 制限
if ($options['limit']) {
$logs = array_slice($logs, 0, $options['limit']);
}
// フォーマット別表示
switch ($options['format']) {
case 'html':
return $this->formatAsHtml($logs, $options);
case 'text':
return $this->formatAsText($logs, $options);
case 'json':
return $this->formatAsJson($logs, $options);
default:
return $this->formatAsHtml($logs, $options);
}
}
private function formatAsHtml($logs, $options) {
$html = '<div class="log-container">';
foreach ($logs as $log) {
$levelClass = 'log-' . strtolower($log['level']);
$message = htmlspecialchars($log['message'], ENT_QUOTES, 'UTF-8');
if ($options['convert_newlines']) {
$message = nl2br($message);
}
$html .= '<div class="log-entry ' . $levelClass . '">';
$html .= '<span class="log-time">' . $log['formatted_time'] . '</span>';
$html .= '<span class="log-level">[' . $log['level'] . ']</span>';
$html .= '<span class="log-message">' . $message . '</span>';
if ($options['show_context'] && !empty($log['context'])) {
$html .= '<div class="log-context">';
$html .= '<strong>Context:</strong> ';
$html .= '<pre>' . htmlspecialchars(json_encode($log['context'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) . '</pre>';
$html .= '</div>';
}
$html .= '</div>';
}
$html .= '</div>';
// CSSを追加
$css = '<style>
.log-container { font-family: monospace; }
.log-entry { margin: 5px 0; padding: 10px; border-left: 4px solid #ccc; }
.log-error { border-left-color: #e74c3c; background-color: #fdf2f2; }
.log-warning { border-left-color: #f39c12; background-color: #fef9e7; }
.log-info { border-left-color: #3498db; background-color: #eaf2f8; }
.log-debug { border-left-color: #95a5a6; background-color: #f8f9fa; }
.log-time { color: #666; margin-right: 10px; }
.log-level { font-weight: bold; margin-right: 10px; }
.log-context { margin-top: 10px; padding-top: 10px; border-top: 1px solid #eee; }
.log-context pre { background: #f5f5f5; padding: 5px; border-radius: 3px; overflow-x: auto; }
</style>';
return $css . $html;
}
private function formatAsText($logs, $options) {
$text = '';
foreach ($logs as $log) {
$message = $log['message'];
$text .= '[' . $log['formatted_time'] . '] ';
$text .= $log['level'] . ': ';
$text .= $message;
if ($options['show_context'] && !empty($log['context'])) {
$text .= ' | Context: ' . json_encode($log['context'], JSON_UNESCAPED_UNICODE);
}
$text .= "\n";
}
return $text;
}
private function formatAsJson($logs, $options) {
return json_encode($logs, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
public function exportLogs($filename, $format = 'text') {
$content = $this->displayLogs(['format' => $format]);
if ($format === 'html') {
$content = '<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>ログエクスポート</title>
</head>
<body>
' . $content . '
</body>
</html>';
}
file_put_contents($filename, $content);
return filesize($filename);
}
}
// 使用例
$logSystem = new LogDisplaySystem();
// ログエントリを追加
$logSystem->addLogEntry('ERROR', "データベースとの接続に失敗しました。\n\n原因:\n- 接続タイムアウト\n- 認証エラー\n- ネットワーク問題\n\n対処方法を確認してください。", ['error_code' => 'DB_CONNECTION_FAILED', 'retry_count' => 3]);
$logSystem->addLogEntry('DEBUG', "ユーザーセッションを作成しました。\nセッションID: abc123\nユーザーID: 456", ['session_id' => 'abc123', 'user_id' => 456]);
echo "=== HTML形式でのログ表示 ===\n";
echo $logSystem->displayLogs(['format' => 'html', 'limit' => 10]);
echo "\n\n=== テキスト形式でのログ表示 ===\n";
echo $logSystem->displayLogs(['format' => 'text', 'level_filter' => 'ERROR']);
// ログファイルとしてエクスポート
$fileSize = $logSystem->exportLogs('exported_logs.html', 'html');
echo "\n\nログファイルをエクスポートしました: exported_logs.html ($fileSize bytes)\n";
?>
セキュリティとベストプラクティス
1. XSS(クロスサイトスクリプティング)対策
<?php
class SecureTextProcessor {
public static function safeNl2br($text, $options = []) {
$options = array_merge([
'escape_html' => true,
'allow_safe_tags' => false,
'safe_tags' => ['b', 'i', 'u', 'em', 'strong'],
'max_length' => null,
'remove_javascript' => true,
'use_xhtml' => true
], $options);
// 長さ制限
if ($options['max_length'] && mb_strlen($text) > $options['max_length']) {
$text = mb_substr($text, 0, $options['max_length']) . '...';
}
// JavaScriptの除去
if ($options['remove_javascript']) {
$text = self::removeJavaScript($text);
}
// HTMLエスケープ(安全なタグを許可しない場合)
if ($options['escape_html'] && !$options['allow_safe_tags']) {
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
// 安全なタグのみを許可
if ($options['allow_safe_tags']) {
$text = self::allowOnlySafeTags($text, $options['safe_tags']);
}
// 改行をbrタグに変換
$text = nl2br($text, $options['use_xhtml']);
return $text;
}
private static function removeJavaScript($text) {
// JavaScriptのイベントハンドラを除去
$text = preg_replace('/on\w+\s*=\s*["\'][^"\']*["\']/i', '', $text);
// javascript: プロトコルを除去
$text = preg_replace('/javascript\s*:/i', '', $text);
// <script>タグを除去
$text = preg_replace('/<script[^>]*>.*?<\/script>/is', '', $text);
return $text;
}
private static function allowOnlySafeTags($text, $safeTags) {
// 許可されたタグ以外をエスケープ
$allowedTags = '<' . implode('><', $safeTags) . '>';
return strip_tags($text, $allowedTags);
}
public static function validateInput($text, $rules = []) {
$errors = [];
// 必須チェック
if (isset($rules['required']) && $rules['required'] && empty(trim($text))) {
$errors[] = '必須項目です';
}
// 長さチェック
if (isset($rules['max_length']) && mb_strlen($text) > $rules['max_length']) {
$errors[] = "最大{$rules['max_length']}文字まで入力可能です";
}
if (isset($rules['min_length']) && mb_strlen($text) < $rules['min_length']) {
$errors[] = "最低{$rules['min_length']}文字必要です";
}
// 不正なスクリプトチェック
if (preg_match('/<script[^>]*>/i', $text)) {
$errors[] = '不正なスクリプトが含まれています';
}
// 改行数チェック
if (isset($rules['max_lines'])) {
$lineCount = substr_count($text, "\n") + 1;
if ($lineCount > $rules['max_lines']) {
$errors[] = "最大{$rules['max_lines']}行まで入力可能です";
}
}
return $errors;
}
public static function sanitizeForDatabase($text) {
// データベース用のサニタイズ
return trim(preg_replace('/\s+/', ' ', $text));
}
}
// 使用例
echo "=== セキュリティテスト ===\n";
$maliciousInput = "こんにちは\n<script>alert('XSS')</script>\nこれは危険なテキストです\n<b>太字</b>は大丈夫";
echo "元のテキスト:\n";
echo $maliciousInput . "\n\n";
echo "安全な処理(HTMLエスケープ):\n";
echo SecureTextProcessor::safeNl2br($maliciousInput) . "\n\n";
echo "安全なタグを許可:\n";
echo SecureTextProcessor::safeNl2br($maliciousInput, [
'allow_safe_tags' => true,
'safe_tags' => ['b', 'i', 'strong', 'em']
]) . "\n\n";
// バリデーションテスト
$validationErrors = SecureTextProcessor::validateInput($maliciousInput, [
'required' => true,
'max_length' => 100,
'max_lines' => 5
]);
if (!empty($validationErrors)) {
echo "バリデーションエラー:\n";
foreach ($validationErrors as $error) {
echo "- $error\n";
}
}
?>
2. パフォーマンス最適化
<?php
class OptimizedTextProcessor {
private static $cache = [];
private static $cacheEnabled = true;
public static function fastNl2br($text, $useXhtml = true, $useCache = true) {
if (!$useCache || !self::$cacheEnabled) {
return self::processText($text, $useXhtml);
}
$cacheKey = md5($text . ($useXhtml ? '1' : '0'));
if (isset(self::$cache[$cacheKey])) {
return self::$cache[$cacheKey];
}
$result = self::processText($text, $useXhtml);
// キャッシュサイズを制限
if (count(self::$cache) > 1000) {
self::$cache = array_slice(self::$cache, -500, null, true);
}
self::$cache[$cacheKey] = $result;
return $result;
}
private static function processText($text, $useXhtml) {
// 最適化された改行変換
$br = $useXhtml ? '<br />' : '<br>';
// 一度ですべての改行パターンを変換
return str_replace(["\r\n", "\r", "\n"], $br, $text);
}
public static function batchProcess($textArray, $useXhtml = true) {
$results = [];
$br = $useXhtml ? '<br />' : '<br>';
foreach ($textArray as $key => $text) {
$results[$key] = str_replace(["\r\n", "\r", "\n"], $br, $text);
}
return $results;
}
public static function streamProcess($inputStream, $outputStream, $useXhtml = true) {
$br = $useXhtml ? '<br />' : '<br>';
$processed = 0;
while (($line = fgets($inputStream)) !== false) {
$processedLine = str_replace(["\r\n", "\r", "\n"], $br, $line);
fwrite($outputStream, $processedLine);
$processed++;
}
return $processed;
}
public static function benchmarkMethods($text, $iterations = 1000) {
$methods = [
'nl2br' => function($text) { return nl2br($text); },
'fastNl2br' => function($text) { return self::fastNl2br($text); },
'str_replace' => function($text) { return str_replace(["\r\n", "\r", "\n"], '<br />', $text); }
];
$results = [];
foreach ($methods as $name => $method) {
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$method($text);
}
$results[$name] = microtime(true) - $start;
}
return $results;
}
public static function clearCache() {
self::$cache = [];
}
public static function setCacheEnabled($enabled) {
self::$cacheEnabled = $enabled;
}
public static function getCacheStats() {
return [
'cache_size' => count(self::$cache),
'cache_enabled' => self::$cacheEnabled,
'memory_usage' => memory_get_usage()
];
}
}
// パフォーマンステスト
echo "=== パフォーマンステスト ===\n";
$testText = str_repeat("テスト行1\nテスト行2\r\nテスト行3\r", 100);
$benchmarkResults = OptimizedTextProcessor::benchmarkMethods($testText, 1000);
echo "処理時間比較 (1000回実行):\n";
$fastest = min($benchmarkResults);
foreach ($benchmarkResults as $method => $time) {
$relative = round($time / $fastest, 2);
echo sprintf("%-15s: %8.4f秒 (%sx)\n", $method, $time, $relative);
}
echo "\n=== キャッシュ統計 ===\n";
print_r(OptimizedTextProcessor::getCacheStats());
// 大量データのバッチ処理テスト
echo "\n=== バッチ処理テスト ===\n";
$batchData = [];
for ($i = 0; $i < 1000; $i++) {
$batchData["item_$i"] = "行1\n行2\r\n行3\r項目 $i";
}
$start = microtime(true);
$batchResults = OptimizedTextProcessor::batchProcess($batchData);
$batchTime = microtime(true) - $start;
echo "バッチ処理: 1000件を " . number_format($batchTime, 4) . "秒で処理\n";
echo "1件あたり: " . number_format($batchTime / 1000 * 1000000, 2) . "マイクロ秒\n";
?>
3. 国際化対応
<?php
class InternationalTextProcessor {
private $locale;
private $encoding;
public function __construct($locale = 'ja_JP', $encoding = 'UTF-8') {
$this->locale = $locale;
$this->encoding = $encoding;
}
public function processText($text, $options = []) {
$options = array_merge([
'convert_newlines' => true,
'normalize_unicode' => true,
'handle_rtl' => false,
'preserve_whitespace' => false,
'use_xhtml' => true
], $options);
// Unicode正規化
if ($options['normalize_unicode'] && function_exists('normalizer_normalize')) {
$text = normalizer_normalize($text, Normalizer::FORM_C);
}
// 右から左への言語(アラビア語、ヘブライ語等)対応
if ($options['handle_rtl']) {
$text = $this->handleRtlText($text);
}
// 空白の処理
if (!$options['preserve_whitespace']) {
$text = $this->normalizeWhitespace($text);
}
// 改行変換
if ($options['convert_newlines']) {
$text = nl2br($text, $options['use_xhtml']);
}
return $text;
}
private function handleRtlText($text) {
// RTL(右から左)テキストの検出
if (preg_match('/[\x{0590}-\x{05ff}\x{0600}-\x{06ff}]/u', $text)) {
return '<div dir="rtl">' . $text . '</div>';
}
return $text;
}
private function normalizeWhitespace($text) {
// 全角・半角空白の統一
$text = preg_replace('/[\x{00A0}\x{3000}]/u', ' ', $text);
// 連続する空白を1つにまとめる(改行は除く)
$text = preg_replace('/[ \t]+/', ' ', $text);
return $text;
}
public function detectLanguage($text) {
// 簡単な言語検出(実際にはより高度なライブラリを使用)
$patterns = [
'japanese' => '/[\x{3040}-\x{309f}\x{30a0}-\x{30ff}\x{4e00}-\x{9faf}]/u',
'korean' => '/[\x{1100}-\x{11ff}\x{3130}-\x{318f}\x{ac00}-\x{d7af}]/u',
'chinese' => '/[\x{4e00}-\x{9fff}]/u',
'arabic' => '/[\x{0600}-\x{06ff}]/u',
'hebrew' => '/[\x{0590}-\x{05ff}]/u',
'cyrillic' => '/[\x{0400}-\x{04ff}]/u'
];
foreach ($patterns as $language => $pattern) {
if (preg_match($pattern, $text)) {
return $language;
}
}
return 'latin';
}
public function formatForEmail($text, $emailType = 'html') {
$language = $this->detectLanguage($text);
if ($emailType === 'html') {
$text = htmlspecialchars($text, ENT_QUOTES, $this->encoding);
$text = nl2br($text, true);
// 言語に応じたフォント指定
$fontFamily = $this->getFontFamily($language);
$text = '<div style="font-family: ' . $fontFamily . '; line-height: 1.6;">' . $text . '</div>';
} else {
// テキストメールの場合は改行を保持
$text = $this->wordWrapMultibyte($text, 72);
}
return $text;
}
private function getFontFamily($language) {
$fonts = [
'japanese' => 'Hiragino Kaku Gothic ProN, Meiryo, sans-serif',
'korean' => 'Malgun Gothic, Dotum, sans-serif',
'chinese' => 'Microsoft YaHei, SimHei, sans-serif',
'arabic' => 'Tahoma, Arial Unicode MS, sans-serif',
'hebrew' => 'David, Arial Unicode MS, sans-serif',
'cyrillic' => 'Arial, Helvetica, sans-serif',
'latin' => 'Arial, Helvetica, sans-serif'
];
return $fonts[$language] ?? $fonts['latin'];
}
private function wordWrapMultibyte($text, $width) {
$lines = explode("\n", $text);
$wrappedLines = [];
foreach ($lines as $line) {
if (mb_strlen($line, $this->encoding) <= $width) {
$wrappedLines[] = $line;
} else {
$words = explode(' ', $line);
$currentLine = '';
foreach ($words as $word) {
if (mb_strlen($currentLine . ' ' . $word, $this->encoding) <= $width) {
$currentLine .= ($currentLine ? ' ' : '') . $word;
} else {
if ($currentLine) {
$wrappedLines[] = $currentLine;
}
$currentLine = $word;
}
}
if ($currentLine) {
$wrappedLines[] = $currentLine;
}
}
}
return implode("\n", $wrappedLines);
}
}
// 使用例
echo "=== 国際化対応テスト ===\n";
$processor = new InternationalTextProcessor();
$multilingualText = "Hello World!\nこんにちは世界!\n안녕하세요 세계!\n你好世界!\nمرحبا بالعالم";
echo "多言語テキスト処理:\n";
echo $processor->processText($multilingualText);
echo "\n\n言語検出:\n";
$texts = [
"これは日本語のテキストです。",
"This is English text.",
"이것은 한국어 텍스트입니다.",
"这是中文文本。",
"هذا نص عربي"
];
foreach ($texts as $text) {
$language = $processor->detectLanguage($text);
echo "$text -> $language\n";
}
echo "\n\nメール形式での処理:\n";
$emailText = "件名: お問い合わせ\n\n平素よりお世話になっております。\n\nご質問がございます。\nよろしくお願いいたします。";
echo $processor->formatForEmail($emailText, 'html');
?>
トラブルシューティング
よくある問題と解決法
<?php
class Nl2brTroubleshooter {
public static function diagnoseText($text) {
echo "=== テキスト診断 ===\n";
echo "文字数: " . mb_strlen($text) . "\n";
echo "バイト数: " . strlen($text) . "\n";
echo "エンコーディング: " . mb_detect_encoding($text) . "\n";
// 改行文字の分析
$lf = substr_count($text, "\n");
$cr = substr_count($text, "\r");
$crlf = substr_count($text, "\r\n");
$realCr = $cr - $crlf; // \r\nでカウントされた\rを除く
echo "改行文字:\n";
echo " LF (\\n): $lf\n";
echo " CR (\\r): $realCr\n";
echo " CRLF (\\r\\n): $crlf\n";
// 特殊文字のチェック
$specialChars = [
'タブ' => "\t",
'全角スペース' => ' ',
'ノーブレークスペース' => "\xA0"
];
foreach ($specialChars as $name => $char) {
$count = substr_count($text, $char);
if ($count > 0) {
echo " $name: {$count}個\n";
}
}
// HTMLタグのチェック
if (preg_match_all('/<[^>]+>/', $text, $matches)) {
echo "HTMLタグ: " . count($matches[0]) . "個\n";
$tags = array_unique($matches[0]);
echo " " . implode(', ', array_slice($tags, 0, 5));
if (count($tags) > 5) {
echo " ...他" . (count($tags) - 5) . "個";
}
echo "\n";
}
echo "\n";
}
public static function fixCommonIssues($text) {
echo "=== 一般的な問題の修正 ===\n";
$original = $text;
$fixes = [];
// Windows改行を統一
if (strpos($text, "\r\n") !== false) {
$text = str_replace("\r\n", "\n", $text);
$fixes[] = "Windows改行(\\r\\n)をUnix改行(\\n)に統一";
}
// Mac改行を統一
if (strpos($text, "\r") !== false) {
$text = str_replace("\r", "\n", $text);
$fixes[] = "Mac改行(\\r)をUnix改行(\\n)に統一";
}
// 連続する改行を制限
if (preg_match('/\n{3,}/', $text)) {
$text = preg_replace('/\n{3,}/', "\n\n", $text);
$fixes[] = "3つ以上の連続改行を2つに制限";
}
// 行末の空白を除去
if (preg_match('/[ \t]+$/m', $text)) {
$text = preg_replace('/[ \t]+$/m', '', $text);
$fixes[] = "行末の空白を除去";
}
// 全角スペースを半角に変換(オプション)
$fullWidthSpaces = substr_count($text, ' ');
if ($fullWidthSpaces > 0) {
// この変換は慎重に行う必要がある
echo "注意: 全角スペース({$fullWidthSpaces}個)が検出されました。\n";
echo "必要に応じて手動で確認してください。\n";
}
if (empty($fixes)) {
echo "修正が必要な問題は見つかりませんでした。\n";
} else {
echo "実行した修正:\n";
foreach ($fixes as $fix) {
echo "- $fix\n";
}
}
echo "\n";
return $text;
}
public static function compareResults($text) {
echo "=== 変換結果の比較 ===\n";
$methods = [
'nl2br (XHTML)' => nl2br($text, true),
'nl2br (HTML)' => nl2br($text, false),
'str_replace' => str_replace(["\r\n", "\r", "\n"], '<br />', $text),
'preg_replace' => preg_replace('/\r\n|\r|\n/', '<br />', $text)
];
foreach ($methods as $method => $result) {
echo "$method:\n";
echo " 結果: " . htmlspecialchars($result) . "\n";
echo " 長さ: " . mb_strlen($result) . "文字\n\n";
}
}
public static function testWithProblematicData() {
echo "=== 問題のあるデータでのテスト ===\n";
$problematicTexts = [
'mixed_newlines' => "Unix\nWindows\r\nMac\rMixed",
'html_content' => "HTML含有<script>alert('test')</script>\n改行あり",
'unicode_text' => "Unicode文字\n絵文字😀\n特殊文字①②③",
'empty_lines' => "空行テスト\n\n\n\n複数空行",
'trailing_spaces' => "行末空白 \n次の行\t\tタブあり",
'false_value' => "値にfalseを含む\n" . false . "\n続き"
];
foreach ($problematicTexts as $type => $text) {
echo "--- $type ---\n";
self::diagnoseText($text);
$fixed = self::fixCommonIssues($text);
echo "nl2br変換結果:\n";
echo htmlspecialchars(nl2br($fixed)) . "\n\n";
}
}
}
// トラブルシューティングの実行
$problematicText = "問題のあるテキスト\r\n\r\n\r\nWindows改行\r\n行末空白あり \nタブ文字\t含む\nHTML<b>タグ</b>あり";
Nl2brTroubleshooter::diagnoseText($problematicText);
$fixedText = Nl2brTroubleshooter::fixCommonIssues($problematicText);
Nl2brTroubleshooter::compareResults($fixedText);
// 問題のあるデータでの包括的テスト
Nl2brTroubleshooter::testWithProblematicData();
?>
まとめ
nl2br
関数は、Webアプリケーション開発において非常に重要な機能を提供する関数です。適切に使用することで、ユーザー入力のテキストを安全かつ見やすい形でWebページに表示できます。
重要なポイント
- 基本機能: 改行文字(\n、\r、\r\n)をHTMLの
<br>
タグに変換 - XHTMLオプション: 第2パラメータで
<br>
または<br />
を選択可能 - セキュリティ: HTMLエスケープとの組み合わせが重要
- パフォーマンス: 大量データではキャッシュや最適化を検討
ベストプラクティス
- セキュリティ: 必ず
htmlspecialchars()
と組み合わせて使用 - バリデーション: ユーザー入力の検証を適切に実装
- パフォーマンス: 大量処理ではキャッシュ機能を活用
- 国際化: 多言語対応を考慮した実装
- テスト: 様々な改行パターンでテストを実施
推奨される使用場面
- ユーザー投稿: ブログ投稿、コメント、レビューの表示
- フォーム処理: テキストエリアの内容を表示
- メール送信: HTMLメールでのテキスト整形
- ログ表示: システムログの見やすい表示
nl2br
関数を理解し適切に使用することで、ユーザビリティが高く安全なWebアプリケーションを開発できます。特にユーザー生成コンテンツを扱う現代のWebアプリケーションでは必須の知識と言えるでしょう。