こんにちは、個人開発でNext.jsを主に利用していますdende-hです。
現在、小説を書く方向けの小説エディターアプリを公開しております。
小説作成アプリ『Re:terature』
今回の記事はアプリに電子書籍ファイルの出力機能を付けたので、その際の覚書です。
開発経緯
小説を書くのに使いやすいシンプルなエディターが欲しかった(特に自分が)ため、できるところから徐々に作っていきました。
Lexical
をテキストエディターとして採用したことで、Undo,Redo機能の付いたシンプル仕様になっています。
これまでの仕様では、書いた小説をTXT形式でダウンロードすることができる機能がありました。日本最大級の投稿サイト小説家になろうではTXT形式でのインポートも可能だからです。
全文のコピーボタンもあるので、もちろんコピペも可能です。
ただ、最近では自身で電子書籍化してAmazonなどで公開するといったことも一般化しつつあるので、書いたものがそのまま簡単に電子書籍化できるといいなと思い機能を追加するに至りました。
使用ライブラリ
-
Node.jsやブラウザで動作可能なepubファイル生成用ライブラリ
HTMLのデータをepubファイルへ変換できる
選定理由
小説用のルビや傍点を振るためにプレビューする際はHTMLで表示をしています。そのためHTMLをそのままepub化できる点と、下記にあるように
このパッケージには、epub-gen-memory/bundle として browserifyd バンドル (UMD) も含まれています。ブラウザ用にビルドしたい場合は、このバンドルを使うことができます。バンドルはCDNからも入手できます: UNPKG (latest, latest 1.x)。このバンドルにはブラウザ用の適切な戻り値型も含まれています (Promise ではなく Promise)。
とブラウザで直接ライブラリを使用でき、戻り値の型にBlobも含まれるからです。
Blob
はTXT形式でのダウンロードでも使っており、コードが使いまわせるのとブラウザの機能を使ってファイルダウンロードができるので、多くの端末が利用できます。
実際のコード
まずテキストをHTML化するcustomフック
正規表現を使って記法を抽出してHTML
に置き換えています。
具体的には
- |漢字《かんじ》→《》内がルビとして使われる
- 《《傍点》》→ 強調のための傍点が振られる
- リンク → ()内のURLへ飛べるリンクが貼られる
- 改行の特殊文字を
タグへ置き換え
またセキュリティとして余計なscript
が入らないようエスケープを実施しています。
/* 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/\//g, "/");
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
構文を利用しています。
趣旨と逸れますので全ての解説はせず、フォームとライブラリの使用箇所を中心に説明します。
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フォーム
- チャプターは自分の小説データからプルダウンで対象を選ぶ形式とした
- チャプターは最低1つは選択必須で、増減可能とした
下記コードは余計なスタイリングを削除しています
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
というプロパティを使用しています。
const { register, control } = useForm({
defaultValues: {
chapters: [{ title: "Chapter 1" }]
},
});
**fields**
はフォームフィールドの配列を表します
useForm
で初期設定したchapters
プロパティは下記のようにuseFieldArray
を使ってアクセスできます。
-
fields
: このオブジェクトには、現在のフィールド配列の状態が含まれています -
append
: この関数は新しいフィールドを配列の末尾に追加します。 -
remove
: この関数は指定したインデックスのフィールドを配列から削除します。
const { fields , append , remove} = useFieldArray({
control,
name: "chapters"
});
//fieldsがchaptersの現在の状態として使える
onClick={() => append({ title: "" })}
onClick={() => remove(index)}
//フィールドの追加と削除に使える
epub形式への変換とダウンロードの処理
ライブラリをインポートします。
Node.jsで動作するライブラリなのですが、クライアントサイドで利用する場合下記のようにepub-gen-memory/bundle
をインポートします。
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化しています。
//チャプターとして選択された小説のタイトルと本文をライブラリで使用する配列オブジェクト化
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参照
//必要データを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オブジェクトのダウンロード処理を実行します。
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で開いてみた
ルビやリンク、字下げ、改行等とくに問題なくできました。
一部PCのビューアーなど試したら、レイアウト滅茶苦茶のものもあったので、ビューアーにもよるかと思いますが、主流のPlaybooksとibooks、あとkindleは大丈夫だったので無問題。
最後にアプリ紹介
ここまで読んで頂いた方はありがとうございました。
最後に公開しているアプリを載せておきますのでもしよろしければ拝見いただき、改善のアドバイスなど頂けると幸いです。
小説作成アプリ『Re:terature』