0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CLI・Claude Code出力コピペの『端末折返し改行』だけを畳む。意図的な改行を残すスマートリフロー設計

0
Posted at

3行まとめ

  • CLI や Claude Code、PDF からコピペしたテキストの「余計な改行・ノイズ」を掃除するツールを作った
  • 一番難しいのは 端末幅で機械的に折り返された改行だけを畳み、意図的な改行(短い行・体言止め)は残すこと
  • 判定の決め手は 行末の助詞・読点 + 行の表示幅(全角2幅で計算)+ 文字の隣接。日本語は空白なしで直結、英文はスペースで連結

ターミナルの出力や Claude Code のログをドキュメントや Slack に貼ると、変な位置で改行が入っていて読みにくい。端末の幅で折り返された改行がそのままテキストに焼き付いているせいだ。かといって「全部の改行を消す」と、箇条書きや段落の切れ目まで潰れてぐちゃぐちゃになる。

ぱんだツールズに作った貼り付け用テキスト整形ツールは、この「畳んでいい改行と、残すべき改行を見分ける」のを真面目にやる。ノイズ除去(ANSI・ゼロ幅文字など)も込みだが、本体は改行のリフロー判定なので、そこを中心にまとめる。

問題:端末折返しの改行と、意図的な改行が混ざっている

CLI 出力をコピペしたテキストには、2種類の改行が混在している。

  • 機械的な折返し改行 — 端末の幅(80桁など)で勝手に折り返された改行。本来は1つの文/段落の途中。畳みたい
  • 意図的な改行 — 箇条書き、ステータスの1行、文末での段落区切り、体言止め。残したい

素朴に「改行を全部スペースに置換」すると後者まで潰れるし、「そのまま」だと前者が残って読みにくい。どちらの改行かを1行ずつ推定する必要がある。これがこのツールの核心 isLikelyWrappedLine。

判定材料1:行末の助詞・読点

日本語で一番効くのが、前の行の末尾がどんな文字で終わっているか。読点や格助詞・接続助詞で終わっていたら、文はほぼ確実に次の行へ続いている。

const JP_CONNECTIVE_END =
  /(?:から|まで|より|ので|のに|けど|けれど|つつ|ながら|たり|ては|ても|でも|くて|として|について|により|という|[、,はがをにへとでもやのてし])$/u // ※一部抜粋(実物は「けれども」「なくて」等も含む)

「〜について(改行)」「〜により(改行)」「〜を(改行)」のように終わる行は、端末幅でぶった切られた折返しと判断して行長に関係なく結合する。逆に「完了。」「— done」のように文末記号や体言止めで終わる行は、意図的な改行として残す。

文末記号の判定はこう。これに当たる行の後ろの改行は維持する。

const SENTENCE_END = /[。.!?\.!?][」』))\]"']*\s*$/

判定材料2:行の「表示幅」(全角は2幅で数える)

助詞で終わっていなくても、前の行が端末幅いっぱいまで伸びていて、区切りでない位置で文字が隣り合っているなら、機械的折返しの可能性が高い。

ここで重要なのが「行の長さ」を文字数ではなく表示幅で測ること。日本語は全角で1文字が2幅ぶんの場所を取るので、string.length だと「見た目の長さ」とズレる。全角を2、半角を1で数える displayWidth を用意する。

function displayWidth(s: string): number {
  let w = 0
  for (const ch of s) {
    const cp = ch.codePointAt(0) ?? 0
    w += isWideChar(cp) ? 2 : 1   // 全角は2幅
  }
  return w
}

function isWideChar(cp: number): boolean {
  return (
    (cp >= 0x3041 && cp <= 0x33ff) || // ひらがな・カタカナ・CJK互換記号
    (cp >= 0x4e00 && cp <= 0x9fff) || // CJK統合漢字
    (cp >= 0xff00 && cp <= 0xff60) || // 全角ASCII・記号
    // …Hangul, CJK拡張A, 互換漢字 など主要範囲
    false
  )
}

この表示幅が閾値以上のときだけ「折返しっぽい」と判定する。日本語は40幅、英文はもっと長くなるので60幅、と分けている。

const WRAP_MIN_WIDTH = 40       // 日本語の機械折返しとみなす最小表示幅
const WRAP_MIN_WIDTH_EN = 60    // 英文はより厳しく

なぜ幅でガードするかというと、短い行を誤って結合しないため。たとえば index.ts utils.ts のような短いファイル名リストは、行末が小文字で次行頭も小文字だが、これは結合したくない。前の行が十分長い(=端末幅で折り返された)ときだけ結合する、という条件にすることで、短い意図的な改行を守れる。

判定材料3:文字の隣接(日本語/英文で分ける)

幅の条件を満たしたうえで、境界の文字種を見る。

