Xteink X3向け縦書きXTCをつくる
Xteink X3 1 で日本語の本を縦書きで読むため、青空文庫形式のテキストをXTCへ変換するPythonツールを作成しました。
CrossPoint Reader2 1.5では、日本語の表示とルビには対応しています。一方、現時点では縦書き表示には対応していません。
Reader本体へ縦書きを実装する方法もありますが、今回は活発に開発されているupstreamをできるだけそのまま利用するため、テキストをあらかじめ縦書きのページ画像へレンダリングし、それをXTCファイルとして格納する方法を選びました。

本記事では、XTCフォーマットをCrossPoint Readerの実装から簡単に確認し、青空文庫形式のテキストを縦書きXTCへ変換するツールについて説明します。
変換ツールを使う
作成したツールは、次のリポジトリで公開しています。
Python 3.10以降と、縦書きの描画に使用する日本語フォントが必要です。
リポジトリを取得し、uvを使用して依存パッケージをインストールします。
git clone https://github.com/llbxg/toxtc.git
cd toxtc
uv sync
青空文庫形式のテキストは、aozora2xtcコマンドで変換できます。
uv run aozora2xtc input.txt output.xtc \
--font NotoSansJP-VF.ttf \
--device x3
生成されたoutput.xtcをXteink X3へコピーし、CrossPoint Readerから開きます。
生成したXTCファイルは、現在のところXteink X3上のCrossPoint Readerでのみ動作を確認しています。
ルビ、傍点、改ページ、見出しなど、基本的な青空文庫記法に対応しています。詳しい対応範囲については後述します。

