Next.jsでepub-genを利用して電子書籍を生成した

こんにちは、**個人開発でNext.jsを主に利用していますdende-h**です。

現在、小説を書く方向けの小説エディターアプリを公開しております。

**小説作成アプリ『[Re:terature](https://novel-editor-ver2.vercel.app/)』**

今回の記事はアプリに電子書籍ファイルの出力機能を付けたので、その際の覚書です。

## 開発経緯

小説を書くのに使いやすい**シンプルなエディター**が欲しかった（特に自分が）ため、できるところから徐々に作っていきました。

`Lexical`を**テキストエディターとして採用**したことで、**Undo,Redo機能**の付いたシンプル仕様になっています。

これまでの仕様では、**書いた小説をTXT形式でダウンロード**することができる機能がありました。日本最大級の投稿サイト[小説家になろう](https://syosetu.com/)ではTXT形式でのインポートも可能だからです。

全文のコピーボタンもあるので、もちろんコピペも可能です。

ただ、最近では**自身で電子書籍化してAmazonなどで公開**するといったことも一般化しつつあるので、書いたものがそのまま簡単に電子書籍化できるといいなと思い機能を追加するに至りました。

### 使用ライブラリ

- **[epub-gen-memory](https://github.com/cpiber/epub-gen-memory)**
    
    Node.jsやブラウザで動作可能な**epubファイル生成用ライブラリ**
    
    **HTMLのデータをepubファイル**へ変換できる
    

### 選定理由

**小説用のルビや傍点を振るためにプレビューする際はHTMLで表示**をしています。そのため**HTMLをそのままepub化**できる点と、下記にあるように

> このパッケージには、epub-gen-memory/bundle として browserifyd バンドル (UMD) も含まれています。ブラウザ用にビルドしたい場合は、このバンドルを使うことができます。バンドルはCDNからも入手できます: UNPKG (latest, latest 1.x)。このバンドルにはブラウザ用の適切な戻り値型も含まれています (Promise<Buffer> ではなく Promise<Blob>)。
> 

とブラウザで直接ライブラリを使用でき、**戻り値の型にBlobも含まれる**からです。

`Blob`はTXT形式でのダウンロードでも使っており、コードが使いまわせるのと**ブラウザの機能を使ってファイルダウンロードができるので、多くの端末が利用**できます。

## 実際のコード

### まずテキストをHTML化するcustomフック

**正規表現**を使って記法を抽出して`HTML`に置き換えています。

具体的には

- |漢字《かんじ》→《》内がルビとして使われる
- 《《傍点》》→　強調のための傍点が振られる
- [リンク](https://～)　→　()内のURLへ飛べるリンクが貼られる
- 改行の特殊文字を<br>タグへ置き換え

また**セキュリティとして余計な`script`が入らないようエスケープ**を実施しています。

```jsx
/* eslint-disable no-useless-escape */

export const useTextToHTML = () => {
	const rubyRegex = /[｜|]([^《｜|]+)《([^》]+)》/g;
	const boutenRegex = /《《([^》]+)》》/g;

//ルビタグへ変換
	function addRubyTags(text: string) {
		return text.replace(rubyRegex, "<ruby>$1<rt>$2</rt></ruby>");
	}

//傍点記法を見つけたらルビ記法へ置き換える
	function addBoutenTags(text: string) {
		return text.replace(boutenRegex, (match, p1) => {
			const boutenText = p1
				.split("")
				.map((char) => `｜${char}《・》`)
				.join("");
			return boutenText;
		});
	}

//リンク記法はaタグへ置き換える
	function addLinkTags(text: string) {
		const linkRegex = /\[([^\]]+)\]\((http[^\)]+)\)/g;
		const escapedText = text
			.replace(/&/g, "&amp;")
			.replace(/</g, "&lt;")
			.replace(/>/g, "&gt;")
			.replace(/"/g, "&quot;")
			.replace(/'/g, "&#x27;")
			.replace(/\//g, "&#x2F;");
		return escapedText.replace(linkRegex, function (match, p1, p2) {
			const rubyText = addRubyTags(p1);
			return `<a href="${p2}" style="text-decoration: underline; color: blue;">${rubyText}</a>`;
		});
	}

//特殊文字を改行タグへ置き換える
	function addBrTags(text: string) {
		return text.replace(/\r?\n/g, "<br>");
	}

//受け取ったテキストへ各関数処理を実行
	function textToHtml(text: string) {
		const aText = addLinkTags(text);
		const boutenText = addBoutenTags(aText);
		const rubyText = addRubyTags(boutenText);
		const brText = addBrTags(rubyText);

		return brText;
	}

	return { textToHtml };
};
```

### 続いて処理ページのコード全体です。

後ほど分解していきます。

下記のコードではコンポーネントライブラリに`ChakraUI`を使っています。状態管理は`Recoil`です。フォーム管理ライブラリとして`react-hook-form`、非同期処理に`acync/await`構文と、`try/catch`構文を利用しています。

趣旨と逸れますので全ての解説はせず、**フォームとライブラリの使用箇所を中心に説明**します。

```jsx
import { useForm, useFieldArray } from "react-hook-form";
import { useRecoilValue } from "recoil";
import {
	Box,
	VStack,
	Heading,
	FormControl,
	FormLabel,
	Input,
	Button,
	Select,
	Spacer,
	Flex,
	FormErrorMessage,
	Center,
	Spinner
} from "@chakra-ui/react";
import { draftObjectArray, drafts } from "../globalState/atoms/drafts";
import { userName } from "../globalState/atoms/userName";
import { useTextToHTML } from "../hooks/useTextToHTML";
import { InfoForEpubGen } from "../components/epub/InfoForEpubGen";
import { useState } from "react";

type FormValues = {
	title: string;
	publisher: string;
	chapters: { title: string }[];
};

function EpubForm() {
	const { textToHtml } = useTextToHTML(); //テキストをHTML化するためのcustomフック
	const [isLoading, setIsLoading] = useState<boolean>(false);

	const {
		register,
		handleSubmit,
		control,
		formState: { errors },
		getValues
	} = useForm<FormValues>({
		defaultValues: {
			chapters: [{ title: "" }]
		},
		mode: "onChange"
	});
	const { fields, append, remove } = useFieldArray({
		control,
		name: "chapters"
	});

	const draftsData = useRecoilValue<draftObjectArray>(drafts);
	const author = useRecoilValue<string>(userName);

	const onSubmit = handleSubmit(async (data) => {
		setIsLoading(true);

		if (data.chapters.length === 0) {
			alert("最低一つ以上の章を追加してください");
			setIsLoading(false);
			return;
		}

		let epub;

		//ライブラリimportをクライアントサイドで行う
		if (typeof window !== "undefined") {
			await import("epub-gen-memory/bundle").then((module) => {
				epub = module.default;
			});
		}

		//チャプターとして選択された小説のタイトルと本文をライブラリで使用する配列オブジェクト化
		const chapters = data.chapters.map((chapter) => {
			const foundDraft = draftsData.find((draft) => draft.id === chapter.title); //chaoter.titleには小説のIdが格納
			return {
				title: foundDraft.title,
				content: textToHtml(foundDraft.body)
			};
		});

		const imgURL = draftsData.find((draft) => draft.id === data.chapters[0].title).imageUrl;

		//必要データをoptionsにまとめる
		const options = {
			title: data.title,
			author: author,
			cover: imgURL,
			publisher: data.publisher,
			tocTitle: "目次",
			version: 3,
			verbose: false,
			lang: "ja",
			css: `
    @font-face {
      font-family: 'Noto Serif JP';
      src: url('./fonts/NotoSerifJP-SemiBold.otf') format('opentype');
    }
    body {
      font-family: 'Noto Serif JP', serif;
    }
  `,
			fonts: [
				{
					filename: "NotoSerifJP-SemiBold.otf",
					url: "https://enjzxtbbcyrptkkutovq.supabase.co/storage/v1/object/public/Fonts/NotoSerifJP-SemiBold.otf"
				}
			]
		};

		try {
			//ライブラリ実行
			const epubBlob: Blob = await epub(options, chapters);
			//実行したものをURL化
			//疑似aタグを生成してクリック関数で実行
			//実行後削除
			const url = URL.createObjectURL(epubBlob);
			const link = document.createElement("a");
			link.href = url;
			link.setAttribute("download", `${data.title}.epub`);
			document.body.appendChild(link);
			link.click();
			link.remove();
		} catch (error) {
			console.error("Failed to generate ebook", error);
			//処理が失敗したときのお知らせ
			alert(`電子書籍の生成に失敗しました: ${error.message}`);
		}
		setIsLoading(false);
	});
	return (
		<>
			<Box p="4" w="100%" h={"90vh"} overflowY="scroll">
				<VStack spacing="6">
					<Heading as="h1" size="xl">
						EPUB生成
					</Heading>
					<InfoForEpubGen />
					<form onSubmit={onSubmit}>
						<VStack align="stretch" spacing="4" w={{ base: "320px", md: "400px", lg: "550px" }}>
							{isLoading ? (
								<Center>
									<Spinner />
								</Center>
							) : (
								<>
									<FormControl isInvalid={!!errors.title}>
										<FormLabel htmlFor="title" fontSize={{ base: "md", md: "lg" }}>
											タイトル(必須)
										</FormLabel>
										<Input
											id="title"
											{...register("title", { required: "タイトルは必須項目です" })}
											size="lg"
											variant="filled"
											shadow="md"
											_hover={{ shadow: "lg" }}
											_focus={{ outline: "none", shadow: "lg" }}
										/>
										<FormErrorMessage>{errors.title && errors.title.message}</FormErrorMessage>
									</FormControl>
									<FormControl>
										<FormLabel htmlFor="publisher" fontSize={{ base: "md", md: "lg" }}>
											出版社（任意）
										</FormLabel>
										<Input
											id="publisher"
											{...register("publisher")}
											size="lg"
											variant="filled"
											shadow="md"
											_hover={{ shadow: "lg" }}
											_focus={{ outline: "none", shadow: "lg" }}
										/>
									</FormControl>
									{fields.map((field, index) => (
										<FormControl
											key={field.id}
											isInvalid={!!(errors.chapters && errors.chapters[index] && errors.chapters[index].title)}
										>
											<Flex align="center">
												<FormLabel htmlFor={`chapters[${index}].title`} fontSize={{ base: "md", md: "lg" }}>
													チャプター{index + 1}
												</FormLabel>
												<Spacer />
												<Button size={"xs"} colorScheme="red" onClick={() => remove(index)}>
													削除
												</Button>
											</Flex>
											<Select
												{...register(`chapters.${index}.title`, { required: "章のタイトルは必須項目です" })}
												id={`chapters[${index}].title`}
												size="lg"
												variant="filled"
												shadow="md"
												_hover={{ shadow: "lg" }}
												_focus={{ outline: "none", shadow: "lg" }}
											>
												{draftsData.map((draft, draftIndex) => (
													<option key={draftIndex} value={draft.id}>
														{draft.title}
													</option>
												))}
											</Select>

											<FormErrorMessage>
												{errors.chapters &&
													errors.chapters[index] &&
													errors.chapters[index].title &&
													errors.chapters[index].title.message}
											</FormErrorMessage>
										</FormControl>
									))}
								</>
							)}
							<Button
								onClick={() => append({ title: "" })}
								w={{ base: "100%", lg: "auto" }}
								alignSelf={{ base: "center", lg: "flex-end" }}
								colorScheme="facebook"
							>
								章を追加
							</Button>
							<Button
								type="submit"
								size="lg"
								colorScheme={"teal"}
								w={{ base: "100%", lg: "auto" }}
								alignSelf={{ base: "center", lg: "flex-end" }}
								disabled={Object.keys(errors).length > 0 || getValues("chapters").every((chapter) => !chapter.title)}
								isDisabled={isLoading}
								isLoading={isLoading}
							>
								生成
							</Button>
						</VStack>
					</form>
				</VStack>
			</Box>
		</>
	);
}
export const getStaticProps = async () => {
	return {
		props: {
			data: "This is static data"
		}
	};
};
export default EpubForm;
```

### 分解して解説

**`react-hook-form`を使った入力フォーム**

- タイトルと出版社(任意)はinputフォーム
- チャプターは自分の小説データからプルダウンで対象を選ぶ形式とした
- チャプターは最低１つは選択必須で、増減可能とした

**下記コードは余計なスタイリングを削除**しています

```jsx

	const {
		register,
		handleSubmit,
		control,
		formState: { errors },
		getValues
	} = useForm<FormValues>({
		defaultValues: {
			chapters: [{ title: "" }]
		},
		mode: "onChange"
	});

　const { fields, append, remove } = useFieldArray({
		control,
		name: "chapters"
	});

//小説のデータ（オブジェクト配列）
	const draftsData = useRecoilValue<draftObjectArray>(drafts);
//作家名
	const author = useRecoilValue<string>(userName);

	const onSubmit = handleSubmit(async (data) => {
		
//サブミットボタンが押された時の処理

｝
	return (
		<>
	　　　...
					<form onSubmit={onSubmit}>
					　
　　　　　　　　　　　　　　　　...

								<>
									<FormControl isInvalid={!!errors.title}>
										<FormLabel htmlFor="title">
											タイトル(必須)
										</FormLabel>
										<Input
											id="title"
											{...register("title", { required: "タイトルは必須項目です" })}
										/>
										<FormErrorMessage>{errors.title && errors.title.message}</FormErrorMessage>
									</FormControl>
									<FormControl>
										<FormLabel htmlFor="publisher" >
											出版社（任意）
										</FormLabel>
										<Input
											id="publisher"
											{...register("publisher")}
										/>
									</FormControl>
									{fields.map((field, index) => (
										<FormControl
											key={field.id}
											isInvalid={!!(errors.chapters && errors.chapters[index] && errors.chapters[index].title)}
										>
											<Flex>
												<FormLabel htmlFor={`chapters[${index}].title`}>
													チャプター{index + 1}
												</FormLabel>
												<Spacer />
												<Button onClick={() => remove(index)}>
													削除
												</Button>
											</Flex>
											<Select
												{...register(`chapters.${index}.title`, { required: "章のタイトルは必須項目です" })}
												id={`chapters[${index}].title`}
											>
												{draftsData.map((draft, draftIndex) => (
													<option key={draftIndex} value={draft.id}>
														{draft.title}
													</option>
												))}
											</Select>

											<FormErrorMessage>
												{errors.chapters &&
													errors.chapters[index] &&
													errors.chapters[index].title &&
													errors.chapters[index].title.message}
											</FormErrorMessage>
										</FormControl>
									))}
								</>
							)}
							<Button
								onClick={() => append({ title: "" })}
							>
								章を追加
							</Button>
							<Button
								type="submit"
							>
								生成
							</Button>
						</VStack>
					</form>
			...
export default EpubForm;
```

### 増減可能のフォーム

`useForm`では引数に**フォームのdefault値を設定**する`defaultValues`というプロパティを使用しています。

```jsx
const { register, control } = useForm({
	defaultValues: {
		chapters: [{ title: "Chapter 1" }]
	},
});
```

`**fields**`は**フォームフィールドの配列**を表します

`useForm`で初期設定した`chapters`プロパティは下記のように`useFieldArray`を使ってアクセスできます。

- **`fields`**: このオブジェクトには、現在のフィールド配列の状態が含まれています
- **`append`**: この関数は新しいフィールドを配列の末尾に追加します。
- **`remove`**: この関数は指定したインデックスのフィールドを配列から削除します。

```jsx
const { fields , append , remove} = useFieldArray({
	control,
	name: "chapters"
});

//fieldsがchaptersの現在の状態として使える
```

```jsx
onClick={() => append({ title: "" })}
onClick={() => remove(index)}

//フィールドの追加と削除に使える
```

### epub形式への変換とダウンロードの処理

**ライブラリをインポート**します。

**Node.jsで動作するライブラリ**なのですが、**クライアントサイドで利用する場合**下記のように`epub-gen-memory/bundle`をインポートします。

```jsx
const onSubmit = handleSubmit(async (data) => {

		let epub;

		//ライブラリimportをクライアントサイドで行う
		if (typeof window !== "undefined") {
			await import("epub-gen-memory/bundle").then((module) => {
				epub = module.default;
			});
		}
```

### ライブラリに引数として渡すオブジェクトを定義します。

下記はフォームで選択された**小説のタイトルと本文をtitleプロパティとcontentプロパティを持ったオブジェクトの配列**にしています。**contentは前述のcustomhookでHTML化**しています。

```jsx
		//チャプターとして選択された小説のタイトルと本文をライブラリで使用する配列オブジェクト化
		const chapters = data.chapters.map((chapter) => {
			const foundDraft = draftsData.find((draft) => draft.id === chapter.title); //chaoter.titleには小説のIdが格納
			return {
				title: foundDraft.title,
				content: textToHtml(foundDraft.body)
			};
		});
```

その他の**必要なデータをoptions**にまとめます。

それぞれの**プロパティの意味は[Readme参照](https://github.com/cpiber/epub-gen-memory)**

```jsx
//必要データをoptionsにまとめる
		const options = {
			title: data.title,　
			author: author,
			cover: imgURL,
			publisher: data.publisher,
			tocTitle: "目次",
			version: 3,
			verbose: false,
			lang: "ja",
			css: `
    @font-face {
      font-family: 'Noto Serif JP';
      src: url('./fonts/NotoSerifJP-SemiBold.otf') format('opentype');
    }
    body {
      font-family: 'Noto Serif JP', serif;
    }
  `,
			fonts: [
				{
					filename: "NotoSerifJP-SemiBold.otf",
					url: "https://enjzxtbbcyrptkkutovq.supabase.co/storage/v1/object/public/Fonts/NotoSerifJP-SemiBold.otf"
				}
			]
		};
```

### ライブラリの関数を実行します。

変数`epub`に定義したライブラリを使用する際、引数に`options`と`chapters`を渡します。

処理は非同期処理となりますので**await**で処理が終わるのを待ちます。

**生成されたepubBlobオブジェクトのダウンロード処理**を実行します。

```jsx

		try {
			//ライブラリ実行
			const epubBlob: Blob = await epub(options, chapters);
	
     //引数として与えられたオブジェクト（ここでは**epubBlob**）に対するURLを作成します。　
			const url = URL.createObjectURL(epubBlob); 
     //新しい**a**要素（リンク）を作成します
			const link = document.createElement("a");
     //新しく作成したリンク要素の**href**属性に、先ほど作成したURLを設定します。
			link.href = url;
     //**download**属性をリンクに追加します
     //ダウンロードされたファイルの名前を指定します。
			link.setAttribute("download", `${data.title}.epub`);
     //リンク要素をHTMLドキュメントに追加します。このステップでは、リンクがHTMLに追加されるだけで、まだユーザーには見えません
			document.body.appendChild(link);
     //プログラムからリンクをクリックします
			link.click();
     //リンク要素をHTMLドキュメントから削除します
			link.remove();
		} catch (error) {
			console.error("Failed to generate ebook", error);
			//処理が失敗したときのお知らせ
			alert(`電子書籍の生成に失敗しました: ${error.message}`);
		}
			})
```

## 実際できたもの

実際にできたファイルを**スマホのPlaybooksで開いてみた**
![F0Rit19aEAEbk3k.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1614172/ef9d0cf9-8f11-327e-091e-4da9a3f7d9ac.jpeg)
![F0RiuBkakAE4_cs.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1614172/fe4e205b-a021-633f-c6d9-b10d5576a523.jpeg)
![F0RiuR6aYAY-9hM.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1614172/8839c000-087a-3ece-3c55-49d1beeb83c6.jpeg)



**ルビやリンク、字下げ、改行等**とくに問題なくできました。

一部PCのビューアーなど試したら、レイアウト滅茶苦茶のものもあったので、ビューアーにもよるかと思いますが、**主流のPlaybooksとibooks、あとkindleは大丈夫だった**ので無問題。

## 最後にアプリ紹介

ここまで読んで頂いた方はありがとうございました。

最後に公開しているアプリを載せておきますのでもしよろしければ拝見いただき、改善のアドバイスなど頂けると幸いです。

小説作成アプリ『[Re:terature](https://novel-editor-ver2.vercel.app/)』