function isLikelyWrappedLine(prev: string, cur: string): boolean {
  const p = prev.replace(/[ \t]+$/, '')
  const c = cur.replace(/^[ \t]+/, '')
  const last = p.slice(-1)
  const first = c.charAt(0)

  // 1. 読点・接続助詞・格助詞で終わる → 結合(行長不問)
  if (JP_CONNECTIVE_END.test(p)) return true

  // 2. 日本語の機械折返し: 前行が十分長く、日本語文字どうしが隣接
  if (displayWidth(p) >= WRAP_MIN_WIDTH && JP_CHAR.test(last) && JP_CHAR.test(first)) return true

  // 3. 英文の単語途中折返し: 前行が十分長く、小文字/カンマ→小文字
  if (displayWidth(p) >= WRAP_MIN_WIDTH && /[a-z,]$/.test(last) && /^[a-z]/.test(first)) return true

  // 4. 英数字→英字(数字・略語含む)はさらに厳しい幅で
  if (displayWidth(p) >= WRAP_MIN_WIDTH_EN && /[A-Za-z0-9]$/.test(last) && /^[A-Za-z]/.test(first)) return true

  return false
}

日本語文字どうしが幅40以上の行末・行頭で隣接していれば折返し、英文は小文字どうしが幅60以上で続いていれば折返し。この多段の条件をすべて外れた改行は「意図的」とみなして残す。完璧な区別は原理的に無理だが、「助詞・幅・隣接」の3点で実用上はかなり当たる。

結合のしかた:日本語は直結、英文はスペース

「結合する」と決まった2行をどう繋ぐかも、日本語と英文で違う。

function joinLines(prev: string, cur: string): string {
  const prevLast = prev.slice(-1)
  const curFirst = cur.charAt(0)

  // 日本語が絡む → 空白なしで直結
  if (JP_CHAR.test(prevLast) || JP_CHAR.test(curFirst)) return prev + cur

  // 英文どうし → 半角スペースで連結
  return prev + ' ' + cur
}

日本語は単語間にスペースを置かないので、折返しを畳むときも空白を入れずに直結する。ここでスペースを入れると「〜について 検討」のような不自然な隙間ができる。一方、英文は単語がスペースで区切られるので、折返し結合時は半角スペースを補う。PDF にありがちな develop-\nment のような行末ハイフン折返しは、この joinLines の中で専用に分岐させ、ハイフンを消して development に繋ぐ。

構造は壊さない:コードフェンスと箇条書きの保護

リフローの前に、触ってはいけない領域を分離しておく。まず Markdown のコードフェンス(``` で囲まれた範囲)は丸ごと保護ブロックとして切り出し、整形対象から外す。コード中の改行やインデントを畳んだら大惨事だからだ。

function cleanupText(input: string, options: CleanupOptions): string {
  const blocks = options.protectStructured
    ? splitProtectedBlocks(input)   // ```ブロックを分離
    : [{ kind: 'text', content: input }]
  return blocks
    .map((b) => b.kind === 'protected' ? b.content : processTextBlock(b.content, options))
    .join('')
}

加えて、見出し(#)・箇条書き(- * + ・)・番号リスト・引用(>)・表(|...|)・インデントコードの行は isStructuredLine で検出し、リフロー対象から除外する。これらの行の前後の改行は常に残す。

処理順の罠:空行圧縮はリフローより先

地味だが大事な実装の勘所が処理の順番。連続する空行の圧縮(collapseBlankLines)は、段落リフローより先に適用しなければいけない。

// collapseBlankLines を reflow より先に。
// reflow の段落分割は \n\n を区切りに使うので、3連続以上の改行が残ると
// 段落判定が壊れる。先に空行を畳んでおくと処理順依存をなくせる。
if (options.collapseBlankLines === 'collapse') {
  result = result.replace(/\n{3,}/g, '\n\n')
}
// …この後で reflowText() を呼ぶ

リフローは「空行(\n\n)で段落を区切り、段落内の改行だけ畳む」という作りなので、\n\n\n のような3連続改行が残っていると段落の分割がずれる。先に空行を \n\n に正規化しておけば、後段のリフローが安定する。処理を関数に分けるときは、こういう「前段の出力形を後段が前提にしている」依存を意識しておかないとバグる。

まとめ

CLI 出力のコピペ整形で「畳む改行/残す改行」を見分ける要点。

  • 端末折返しと意図的改行の判別が核心。isLikelyWrappedLine で 行末の助詞・読点 / 行の表示幅 / 文字の隣接の3点から推定
  • 行長は 文字数でなく表示幅(全角2・半角1) で測る。短い行(ファイル名リスト等)を誤結合しない幅ガードが効く
  • 結合は 日本語=空白なし直結 / 英文=半角スペース。PDF の行末ハイフン折返しは専用処理
  • コードフェンス・箇条書き・表は保護して触らない
  • 空行圧縮は リフローより先。段落分割が \n\n 前提なので処理順で壊れる

「改行を全部消す/全部残す」の二択をやめて、行ごとに折返しか意図かを推定するだけで、CLI やエディタからのコピペがそのまま読める文章になる。完全な判別は無理でも、助詞と表示幅を足すと体感かなり当たる。テキストはサーバーに送らずブラウザ内で処理するので、ログや社内文書の整形にも安心して使える。

ぱんだツールズ では他にもテキスト差分・文字数カウント・改行コード変換・正規表現テストなど、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・入力はサーバーに送られない。
https://sakutto-panda.com


この記事は Zenn にも同じ内容を投稿しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?