Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ pnpm typecheck # tsc -b のみ
- ハイライト条件(キャラ選択 ∧ ボイス不要でない)は `src/lib/dialogue.ts` の `isHighlighted` に集約。件数集計・表示判定・連番付与がすべてこれを共有する
- `Dialogue.character === ""` が地の文を表す(null ではなく空文字)
- `toggleCharacter` は選んだ名前を部分文字列として含む全キャラを一括トグルする(例 「田中」で「田中A」「田中B」も切り替わる)
- `Dialogue.text` は `toZenKaku` で全角化済みの表示用、`Dialogue.rawText` は元の「」の中身そのまま(変換なし)。txt 書き出し(`src/lib/exportDialogue.ts`)は `rawText` を使う
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- 縦書きの日本語台本を表示
- txt ファイルからのデータ読み込み
- キャラクター名別にセリフの強調表示
- 選択キャラのセリフを 1 行 1 セリフのプレーンテキスト txt で書き出し(元の「」の中身を変換せずそのまま出力)
- 印刷用のスタイル設定(背景色、フォントサイズなど)
- ブラウザ上での直接印刷サポート

Expand All @@ -25,6 +26,7 @@
2. txt ファイルを選択してアップロードします。
3. キャラクター名のチェックボックスを使用してセリフの表示をトグルします。
4. ブラウザから印刷します。
5. 右下の「txt」ボタンで、選択中キャラのセリフをプレーンテキストの txt として書き出せます。

## ローカルでのセットアップ

Expand Down
14 changes: 13 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ScriptSummary } from "./components/ScriptSummary";
import { ScriptView } from "./components/ScriptView";
import { toggleCharactersByMatch } from "./lib/characterToggle";
import { isHighlighted } from "./lib/dialogue";
import { buildDialogueText, exportFileName } from "./lib/exportDialogue";
import { parseScript, type ParseWarning } from "./lib/parseScript";
import { toggleInSet } from "./lib/setOps";
import type { Dialogue } from "./types";
Expand Down Expand Up @@ -72,6 +73,17 @@ export function App() {
setNoVoice((prev) => toggleInSet(prev, id));
};

const exportText = () => {
const text = buildDialogueText(dialogues, selected, noVoice);
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = exportFileName(title);
a.click();
URL.revokeObjectURL(url);
};

