Reactで、入力したタイトルをファイル名に使える形に直す方法

​​React​Blob,Intl.Segmenter,Playwright,TypeScript,Unicode

ユーザーが入力したタイトルをファイル名にするときは、/ や : のようにファイル名に使えない文字を _ に置き換え、予約された名前と長すぎる名前を避けてから、download 属性に渡します。Chromium で試したところ、保存の前に画面に出す名前と、Chromium が提案するファイル名がそろいました。

その関数 toSafeFilename と、入力したタイトルで文章を保存する React のコンポーネントを作り、Node.js と Chromium で、どんな名前になるかを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。保存は Playwright でボタンを押して受け取りました。ファイルシステムは ext4 です。Windows と macOS、Firefox と Safari、スマートフォンのブラウザ、本番用ビルドでは試していません。Windows で使えない文字と名前は、Microsoft のドキュメントをもとにしています。

「タイトル」の入力欄に「議事録: 第3回 <確定>」、「本文」の入力欄に2行の文章が入っている。その下に「保存する名前:議事録_ 第3回 _確定_.txt」と出て、いちばん下に水色の「保存」ボタンがある
コロンと山かっこを含むタイトルを入力したときの表示(表示の幅375px)

どの文字を、どう直すか

検証環境の ext4 では、議事録: 第3回?.txt という名前のファイルもそのまま作れました。ただ、保存したファイルは Windows で開かれるかもしれません。そこで、Microsoft の「Naming Files, Paths, and Namespaces」にある Windows の決まりに合わせます。

  • 使えない文字:< > : " / \ | ? * と、文字コードが 0〜31 の制御文字
  • 使えない名前:CON、PRN、AUX、NUL、COM1〜COM9、LPT1〜LPT9(COM¹ のように上付きの数字を使ったものも含む)。NUL.txt のように拡張子が付いていても同じです
  • 名前の最後に、空白や点を置かない

使えない文字は _ に置き換え、予約された名前には先頭に _ を付けます。最後の空白と点は取り除きます。

ts
// safeFilename.ts
// Windows で名前に使えない文字と、制御文字(U+0000〜U+001F、U+007F)
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
// 書式文字(ゼロ幅接合子 U+200D、右から左への上書き U+202E など)
const FORMAT_CHARS = /\p{Cf}/gu;
// Windows の予約名(後ろに拡張子が付いていても同じ扱い)
const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(\..*)?$/i;

const encoder = new TextEncoder();
const byteLength = (s: string) => encoder.encode(s).length;

type Options = {
  replacement?: string; // 使えない文字の代わりに入れる文字
  maxBytes?: number; // UTF-8 で数えた長さの上限(拡張子を含む)
  fallback?: string; // 何も残らなかったときの名前
};

export function toSafeFilename(
  title: string,
  extension: string, // '.txt' のように、点を付けて渡す
  { replacement = '_', maxBytes = 255, fallback = 'untitled' }: Options = {},
): string {
  let base = title
    .normalize('NFC')
    .replace(FORMAT_CHARS, '')
    .replace(FORBIDDEN, replacement)
    .trim()
    .replace(/^\.+/, '') // 先頭の点を取る
    .replace(/[. ]+$/, ''); // 最後の点と空白は Windows で使えない

  if (base === '') base = fallback;
  if (RESERVED.test(base)) base = `${replacement}${base}`;

  // 文字の途中で切らないように、書記素(見た目の1文字)の単位で縮める
  const limit = maxBytes - byteLength(extension);
  if (byteLength(base) > limit) {
    const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
    let cut = '';
    for (const { segment } of segmenter.segment(base)) {
      if (byteLength(cut + segment) > limit) break;
      cut += segment;
    }
    base = cut.replace(/[. ]+$/, '') || fallback;
  }

  return base + extension;
}

拡張子は、タイトルとは別の引数で受け取ります。タイトルが report.exe でも、できる名前は report.exe.txt で、拡張子はアプリが決めた .txt になりました。

