0. はじめに
久しぶりに電子書籍を購入した。縦長画面のタブレットで読むのならともかく,たいていの PC 画面は横長であるから PC で電子書籍を読もうとすると画面の半分が余って勿体ない。かといって PDF リーダーで見開きページ表示にすると,たまたま購入した電子書籍は
となってしまい,ページの左右が逆になってしまう。和書(右綴じ)なので以下のように表示させたいのだ。
1. PDFファイルの内部構造について
PDF ファイルの内部構造については参考文献[1]を参照されたい。
PDF ファイルの文書オブジェクトの階層構造の中で「カタログ」辞書というものがあり,その中で単ページ表示 or 見開き表示などのページレイアウトを規定している。
具体的にいうと PageLayout
と ViewerPreference
の二つのキーで規定する。
$\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
に設定すればよい。
$\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
とバージョンが記載されている。TwoPageLeft
と TwoPageRight
は PDF1.5 以上でサポートされた値である。
もう一つ,ビューアプリファレンス辞書のエントリを以下に示す。ビューアプリファレンス辞書自体がオプションのため,未指定の場合はデフォルト値が採用される。
$\textsf{キー}\hspace{8em}$ | $\textsf{型}\hspace{1em}$ | $\textsf{値}$ |
---|---|---|
HideToolBar | 論理 | オプション |
HideMenuBar | 論理 | オプション |
HideWindowUI | 論理 | オプション |
FitWindow | 論理 | オプション |
CenterWindow | 論理 | オプション |
NonFullScreenPageMode | 名前 | オプション |
Direction | 名前 | オプション,テキストを読み進める際にページをめくる決まった順序。表4参照 |
ページをめくる順序は Direction
の値で決まる。デフォルトでは L2R
となっているので,このエントリを書き換えもしくは新規追加して R2L
に設定すればよい。
$\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 ビューアーで内容を見ることができる。
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バイトのバイナリデータ行という意味である。
%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 のバージョンも上げておく。
%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 モードのファイルを作成する。
txt2qdf sample.txt sample.qdf sample_R2L.qdf
2.6 PDF ファイルへの逆変換(インデクスの修正)
こうして得られた QDF モードのファイルは編集を行ったことでインデクスの対応が壊れている。PDF ビュアーによってはそのまま開ける場合もあるかもしれないが,念のため fix-qdf というツール(QPDF のツール群の一つ)を用いて修正する。
fix-qdf sample_R2L.qdf > sample_R2L.pdf
こうして得られた PDF ファイルは見開きページ表示かつ右綴じになっているはずだ。
3. 自作ツールの実装コード
手早く作るため 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!!" );
}
}
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