EbookViewer

実装のしくみ

EbookViewer が各形式をどう読み、本文をどう組んでいるかの記録です。 操作方法は 操作と画面 にあります。

対応形式の詳細

電子書籍

形式 拡張子 備考
EPUB 2 / 3 .epub nav.xhtml / NCX 目次、IDPF・Adobe 方式のフォント難読化解除に対応
KF8 .azw3 .kf8 スケルトン/フラグメント索引から XHTML を再構成。kindle:pos:fid / kindle:embed / kindle:flow を解決
ハイブリッド MOBI .mobi KF8 部があればそちらを優先。無ければ旧形式 (KF7) として読む
旧形式 MOBI .mobi .prc .azw filepos / recindex / mbp:pagebreak を変換

PalmDOC 圧縮・HUFF/CDIC 圧縮の両方を展開できます。DRM 付きのファイルは開けません (エラーになります)。

文字コードは UTF-8 / UTF-16 / CP1252 / Latin-1 に加え、Foundation が扱える Shift_JIS・EUC-JP・ISO-2022-JP に対応します。

コミック

OpenComic を参考にしたコミックリーダーを内蔵しています。

形式 備考
.cbz / .zip 自前の ZipArchive で展開。ファイル名の自然順 (2.jpg < 10.jpg) に並べ、__MACOSX や隠しファイルは除外
.cbr / .rar Unrar.swift (unrar 同梱) で展開。RAR 4 / 5 に対応、ページは要求時に取り出す (パスワード付き・分割書庫は不可)
画像フォルダー サブフォルダーも含めて相対パスの自然順に並べる。JPEG / PNG / GIF / WebP / BMP / TIFF / HEIC / AVIF / JXL
.pdf PDFKit で各ページを描画して画像として表示 (パスワード付きは不可)
.cb7 / .cbt 7z / tar は展開できないため、CBZ への変換を促すエラーを表示

書庫やフォルダーに ComicInfo.xml (スキーマ v2.0 / v2.1) があれば読み取り、次のように使います。