先頭の点を取ることと書式文字を消すことは、最後の節で見る Chromium の動きに合わせたものです。

タイトルごとに、できあがるファイル名

Node.js で toSafeFilename にいろいろなタイトルを渡した結果です。拡張子はすべて .txt です(nul.tar だけは .gz)。

入力したタイトル できる名前
見積書 2026/11/08 見積書 2026_11_08.txt
議事録: 第3回 <確定> 議事録_ 第3回 _確定_.txt
A|B?C*D"E\F A_B_C_D_E_F.txt
改行とタブを含むタイトル 改行を_含む_タイトル.txt
前後に半角の空白が2つずつある 前後に空白 前後に空白.txt
終わりが点... 終わりが点.txt
CON _CON.txt
nul.tar _nul.tar.gz
COM¹ _COM¹.txt
CONSOLE CONSOLE.txt
.. untitled.txt
空の文字列 untitled.txt
.hidden hidden.txt
report.exe report.exe.txt
前後に全角の空白がある 全角の空白 全角の空白.txt

CONSOLE のように、予約名で始まるだけの名前はそのままです。全角の空白(U+3000)も、String.prototype.trim() が空白として取り除きました。

_ の代わりに別の文字を入れたいときは、replacement で渡します。{ replacement: '-' } にすると、見積書 2026/11/08 は 見積書 2026-11-08.txt になりました。

長い名前は、UTF-8 のバイト数で切る

検証環境の ext4 では、getconf NAME_MAX が 255 でした。これは文字数ではなくバイト数です。名前の長さを変えてファイルを作ると、次のようになりました。

名前 UTF-8 のバイト数 結果
a を255個 255 作れる
a を256個 256 ENAMETOOLONG
ね を85個 255 作れる
ね を86個 258 ENAMETOOLONG

ね は UTF-8 で3バイトなので、86文字で上限を超えます。JavaScript の length は UTF-16 の単位で数えるので、バイト数とは合いません。次に使う長いタイトルは、length では 104 ですが、UTF-8 では 294 バイトでした。そこで、TextEncoder.encode() で UTF-8 のバイト列にして長さを数えています。

切るときは、文字の途中で切らないようにします。ね を80個並べた後に家族の絵文字(👨‍👩‍👧)を3つ付けたタイトルで、UTF-8 のバイト列を250バイトで切ってから文字列に戻すと、最後は 👨 と U+200D と、壊れた文字の代わりに入る U+FFFD(�)になりました。

Intl.Segmenter を granularity: 'grapheme' で使うと、見た目の1文字ごとに区切れます。toSafeFilename はこの単位で1文字ずつ足していき、上限を超える手前で止めます。上のタイトルは 294 バイトで、toSafeFilename を通すと 252 バイトの名前になり、ext4 にそのまま作れました。元の 298 バイトの名前(.txt 付き)は ENAMETOOLONG でした。

toSafeFilename は先に書式文字を消すので、ゼロ幅接合子でつないだ絵文字は、名前のどこにあっても、つなぐ前の個々の絵文字に分かれます。家族の絵文字は 👨 👩 👧 の3文字に、黒猫の絵文字は 🐈 と ⬛ になりました。そのため、上のタイトルを切ると、家族の絵文字の途中までが 👨👩 のように残りました。

もう1つ、normalize('NFC') を最初に通しています。「が」は、1文字の「が」(U+304C)と、「か」(U+304B)に濁点の結合文字(U+3099)を付けた2文字の、2通りで書けます。後者は UTF-8 で6バイト、NFC で1文字にまとめると3バイトでした。String.prototype.normalize() で形をそろえておくと、同じ見た目の名前は同じバイト列になります。

React のコンポーネント

タイトルと本文を入力すると、保存する名前を下に出し、「保存」ボタンでその名前のテキストファイルを保存します。名前は入力のたびに toSafeFilename で計算するので、state にはタイトルと本文だけを持たせています。

