電子書籍PDFを見開きページ表示にして右綴じに書き換えた話

# 0. はじめに

久しぶりに電子書籍を購入した。縦長画面のタブレットで読むのならともかく，たいていの PC 画面は横長であるから PC で電子書籍を読もうとすると画面の半分が余って勿体ない。かといって PDF リーダーで見開きページ表示にすると，たまたま購入した電子書籍は

```plantuml
@startuml
<style>
root {
  margin: 0;
  padding: 0;
}
caption {
  fontSize: 16
}
</style>
sprite FIGURE <svg width="260" height="350" viewbox="0 0 260 350">
<path d="M80  20  h100 v150 h-100" fill="black" />
<path d="M81  21  h98  v148 h-98"  fill="white" />
<path d="M20  180 h100 v150 h-100" fill="black" />
<path d="M21  181 h98  v148 h-98"  fill="white" />
<path d="M140 180 h100 v150 h-100" fill="black" />
<path d="M141 181 h98  v148 h-98"  fill="white" />
<text x="80"  y="130" fill="black" font-size="100">１</text>
<text x="20"  y="290" fill="black" font-size="100">２</text>
<text x="140" y="290" fill="black" font-size="100">３</text>
</svg>
title <$FIGURE>
caption 図１　左綴じの場合
@enduml
```

となってしまい，ページの左右が逆になってしまう。和書（右綴じ）なので以下のように表示させたいのだ。

```plantuml
@startuml
<style>
root {
  margin: 0;
  padding: 0;
}
caption {
  fontSize: 16
}
</style>
sprite FIGURE <svg width="260" height="350" viewbox="0 0 260 350">
<path d="M80  20  h100 v150 h-100" fill="black" />
<path d="M81  21  h98  v148 h-98"  fill="white" />
<path d="M20  180 h100 v150 h-100" fill="black" />
<path d="M21  181 h98  v148 h-98"  fill="white" />
<path d="M140 180 h100 v150 h-100" fill="black" />
<path d="M141 181 h98  v148 h-98"  fill="white" />
<text x="80"  y="130" fill="black" font-size="100">１</text>
<text x="20"  y="290" fill="black" font-size="100">３</text>
<text x="140" y="290" fill="black" font-size="100">２</text>
</svg>
title <$FIGURE>
caption 図２　右綴じの場合
@enduml
```

# 1. PDFファイルの内部構造について

PDF ファイルの内部構造については参考文献[1]を参照されたい。

PDF ファイルの文書オブジェクトの階層構造の中で「カタログ」辞書というものがあり，その中で単ページ表示 or 見開き表示などのページレイアウトを規定している。

具体的にいうと `PageLayout` と `ViewerPreference` の二つのキーで規定する。

<table>
<caption>表１　カタログ辞書のエントリ</caption>
<thead>
<tr><th>$\textsf{キー}\hspace{5.5em}$</th><th>$\textsf{型}\hspace{5.5em}$</th><th>$\textsf{値}$</th></tr>
</thead>
<tbody>
<tr><td>Type</td><td>名前</td><td>必須，Catalog であること。</td></tr>
<tr></tr>
<tr><td>Pages</td><td>辞書</td><td>必須，文書のページツリーのルートとなるページツリーノード。</td></tr>
<tr></tr>
<tr><td>PageLabels</td><td>数値ツリー</td><td>オプション</td></tr>
<tr></tr>
<tr><td>Names</td><td>辞書</td><td>オプション</td></tr>
<tr></tr>
<tr><td>Dests</td><td>辞書</td><td>オプション</td></tr>
<tr><td>PageLayout</td><td>名前</td><td>オプション，文書を開いたときに使われるページレイアウトを指定する名前オブジェクト。<br>表２参照</td></tr>
<tr></tr>
<tr><td>ViewerPreferences</td><td>辞書</td><td>オプション，文書の画面表示方法を指定するビューアプリファレンス辞書。このエントリがない場合，ビューアアプリケーションは，独自に持っている現在のユーザープリファレンス設定を使用することになる。表３参照</td></tr>
<tr><td>PageMode</td><td>名前</td><td>オプション</td></tr>
<tr></tr>
<tr><td>Outlines</td><td>辞書</td><td>オプション</td></tr>
<tr></tr>
<tr><td>Threads</td><td>配列</td><td>オプション</td></tr>
<tr></tr>
<tr><td>OpenAction</td><td>配列または辞書</td><td>オプション</td></tr>
<tr></tr>
<tr><td>URI</td><td>辞書</td><td>オプション</td></tr>
<tr></tr>
<tr><td>AcroForm</td><td>辞書</td><td>オプション</td></tr>
<tr></tr>
<tr><td>StructTreeRoot</td><td>辞書</td><td>オプション</td></tr>
<tr></tr>
<tr><td>SpiderInfo</td><td>辞書</td><td>オプション</td></tr>
</tbody>
</table>

