
【UWP】WebViewでePubを表示+ページ送りを表現する
本記事はTsubameViewerを作っていた中で得られた知見の一つ、「ePubをWebViewでページ送りさせる方法」について書いたものです。
いきなり宣伝ですが、ePubが読める画像/ePubリーダーアプリ「TsubameViewer」をリリースしました。無料で試せるので良ければ是非。
宣伝終わり。
以下、ePubを表示する方法について説明していきます。
ePub = zipで固めたHTML+JS+CSS+画像+α
ePubのコンテンツはxhtml(ほぼhtml)なのでWebViewで扱えます。
よしじゃあWebViewにHtmlを流し込んで完成だな! とすることもできますが、ページ送り(滑らかにスクロールさせず、一ページごとに切り替える)を表現するとなると一工夫必要になります。
その一工夫の前に、まずは横書き縦書きの基礎にあたるCSSプロパティの「writing-mode」について説明していって、その後工夫の話をしてきます。
縦書き/横書き(writing-modeについて)
writing-mode プロパティは縦書き・横書きと文章のフロー方向(流れる方向)を指定するためのものです。詳しくはリンクを読んでください。
ePubで使われるwriting-modeは主にhorizontal-tbとvertical-rlです。
horizontal-tbは横書きを上から下(tb=top-bottom)に流す表示モードで、 vertical-rlは縦書きを右から左(rl=right-left)です。特に日本語の小説等ではvertical-rlが見られます。
vertical-rlは内部的に描画前のレイアウト段階で90度回転させているようで、一部のスクロール位置に関するプロパティにおいて縦横の方向が変化している場合があるようです。
そのあたり詳しくはググって欲しいんですが、例えば、window.scrollTo(x, y); でスクロール位置を指定できますが、writing-mode: vertical-rl; の表示状態では window.scrollTo(150, 0); と指定することで縦方向へのスクロールが行なわれます。本来なら縦方向スクロールは window.scrollTo(0, 150); とy座標方向にスクロール位置を指定しますが、vertical-rlの場合は90度回転してるからX座標方向のスクロールを指定する必要があるようです。
次に段組みに関するCSSプロパティの「column-count」について説明していきます。一工夫はもう一個後です。
段組み表示を有効にする(column-count について)
column-countは段組みの数を指定するCSSプロパティです。例えばwriting-mode: horizontal-tb;の表示状態で column-count: 2; と指定した場合は、左右2段に分かれた形で段組み表示されます。文章量が多ければ左右2段のまま縦に長く表示されます。
ページに区切られたコンテンツ表示
段組み有り無しに関わらず、ページ送りUIを実現するにはページ単位でコンテンツを区切って表示する必要があります。その上で、1ページごとのサイズを一回のスクロール量として与えることでページ送りを表現することができます。
1ページとするコンテンツサイズを表現するには、column-count に1以上を指定した上で、縦書きの場合はページの高さをビューポートの高さに揃えることで横方向にスクロールできるようにします。具体的には body.style = "max-height: 100vh"; と指定することでページの高さにコンテンツの高さを制限できます。
なお、段組み表示しない1段だけで表示する場合も column-count: 1; の指定は必要になります。column-count: 1; を明示的に指定して「段組み表示モード」にすることで1ページごとにコンテンツが区切られた表示になるためです。
ページ毎のスクロール量を計算する
あとは段組みされたページ内ページの1ページ分のスクロール量を求めて、ユーザー入力に応じてスクロール量を適宜与えることで表現できます。
ただ、スクロール量を求めるベストな方法がわからなかったので力業で組んでます。
1. 全p要素の「ウィンドウからの相対位置」を配列に格納 = pHeightArray
2. pHeightArray から同一の高さを1つずつに(いわゆるDistinct)する = distinctHeightArray
3. distinctHeightArrayから規則的に並んだ高さだけを頑張って取り出す = pageSize
3番目の「頑張って」のところがだいぶ面倒くさいんですが、例えば「章のタイトルやエピソードの区切りテキストは高さズラしてちょっと大きく表示」みたいな表示がよくありますが、そのp要素の高さ規則的に並んだページ内ページの高さとはズレてしまうためページサイズ計算に使えません。頑張って除外していきましょう。
自分の場合は、distinctHeightArrayの先頭から5個をページの高さ候補(candidatePageSize)として、distinctHeightArrayの後方から10個に対して、candidatePageSizeで除算して余りが0となった個数がより多かったcandidatePageSizeを最終的なページサイズとして選択する方法を取りました。以下参考コード
string offsetText = IsVerticalLayout ? "offsetTop" : "offsetLeft";
var sizeList = await WebView.InvokeScriptAsync("eval", new[]
{
$@"
const pList = document.querySelectorAll('p, div, img, span');
const heightArray = [];
var count = 0;
for (var i = 0; i < pList.length; i++)
{{
const elementScrollTop = pList[i].{offsetText};
if (elementScrollTop == null) {{ continue; }}
if (heightArray.length == 0)
{{
heightArray.push(elementScrollTop);
}}
else if (heightArray[count] != elementScrollTop)
{{
heightArray.push(elementScrollTop);
count++;
}}
}}
JSON.stringify(heightArray);
"
});
var sizeItems = JsonConvert.DeserializeObject<int[]>(sizeList).Distinct();
var first = sizeItems.ElementAtOrDefault(0);
sizeItems = sizeItems.Select(x => x - first).ToArray();
var pageRealSize = IsVerticalLayout ? await GetPageHeight() : await GetPageWidth();
const int candidateSampleCount = 5;
const int compareSampleCount = 10;
int heroPageHeight = -1;
int heroHitCount = -1;
foreach (var candidatePageSize in sizeItems.Skip(1).Where(x => x > pageRealSize).Take(candidateSampleCount))
{
var hitCount = sizeItems.TakeLast(compareSampleCount).Count(x => x % candidatePageSize == 0);
if (hitCount > heroHitCount)
{
heroPageHeight = candidatePageSize;
heroHitCount = hitCount;
}
}
(上記コードの補足:firstの値を全要素から減算してるのは、0始まりの高さに揃える意味があります)
あとは得られたページサイズで除算して余りが0だった高さの個数をページ数とした上で、ユーザーインプットに応じてページスクロールを設定していけばOKです。
説明としては以上です。以下補足です。
補足1:計算された writing-mode を取得するには
var writingModeString = await WebView.InvokeScriptAsync("eval", new[]
{
@"
window.getComputedStyle(document.body).getPropertyValue('writing-mode')
"
});
補足2:段組みの最後のページ内ページに対するスクロールするページ幅が足りない場合の対処方法
// ページ最後尾にスクロール用の余白を作る
// 最後のページのスクロール位置が前ページを含んだ形になってしまう問題を回避する
await WebView.InvokeScriptAsync("eval", new[]
{
$@"
for (var i = 0; i < 100; i++)
{{
document.body.appendChild(document.createElement('p'));
}}
"
});
最後に実装されたコードを読みたい人はこちらをどうぞ
TsubameViewer/TsubameViewer/Views/EBookControls at develop · tor4kichi/TsubameViewer (github.com)