tsx
// SafeFilenameDownload.tsx
import { useState } from 'react';
import { toSafeFilename } from './safeFilename';

function saveText(text: string, filename: string): void {
  const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

export function SafeFilenameDownload() {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  const filename = toSafeFilename(title, '.txt');

  return (
    <div className="demo">
      <label>
        タイトル
        <input value={title} onChange={(e) => setTitle(e.target.value)} />
      </label>
      <label>
        本文
        <textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} />
      </label>
      <p>
        保存する名前:<output>{filename}</output>
      </p>
      <button onClick={() => saveText(body, filename)}>保存</button>
    </div>
  );
}

Playwright でタイトルを8通り入力して「保存」を押し、画面の「保存する名前」と、Chromium が提案するファイル名を比べました。提案するファイル名は、Playwright の suggestedFilename() で取り出せます。8通りすべてで同じでした。保存した8つのファイルの中身は、どれも入力した本文のとおりでした。

Chromium も名前を直すが、そのままでは表示とずれる

MDN の a 要素の説明によると、download 属性の値の / と \ は _ に変わり、ほかの文字もファイルシステムに合わせてブラウザが直すことがあります。直した結果は download プロパティからは見えません。検証環境でも、a.download = 'a/b:c.txt' とした後の a.download の値は a/b:c.txt のままでした。

そこで、toSafeFilename を通さずに download 属性へ名前を入れて、Chromium 153 が提案するファイル名を調べました。

download 属性に入れた名前 Chromium が提案するファイル名
見積書 2026/11/08.txt 見積書 2026_11_08.txt
議事録: 第3回 <確定>.txt 議事録_ 第3回 _確定_.txt
A|B?C*D"E.txt A_B_C_D_E.txt
前後に空白 の前後に半角の空白を2つずつ入れて、.txt を付けた名前 _ 前後に空白 .txt
終わりが点... 終わりが点.txt
CON.txt CON.txt
.hidden.txt hidden.txt
猫🐈‍⬛.txt(黒猫の絵文字) 猫🐈_⬛.txt

Linux の Chromium でも、Windows で使えない文字は _ に置き換わりました。一方で、CON.txt はそのままでした。先頭の空白2つのうち1つ目は _ に変わり、.txt の前の空白2つは残りました。点で始まる名前は点が取れ、点で終わる名前には .txt が付きました。

いちばん分かりにくいのは絵文字です。黒猫の絵文字は、猫の絵文字と黒い四角を、ゼロ幅接合子(U+200D)でつないだものです。Chromium はこのゼロ幅接合子を _ に置き換えました。ほかの書式文字も同じで、ゼロ幅スペース(U+200B)、右から左への上書き(U+202E)、BOM(U+FEFF)も _ になりました。異体字セレクター(U+FE0F)と、ノーブレークスペース(U+00A0)はそのままでした。

toSafeFilename で書式文字を先に消しているのは、このためです。消しておけば、画面に出した名前と、Chromium が提案するファイル名がそろいます。photo と gpj の間に右から左への上書き(U+202E)を挟んだタイトルも、toSafeFilename を通すと photogpj.txt になりました。

長さも、Chromium は切りませんでした。ね を80個と家族の絵文字3つを並べた名前を入れると、Chromium が提案するファイル名は 286 バイトでした。元の 298 バイトから減ったのは、ゼロ幅接合子(3バイト)6つが _(1バイト)に変わった分だけで、上限に合わせて切られてはいません。ext4 の上限の255バイトを超えています。toSafeFilename を通すと、Chromium が提案するファイル名も 252 バイトの ねね…ね👨👩.txt になりました。

ブラウザが名前を直した結果は、アプリからは見えません。アプリの側で直した名前を download 属性に渡しておくと、Chromium 153 では、画面に出した名前がそのまま提案されました。

​Posted by 86