`PageLayout` の値を示す。`PageLayout` はオプションであり，未指定の場合のデフォルト値は `SinglePage` になっているので，このエントリを書き換え，もしくは新規追加して `TwoPageRight` に設定すればよい。

<table>
<caption>表２　PageLayout の値</caption>
<thead>
<tr><th>$\textsf{値}$</th><th>$\textsf{内容}$</th></tr>
</thead>
<tbody>
<tr><td>SinglePage</td><td>一度に１ページを表示　※デフォルト</td></tr>
<tr></tr>
<tr><td>OneColumn</td><td>ページを一列で表示</td></tr>
<tr></tr>
<tr><td>TwoColumnLeft</td><td>奇数ページを左側にして二列で表示</td></tr>
<tr></tr>
<tr><td>TwoColumnRight</td><td>奇数ページを右側にして二列で表示</td></tr>
<tr></tr>
<tr><td>TwoPageLeft</td><td>一度に２ページを表示で，奇数ページを左側に表示（PDF1.5）</td></tr>
<tr><td>TwoPageRight</td><td>一度に２ページを表示で，奇数ページを右側に表示（PDF1.5）</td></tr>
</tbody>
</table>

:::note
Page と Column の違い
Page はページ毎に区切って表示するが，Column は上下のぺージを繋げてスクロール表示するという違いがある。
:::

:::note warn
PDF のバージョン問題
PDF にはバージョンがある。PDF ファイルの先頭に例えば `%PDF-1.6` とバージョンが記載されている。`TwoPageLeft` と `TwoPageRight` は PDF1.5 以上でサポートされた値である。
:::

もう一つ，ビューアプリファレンス辞書のエントリを以下に示す。ビューアプリファレンス辞書自体がオプションのため，未指定の場合はデフォルト値が採用される。

<table>
<caption>表３　ビューアプリファレンス辞書のエントリ</caption>
<thead>
<tr><th>$\textsf{キー}\hspace{8em}$</th><th>$\textsf{型}\hspace{1em}$</th><th>$\textsf{値}$</th></tr>
</thead>
<tbody>
<tr><td>HideToolBar</td><td>論理</td><td>オプション</td></tr>
<tr></tr>
<tr><td>HideMenuBar</td><td>論理</td><td>オプション</td></tr>
<tr></tr>
<tr><td>HideWindowUI</td><td>論理</td><td>オプション</td></tr>
<tr></tr>
<tr><td>FitWindow</td><td>論理</td><td>オプション</td></tr>
<tr></tr>
<tr><td>CenterWindow</td><td>論理</td><td>オプション</td></tr>
<tr></tr>
<tr><td>NonFullScreenPageMode</td><td>名前</td><td>オプション</td></tr>
<tr><td>Direction</td><td>名前</td><td>オプション，テキストを読み進める際にページをめくる決まった順序。表４参照</td></tr>
<tr></tr>
</tbody>
</table>

ページをめくる順序は `Direction` の値で決まる。デフォルトでは `L2R` となっているので，このエントリを書き換えもしくは新規追加して `R2L` に設定すればよい。