変換の流れ
aozora2xtcでは、青空文庫形式のテキストをそのままXTCへ格納しているわけではありません。
まず、入力されたテキストから本文、ルビ、傍点、見出し、改ページなどの記法を解析します。解析した内容をXteink X3の画面サイズに合わせて縦書きで配置し、各ページを1-bitの画像として描画します。
最後に、それぞれのページ画像をXTG形式へ変換し、書籍情報やページテーブルとともにXTCファイルへ格納します。
- 青空文庫形式のテキストを読み込む
- ルビ、傍点、見出しなどを解析する
- 縦書きでレイアウトする
- 各ページを1-bit画像として描画する
- ページ画像をXTGへ変換する
- 複数のXTGをXTCへ格納する
この方式では、縦書きの処理はCrossPoint Readerではなく、変換ツール側で行います。
XTC内に文章や縦書きのレイアウト情報が保存されているわけではありません。あらかじめ縦書きで描画されたページ画像が保存されています。
そのため、Reader側に縦書きのレイアウト処理を追加しなくても、生成時の見た目のままページを表示できます。
一方、ページは固定された画像になるため、Reader上で文字サイズを変更したり、画面サイズに合わせて文章を再配置したりすることはできません。
XTCフォーマット
XTCは、ページごとにレンダリングされた画像を格納する電子書籍フォーマットです。
CrossPoint Readerでは、XTCフォーマットの型がXtcTypes.hに定義されています。
// lib/Xtc/Xtc/XtcTypes.h
constexpr uint32_t XTC_MAGIC = 0x00435458;
constexpr uint32_t XTCH_MAGIC = 0x48435458;
constexpr uint32_t XTG_MAGIC = 0x00475458;
constexpr uint32_t XTH_MAGIC = 0x00485458;
XTCとXTCHは、書籍全体を格納するコンテナ形式です。
XTGとXTHは、コンテナ内に格納される1ページ分の画像形式です。
| 形式 | 用途 | 階調 |
|---|---|---|
| XTC | 複数ページを格納する書籍 | 1-bit |
| XTCH | 複数ページを格納する書籍 | 2-bit |
| XTG | 1ページ分の画像 | 1-bit |
| XTH | 1ページ分の画像 | 2-bit |
今回は白黒表示を目的としているため、XTCとXTGを使用します。
XTC Header
XTCの先頭には、56バイトのXtcHeaderが配置されます。
// lib/Xtc/Xtc/XtcTypes.h
#pragma pack(push, 1)
struct XtcHeader {
uint32_t magic;
uint8_t versionMajor;
uint8_t versionMinor;
uint16_t pageCount;
uint8_t readDirection;
uint8_t hasMetadata;
uint8_t hasThumbnails;
uint8_t hasChapters;
uint32_t currentPage;
uint64_t metadataOffset;
uint64_t pageTableOffset;
uint64_t dataOffset;
uint64_t thumbOffset;
uint32_t chapterOffset;
uint32_t padding;
};
#pragma pack(pop)
#pragma pack(push, 1)が指定されているため、各メンバーの間にアライメント用のパディングは入りません。
主に使用する値は次のとおりです。
magic: XTCまたはXTCHの識別子pageCount: ページ数readDirection: 読書方向metadataOffset: メタデータの位置pageTableOffset: ページテーブルの位置dataOffset: 最初のページデータの位置chapterOffset: 章情報の位置
CrossPoint Readerでは、最初にヘッダーを読み込み、magicとバージョンを確認しています。
// lib/Xtc/Xtc/XtcParser.cpp
size_t bytesRead =
m_file.read(reinterpret_cast<uint8_t*>(&m_header),
sizeof(XtcHeader));
if (bytesRead != sizeof(XtcHeader)) {
return XtcError::READ_ERROR;
}
if (m_header.magic != XTC_MAGIC &&
m_header.magic != XTCH_MAGIC) {
return XtcError::INVALID_MAGIC;
}
XTCかXTCHかは、magicの値から判定されます。
m_bitDepth =
(m_header.magic == XTCH_MAGIC) ? 2 : 1;
今回生成するファイルではXTC_MAGICを設定するため、ページは1-bit画像として扱われます。
Page table
XTCでは、各ページの位置をページテーブルで管理しています。
// lib/Xtc/Xtc/XtcTypes.h
#pragma pack(push, 1)
struct PageTableEntry {
uint64_t dataOffset;
uint32_t dataSize;
uint16_t width;
uint16_t height;
};
#pragma pack(pop)
1ページにつき16バイトのエントリーを持ちます。
dataOffset: XTCファイル内のページ位置dataSize: ページデータ全体のサイズwidth: ページ画像の幅height: ページ画像の高さ
CrossPoint Readerは、表示するページに対応するエントリーだけを読み込みます。
const uint64_t entryOffset =
m_header.pageTableOffset +
static_cast<uint64_t>(pageIndex) *
sizeof(PageTableEntry);
m_file.seek64(entryOffset);
PageTableEntry entry;
m_file.read(
reinterpret_cast<uint8_t*>(&entry),
sizeof(PageTableEntry)
);
読み込んだdataOffsetを使い、対象ページのXTGデータへ移動します。
XTC内の構造は、概ね次のようになります。
XTC
├── Header
├── Metadata
├── Chapter data
├── Page table
├── XTG page 1
├── XTG page 2
└── ...XTG
XTGは、1ページ分の白黒画像を格納する形式です。
CrossPoint Readerでは、XTGとXTHに共通する22バイトのヘッダーが定義されています。
// lib/Xtc/Xtc/XtcTypes.h
#pragma pack(push, 1)
struct XtgPageHeader {
uint32_t magic;
uint16_t width;
uint16_t height;
uint8_t colorMode;
uint8_t compression;
uint32_t dataSize;
uint64_t md5;
};
#pragma pack(pop)
XTGは、概ね次のような構造です。
XTG
├── Page header
│ ├── Magic number
│ ├── Width
│ ├── Height
│ ├── Color mode
│ ├── Compression
│ ├── Data size
│ └── MD5
└── Bitmap data
└── 1ページ分の白黒画像
ヘッダーの直後に、実際のビットマップデータが続きます。
本文やルビが文字データとして保存されているわけではなく、描画後のページ全体が画像として保存されています。
XTGの画像データは、次の形式で格納されます。
// XTG (1-bit): Row-major, 8 pixels/byte, MSB first
// dataSize = ((width + 7) / 8) * height
Row-majorなので、画像は上の行から順番に保存されます。
各ピクセルは白または黒であり、8ピクセル分が1バイトにまとめられます。
CrossPoint Readerは、ページテーブルのdataOffsetへ移動したあと、ページ先頭のXtgPageHeaderを読み込みます。
XtgPageHeader pageHeader;
size_t headerRead =
m_file.read(
reinterpret_cast<uint8_t*>(&pageHeader),
sizeof(XtgPageHeader)
);
その後、ページのMagic Numberを確認します。
const uint32_t expectedMagic =
(m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (pageHeader.magic != expectedMagic) {
return XtcError::INVALID_MAGIC;
}
md5には、画像データから計算したMD5の先頭8バイトを格納できます。
uint64_t md5; // MD5 checksum (first 8 bytes, optional)
ただし、現在のCrossPoint Readerの実装では、md5の値は検証されていません。ヘッダーの一部として読み込まれますが、ページ表示時には使用されていません。
PythonでXTGを生成する
toxtcでは、Pillowの画像を1-bitへ変換し、白黒のピクセルをXTGのBitmap dataへ格納します。
その後、CrossPoint ReaderのXtgPageHeaderに対応する22バイトのヘッダーをstruct.packで生成します。
digest = hashlib.md5(data).digest()[:8]
header = struct.pack(
"<IHHBBI8s",
0x00475458,
width,
height,
0,
0,
len(data),
digest,
)
フォーマット文字列の先頭にある<は、各値をリトルエンディアンで格納することを表します。
残りの値は、XtgPageHeaderの各メンバーに順番に対応しています。
I magic
H width
H height
B colorMode
B compression
I dataSize
8s md5
生成したヘッダーに画像データを連結することで、1ページ分のXTGデータになります。
return header + data実装
toxtcでは、XTCを書き出す処理と、青空文庫を解析・描画する処理を分離しています。
toxtc
├── XTC writer
└── Aozora Bunko converter
├── Parser
└── Vertical renderer
青空文庫に依存する処理は、主に次の2つです。
- 青空文庫記法の解析
- 縦書きページの描画
一方、XTCを生成する処理は入力形式に依存しません。
Pillowの画像を受け取り、それぞれをXTGへ変換してXTCへ格納します。
from pathlib import Path
from PIL import Image
from toxtc import BookMetadata, write_xtc
pages = [
Image.open("page-0001.bmp"),
Image.open("page-0002.bmp"),
]
write_xtc(
Path("book.xtc"),
pages,
BookMetadata(title="Example"),
)
XTC writerを独立させたことで、青空文庫以外の入力にも利用できます。
例えば、HTMLやEPUBを別の方法でページ画像へ変換した場合や、カレンダーなどの画像をXTCとしてまとめる場合にも、同じXTC生成処理を再利用できます。
青空文庫記法の解析
入力されたテキストは、描画前に内部の文書モデルへ変換します。
例えば、次の青空文庫形式のルビは、
|親文字《おやもじ》
親文字の親文字と、読みのおやもじに分けて保持します。
これにより、解析処理ではルビの構造だけを取り出し、描画処理で本文と読みの位置や文字サイズを決定できます。
傍点、見出し、改ページについても同様に、それぞれの要素として扱います。
青空文庫記法の解析と縦書きの描画処理を分離することで、レンダリング処理側では入力テキストの記法を直接意識せずに描画できます。
縦書きレンダリング
解析した要素は、Pillowを使用して1ページずつ画像へ描画します。
本文は上から下へ配置し、列が埋まると左側の次の列へ移動します。ルビは本文の右側へ、小さい文字で配置します。
縦書きでは、単純に文字を90度回転させるだけでは正しく表示できません。
句読点や括弧には縦書き用の配置が必要です。また、行頭や行末では禁則処理が必要になります。
ルビや傍点についても、本文との位置関係を保ちながら描画する必要があります。
現時点では、実際に本を読むために必要な範囲から順に対応しています。
現在の対応範囲と制約
現在、青空文庫形式の次の要素に対応しています。
- 明示的なルビ
- 暗黙的なルビ
- 傍点
- 改ページ
- 大見出し
- 中見出し
- 小見出し
一方、青空文庫で使用されるすべての注記には対応していません。
未対応の注記は、基本的に本文へ描画せず読み飛ばします。
また、ページを画像として生成する方式には、次の制約があります。
- Reader上で文字サイズを変更できない
- 画面サイズごとにページを再生成する必要がある
- フォントを変更する場合はXTCを再生成する必要がある
- 縦書きの禁則処理や約物配置は完全ではない
その代わり、Reader側のレイアウト機能に依存せず、日本語の縦書き、ルビ、傍点などを変換ツール側で制御できます。
CrossPoint Readerのupstreamを大きく変更せず、現時点で縦書きの本を読むという目的に対しては、扱いやすい方法だと考えています。
まとめ
青空文庫形式のテキストを縦書きのページ画像へレンダリングし、XTGとしてXTCへ格納するPythonツールを作成しました。
ページを画像として事前に生成することで、CrossPoint Reader本体へ縦書きレイアウトを実装せず、日本語の縦書き、ルビ、傍点を表示できます。
一方、生成後のページは固定画像となるため、Reader上での文字サイズ変更などには対応できません。
現在はXteink X3上のCrossPoint Readerでのみ動作を確認しています。未対応の青空文庫記法や縦書きレイアウトについては、必要に応じて対応していきます。