return (
<>
<ScriptSummary
Expand All @@ -92,7 +104,7 @@ export function App() {
noVoice={noVoice}
onToggleNoVoice={toggleNoVoice}
/>
<FloatingActions />
<FloatingActions onExportText={exportText} />
</>
);
}
14 changes: 13 additions & 1 deletion src/components/FloatingActions.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export function FloatingActions() {
type Props = {
onExportText: () => void;
};

export function FloatingActions({ onExportText }: Props) {
const scrollToStart = () =>
window.scrollTo({ left: 0, top: 0, behavior: "smooth" });
const scrollToEnd = () =>
Expand Down Expand Up @@ -27,6 +31,14 @@ export function FloatingActions() {
>
</button>
<button
type="button"
onClick={onExportText}
title="選択キャラのセリフを txt で書き出す"
aria-label="セリフを txt で書き出す"
>
txt
</button>
<button
type="button"
onClick={handlePrint}
Expand Down
1 change: 1 addition & 0 deletions src/lib/dialogue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const make = (over: Partial<Dialogue>): Dialogue => ({
id: 0,
character: "田中",
text: "セリフ",
rawText: "セリフ",
...over,
});

Expand Down
66 changes: 66 additions & 0 deletions src/lib/exportDialogue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { buildDialogueText, exportFileName } from "./exportDialogue";
import type { Dialogue } from "../types";

const make = (over: Partial<Dialogue>): Dialogue => ({
id: 0,
character: "田中",
text: "セリフ",
rawText: "セリフ",
...over,
});

describe("buildDialogueText", () => {
it("選択キャラのセリフを 1 行 1 セリフで書き出す", () => {
const dialogues = [
make({ id: 0, character: "田中", rawText: "おはよう" }),
make({ id: 1, character: "山田", rawText: "こんにちは" }),
make({ id: 2, character: "田中", rawText: "またね" }),
];
const text = buildDialogueText(dialogues, new Set(["田中"]), new Set());
expect(text).toBe("おはよう\nまたね");
});

it("地の文 (character === '') は書き出さない", () => {
const dialogues = [
make({ id: 0, character: "", rawText: "朝だった。" }),
make({ id: 1, character: "田中", rawText: "おはよう" }),
];
const text = buildDialogueText(dialogues, new Set(["田中"]), new Set());
expect(text).toBe("おはよう");
});

it("ボイス不要のセリフは除外する", () => {
const dialogues = [
make({ id: 0, character: "田中", rawText: "A" }),
make({ id: 1, character: "田中", rawText: "B" }),
];
const text = buildDialogueText(dialogues, new Set(["田中"]), new Set([1]));
expect(text).toBe("A");
});

it("全角化などの変換をせず rawText をそのまま書き出す", () => {
const dialogues = [make({ character: "田中", rawText: "hello 123" })];
const text = buildDialogueText(dialogues, new Set(["田中"]), new Set());
expect(text).toBe("hello 123");
});

it("該当セリフがなければ空文字を返す", () => {
const dialogues = [make({ character: "田中", rawText: "A" })];
expect(buildDialogueText(dialogues, new Set(["山田"]), new Set())).toBe("");
});
});

describe("exportFileName", () => {
it("末尾の .txt を除いてサフィックスを付ける", () => {
expect(exportFileName("script.txt")).toBe("script_セリフ.txt");
});

it("拡張子がなければそのままサフィックスを付ける", () => {
expect(exportFileName("台本")).toBe("台本_セリフ.txt");
});

it("空タイトルは script にフォールバックする", () => {
expect(exportFileName("")).toBe("script_セリフ.txt");
});
});
23 changes: 23 additions & 0 deletions src/lib/exportDialogue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Dialogue } from "../types";
import { isHighlighted } from "./dialogue";

/**
* 選択中キャラ(かつボイス不要でない)のセリフを 1 行 1 セリフで連結する。
* 変換は一切行わず、元の台本にある「」の中身(rawText)をそのまま書き出す。
*/
export function buildDialogueText(
dialogues: Dialogue[],
selected: Set<string>,
noVoice: Set<number>,
): string {
return dialogues
.filter((d) => isHighlighted(d, selected, noVoice))
.map((d) => d.rawText)
.join("\n");
}

/** タイトルから書き出し用のファイル名を組み立てる */
export function exportFileName(title: string): string {
const base = title.replace(/\.txt$/i, "").trim() || "script";
return `${base}_セリフ.txt`;
}
47 changes: 41 additions & 6 deletions src/lib/parseScript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,29 @@ import { parseScript } from "./parseScript";
describe("parseScript", () => {
it("基本形「キャラ「セリフ」」を抽出する", () => {
const { dialogues, characters } = parseScript("田中「おはよう」");
expect(dialogues[0]).toEqual({ id: 0, character: "田中", text: "おはよう" });
expect(dialogues[0]).toEqual({
id: 0,
character: "田中",
text: "おはよう",
rawText: "おはよう",
});
expect(characters).toEqual(["田中"]);
});

it("地の文とセリフを混在させて連番 id を振る", () => {
const { dialogues } = parseScript("朝だった。\n田中「おはよう」");
expect(dialogues[0]).toEqual({ id: 0, character: "", text: "朝だった。" });
expect(dialogues[1]).toEqual({ id: 1, character: "田中", text: "おはよう" });
expect(dialogues[0]).toEqual({
id: 0,
character: "",
text: "朝だった。",
rawText: "朝だった。",
});
expect(dialogues[1]).toEqual({
id: 1,
character: "田中",
text: "おはよう",
rawText: "おはよう",
});
});

it("同一キャラの登場を重複排除する", () => {
Expand All @@ -25,11 +40,31 @@ describe("parseScript", () => {
expect(dialogues[0].text).toBe("hello 123");
});

it("rawText は全角化せず元の「」の中身をそのまま保持する", () => {
const { dialogues } = parseScript("tanaka「hello 123」");
expect(dialogues[0].rawText).toBe("hello 123");
});

it("複数行の地の文が複数のエントリに分解される", () => {
const { dialogues } = parseScript("一行目\n二行目\n田中「セリフ」");
expect(dialogues[0]).toEqual({ id: 0, character: "", text: "一行目" });
expect(dialogues[1]).toEqual({ id: 1, character: "", text: "二行目" });
expect(dialogues[2]).toEqual({ id: 2, character: "田中", text: "セリフ" });
expect(dialogues[0]).toEqual({
id: 0,
character: "",
text: "一行目",
rawText: "一行目",
});
expect(dialogues[1]).toEqual({
id: 1,
character: "",
text: "二行目",
rawText: "二行目",
});
expect(dialogues[2]).toEqual({
id: 2,
character: "田中",
text: "セリフ",
rawText: "セリフ",
});
});

it("キャラ名と「の間の空白は trim でキャラ名から除去される", () => {
Expand Down
7 changes: 4 additions & 3 deletions src/lib/parseScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ export function parseScript(raw: string): {

if (match) {
const name = toZenKaku(match[1].trim());
const speech = toZenKaku(match[2].trim());
dialogues.push({ id: id++, character: name, text: speech });
const rawSpeech = match[2];
const speech = toZenKaku(rawSpeech.trim());
dialogues.push({ id: id++, character: name, text: speech, rawText: rawSpeech });
characterSet.add(name);
} else {
dialogues.push({ id: id++, character: "", text: toZenKaku(line) });
dialogues.push({ id: id++, character: "", text: toZenKaku(line), rawText: line });
collectNarrativeWarnings(line, lineNumber, warnings);
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ export type Dialogue = {
id: number;
character: string;
text: string;
/** 元の台本にある「」の中身。全角化などの変換を行っていない生テキスト */
rawText: string;
};