<table>
<caption>表４　Direction の値</caption>
<thead>
<tr><th>$\textsf{値}$</th><th>$\textsf{内容}$</th></tr>
</thead>
<tbody>
<tr><td>L2R</td><td>左から右へ　※デフォルト</td></tr>
<tr><td>R2L</td><td>右から左へ（中国語，韓国語，日本語など縦書きの場合）</td></tr>
</tbody>
</table>

「カタログ」辞書を見開きページ表示かつ右綴じに設定した場合のサンプルを以下に示す。

```
1 0 obj
<< /Type /Catalog
   /Pages 2 0 R
   /PageLayout /TwoPageRight
   /ViewerPreferences << /Direction /R2L >>
>>
endobj
```

# 2. どうやって書き換える？

Adobe Acrobat を持っている人はここから先を読む必要はありません。

## 2.1 QPDF の入手
まず QPDF が必要なので下記のサイトから入手して欲しい。QPDF とは PDF ファイルの暗号化や復号，分割や結合などの加工が行えるコマンドラインツールのことである。

https://qpdf.sourceforge.io/

## 2.2 QDF モードへの変換

QPDF をインストールしたら，対象の PDF ファイルを QDF モードに変換する。慣習に従ってファイルの拡張子を *.QDF に変えているが，その実態は PDF ファイルそのものであり，普通に PDF ビューアーで内容を見ることができる。

```:QDF モードへの変換
qpdf --qdf sample.pdf sample.qdf
```

## 2.3 純テキストファイルへの変換

QDF モードとは「圧縮されていたオブジェクトを展開してテキスト編集可能な形式」とされているが，ストリーム等にバイナリデータが含まれている場合がある。その場合は普通のテキストエディタでは編集できない。このため純テキストファイルに変換する自作ツール QDF2TXT を作成した。

```:純テキストファイルへの変換
qdf2txt sample.qdf sample.txt
```
:::note
ここでいう純テキストファイルとは，文字コード 0x20 ～ 0x7E の範囲内の文字，いわゆる空白と印字可能な文字しか使用していないという意味で用いている。
:::

## 2.4 純テキストファイルの編集

QDF2TXT は QDF ファイルに含まれるバイナリデータを QDF ファイルにおける位置と長さという間接参照によって示すことで純テキストファイル化している。以下の `%BINARY_STRING 9 5` は元の QDF ファイルの９バイト目から連続５バイトのバイナリデータ行という意味である。

```:sample.txt（編集前）
%PDF-1.4
%BINARY_STRING 9 5
%QDF-1.0

%% Original object ID: 1876 0
1 0 obj
<<
  /Metadata 3 0 R
  /Outlines 5 0 R
  /Pages 6 0 R
  /Type /Catalog
>>
endobj

% 以下略・・・
```

こうして純テキストファイルをテキストエディタで編集して「カタログ」辞書に `PageLayout` と `ViewerPreferences` を追加する。念のため PDF のバージョンも上げておく。

```:sample.txt（編集後）
%PDF-1.5
%BINARY_STRING 9 5
%QDF-1.0

%% Original object ID: 1876 0
1 0 obj
<<
  /Metadata 3 0 R
  /Outlines 5 0 R
  /Pages 6 0 R
  /PageLayout /TwoPageRight
  /ViewerPreferences << /Direction /R2L >>
  /Type /Catalog
>>
endobj

% 以下略・・・
```

なお，ここで使用するテキストエディタは元のファイルに含まれる改行コードの形式（DOS 形式もしくは UNIX 形式）を保持しなくてはならない。ただし Windows10 以降のメモ帳であれば大丈夫なはずだ。

## 2.5 QDF モードへの逆変換

これもまた自作ツールで QDF モードに逆変換する。TXT2QDF は編集後の純テキストファイルと，元になった QDF モードのファイルを参照して新たな QDF モードのファイルを作成する。