ComicInfo 反映先
Series / Number / Volume / Title 表示タイトル (Series #3 – Title)
Writer / Penciller / Inker / CoverArtist サイドバーの作者行
Manga 読み方向が「自動」のとき、YesAndRightToLeft なら右開き、Yes / No なら左開き (未指定なら右開き)
Pages/Page の Type="FrontCover" "BackCover" 見開きにせず単独表示 (「表紙を単独で表示」が有効なとき)
Pages/Page の DoublePage="true" 見開きにせず単独表示
Pages/Page の Type="Deleted" ページから除外
Pages/Page の Bookmark サイドバー先頭のブックマーク一覧 (章ジャンプ)

Page の Image は書庫内の画像をファイル名順に並べたときの 0 始まりの番号として解釈します。複数の ComicInfo.xml があれば最も浅い階層のものを使います。

縦書き表示のしくみ

本文を組む「本文エンジン」を 2 つ持っていて、「表示」メニューで切り替えられます (⌘⇧E)。

エンジン 組版 ルビ 強調 画像 既定
stone_engine stone_engine の STLabel (禁則処理・約物半角・縦中横・文字種ごとのフォント) 表示する 圏点・太字・斜体・下線 表示する ○
WebView WKWebView + CSS の多段組み 表示する 本文の CSS に従う 表示する  

組方向は「自動 → 縦書き → 横書き」の順で切り替えられます (どちらのエンジンでも同じ)。「自動」では、書籍側の宣言 (page-progression-direction="rtl"、primary-writing-mode、CSS の writing-mode: vertical-*、 KF8 の EXTH 525/527) を優先し、宣言が無ければ日本語の本を縦書きにします。

stone_engine (既定)

日本語組版エンジン stone_engine の STLabel で本文を描きます。 STLabel は UIKit 専用なので、macOS でも使えるようソースを EbookCore/Sources/StoneEngine/ に取り込み、AppKit 向けのシムと ページ分割を足しています (変更点は 同ディレクトリーの README)。

STLabel は文字列しか受け取らないので、本文ドキュメントは PlainTextExtractor でプレーンテキストに変換します。 ブロック要素の境目を改行にし (見出し・表・水平線の前後は空行)、全角文字に挟まれた原文の改行は、CSS の segment break transformation rules と同じく空白にせず詰めます。id / name 属性の位置も覚えておき、 目次のリンク先 (#fragment) から表示ページを求めるのに使います。

ページ分割は、章全体を 1 回だけ組んでから行を block 方向 (縦書きなら右から左) に区切って行います。 ページを送るときは描画位置をずらすだけなので、組み直しは起きません。

ルビ (<ruby> / <rt>) は親文字の位置とともに取り出し、STLabel に渡して縦書きなら親文字の右、 横書きなら上の行間に組みます。「承 (うけたまわ)」のようにルビのほうが長いときは、まず隣の仮名へ 掛け (JIS X 4051 のルビの掛け。掛けるのは隣が仮名のときだけ、ルビ 1 文字ぶんまで)、それでも 収まらないぶんだけ親文字の字間を広げて長さをそろえます (外側の空きは内側の半分)。 親文字が行をまたぐときはルビも分けて付けます。二段ルビ (<rtc>) は 2 段目を親文字の反対側 — 縦書きなら左、横書きなら下 — に置きます (しくみは StoneEngine の README)。

強調は次のように割り当てます。日本語には斜体が無いので、<em> / <i> は圏点 (傍点) にします。

本文 表示
<strong> <b> 太字 (Hiragino なら W6)
<em> <i> で全角文字を含む範囲 圏点 (ゴマ点)
<em> <i> で欧文だけの範囲 斜体
<u> <ins> 傍線
<s> <strike> <del> 取り消し線

青空文庫の注記から変換された本文は、class でも読み取ります。

class 表示
futoji / shatai 太字 / 斜体
sesame_dot white_sesame_dot black_circle white_circle black_up-pointing_triangle white_up-pointing_triangle bullseye fisheye saltire 傍点 (ゴマ点 ﹅ 白ゴマ ﹆ 黒丸 ● 白丸 ○ 黒三角 ▲ 白三角 △ 二重丸 ◎ 蛇の目 ◉ ばつ ×)
上記 + _after 左に傍点 (縦書きでは左、横書きでは下)
underline_solid _double _dotted _dashed _wave 傍線・二重傍線・鎖線・破線・波線
overline_* 左傍線 (同じ 5 種)
strike / dbl_strike 取り消し線 / 二重取り消し線
tcy 縦中横
dai1〜dai5 / sho1〜sho5 大きな文字 / 小さな文字 (1 段階ごとに 1.2 倍 / 0.9 倍)
font1〜font6 / font-1〜font-6 110%〜160% / 90%〜62%
o-midashi naka-midashi ko-midashi (chap1〜chap3) 大見出し 140% / 中見出し 120% / 小見出し 110%
notes caption warichu 小さめ (83%)
superscript / subscript (sup / sub)、upr / swr 上付き・下付き小文字、行右小書き・行左小書き (60% にして行の端へ寄せる)
kaeriten / okurigana 返り点 (親字の左) / 訓点送り仮名 (親字の右)。60% にして寄せる
dogyo-* / mado-* 同行見出し・窓見出し (本文と同じ行に組む)
chuuou / center 行を中央にそろえる
jisage_3 (jisage) 3 字下げ (ブロック全体)
burasage 折り返して字下げ (1 行目と折り返した行で字下げが違うもの。字数は style から読む)
chitsuki_0 / chitsuki_2 地付き / 地から 2 字上げ
jizume_10 字詰め (1 行を 10 文字ぶんの長さで折り返す)
warichu 割り注 (本文 1 行ぶんの幅に、半分の大きさで 2 行に組む)
keigakomi / border / dashed_border 罫囲み (実線 / 実線 / 破線)
yokogumi / yoko 縦組み中の横組み (ブロックなら 1 ページを使い、行の中なら縦中横)
kaicho / kaipage / kaimihiraki / kaidan 改丁・改ページ・改見開き・改段 (そこから新しいページを始める)

電書連 (電書協) EPUB 3 制作ガイド の CSS が定める class も読み取ります。 見た目を表す語をそのまま class 名にした体系で、style-standard.css / style-advance.css に 指定があるものです。

class 表示
em-sesame em-sesame-open em-dot em-dot-open em-circle em-circle-open em-double-circle em-double-circle-open em-triangle em-triangle-open 傍点 (ゴマ ﹅ 白ゴマ ﹆ 小黒丸 • 小白丸 ◦ 黒丸 ● 白丸 ○ 蛇の目 ◉ 二重丸 ◎ 黒三角 ▲ 白三角 △)
em-line / em-line-outside 傍線 (縦組みでは右) / 左傍線
line-through 取り消し線
bold / italic 太字 / 斜体
font-weight-normal / font-style-normal 入れ子の中で太字・斜体を元に戻す
font-120per / font-1em20 文字サイズ (120% / 1.20em)
start-2em / end-2em50 行頭の空き / 行末の空き (論理方向)
indent-1em / h-indent-1em 1 行目だけの字下げ / ぶら下げ (折り返した行だけ下げる)
measure-10em / max-measure-20em 字詰め
align-center / block-align-center 中央ぞろえ
align-end / align-right 地寄せ
tcy / upright-1 縦中横
upright / sideways 縦組みで倒さず正立させる / かならず倒す (text-orientation)
super / sub / valign-super / valign-sub 上付き・下付き
kunten / kunten-okuri 返り点 / 訓点送り仮名
kogaki 小書き (75%)
k-solid k-dashed k-dotted k-double 罫囲み (四方を囲むもの)
上記 + -before / -after / -right / -left 1 本だけの罫は傍線・左傍線にする
pagebreak / pagebreak-before / pagebreak-both 改ページ (後ろ / 手前 / 両方)

組方向は OPF の page-progression-direction から決まるので、vrtl / hltr の class は見ていません。 色・背景・回り込み・行間など、stone_engine で表せない指定は読み飛ばします。行が進む向きの罫 (縦組みの上下) も引けないので読み飛ばします。

<h1>〜<h6> と style="font-size: 140%" (1.4em も) からも大きさを読みます。字下げは style="margin-left: 3em" (縦書きの margin-right、text-indent も)、字詰めは style="width: 10em" (height も) から読みます (line-height や max-width のような、 名前の一部がたまたま同じプロパティは拾いません)。 style="margin-left: 3em; text-indent: -1em" のように margin と負の text-indent が組になって いるときは、CSS のぶら下げインデント ―「ここから 2 字下げ、折り返して 3 字下げ」― として読み、 1 行目と折り返した行で字下げを変えます。style の指定は class より優先します。

圏点はルビと同じ行間に置きます。同じ文字にルビもあるときは、CSS の text-emphasis と同じく 圏点を反対側へ逃がします。傍線を引く側 (縦書きでは右、横書きでは下) は WebKit が縦組みで引く側に 合わせてあり、WebView 表示と見た目がそろいます。

縦組みでの文字の向きは、upright / sideways の class と style="text-orientation: upright" (-epub- / -webkit- つきも) から読みます。upright は倒さず正立させ、半角の文字でも 1 文字ぶんの 枠の中央にそろえます。sideways はかならず倒し、縦書き用の字形には替えません。

2 桁までの数字は注記が無くてもエンジンが自動で縦中横にします。ただし H2O や x2 のように 欧文の語や式の中にある数字は、まわりの欧文と同じく倒します (数字だけ正立させると、隣の 欧文だけが倒れてちぐはぐになるため)。tcy の指定は、1 文字ぶんに収まる長さのときだけ従います (stone_engine は字を詰めないので、長いまとまりを横に並べると隣の行にはみ出すため)。

文字の大きさを変えても行送りは変わりません。隣の行とぶつからないよう、行送り (「表示」→「行間」の倍率、既定 1.9) を超える大きさにはしません。小さな文字は行の中央にそろえます。

上付き・下付き (<sup> / <sub>、青空文庫の「上付き小文字」「下付き小文字」「行右小書き」 「行左小書き」) は、小さくしたうえで、小さくしたぶんの半分だけ上付きの側へ動かします。縦書きでは 1 文字ぶんの枠のちょうど端 (上付きが右、下付きが左) に寄り、横書きではベースラインが上下します (CSS の vertical-align: super / sub と同じ考え方)。

割り注が 1 行に収まらないとき (行をまたぐとき) は、積み直すと読み順が崩れるので、半分の 大きさのまま 1 行に流します。

中央ぞろえは、その行を行の中で中央にそろえます (縦書きなら上下の中央)。text-align: center と chuuou などの class から読みます。なお青空文庫の「ページの左右中央」は、XHTML には空行としてしか 残らない (class も style も付かない) ので、注記としては見分けられません。

同行見出し・窓見出し (dogyo-* / mado-*) は、ふつうの見出しと違って前後に空行を入れず、本文と 同じ行に組みます。窓見出しの「本文が見出しのまわりを回り込む」組み方はできないので、同行見出しと 同じ扱いにしています。

訓点は、返り点 (kaeriten) を親字の左、訓点送り仮名 (okurigana) を右へ寄せます。どちらも本文の 流れの中に 1 文字として残るので、本のように親字の真横には付かず、1 つあとに来ます。

改ページは、その位置の行から新しいページを始めます。ページがまだ余っていても区切ります。 青空文庫の class のほか、style="page-break-before: always" (break-before、page-break-after も) からも読みます。改丁・改見開き・改段も、1 段組みの表示では改ページと同じに扱います。 改ページを 2 つ続けて書いたときは、間に白いページを 1 枚挟みます (青空文庫の「空白ページの挿入」が この書き方です)。

字詰めは行を短くして折り返します。長さは行の始まり (字下げのあと) から数えるので、 字下げと重ねて指定できます。表示領域より長い字詰めを指定しても、表示領域からははみ出しません。 行は語の切れ目で折り返すので、指定した字数ちょうどではなく、その字数までで折り返します。

罫囲みは、囲む文字のまわりに罫を引きます。行をまたぐときは行ごとに枠を分けるので、 途中の行の囲んでいない文字を枠の中に取り込むことはありません。

縦組みの中の横組み (「ここから横組み」) は、そのブロックだけを別に横書きで組み、挿絵と同じように 1 ページを使って表示します。1 本の流れの中に向きの違う組版は入れ子にできないためです。ブロックの 中のルビ・強調・傍線・罫囲み・字下げ・画像はそのまま効きます。行の中の短い横組み (<span class="yokogumi">) は 1 ページを使わず、縦中横にします。

本と見え方が違うところ: 縦組み中の横組みは 1 ページを使います (本では縦組みの本文の途中に 横組みの一角が入ります)。窓見出しは同行見出しと同じ組み方になります。訓点は親字の真横ではなく 1 つあとに来ます。文字の大きさを変えても行送りは変わりません。

どちらも気になるときは WebView 表示に切り替えてください。本文の CSS をそのまま使うので、 本に近い見た目になることがあります。

画像 (<img>) は本文に置き場所を 1 文字だけ残して取り出し、ImageIO でデコードして描きます。 段落に 1 つだけ置かれた挿絵は 1 ページを使って表示し、本文に挟まれた小さな画像 (外字など) は 行の中に収めます。読めなかった画像のところは空白になります。

WebView

本文は WKWebView で描画します。本文ドキュメントに viewer.css / viewer.js を注入し、 body を writing-mode: vertical-rl の多段組みコンテナ (column-width = 表示領域の高さ) にすることで、 1 段 = 1 ページとして行が途中で切れないページ分割を行っています。縦書きではページが縦方向に積まれるため 縦スクロール、横書きでは横スクロールでページを送ります。

縦書きで表示するときは、青空文庫の字下げ・字詰めを論理プロパティへ読み替えます。青空文庫の XHTML は横組み前提で書かれていて、字下げは margin-left、字詰めは width という物理的な プロパティで表されます。縦書きにすると margin-left はページの左端の余白、width は行が 積まれる向きの幅になり、行頭の空き・行の長さになりません。そこで viewer.js が、em で直接 書かれたこれらの指定を margin-inline-start / inline-size へ移します (class 側の指定も 打ち消します)。横書きに戻すと元の style 属性をそのまま書き戻します。

本文・画像・CSS は独自スキーム ebook://book/ (WKURLSchemeHandler) で WKWebView に配信します。 本文の展開と CSS/JS 注入はバックグラウンドキューで行い、応答はメインスレッドで返します。 iOS では WKWebView のネイティブスクロールを止め、viewer.js の touch ハンドラーでスワイプ / タップによるページ送りを行います。 ⌘ キー付きのショートカットは JS 側で無視し、SwiftUI のメニューコマンドに任せます。

注記の見本

Samples/annotations.epub は、組版を目で確かめるための短い EPUB です。6 章あり、それぞれ折り返して字下げ・上付き下付き・ 縦組み中の横組み・字詰め・改ページ・中央ぞろえと訓点を、くらべるための本文といっしょに 並べてあります。アプリで開けば 実機やシミュレーターでそのまま確認できます (テストからも読んでいます)。

本物の青空文庫の XHTML と同じように、注記の class にスタイルをあてる aozora.css を同梱して いるので、2 つのエンジンを見くらべるのにも使えます。見出し・傍点・傍線・罫囲み・ルビ・ 上付き下付き・縦中横・横組み・字下げ・折り返して字下げ・字詰め・改ページは、どちらのエンジンでも ほぼ同じに出ます。

構成

EbookViewer.Apple/
  EbookViewer.xcodeproj      Xcode プロジェクト (xcodegen で project.yml から生成)
  project.yml                XcodeGen 定義
  App/EbookViewer/           SwiftUI アプリ (macOS / iOS 共通ソース)
    EbookViewerApp.swift     App、メニューコマンド、Finder からのオープン
    ContentView.swift        画面全体 (Mac/iPad: サイドバー + 本文、iPhone: 本文 + 目次シート)
    ReaderModel.swift        画面状態と操作 (開いている本、章・ページ、設定の反映)
    ReaderModel+Text.swift   STLabel 表示用に本文をプレーンテキストにして渡す
    BookTextView.swift       STLabel のラッパー (ページ送り、キー・タップ・スワイプ操作)
    BookWebView.swift        WKWebView のラッパーと JS ブリッジ
    BookSchemeHandler.swift  ebook:// スキームで本文・画像・CSS を配信
    ReaderSettings.swift     設定・読書位置・最近開いたファイルの保存
    TocView.swift            目次パネル (コミックではページ一覧)
    ReaderModel+Comic.swift  コミックの状態と操作 (見開きの組み立て、ページ送り、設定)
    ComicReaderView.swift    コミックのページ表示 (単ページ / 見開き / 縦スクロール、ジェスチャー、キー操作)
    ComicThumbnailsView.swift サイドバーのサムネイル一覧
    ComicImageStore.swift    ページ画像のデコード (ImageIO で縮小) とキャッシュ・先読み
    PdfComicLoader.swift     PDF を PDFKit でページ画像にする
    Info.plist / *.entitlements / Assets.xcassets
  EbookCore/                 形式非依存のコア (Swift Package、Linux でもテスト可能)
    Sources/EbookCore/
      Model/                 BookDocument / SpineItem / TocEntry / BookResource / PathUtil
      Comic/                 ComicDocument / ComicPage と ComicLoader (CBZ / ZIP / CBR / RAR / フォルダー)
      Epub/                  ZipArchive (自前の ZIP 読み込み) と EpubLoader
      Mobi/                  PalmDB, MOBI ヘッダー/EXTH, INDX, PalmDOC/HUFF-CDIC 展開, KF7/KF8 ビルダー
      Rendering/             ContentPreparer (CSS/JS 注入)、PlainTextExtractor (プレーンテキスト化) と Resources/viewer.css, viewer.js
      Support/               XML 簡易 DOM (XMLNode)、zlib、SHA-1、CP1252、HTML 実体、正規表現ヘルパー
    Sources/StoneEngine/     stone_engine (STLabel) の取り込み + AppKit シム + ページ分割 + ルビ
    Tests/EbookCoreTests/    XCTest / Swift Testing (サンプル書籍は青空文庫『機械』横光利一、『奇巌城』ルブラン/菊池寛訳 (図版 4 枚))
    Tests/StoneEngineTests/  STLabel のページ分割と描画 (AppKit 移植の確認)
design/icons/                アプリアイコンの SVG 原稿 (採用: 01_open-book.svg)

XML は XMLParser で組み立てる軽量 DOM (XMLNode) で扱います (iOS には XMLDocument が無いため)。 ZIP は自前の ZipArchive と zlib で読みます (Linux では zlib を直接リンクし、Apple では Compression framework を使います)。

テストで確認していること

WebView 表示については、見本の本を WKWebView に実際に組ませて次を確かめています (macOS のみ)。