0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-10-20

0. はじめに

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

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

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

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

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

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

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

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

表2 PageLayout の値
$\textsf{値}$ $\textsf{内容}$
SinglePage 一度に1ページを表示 ※デフォルト
OneColumn ページを一列で表示
TwoColumnLeft 奇数ページを左側にして二列で表示
TwoColumnRight 奇数ページを右側にして二列で表示
TwoPageLeft 一度に2ページを表示で,奇数ページを左側に表示(PDF1.5)
TwoPageRight 一度に2ページを表示で,奇数ページを右側に表示(PDF1.5)

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

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

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

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

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

表4 Direction の値
$\textsf{値}$ $\textsf{内容}$
L2R 左から右へ ※デフォルト
R2L 右から左へ(中国語,韓国語,日本語など縦書きの場合)

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

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

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

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

2.1 QPDF の入手

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

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

ここでいう純テキストファイルとは,文字コード 0x20 ~ 0x7E の範囲内の文字,いわゆる空白と印字可能な文字しか使用していないという意味で用いている。

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

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

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

% 以下略・・・

こうして純テキストファイルをテキストエディタで編集して「カタログ」辞書に PageLayoutViewerPreferences を追加する。念のため 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# を使った。

QDF2TXT.CS
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!!" );
	}
}
TXT2QDF.CS
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!!" );
	}
}

4. まとめ

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

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

5. 参考文献

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

[1] 横浜工文社 荒井文吉,手書きPDF入門,1999年11月
[2] アドビシステムズ,PDFリファレンス第2版 (Version 1.3),2001年9月
[3] アドビシステムズ,PDFリファレンス第6版 (Version 1.7),2006年6月
[4] PDFを扱う開発のお供にQPDFのすすめ - Qiita

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?