```:QDF モードへの逆変換
txt2qdf sample.txt sample.qdf sample_R2L.qdf
```

## 2.6 PDF ファイルへの逆変換（インデクスの修正）

こうして得られた QDF モードのファイルは編集を行ったことでインデクスの対応が壊れている。PDF ビュアーによってはそのまま開ける場合もあるかもしれないが，念のため fix-qdf というツール（QPDF のツール群の一つ）を用いて修正する。

```:PDF ファイルへの逆変換（インデクスの修正）
fix-qdf sample_R2L.qdf > sample_R2L.pdf
```

こうして得られた PDF ファイルは見開きページ表示かつ右綴じになっているはずだ。

# 3. 自作ツールの実装コード

手早く作るため C# を使った。

<details><summary>QDF2TXT.CS</summary>

```C#:QDF2TXT.CS
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
//------------------------------------------------------------------------------
// QDFファイル／テキスト変換ツール
//------------------------------------------------------------------------------
class QDF2TXT {
	//--------------------------------------------------------------------------
	// メイン関数
	//--------------------------------------------------------------------------
	static	int		Main( string[] args ) {
		//----------------------------------------------------------------------
		// ヘルプメッセージ
		//----------------------------------------------------------------------
		if( args.Length < 2 ) {
			Console.Error.WriteLine( "QDFファイルのバイナリデータに対し，元のQDFファイルを参照する形で" );
			Console.Error.WriteLine( "テキストファイルに変換します。" );
			Console.Error.WriteLine( "" );
			Console.Error.WriteLine( "QDF2TXT(.EXE) [入力ファイル(.QDF)] [出力ファイル(.TXT)]" );
			return( -1 );
		}
		//----------------------------------------------------------------------
		// 入力ファイルの一括読み込み
		//----------------------------------------------------------------------
		if( !File.Exists( args[0] ) ) {
			Console.Error.WriteLine( "ファイル {0} は存在しません!!", args[0] );
			return( -1 );
		}
		var	bytes = File.ReadAllBytes( args[0] );
		//----------------------------------------------------------------------
		// 出力ファイルのオープン
		//----------------------------------------------------------------------
		var	stream = new FileStream( args[1], FileMode.Create, FileAccess.Write );
		var	binary = new BinaryWriter( stream );
		//----------------------------------------------------------------------
		// バイナリ行／バイナリストリームを間接参照する
		//----------------------------------------------------------------------
		for( int pos = 0, next = 0; pos < bytes.Length; pos = next ) {
			int	eol = FindEOL( bytes, pos );
			if( IsAsciiString( bytes, pos, eol - pos ) ) {
				var	s = Encoding.ASCII.GetString( bytes, pos, eol - pos );
				if( s == "stream" ) {
					next = SkipEOL( bytes, eol );
					for( int i = pos; i < next; i++ ) binary.Write( bytes[i] );
					pos = next;
					next = FindString( bytes, pos, "endstream" );
					eol = BackEOL( bytes, next );
					if( IsAsciiString( bytes, pos, eol - pos ) ) {
						for( int i = pos; i < next; i++ ) binary.Write( bytes[i] );
					} else {
						var	t = string.Format( "%BINARY_STREAM {0} {1}\n", pos, next - pos );
						var	a = Encoding.ASCII.GetBytes( t );
						binary.Write( a );
					}
				} else {
					next = SkipEOL( bytes, eol );
					for( int i = pos; i < next; i++ ) binary.Write( bytes[i] );
				}
			} else {
				var	s = string.Format( "%BINARY_STRING {0} {1}", pos, eol - pos );
				var	a = Encoding.ASCII.GetBytes( s );
				binary.Write( a );
				next = SkipEOL( bytes, eol );
				for( int i = eol; i < next; i++ ) binary.Write( bytes[i] );
			}
		}
		//----------------------------------------------------------------------
		// 出力ファイルのクローズ
		//----------------------------------------------------------------------
		binary.Close();
		stream.Close();
		return( 0 );
	}
	//--------------------------------------------------------------------------
	// EOLの一文字目を探す
	//--------------------------------------------------------------------------
	static	int		FindEOL( byte[] bytes, int pos ) {
		while( pos < bytes.Length ) {
			var	c = (char)bytes[pos];
			if( c == '\r' || c == '\n' ) break;
			pos++;
		}
		return( pos );
	}
	//--------------------------------------------------------------------------
	// EOLをスキップする
	//--------------------------------------------------------------------------
	static	int		SkipEOL( byte[] bytes, int pos ) {
		if( pos < bytes.Length && (char)bytes[pos] == '\n' ) {
			return( pos + 1 );
		} else if( pos < bytes.Length - 1 && (char)bytes[pos] == '\r' && (char)bytes[pos + 1] == '\n' ) {
			return( pos + 2 );
		}
		throw new Exception( "[SkipEOL] FATAL ERROR!!" );
	}
	//--------------------------------------------------------------------------
	// EOLの一文字目を探す
	//--------------------------------------------------------------------------
	static	int		BackEOL( byte[] bytes, int pos ) {
		if( pos > 0 && (char)bytes[pos - 1] == '\n' ) {
			if( pos > 1 && (char)bytes[pos - 2] == '\r' )
				return( pos - 2 );
			else
				return( pos - 1 );
		}
		throw new Exception( "[BackEOL] FATAL ERROR!!" );
	}
	//--------------------------------------------------------------------------
	// バイト列がASCII文字列かどうかチェックする
	//--------------------------------------------------------------------------
	static	bool	IsAsciiString( byte[] bytes, int pos, int len ) {
		for( int i = pos, j = 0; j < len; i++, j++ ) {
			var	c = (char)bytes[i];
			if( c != '\r' && c != '\n' && ( c < ' ' || c > '~' ) ) return( false );
		}
		return( true );
	}
	//--------------------------------------------------------------------------
	// 文字列を検索する
	//--------------------------------------------------------------------------
	static	int		FindString( byte[] bytes, int pos, string s ) {
		for( int i = pos; i < bytes.Length; i++ ) {
			var	match = true;
			for( int j = i, k = 0; k < s.Length; j++, k++ ) {
				if( (char)bytes[j] != s[k] ) {
					match = false;
					break;
				}
			}
			if( match ) return( i );
		}
		throw new Exception( "[FindString] Fatal Error!!" );
	}
}
```

