MongoDB に保存した文字列の \n が C#/QuestPDF に渡したときにそのまま「\n」と表示されてしまう――PDF 生成の現場ではよく出会う落とし穴です。本稿では、なぜ起きるのかをデータ/言語レイヤまで遡って分解し、既存データの一括修正からアプリ側の安全な前処理、運用時のチェックリストまで、実践コードとともに徹底解説します。
問題の再現と期待する出力
以下の文字列を MongoDB に保存したとします。
{
"desc": "Hello I am Vinicius. \\n I play for Real Madrid"
}
これを C#(QuestPDF)で次のように出力したとき、
Text(dbFootbalCol.heading.desc);
PDF には 実際の改行ではなく バックスラッシュ+n(\n)がそのまま印字されます。期待するのは次の2行表示です。
Hello I am Vinicius.
I play for Real Madrid
原因の本質:二重エスケープ(リテラルの「\n」と制御文字の改行)
保存されているのは \\n(バックスラッシュ2つ+n)です。これは「改行」を意味する制御文字ではなく、単なる2文字の連なりであり、C# に読み込まれてもそのまま \ と n の2文字として扱われます。そのため QuestPDF も改行として解釈できません。
どの層で何が起きているか
| レイヤ | 値の実体 | 代表的な見え方/扱い |
|---|---|---|
| MongoDB(保存) | \\n | 文字 \ と n。改行ではない。 |
| JSON 表示 | "\\n" | JSON 文字列中のバックスラッシュは \\ としてエスケープ表示される。 |
| C# で取得 | \\n | 通常の string として \n という2文字を受け取る。 |
| QuestPDF | \\n | 段落分割の対象ではないため、そのまま印字される。 |
最も確実な直し方(結論)
対処は大きく2パターンあります。データ側で正しい改行コードに置き換えるか、アプリ側で正規化してから渡すかです。どちらを採用しても良いですが、長期的には「保存するデータを正規化」しておく方が運用コストが下がります。
方法A:データ側で修正(推奨)
MongoDB に保存済みの \\n を実際の改行(Unix 系なら \n、Windows 系なら \r\n)に一括変換します。mongosh の更新パイプラインで安全に置き換えられます。
// mongosh 例:literal な "\n" を検索し、実際の改行 "\n" に置換
db.football.updateMany(
{ "heading.desc": { $regex: "\\\\n" } }, // バックスラッシュ \ を正規表現と JS 文字列の双方でエスケープ
[
{
$set: {
"heading.desc": {
$replaceAll: {
input: "$heading.desc",
find: "\\n", // 文字列リテラルの "\n"(= バックスラッシュ + n)
replacement: "\n" // 実際の改行コード
}
}
}
}
]
)
Windows で PDF を作るなど、OS の改行に合わせたい場合は replacement を "\r\n" にします。
複合パターンも同時に直す(\\r\\n 等)
データに \\r\\n と \\n が混在しているケースでは、次のように段階的に置換します。
// 1. "\\r\\n" → "\r\n"
db.football.updateMany(
{ "heading.desc": { $regex: "\\\\r\\\\n" } },
[
{ $set: { "heading.desc": { $replaceAll: { input: "$heading.desc", find: "\\r\\n", replacement: "\r\n" } } } }
]
);
// 2. "\n" → "\n"(Windows でも "\n" を許容するならこちらだけでも可)
db.football.updateMany(
{ "heading.desc": { $regex: "\\n" } },
[
{ $set: { "heading.desc": { $replaceAll: { input: "$heading.desc", find: "\n", replacement: "\n" } } } }
]
);
方法B:アプリ側で修正(既存データを段階的に直すとき)
アプリケーションの読み出し時に、\\n などのエスケープシーケンスを実際の制御文字へ変換します。もっとも単純なのは Replace の連鎖です。
var raw = dbFootbalCol.heading.desc;
// OSに合わせた改行を使いたいなら Environment.NewLine を採用
var normalized = raw
.Replace("\r\n", Environment.NewLine)
.Replace("\n", Environment.NewLine)
.Replace("\r", Environment.NewLine); // 旧Mac系などに配慮
table.Cell().Column(1).Row(1).Text(normalized);
他にも、C# の Regex.Unescape を用いると、\\n、\\t、\\uXXXX などの代表的なシーケンスをまとめて実体化できます。
using System.Text.RegularExpressions;
var normalized = Regex.Unescape(dbFootbalCol.heading.desc);
// 必要に応じて OS の改行に正規化
normalized = normalized.Replace("\r\n", "\n").Replace("\r", "\n"); // 統一
Text(normalized.Replace("\n", Environment.NewLine));
注意:Regex.Unescape は「正規表現のエスケープ」を基準に復元するため、\d のような正規表現系のエスケープを含む文字列では意図せず変化することがあります。対象が \\r\\n / \\n / \\t 程度に限定されるなら Replace 方式が安全です。
QuestPDF 側のポイント
- Text 要素は改行を解釈:渡された
string内の\n(または\r\n)を段落分割として扱います。従って、前処理で「実際の改行」にしておけば OK です。 - 混在改行は正規化:
\r単体、\n、\r\nが混在すると段落の空きや行高にブレが出ることがあります。PDF 生成前に 1 つの形式に統一しましょう。 - 段落スタイル:空行(連続した改行)を段落間スペースに変換したい場合は、前処理で
\n\nを検知して 段落 と 行 を使い分けると見栄えが安定します。
最小再現コード(正常系)
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
var raw = "Hello I am Vinicius. \n I play for Real Madrid"; // DBから来た値を想定(\ と n の2文字)
var fixedText = raw.Replace("\n", Environment.NewLine);
Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.Content().Text(fixedText);
});
})
.GeneratePdf("output.pdf");
保存時の落とし穴:C# 文字列リテラルと @(逐語的リテラル)
そもそも \\n が保存されてしまう原因のひとつに、C# のリテラルの使い分けミスがあります。
| リテラル種別 | 記法 | \n の扱い | 例(評価後の実体) |
|---|---|---|---|
| 通常文字列 | "..." | 制御文字として解釈 | "Hello\nWorld" → 実際の改行 |
| 逐語的文字列 | @"..." | バックスラッシュは通常文字 | @"Hello\nWorld" → \\n(2文字) |
保存前に @"..." を使っていると、意図せず \\n が DB に入ります。改行を保存したいときは 通常文字列 を使うか、アプリ側で正規化してから保存しましょう。
どちらの方法を選ぶべきか(比較表)
| 方法 | メリット | デメリット | 適用範囲 | 推奨度 |
|---|---|---|---|---|
| データ側で修正(更新パイプライン) | 一度直せば以降の処理がシンプル。表示先を問わず一貫。 | バッチ作業が必要。大量データではロック/I/O に配慮。 | 既存ドキュメント全体 | 高 |
| アプリ側で前処理(Replace / Unescape) | 段階的に導入できる。影響範囲を限定可能。 | 処理箇所の漏れがあると再発。複数アプリがあるとズレやすい。 | 読み出し経路 | 中 |
安全に一括修正するための実務ノウハウ
対象レコードの洗い出し
// literal の "\n" を含むレコードを件数確認
db.football.countDocuments({ "heading.desc": { $regex: "\\\\n" } });
// サンプル確認(抜粋)
db.football.find(
{ "heading.desc": { $regex: "\\n" } },
{ _id: 1, "heading.desc": 1 }
).limit(5);
- 本番実行前に ステージング環境で検証し、生成 PDF の目視確認まで行う。
- 更新は 小さなバッチ(例:1,000 件単位)に分け、都度ログを残す。
ロールバック戦略
- 更新前に対象ドキュメントを
archiveDescのような別フィールドへ退避しておく。
db.football.updateMany(
{ "heading.desc": { $regex: "\\\\n" } },
[
{ $set: { "heading.archiveDesc": "$heading.desc" } }
]
);
アプリ側の堅牢化:正規化ユーティリティを 1 箇所に集約
表示系(PDF/メール/Web)で文字列正規化の重複実装を避けるため、ドメイン共通のユーティリティを作っておきます。
public static class TextNormalization
{
/// <summary>
/// DBから来るliteralなエスケープ("\\r\\n","\\n","\\t" 等)を実体化し、
/// 改行はOSに合わせて Environment.NewLine に統一
/// </summary>
public static string NormalizeForOutput(string? input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
// まず代表的なliteralを手堅く置換
var s = input
.Replace("\\r\\n", "\r\n")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t");
// 改行をOSに揃える
s = s.Replace("\r\n", "\n").Replace("\r", "\n")
.Replace("\n", Environment.NewLine);
return s;
}
}
以後は出力直前で必ず TextNormalization.NormalizeForOutput を通すようにすれば、ライブラリ差や OS 差の影響を最小化できます。
QuestPDF での段落制御のコツ
- 明示的な段落切替:長文を
Text()にべた渡しではなく、.Split('\n')で分割してtext.Line()を使うと、空行や見出し風の強調を混ぜやすくなります。
page.Content().Text(t =>
{
foreach (var line in normalized.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None))
t.Line(line);
});
- 複数段落+余白:「空行2つ以上を段落間スペースに変換」などの前処理を行うと、本文の読みやすさが上がります。
テストで守る:改行の振る舞いを固定化
改行の扱いは環境差に敏感です。ユニットテストで期待を固定化しましょう。
[Fact]
public void NormalizeForOutput_Should_Convert_Literal_Newlines()
{
var input = "Hello\\nWorld\\r\\nNext\\rTab\\tEnd";
var result = TextNormalization.NormalizeForOutput(input);
// 期待:OS改行に統一され、\t もタブ化
Assert.Contains(Environment.NewLine + "World", result);
Assert.Contains(Environment.NewLine + "Next", result);
Assert.Contains(Environment.NewLine + "Tab\tEnd", result);
}
入力時のベストプラクティス(再発防止)
- 保存前に正規化:受け取った文字列は
NormalizeForOutputの 前段 に「保存用正規化」を噛ませ、DB は常に\n(または\r\n)を実体として持つようにします。 - 逐語的リテラルの乱用を避ける:C# の
@""を説明文字列に使うと\\nを発生させがち。改行を含む可能性がある値は通常リテラルで構築する。 - UI 入力のガード:「\n」を入力されたら実際の改行に置換するなど、フロント側でも軽く正規化してから送信する。
よくある質問(FAQ)
Q. Windows 向け PDF なので、\r\n を使うべき?
A. 多くの PDF ライブラリは \n だけでも段落分割できます。ただし、他の出力(例えばプレーンテキストファイル)と共通化したい場合は Environment.NewLine に揃えるのが無難です。
Q. 既存データには \\n を残しつつ、表示だけ改行したい。
A. 可能です。アプリ側で前処理する方法(方法B)を採用してください。ただし、別アプリ(バッチ/管理画面/エクスポート等)が同じデータを使うときに揺らぎやすい点に注意が必要です。
Q. 画像のキャプションや YAML など、\n を文字として見せたいケースもあります。
A. その場合は「表示目的ごとに正規化関数を分ける」か、「\\n → 実際の改行」の逆変換(\n を \\n に)を明示的に適用してください。
現場向けチェックリスト
- [保存前]入力文字列に
\\r\\n/\\nが混じっていないか。 - [DB]改行は 実体として 保存されているか(文字の
\nではない)。 - [読み出し]出力経路で
NormalizeForOutputを必ず通しているか。 - [PDF]空行の扱い(段落間スペース)を意図どおりに制御できているか。
- [テスト]OS 差(Windows / Linux)に依存しない期待がテストで担保されているか。
まとめ
MongoDB に保存された \\n は、実際の改行ではないため、C#/QuestPDF にそのまま渡しても段落分割は起きません。解決策はシンプルで、(1)データ側で実体の改行に置き換える、または(2)アプリ側で正規化する、のいずれかです。特に運用・保守を考えると、DB を真の状態(実体の改行)に保つアプローチが最も安定します。本文のスクリプトやユーティリティをそのまま取り入れ、生成 PDF の読みやすさと保守性を両立させてください。
付録:代表的なコード断片(コピペ可)
MongoDB:一括置換
// "\\n" → "\n"
db.football.updateMany(
{ "heading.desc": { $regex: "\\\\n" } },
[ { $set: { "heading.desc": { $replaceAll: { input: "$heading.desc", find: "\\n", replacement: "\n" } } } } ]
);
C#:改行正規化ユーティリティ
public static string NormalizeForOutput(string? input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
var s = input
.Replace("\\r\\n", "\r\n")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t");
s = s.Replace("\r\n", "\n").Replace("\r", "\n")
.Replace("\n", Environment.NewLine);
return s;
}
QuestPDF:行単位で描画
page.Content().Text(t =>
{
foreach (var line in NormalizeForOutput(dbFootbalCol.heading.desc)
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None))
t.Line(line);
});
ケーススタディ:入力〜保存〜出力の流れを可視化
| フェーズ | 入力値の例 | 処理 | 保存/出力の結果 |
|---|---|---|---|
| フロント入力 | Hello I am Vinicius. \n I play for Real Madrid | 入力ガードで \n を 実際の改行に変換 | Hello I am Vinicius.\nI play for Real Madrid(実体) |
| API 受信 | JSON 文字列 | サーバで保存用正規化を実施 | DB には実体の改行のみが保存される |
| PDF 出力 | DB から取得 | 出力前正規化(保険)→ QuestPDF へ | 2 行に分かれて美しく印字 |
実装の落とし穴と対策
- 文字列のダブルエスケープ:API 層で JSON エンコード → DB ドライバで再エンコードの二重化が起こると
\\nが生まれやすい。どこでエスケープされるかを図示して設計する。 - 複数アプリの併存:管理画面/バッチ/バックエンドが別リポジトリに分かれている場合、共通の正規化ライブラリを NuGet 内製にして配布する。
- 国際化:全角のバックスラッシュ(¥記号)と混同しないよう正規化時に注意する(必要なら NFKC 正規化の導入を検討)。
最後に(チェックポイント)
- DB に文字としての
\\nを残さない(保存時に実体へ)。 - やむを得ず残す場合は、出力直前で
ReplaceかRegex.Unescapeを適用。 - QuestPDF は改行を素直に解釈する。渡す前の正規化がすべて。

コメント