こんにちは!any 株式会社でプロダクトチームに所属しているエンジニアのなおとぅ(@Rasukarusan)です!
この記事は、any Product Team Advent Calendar 2025 シリーズ3の 11 日目の記事になります。
はじめに
ターミナルの出力やCSVファイルの中身を、NotionやGoogleスプレッドシートに表として貼り付けたい。そんなことありませんか?
例えば、こういうデータをコピーして、
$ cat hoge.tsv
名前 年齢 職業
田中 30 エンジニア
そのまま Notionに貼り付けると、表にならずテキストのまま貼り付けられてしまう。
そうじゃなくてこうなってほしい↓。
これを解決するCLIツール table-to-clipboard を作ったのですが、その過程でmacOSのクリップボードやコマンドについて、いくつか発見があったので共有します。
TL;DR
- macOSのクリップボードは複数の形式を同時に保持できる
- NotionやGoogleスプレッドシートに表として貼り付けるにはRTF形式でコピーする必要がある
-
pbcopyはプレーンテキスト専用。RTFはtextutilでHTML→RTF変換し、SwiftからCocoaのNSPasteboardでセット
macOSクリップボードの仕組み
複数形式の同時保持
macOSのクリップボードは、実は同じデータを複数の形式で同時に保持しています。
試しに、スプレッドシートで表をコピーして貼り付けてみると、
$ pbpaste
名前 年齢 職業
田中 30 エンジニア
プレーンテキストが出力されます。
でもNotionに貼り付けるとちゃんと表として認識されます。
これはスプレッドシートからコピーした際、プレーンテキストとリッチテキストの両方を保持するようになっているからなんですよね。内部的には以下のような形です。
| 形式 | 用途 |
|---|---|
public.utf8-plain-text |
プレーンテキスト(ターミナルへの貼り付け等) |
public.rtf |
リッチテキスト(Word、スプレッドシート等) |
これはmacOSのNSPasteboardの仕組みで、各データ形式はUniform Type Identifier (UTI)で識別されています。
pbcopyの限界
pbcopyは便利ですが、プレーンテキストしかコピーできません。
echo "名前\t年齢\n田中\t30" | pbcopy # プレーンテキストとしてコピーされる
これをNotionに貼り付けても、表として認識されずテキストのまま貼り付けられてしまいます。
RTF形式でクリップボードにコピーする
Notionに表として貼り付けるには、RTF形式でコピーする必要があります。
Step 1: HTMLテーブルを作る
まず、データをHTMLのテーブルに変換します。
<table>
<tr>
<th>名前</th>
<th>年齢</th>
</tr>
<tr>
<td>田中</td>
<td>30</td>
</tr>
</table>
Step 2: textutilでRTFに変換
macOS標準のtextutilでHTMLをRTFに変換できます。
textutil -convert rtf -output output.rtf input.html
Step 3: Cocoaでクリップボードにセット
SwiftからCocoaのNSPasteboardを直接呼び出してクリップボードにセットします。
import Cocoa
let rtfPath = "/tmp/clipboard-temp.rtf"
let textPath = "/tmp/clipboard-temp.txt"
guard let rtfData = FileManager.default.contents(atPath: rtfPath),
let textData = FileManager.default.contents(atPath: textPath) else {
exit(1)
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setData(rtfData, forType: .rtf)
pasteboard.setData(textData, forType: .string)
RTFとプレーンテキストの両方を同時にセットして、貼り付け先に応じて適切な形式が使われるようにします。
実装例
一連の流れをTypeScriptで書くとこんな感じ。
function copyHtmlToClipboard(html: string, plainText: string): void {
const tempHtml = "/tmp/clipboard-temp.html";
const tempRtf = "/tmp/clipboard-temp.rtf";
const tempText = "/tmp/clipboard-temp.txt";
const tempSwift = "/tmp/clipboard-temp.swift";
// HTMLを保存
const fullHtml = `<!DOCTYPE html><html><body>${html}</body></html>`;
fs.writeFileSync(tempHtml, fullHtml);
fs.writeFileSync(tempText, plainText);
// RTFに変換
execSync(`textutil -convert rtf -output "${tempRtf}" "${tempHtml}"`);
// Swiftスクリプトでクリップボードにセット
const swiftCode = `
import Cocoa
let rtfPath = "${tempRtf}"
let textPath = "${tempText}"
guard let rtfData = FileManager.default.contents(atPath: rtfPath),
let textData = FileManager.default.contents(atPath: textPath) else {
exit(1)
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setData(rtfData, forType: .rtf)
pasteboard.setData(textData, forType: .string)
`;
fs.writeFileSync(tempSwift, swiftCode);
execSync(`swift "${tempSwift}"`);
}
TypeScriptというより、シェルコマンドやSwiftも混ざり合った、夢のコラボレーションスクリプトになっていますね!
こういうカオスなスクリプトは大好きです😇
開発中の細かい対応
AppleScriptの文字数制限
先ほどのCocoaのNSPasteboardでクリップボードにコピーする箇所は、最初はosascript(AppleScript)を使ってクリップボードにセットしていました。
osascript -e 'set the clipboard to {«class RTF »:«data RTF XXXX»}'
XXXXの部分にはRTFファイルの内容を16進数エンコードしたものが入ります。
しかし、hex文字列が長すぎるとエラーが出るという問題がありました。少しでも大きなテーブルになると簡単にこの制限に引っかかってしまったので、SwiftのCocoaを利用する形にしました。
リテラル文字列の対応
# これは動く
echo -e "名前\t年齢" | table-to-clipboard
# これは動かない(\tがタブにならない)
echo "名前\t年齢" | table-to-clipboard
echo -eを使うと\tはタブに変換されますが、-eなしだと\tはただの2文字(バックスラッシュ + t)です。
また、echoの挙動はbashとzshでも異なるので、この辺の差異を吸収するため、リテラル文字列を実際のエスケープシーケンスに変換する処理を入れました。
function unescapeLiterals(text: string): string {
return text.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
}
これで、どちらのパターンでも動くようになりました。
スペース区切りの対応
もう一つ細かいですが、ターミナルの出力をコピーした場合にも対応しました
# ターミナルでTSVを出力
cat data.tsv
# 名前 年齢 職業
# 田中 30 エンジニア
この出力をマウスで選択してコピーすると、タブがスペースに変換されています。
ターミナルは表示時にタブを複数のスペースに展開して表示するため、見た目は同じでも中身が違うんですよね。
解決策としてはシンプルに「2つ以上の連続スペース」を区切り文字として認識するようにしました。
function parseSpaceSeparated(data: string): string[][] {
return data
.trim()
.split("\n")
.map((line) => {
// 2つ以上の連続スペースで分割
return line.split(/ +/).map((cell) => cell.trim());
});
}
これなら Hello World のような単一スペースはセル内に残り、列の区切りだけを認識できます。
SlackはpbcopyでOK
ちなみにSlackの場合は、RTFにしなくてもタブ区切りのプレーンテキストなら表として認識されます。
# Slackの場合はこれでOK
echo -e "名前\t年齢\n田中\t30" | pbcopy
完成したツール
こうして完成したのが table-to-clipboard です。
# インストール
npm install -g @rasukarusan/table-to-clipboard
対応形式は以下の4種類で、コピーしたものをとりあえずパイプで渡せばOKです。
- TSV(タブ区切り)
- CSV(カンマ区切り)
- スペース区切り(2つ以上の連続スペース)
- Markdownテーブル
# 使い方
pbpaste | table-to-clipboard
cat data.csv | table-to-clipboard
echo "A,B,C" | table-to-clipboard
個人的にはスペース区切りやマークダウンの表を貼り付けるときに、気持ちよさを感じますね😄
# スペース区切りの表
名前 年齢 職業
田中 30 エンジニア
# markdownで出力された表
| 名前 | 年齢 | 職業 |
|------|----:|----------|
| 田中 | 30 | エンジニア |
特に最近はClaude Codeを使っている際に、markdownで出力されることが多いのでよく活躍しています。
終わり
ちょっとしたツールでも、実際に作ってみると知らないことが多くあり、学びがたくさんありました。
AI時代、モノ自体は簡単に作れるので、気になった挙動など細かい部分を深掘りする時間が多く取れて楽しいですね!
これからも色々作って知識を増やしていきたいです💪
それではまた!
any株式会社ではナレッジ経営クラウドQastのエンジニアを絶賛募集中です。
是非採用ページをご覧ください!
エンジニア組織/文化について詳しく知りたい方はこちら