</details>

<details><summary>TXT2QDF.CS</summary>

```C#:TXT2QDF.CS
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
//------------------------------------------------------------------------------
// QDFファイル／テキスト逆変換ツール
//------------------------------------------------------------------------------
class TXT2QDF {
	//--------------------------------------------------------------------------
	// メイン関数
	//--------------------------------------------------------------------------
	static	int		Main( string[] args ) {
		//----------------------------------------------------------------------
		// ヘルプメッセージ
		//----------------------------------------------------------------------
		if( args.Length < 3 ) {
			Console.Error.WriteLine( "テキストファイルと元のQDFファイルからQDFファイルに逆変換します。" );
			Console.Error.WriteLine( "" );
			Console.Error.WriteLine( "TXT2QDF(.EXE) [入力ファイル(1)(.TXT)] [入力ファイル(2)(.QDF)] [出力ファイル(.QDF)]" );
			return( -1 );
		}
		//----------------------------------------------------------------------
		// 入力ファイル(1)の一括読み込み
		//----------------------------------------------------------------------
		if( !File.Exists( args[0] ) ) {
			Console.Error.WriteLine( "ファイル {0} は存在しません!!", args[0] );
			return( -1 );
		}
		var	bytes1 = File.ReadAllBytes( args[0] );
		//----------------------------------------------------------------------
		// 入力ファイル(2)の一括読み込み
		//----------------------------------------------------------------------
		if( !File.Exists( args[1] ) ) {
			Console.Error.WriteLine( "ファイル {0} は存在しません!!", args[1] );
			return( -1 );
		}
		var	bytes2 = File.ReadAllBytes( args[1] );
		//----------------------------------------------------------------------
		// 出力ファイルのオープン
		//----------------------------------------------------------------------
		var	stream = new FileStream( args[2], FileMode.Create, FileAccess.Write );
		var	binary = new BinaryWriter( stream );
		//----------------------------------------------------------------------
		// 間接参照をバイナリに変換する
		//----------------------------------------------------------------------
		for( int pos = 0, next = 0; pos < bytes1.Length; pos = next ) {
			int	eol = FindEOL( bytes1, pos );
			var	s = Encoding.ASCII.GetString( bytes1, pos, eol - pos );
			var	m = Regex.Match( s, @"%BINARY_(STRING|STREAM) (\d+) (\d+)" );
			if( m.Success ) {
				int	p = int.Parse( m.Groups[2].Value );
				int	n = int.Parse( m.Groups[3].Value );
				for( int i = p, j = 0; j < n; i++, j++ )
					binary.Write( bytes2[i] );
				next = SkipEOL( bytes1, eol );
				if( m.Groups[1].Value == "STRING" )
					for( int i = eol; i < next; i++ ) binary.Write( bytes1[i] );
			} else {
				next = SkipEOL( bytes1, eol );
				for( int i = pos; i < next; i++ ) binary.Write( bytes1[i] );
			}
		}
		//----------------------------------------------------------------------
		// 出力ファイルのクローズ
		//----------------------------------------------------------------------
		binary.Close();
		stream.Close();
		return( 0 );
	}
	//--------------------------------------------------------------------------
	// EOLの一文字目を探す
	//--------------------------------------------------------------------------
	static	int		FindEOL( byte[] bytes, int pos ) {
		while( pos < bytes.Length ) {
			var	c = (char)bytes[pos];
			if( c == '\r' || c == '\n' ) break;
			pos++;
		}
		return( pos );
	}
	//--------------------------------------------------------------------------
	// EOLをスキップする
	//--------------------------------------------------------------------------
	static	int		SkipEOL( byte[] bytes, int pos ) {
		if( pos < bytes.Length && (char)bytes[pos] == '\n' ) {
			return( pos + 1 );
		} else if( pos < bytes.Length - 1 && (char)bytes[pos] == '\r' && (char)bytes[pos + 1] == '\n' ) {
			return( pos + 2 );
		}
		throw new Exception( "[SkipEOL] FATAL ERROR!!" );
	}
}
```

</details>

# 4. まとめ

今回，見開きページ表示にして右綴じに書き換える PDF ファイルは一つだけだったので純テキストファイルの編集は手作業で行った。もしも，このようなファイルが大量にあればスクリプトによる自動化も考えなくはないが，PDF ファイルのフォーマットは自由度があり過ぎるので，真面目に作ると大変そうだ。

ネットを探すとフリーの右綴じ変換ツールも散見されるが，対応している電子書籍が限られていたりする。おそらく PDF ファイルの仕様に完全準拠するのは難しいからだと思う。

# 5. 参考文献

初学者はまず参考文献[1]のサイトを見て学ぶとよいと思う。参考文献[2]は，日本語で書かれた PDF リファレンスとしては最後のバージョンではないかと思う。今となっては貴重な資料だ。

[1] [横浜工文社　荒井文吉，手書きPDF入門，1999年11月](https://kobu.com/docs/pdf/pdfxhand.htm)
[2] アドビシステムズ，PDFリファレンス第2版 (Version 1.3)，2001年9月
[3] [アドビシステムズ，PDFリファレンス第6版 (Version 1.7)，2006年6月 <img width=16 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3490655/66e66c9d-a6f3-f215-951a-9d79cc94a9d6.png">](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/pdfreference1.7old.pdf)
[4] [PDFを扱う開発のお供にQPDFのすすめ - Qiita](https://qiita.com/ssc-ynakamura/items/343e3669c216c8e23e1b)