【非実用的】シェルスクリプトで PDF を作成

## はじめに

ふと思いついたので、シェルスクリプトで PDF を作成してみました。

- タイトルにもあるように、とても非実用的な物です
- 扱うデータは基本的に US-ASCII の範囲とします（つまり日本語とかは扱えません）
- PDF のフォーマットの一部抜粋の概要はあるけど、詳しい話はしません（できません）

## まずは手動で Hello World.

知っている人も多いと思いますが PDF はかなりの部分がプレーンテキストです。
圧縮や暗号化、画像などを使わなければ、ほぼプレーンテキストで作ることができます。
（実際には圧縮や暗号化、画像などもエンコードを選べば間延びするもののプレーンテキストで作れなくもないですが、それはおいといて）

以下の内容を、改行コードを LF で指定して保存すれば、PDF として開けると思います。

```txt:helloworld.pdf
%PDF-1.2
2 0 obj
<< /Type /Catalog /Pages 1 0 R >>
endobj
3 0 obj
<< /Font << /F0 4 0 R >> >>
endobj
4 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Courier
/Encoding /WinAnsiEncoding
>>
endobj
5 0 obj
<<
/Type /Page
/Parent 1 0 R
/Resources 3 0 R
/Contents 6 0 R
>>
endobj
6 0 obj
<< /Length 76 >>
stream
1. 0. 0. 1. 50. 720. cm
BT
/F0 36 Tf
40 TL
(Hello) Tj T*
(  World.) Tj T*
ET
endstream
endobj
1 0 obj
<<
/Type /Pages
/Kids [5 0 R]
/Count 1
/MediaBox [0 0 595 842]
>>
endobj
xref
0 7
0000000000 65535 f 
0000000402 00000 n 
0000000009 00000 n 
0000000058 00000 n 
0000000101 00000 n 
0000000196 00000 n 
0000000276 00000 n 
trailer
<<
/Size 7
/Root 2 0 R
>>
startxref
483
%%EOF
```

実際には、改行コードを CRLF にしても、PDF リーダーによっては問題なく開けちゃうかもしれませんが（最近のアプリは賢いので、壊れた PDF でも頑張って表示しちゃうので）、内部で持っている数字はファイルの先頭からのバイト数分のオフセットだったりするので、改行コードが違うとずれてしまいます。

非常に簡単なフォーマットの概要ですが、ヘッダーにはファイルフォーマットのバージョンが書いてあります。

```txt:PDFのヘッダー
%PDF-1.2
```

ちなみに `%` から始まる行はコメントであり、通常はヘッダー行の次にコメントがあって、そこにはバイナリが含まれていることが多いです。

```txt:よく見るPDFのヘッダーの例
%PDF-1.7
%（バイナリが数バイト入っている）
```

これは、PDF の先頭を見て「あれ、これテキストファイルじゃん、気を利かせて改行コードを変換しておいてやろ！」とかしちゃうツールがあったときに、テキストファイルだと誤判断されないためのものだそうです。とりあえず Hello World では無視しておきます。

次にファイルの末尾にある、トレーラーを見ます。ここがデータを読み取る起点となります（余談ですが PDF を作るツールによっては、「インターネットからダウンロードするとき用に最適化」みたいなオプションがありますが、その場合にはトレーラーだったかこの後のクロスリファレンスだったかが先頭付近に作られるはずです）。

```txt:PDFのトレーラー
trailer
<<
/Size 7
/Root 2 0 R
>>
startxref
483
%%EOF
```

この中の `<<` と `>>` にくくられた部分は「辞書」という構造で、詳細は省きますが、「Size」というキーに対して値は「7」、「Root」というキーに対して値 `2 0 R` で、これは「2番目のオブジェクト」への参照という意味です。

また「startxref」の後ろの数字は、クロスリファレンスのある位置であり、ファイルの先頭からのオフセットを10進数で表記したものです。

つまり、少し上にある「xref」から始まる行は、ファイルの先頭から 483 バイト目にあるということです。

つぎはそのクロスリファレンスです。

```txt:PDFのクロスリファレンス
xref
0 7
0000000000 65535 f 
0000000402 00000 n 
0000000009 00000 n 
0000000058 00000 n 
0000000101 00000 n 
0000000196 00000 n 
0000000276 00000 n 
```

細かいことを省略すると「xref」の次の行の「`0 7`」の「`7`」は、オブジェクトが 0～6 番の 7 つあるという意味です。実際には 0 は特別らしく、1～6 の 6 個しか作っていませんので、作ったオブジェクトの数に +1 した値を指定すると思えば良いはずです。

その次の `0000000000 65535 f ` は 0 番目のオブジェクトを示すけど、基本はこの値固定でいいはず。

その次からが 1～6 番目のオブジェクトの位置で 10 桁の数字がオブジェクトの開始位置（ファイルの先頭からのオフセットの 10 進数表記）です。後ろの `00000 n` はとりあえず固定値。
ちなみに、ここの行は改行を含めて 20 バイトにする必要があるらしく、LF で保存する場合には `n` の後ろにスペースを付けておく必要があります。改行コードを CRLF で作っている場合には空白は不要です（このあたりが正しくなっていれば、PDF の改行コードは LF でも CRLF でも、今はないかもしれないけど CR だけでも大丈夫。今回は LF で作る想定）。

先ほどのトレーラーに「Root」は 2 番目のオブジェクトだ、とあったので、2 番目のオブジェクト、つまりファイルオフセット 0000000009 の位置を見ます。

```txt:PDFの2番目のオブジェクト
2 0 obj
<< /Type /Catalog /Pages 1 0 R >>
endobj
```

`2 0 obj` が 2 番目のオブジェクトの開始で、`endobj` がオブジェクトの終了です。
ちなみに `2` の後ろの `0` は世代番号らしいですが、基本は `0` 固定のはずです。

中身は「辞書」で、「Type」が「Catalog」、「Pages」は 1 番目のオブジェクトを参照しています。

同様に 1 番目のオブジェクトを見ると、以下のようになっています。

```txt:PDFの1番目のオブジェクト
1 0 obj
<<
/Type /Pages
/Kids [5 0 R]
/Count 1
/MediaBox [0 0 595 842]
>>
endobj
```

「Type」が「Pages」、つまりページをまとめている情報です。
「Kids」は、子どものノードである「Page」を指定しています。つまり 5 番目のオブジェクトがページの情報となります。

実際には、「Kids」に指定するのは「Page」だけではなく、たとえば「Pages」を指定してツリー構造のようにもできるようです。
今回はシンプルに、「Kids」には「Page」だけを入れています。
「Count」は「1」で、これはページ（子供）の数を示しています。
「MediaBox」はページのサイズで、この数値は A4 縦のサイズをポイント単位で指定しています（72ポイント＝1インチ）。

ページサイズは「Page」にも指定できますが、今回はあとで複数の「Page」を作るときもすべて同じサイズにする予定なので、「Pages」で指定しています。

次は Kids である 5 番目のオブジェクトを見てみます。

```txt:PDFの5番目のオブジェクト
5 0 obj
<<
/Type /Page
/Parent 1 0 R
/Resources 3 0 R
/Contents 6 0 R
>>
endobj
```

やはり「辞書」で「Type」は「Page」、「Parent」は 1 番目のオブジェクト、「Resources」は 3 番目のオブジェクト、「Contents」は 6 番目のオブジェクトとなっています。
「Parent」は当然先ほどの「Pages」のオブジェクトをさしています。
「Resources」はフォントとか色とか画像とかのリソースを指定しています。今回はフォントの定義があるだけですが。
「Contents」は、ページに表示すべきデータが格納されているオブジェクトです。

リソースを定義している 3 番目のオブジェクトを見てみます。

```txt:PDFの3番目のオブジェクト
3 0 obj
<< /Font << /F0 4 0 R >> >>
endobj
```

「辞書」で、タイプはなくて、「Font」というキーを定義しています。
「Font」の値も辞書で、「F0」という名前に対して、4 番目のオブジェクトを指定しています。
描画するときに、複数のフォントを使い分けるために、通常は必要なフォントを複数定義するものですが、今回は 1 つのフォントだけを使うので、「F0」という名前だけを定義しています。

その「F0」の定義である 4 番目のオブジェクトを見てみます。

```txt:PDFの4番目のオブジェクト
4 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Courier
/Encoding /WinAnsiEncoding
>>
endobj
```

「辞書」になっており、「Type」は「Font」、「Subtype」は「Type1」、「BaseFont」は「Courier」、「Encoding」は「WinAnsiEncoding」となっています。

「Courier」というフォントは標準フォントの 1 つなので結構シンプルな定義で終わっていますが、日本語フォントを使おうとすると、フォント情報の構造も、後で説明する「Contents」のエンコードもめんどくさいです。

なので、今回は US-ASCII だけを対象として、フォントも標準フォントのみを使用することにします（タイトル通り、非実用的です）。

ちなみに、標準フォントは 14 種類あるようです。

| フォントの種類 | 標準体 | 太字 | 斜体 | 太字 + 斜体 |
|:---:|:---:|:---:|:---:|:---:|
| ローマン体 | Times-Roman | Times-Bold | Times-Italic | Times-BoldItalic |
| ゴシック体 | Helvetica | Helvetica-Bold | Helvetica-Oblique | Helvetica-BoldOblique |
| 等幅      | Courier | Courier-Bold | Courier-Oblique | Courier-BoldOblique |

| フォントの種類 | その1 | その2 |
|:---:|:---:|:---:|
| 記号系 | Symbol | ZapfDingbats |

また、「WinAnsiEncoding」というエンコードは、英語版（など）の Windows で使われていた標準のエンコードで「Windows-1252」とか「コードページ1252」と呼ばれているものです。
ISO-8859-1 の 0x80-0x9f の制御コードの代わりに図形文字を入れたものだそうです（詳しくは知らない）。

0x00-0x7f の範囲は、たぶん US-ASCII とほぼ一緒だと思います。

「Subtype」は複雑なので割愛します（というか詳しく説明できません。「Type0」とか「TrueType」とか「CIFFontType0」とかいろいろある）。

少しもどって「Page」の「Contents」である、6 番目のオブジェクトを見てみます。

```txt:PDFの6番目のオブジェクト
6 0 obj
<< /Length 76 >>
stream
1. 0. 0. 1. 50. 720. cm
BT
/F0 36 Tf
40 TL
(Hello) Tj T*
(  World.) Tj T*
ET
endstream
endobj
```

これは他の少し構造が違っており「辞書」の後ろにストリームがついています。

「辞書」は「Length」に「76」が定義されており、これはストリームのバイト長です。

ストリームは、実際の描画などの命令が入っています。今回ストリームはプレーンテキストですが、圧縮などもできます。その場合には（たいていは）バイナリデータになっていると思います。

PDF の描画命令などは PostScript などと同じでスタック処理言語なので、「引数1 引数2 引数3 命令」みたいな、命令が最後につく形となっています。

最初の `1. 0. 0. 1. 50. 720. cm` は「`cm`」が命令で、アフィン変換の回転と移動を表わしているはずです。
`1. 0. 0. 1.` の部分が回転なしの単位行列、原点が左下で、`50. 720.` が左から `50` 、下から `720` 移動したところを基点に描画を始めるという意味だと思えばよいかと。

`BT` がテキスト描画開始、`ET` が同じく終了です。

`Tf` は、フォント名( `F0` )と、サイズを引数にとって、使用するフォント/サイズを指定します。

`TL` は行送りの幅、つまり 1 行の高さを指定します。

`(` と `)` でくくられている部分が、US-ASCII（実際には WinAnsiEncoding の）文字列です。
出力したい文字の中にカッコがある場合にはエスケープが必要です（例： `(foo \(bar\) baz)` ）。
実際には左右が対応しているカッコの場合にはエスケープしないでもいいらしいですが、チェックするのが面倒なので、一律変換しちゃうつもりでいます。

日本語などの場合には違うエンコーディングが必要になります（が詳細は説明しません）。

`Tj` はテキストを描画するコマンドです。

`T*` は行送りのコマンドです。

以上で、一通りの内容を確認できました。

これぐらいなら ShellScript でも作れそうですよね？

ちなみに、構造のなかで「辞書」とオブジェクトの参照（ `3 0 R` みたいなもの）がありましたが、これはある程度好きに分けたり、くっつけたりできます。

例えば、上記の例ではリソース/フォントの情報を 2 つのオブジェクトに定義していましたが、あわせてしまって以下のようにも定義可能です。

```txt:リソースとフォントをまとめて定義
3 0 obj
<<
  /Font <<
    /F0 <<
      /Type /Font
      /Subtype /Type1
      /BaseFont /Courier
      /Encoding /WinAnsiEncoding
    >>
  >>
>>
endobj
```

逆に例えばストリームの長さだけを別にする、ということも可能です。

```diff:ストリームの長さを別オブジェクトにした例
  6 0 obj
- << /Length 76 >>
+ << /Length 7 0 R >>
  stream
  1. 0. 0. 1. 50. 720. cm
  BT
  /F0 36 Tf
  40 TL
  (Hello) Tj T*
  (  World.) Tj T*
  ET
  endstream
  endobj
+ 7 0 obj
+ 76
+ endobj
```

この例では 6 番目のオブジェクトの「Length」に 7 番目のオブジェクトを参照するようにして、7 番目のオブジェクトには「76」という数値だけを登録しています。
こうするとストリームがどれだけのサイズになるかわからなくても、順番に出力していって最後にストリームのサイズだけを出力できるので、作る側としては簡単になります。

また、オブジェクトの番号は特に意味のあるものではないので、出力順が数字順になっていなくても問題ありません。
いままでの例でも 1 番目のオブジェクトが最後に定義されていますね。

## シェルスクリプトで Hello World.

というわけで、適当にでっち上げました。オブジェクトの順番は、先ほどの例とは変わっています（こちらの方が素直な順番になっています）。

```shell:helloworldpdf
#!/bin/sh
# PDF として Hello World を出力する

set -eu

## ラインフィード
LF="
"

## 前ゼロ埋めして10桁にして返す
## @param $1 編集結果を入れる変数名
## @param $2 編集対象の文字列
zero_padding_10() {
  until [ 10 -le "${#2}" ] && eval "$1=\$2"; do
    set -- "$1" "0$2"
  done
}

## ファイル先頭からの出力済バイト数
FILE_OFFSET=0

## 標準出力に出力する
output_line() {
  FILE_OFFSET=$(( FILE_OFFSET + ${#1} + 1 ))  # +1 は改行分
  printf "%s\n" "$1"
}

## xref のデータ
XREF_BODY="0000000000 65535 f "

## 前ゼロ埋めしたオフセット。一時利用のワーク変数
PADDED_OFFSET=""

## obj を出力する
## @param $1 obj の番号
output_beginobj() {
  zero_padding_10 PADDED_OFFSET "$FILE_OFFSET"
  XREF_BODY="${XREF_BODY}${LF}${PADDED_OFFSET} 00000 n "
  output_line "$1 0 obj"
}

## 出力中の stream データ
STREAM_BODY=""

## stream を開始する
## @param $1 ストリームに設定する値
start_stream() { STREAM_BODY="$1"; }

## stream にデータを追加する
## @param $1 ストリームに追加する値
append_stream() { STREAM_BODY="${STREAM_BODY}${LF}$1"; }

## stream を出力する
output_stream() {
  output_line "<< /Length ${#STREAM_BODY} >>"
  output_line "stream"
  output_line "$STREAM_BODY"
  output_line "endstream"
  output_line "endobj"
  STREAM_BODY=""
}

# ヘッダーを出力
output_line "%PDF-1.2"

# catalog (root オブジェクト)の出力
output_beginobj 1
output_line "<< /Type /Catalog /Pages 2 0 R >>"
output_line "endobj"

# pages の出力
output_beginobj 2
output_line "<<"
output_line "/Type /Pages"
output_line "/Kids [5 0 R]"
output_line "/Count 1"
output_line "/MediaBox [0 0 595 842]"
output_line ">>"
output_line "endobj"

# resources の出力
output_beginobj 3
output_line "<< /Font << /F0 4 0 R >> >>"
output_line "endobj"

# font の出力
output_beginobj 4
output_line "<<"
output_line "/Type /Font"
output_line "/Subtype /Type1"
output_line "/BaseFont /Courier"
output_line "/Encoding /WinAnsiEncoding"
output_line ">>"
output_line "endobj"

# page の出力
output_beginobj 5
output_line "<<"
output_line "/Type /Page"
output_line "/Parent 2 0 R"
output_line "/Resources 3 0 R"
output_line "/Contents 6 0 R"
output_line ">>"
output_line "endobj"

# page の contents の出力
output_beginobj 6
start_stream "1. 0. 0. 1. 50. 720. cm"
append_stream "BT"
append_stream "/F0 36 Tf"
append_stream "40 TL"
append_stream "(Hello) Tj T*"
append_stream "(  World.) Tj T*"
append_stream "ET"
output_stream

# xref の出力
XREF_OFFSET=$FILE_OFFSET
output_line "xref"
output_line "0 7"
output_line "$XREF_BODY"

# trailer の出力
output_line "trailer"
output_line "<<"
output_line "/Size 7"
output_line "/Root 1 0 R"
output_line ">>"
output_line "startxref"
output_line "$XREF_OFFSET"
output_line "%%EOF"
```

出力しながら、ファイルの先頭からのオフセットを計算していって、最後にクロスリファレンスを出力しているだけです。

シェルスクリプトでバイト数を数えるあたりがちょっと気になりますが、まあ大丈夫でしょう（ほとんどの環境で 0x00-0x7f の範囲はほぼ US-ASCII と同等でしょう、よくしらないけど。ロケールを `C` にしたほうが良いのかな）。

クロスリファレンスを作るときに、オフセットを 10 桁の前ゼロ埋めをしないといけないので、シェル関数 `zero_padding_10` を作っています。

別に `PADDED_OFFSET=$(printf "%010d" "$FILE_OFFSET")` でも良い気はしますが、一応サブシェルを使わないようにしてみました。
`printf -v PADDED_OFFSET "%010d" "$FILE_OFFSET"` も、使えないシェルがありそうなので。

## 標準入力を PDF に変換してみる

次は固定文字列ではなく標準入力から読み込んで、標準出力へ PDF を出力してみます。

「Pages」の「Kids」がどのぐらいのサイズになるのかが事前にはわからないので、「Pages」を最後に出力するのが大きく違う点でしょうか。

```shell:ascii2pdf
#!/bin/sh
## US-ASCII のデータを PDF にして出力
##
## - 入力データは US-ASCII (つまり7bitデータ)を想定しています
## - 入力データの最終行にも改行がついていることを想定しています
## - 自動での行折り返しはしないので、必要であれば事前に編集する必要がある

set -eu

## ページの幅(pt)
PAGE_WIDTH='595'

## ページの高さ(pt)
PAGE_HEIGHT='842'

## フォントサイズ(pt)
FONT_SIZE='12'

## 1行の高さ(pt)
LINE_HEIGHT='15'

## 左マージン(pt)
LEFT_MARGIN='50'

## 上マージン(pt)
TOP_MARGIN='80'

## １ページあたりの行数
PAGE_LINES='45'

## フォント名
FONT_NAME='Courier'

## ラインフィード
LF="
"

## 前ゼロ埋めして10桁にして返す
## @param $1 編集結果を入れる変数の名
## @param $2 編集対象の文字列
zero_padding_10() {
  until [ 10 -le ${#2} ] && eval "$1=\$2"; do
    set -- "$1" "0$2"
  done
}

## 一括置換
## @param $1 編集結果を入れる変数の名
## @param $2 編集対象の文字列
## @param $3 from文字列
## @param $4 to文字列
## ref. <https://qiita.com/ko1nksm/items/b4b342f77f6d3ee1a0a9>
replace_all() {
  set -- "$1" "$2" "$3" "$4" "${2#*"$3"}" ""
  until [ "$2" = "$5" ] && eval "$1=\$6\$2"; do
    set -- "$1" "$5" "$3" "$4" "${5#*"$3"}" "$6${2%%"$3"*}$4"
  done
}

## ファイル先頭からの出力済バイト数
FILE_OFFSET=0

## 標準出力に出力する、その際オフセットを計算する
## @param $1 出力文字列
output_line() {
  FILE_OFFSET=$(( FILE_OFFSET + ${#1} + 1 ))
  printf "%s\n" "$1"
}

## xref のデータ。0 と 1 (pages)のオブジェクト情報は入れないので注意
XREF_BODY=""

## ワーク変数
PADDED_OFFSET=""

## obj を開始する
## @param $1 obj の番号
output_beginobj() {
  zero_padding_10 PADDED_OFFSET "$FILE_OFFSET"
  XREF_BODY="${XREF_BODY}${LF}${PADDED_OFFSET} 00000 n "
  output_line "$1 0 obj"
}

## 出力中の stream データ
STREAM_BODY=""

## stream を開始する
## @param $1 ストリームに設定する値
start_stream() { STREAM_BODY="$1"; }

## stream にデータを追加する
## @param $1 ストリームに追加する値
append_stream() { STREAM_BODY="${STREAM_BODY}${LF}$1"; }

## stream を出力する
output_stream() {
  output_line "<< /Length ${#STREAM_BODY} >>"
  output_line "stream"
  output_line "$STREAM_BODY"
  output_line "endstream"
  output_line "endobj"
  STREAM_BODY=""
}

# ヘッダーを出力
output_line "%PDF-1.2"
output_line "%xxxx"  # ダミー

# catalog (root オブジェクト)を出力
output_beginobj 2
output_line "<<"
output_line "/Type /Catalog"
output_line "/Pages 1 0 R"
output_line ">>"
output_line "endobj"

# resources の出力
output_beginobj 3
output_line "<< /Font << /F0 4 0 R >> >>"
output_line "endobj"

# font の出力
output_beginobj 4
output_line "<<"
output_line "/Type /Font"
output_line "/Subtype /Type1"
output_line "/BaseFont /$FONT_NAME"
output_line "/Encoding /WinAnsiEncoding"
output_line ">>"
output_line "endobj"

OBJ_ID=5
PAGES_KIDS=""
PAGE_COUNT=0

## page/contents の出力開始
begin_page() {
  # pages の Kids 用の編集
  PAGES_KIDS="$PAGES_KIDS $OBJ_ID 0 R"
  PAGE_COUNT=$(( PAGE_COUNT + 1))

  # page を出力
  output_beginobj "$OBJ_ID"
  output_line "<<"
  output_line "/Type /Page"
  output_line "/Parent 1 0 R"
  output_line "/Resources 3 0 R"
  output_line "/Contents $(( OBJ_ID + 1 )) 0 R"
  output_line ">>"
  output_line "endobj"
  OBJ_ID=$(( OBJ_ID + 1 ))

  # contents を出力開始 / stream の編集開始
  output_beginobj "$OBJ_ID"
  start_stream "1. 0. 0. 1. ${LEFT_MARGIN}. $(( PAGE_HEIGHT - TOP_MARGIN )). cm"
  append_stream "BT"
  append_stream "/F0 $FONT_SIZE Tf"
  append_stream "$LINE_HEIGHT TL"
  OBJ_ID=$(( OBJ_ID + 1 ))
}

## page/contents の出力終了
end_page() {
  # stream の編集終了
  append_stream "ET"
  output_stream
}

## テキスト編集用のワーク変数
WORK_TEXT=""

## ページ内の出力済行数
LINE_COUNT=0

## US-ASCII テキストをストリームに出力
## @param $1 テキスト
output_text() {
  if [ "$LINE_COUNT" -eq "$PAGE_LINES" ]; then
    end_page
    LINE_COUNT=0
  fi
  if [ "$LINE_COUNT" -eq 0 ]; then
    begin_page
  fi
  LINE_COUNT=$(( LINE_COUNT + 1 ))
  WORK_TEXT="$1"
  replace_all WORK_TEXT "$WORK_TEXT" "(" '\('
  replace_all WORK_TEXT "$WORK_TEXT" ")" '\)'
  append_stream "($WORK_TEXT) Tj T*"
}

while IFS= read -r LINE; do
  output_text "$LINE"
done

if [ "$LINE_COUNT" -ne 0 ]; then
  end_page
fi

# pages の出力。注：output_beginobj は使わない
PAGES_OFFSET="$FILE_OFFSET"
output_line "1 0 obj"
output_line "<<"
output_line "/Type /Pages"
output_line "/Kids [${PAGES_KIDS}]"
output_line "/Count $PAGE_COUNT"
output_line "/MediaBox [0 0 $PAGE_WIDTH $PAGE_HEIGHT]"
output_line ">>"
output_line "endobj"

# xref の出力
XREF_OFFSET=$FILE_OFFSET
output_line "xref"
output_line "0 $OBJ_ID"  # 存在する obj の數 + 1
output_line "0000000000 65535 f "
zero_padding_10 PAGES_OFFSET "$PAGES_OFFSET"
output_line "$PAGES_OFFSET 00000 n $XREF_BODY"

# trailer の出力
output_line "trailer"
output_line "<<"
output_line "/Size $OBJ_ID"  # 存在する obj の數 + 1
output_line "/Root 2 0 R"
output_line ">>"
output_line "startxref"
output_line "$XREF_OFFSET"
output_line "%%EOF"
```

前述の通り、日本語などの文字が含まれるとうまく表示できないはずです。

また、行の折り返しなどは考えていません。
必要であれば、このコマンドへ渡す前に折り返し処理をしておいてください。
（それもあって、ローマン体やゴシック体ではなく、等幅フォントを選んでいます）

## もう少しコマンドっぽくしてみる

最後に、いろいろなパラメタをオプションで変更可能にします。PDFを生成するあたりは基本変わっていません。

<details><summary>ちょっとソースが長いので、興味がある人だけ見てください。</summary>

```shell:ascii2pdf
#!/bin/sh
## US-ASCII のデータを PDF にして出力
##
## - 入力データは US-ASCII (つまり7bitデータ)を想定しています
## - 入力データの最終行にも改行がついていることを想定しています
## - 自動での行折り返しはしないので、必要であれば事前に編集する必要がある

set -eu

VERSION="0.1.0"

# 再生成は `gengetoptions embed --overwrite ascii2pdf`
# @getoptions
parser_definition() {
  setup   REST help:usage -- "Usage: example.sh [options]... [arguments]..." ''
  msg -- 'Options:'
  param   PAGE_WIDTH -w --pages-width validate:number init:="595"  -- "Widht of pages [pt]"
  param   PAGE_HEIGHT -h --pages-height validate:number init:="842" -- "Height of pages [pt]"
  param   FONT_SIZE  -S --font-size validate:number init:="12"    -- "Font size [pt]"
  param   LINE_HEIGHT -H --line-height validate:number init:="15"  -- "Line height [pt]"
  param   LEFT_MARGIN -L --left-margin validate:number init:="50"  -- "Left margin [pt]"
  param   TOP_MARGIN -T --top-margin validate:number init:="80"  -- "Top margin [pt]"
  param   PAGE_LINES -n --line-per-page validate:number init:="45" -- "Lines per page"
  param   FONT_NAME -f --font-name init:="Courier" \
   pattern:"Times-Roman | Times-Bold | Times-Italic | Times-BoldItalic | \
Helvetica | Helvetica-Bold | Helvetica-Oblique | Helvetica-BoldOblique | \
Courier | Courier-Bold | Courier-Oblique | Courier-BoldOblique | \
Symbol | ZapfDingbats" -- "Font name"
  param   INPUT_NAME -i --input-file init:="-"  -- "Input file-name"
  param   OUTPUT_NAME -o --output-file init:="-" -- "Output file-name"
  disp    :usage -h --help
  disp    VERSION -v --version
}
# @end

# @gengetoptions parser -i parser_definition parse
# Generated by getoptions (BEGIN)
# URL: https://github.com/ko1nksm/getoptions (v3.3.2)
PAGE_WIDTH='595'
PAGE_HEIGHT='842'
FONT_SIZE='12'
LINE_HEIGHT='15'
LEFT_MARGIN='50'
TOP_MARGIN='80'
PAGE_LINES='45'
FONT_NAME='Courier'
INPUT_NAME='-'
OUTPUT_NAME='-'
REST=''
parse() {
  OPTIND=$(($#+1))
  while OPTARG= && [ "${REST}" != x ] && [ $# -gt 0 ]; do
    case $1 in
      --?*=*) OPTARG=$1; shift
        eval 'set -- "${OPTARG%%\=*}" "${OPTARG#*\=}"' ${1+'"$@"'}
        ;;
      --no-*|--without-*) unset OPTARG ;;
      -[whSHLTnfio]?*) OPTARG=$1; shift
        eval 'set -- "${OPTARG%"${OPTARG#??}"}" "${OPTARG#??}"' ${1+'"$@"'}
        ;;
      -[hv]?*) OPTARG=$1; shift
        eval 'set -- "${OPTARG%"${OPTARG#??}"}" -"${OPTARG#??}"' ${1+'"$@"'}
        case $2 in --*) set -- "$1" unknown "$2" && REST=x; esac;OPTARG= ;;
    esac
    case $1 in
      '-w'|'--pages-width')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        number || { set -- number:$? "$1" number; break; }
        PAGE_WIDTH="$OPTARG"
        shift ;;
      '-h'|'--pages-height')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        number || { set -- number:$? "$1" number; break; }
        PAGE_HEIGHT="$OPTARG"
        shift ;;
      '-S'|'--font-size')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        number || { set -- number:$? "$1" number; break; }
        FONT_SIZE="$OPTARG"
        shift ;;
      '-H'|'--line-height')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        number || { set -- number:$? "$1" number; break; }
        LINE_HEIGHT="$OPTARG"
        shift ;;
      '-L'|'--left-margin')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        number || { set -- number:$? "$1" number; break; }
        LEFT_MARGIN="$OPTARG"
        shift ;;
      '-T'|'--top-margin')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        number || { set -- number:$? "$1" number; break; }
        TOP_MARGIN="$OPTARG"
        shift ;;
      '-n'|'--line-per-page')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        number || { set -- number:$? "$1" number; break; }
        PAGE_LINES="$OPTARG"
        shift ;;
      '-f'|'--font-name')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        case $OPTARG in Times-Roman | Times-Bold | Times-Italic | Times-BoldItalic | Helvetica | Helvetica-Bold | Helvetica-Oblique | Helvetica-BoldOblique | Courier | Courier-Bold | Courier-Oblique | Courier-BoldOblique | Symbol | ZapfDingbats) ;;
          *) set "pattern:Times-Roman | Times-Bold | Times-Italic | Times-BoldItalic | Helvetica | Helvetica-Bold | Helvetica-Oblique | Helvetica-BoldOblique | Courier | Courier-Bold | Courier-Oblique | Courier-BoldOblique | Symbol | ZapfDingbats" "$1"; break
        esac
        FONT_NAME="$OPTARG"
        shift ;;
      '-i'|'--input-file')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        INPUT_NAME="$OPTARG"
        shift ;;
      '-o'|'--output-file')
        [ $# -le 1 ] && set "required" "$1" && break
        OPTARG=$2
        OUTPUT_NAME="$OPTARG"
        shift ;;
      '-h'|'--help')
        usage
        exit 0 ;;
      '-v'|'--version')
        echo "${VERSION}"
        exit 0 ;;
      --)
        shift
        while [ $# -gt 0 ]; do
          REST="${REST} \"\${$(($OPTIND-$#))}\""
          shift
        done
        break ;;
      [-]?*) set "unknown" "$1"; break ;;
      *)
        REST="${REST} \"\${$(($OPTIND-$#))}\""
    esac
    shift
  done
  [ $# -eq 0 ] && { OPTIND=1; unset OPTARG; return 0; }
  case $1 in
    unknown) set "Unrecognized option: $2" "$@" ;;
    noarg) set "Does not allow an argument: $2" "$@" ;;
    required) set "Requires an argument: $2" "$@" ;;
    pattern:*) set "Does not match the pattern (${1#*:}): $2" "$@" ;;
    notcmd) set "Not a command: $2" "$@" ;;
    *) set "Validation error ($1): $2" "$@"
  esac
  echo "$1" >&2
  exit 1
}
usage() {
cat<<'GETOPTIONSHERE'
Usage: example.sh [options]... [arguments]...

Options:
  -w, --pages-width PAGE_WIDTH 
                              Widht of pages [pt]
  -h, --pages-height PAGE_HEIGHT 
                              Height of pages [pt]
  -S, --font-size FONT_SIZE   Font size [pt]
  -H, --line-height LINE_HEIGHT 
                              Line height [pt]
  -L, --left-margin LEFT_MARGIN 
                              Left margin [pt]
  -T, --top-margin TOP_MARGIN Top margin [pt]
  -n, --line-per-page PAGE_LINES 
                              Lines per page
  -f, --font-name FONT_NAME   Font name
  -i, --input-file INPUT_NAME Input file-name
  -o, --output-file OUTPUT_NAME 
                              Output file-name
  -h, --help                  
  -v, --version               
GETOPTIONSHERE
}
# Generated by getoptions (END)
# @end

parse "$@"


# 入力ファイル名が指定された標準入力に切り替える
if [ "$INPUT_NAME" != "-" ]; then
  exec < "$INPUT_NAME"
fi

# 出力ファイル名が指定された標準出力に切り替える
if [ "$OUTPUT_NAME" != "-" ]; then
  exec > "$OUTPUT_NAME"
fi

## ラインフィード
LF="
"

## 前ゼロ埋めして10桁にして返す
## @param $1 編集結果を入れる変数の名
## @param $2 編集対象の文字列
zero_padding_10() {
  until [ 10 -le ${#2} ] && eval "$1=\$2"; do
    set -- "$1" "0$2"
  done
}

## 一括置換
## @param $1 編集結果を入れる変数の名
## @param $2 編集対象の文字列
## @param $3 from文字列
## @param $4 to文字列
## ref. <https://qiita.com/ko1nksm/items/b4b342f77f6d3ee1a0a9>
replace_all() {
  set -- "$1" "$2" "$3" "$4" "${2#*"$3"}" ""
  until [ "$2" = "$5" ] && eval "$1=\$6\$2"; do
    set -- "$1" "$5" "$3" "$4" "${5#*"$3"}" "$6${2%%"$3"*}$4"
  done
}

## ファイル先頭からの出力済バイト数
FILE_OFFSET=0

## 標準出力に出力する、その際オフセットを計算する
## @param $1 出力文字列
output_line() {
  FILE_OFFSET=$(( FILE_OFFSET + ${#1} + 1 ))
  printf "%s\n" "$1"
}

## xref のデータ。0 と 1 (pages)のオブジェクト情報は入れないので注意
XREF_BODY=""

## ワーク変数
PADDED_OFFSET=""

## obj を開始する
## @param $1 obj の番号
output_beginobj() {
  zero_padding_10 PADDED_OFFSET "$FILE_OFFSET"
  XREF_BODY="${XREF_BODY}${LF}${PADDED_OFFSET} 00000 n "
  output_line "$1 0 obj"
}

## 出力中の stream データ
STREAM_BODY=""

## stream を開始する
## @param $1 ストリームに設定する値
start_stream() { STREAM_BODY="$1"; }

## stream にデータを追加する
## @param $1 ストリームに追加する値
append_stream() { STREAM_BODY="${STREAM_BODY}${LF}$1"; }

## stream を出力する
output_stream() {
  output_line "<< /Length ${#STREAM_BODY} >>"
  output_line "stream"
  output_line "$STREAM_BODY"
  output_line "endstream"
  output_line "endobj"
  STREAM_BODY=""
}

# ヘッダーを出力
output_line "%PDF-1.2"
output_line "%xxxx"  # ダミー

# catalog (root オブジェクト)を出力
output_beginobj 2
output_line "<<"
output_line "/Type /Catalog"
output_line "/Pages 1 0 R"
output_line ">>"
output_line "endobj"

# resources の出力
output_beginobj 3
output_line "<< /Font << /F0 4 0 R >> >>"
output_line "endobj"

# font の出力
output_beginobj 4
output_line "<<"
output_line "/Type /Font"
output_line "/Subtype /Type1"
output_line "/BaseFont /$FONT_NAME"
output_line "/Encoding /WinAnsiEncoding"
output_line ">>"
output_line "endobj"

OBJ_ID=5
PAGES_KIDS=""
PAGE_COUNT=0

## page/contents の出力開始
begin_page() {
  # pages の Kids 用の編集
  PAGES_KIDS="$PAGES_KIDS $OBJ_ID 0 R"
  PAGE_COUNT=$(( PAGE_COUNT + 1))

  # page を出力
  output_beginobj "$OBJ_ID"
  output_line "<<"
  output_line "/Type /Page"
  output_line "/Parent 1 0 R"
  output_line "/Resources 3 0 R"
  output_line "/Contents $(( OBJ_ID + 1 )) 0 R"
  output_line ">>"
  output_line "endobj"
  OBJ_ID=$(( OBJ_ID + 1 ))

  # contents を出力開始 / stream の編集開始
  output_beginobj "$OBJ_ID"
  start_stream "1. 0. 0. 1. ${LEFT_MARGIN}. $(( PAGE_HEIGHT - TOP_MARGIN )). cm"
  append_stream "BT"
  append_stream "/F0 $FONT_SIZE Tf"
  append_stream "$LINE_HEIGHT TL"
  OBJ_ID=$(( OBJ_ID + 1 ))
}

## page/contents の出力終了
end_page() {
  # stream の編集終了
  append_stream "ET"
  output_stream
}

## テキスト編集用のワーク変数
WORK_TEXT=""

## ページ内の出力済行数
LINE_COUNT=0

## US-ASCII テキストをストリームに出力
## @param $1 テキスト
output_text() {
  if [ "$LINE_COUNT" -eq "$PAGE_LINES" ]; then
    end_page
    LINE_COUNT=0
  fi
  if [ "$LINE_COUNT" -eq 0 ]; then
    begin_page
  fi
  LINE_COUNT=$(( LINE_COUNT + 1 ))
  WORK_TEXT="$1"
  replace_all WORK_TEXT "$WORK_TEXT" "(" '\('
  replace_all WORK_TEXT "$WORK_TEXT" ")" '\)'
  append_stream "($WORK_TEXT) Tj T*"
}

while IFS= read -r LINE; do
  output_text "$LINE"
done

if [ "$LINE_COUNT" -ne 0 ]; then
  end_page
fi

# pages の出力。注：output_beginobj は使わない
PAGES_OFFSET="$FILE_OFFSET"
output_line "1 0 obj"
output_line "<<"
output_line "/Type /Pages"
output_line "/Kids [${PAGES_KIDS}]"
output_line "/Count $PAGE_COUNT"
output_line "/MediaBox [0 0 $PAGE_WIDTH $PAGE_HEIGHT]"
output_line ">>"
output_line "endobj"

# xref の出力
XREF_OFFSET=$FILE_OFFSET
output_line "xref"
output_line "0 $OBJ_ID"  # 存在する obj の數 + 1
output_line "0000000000 65535 f "
zero_padding_10 PAGES_OFFSET "$PAGES_OFFSET"
output_line "$PAGES_OFFSET 00000 n $XREF_BODY"

# trailer の出力
output_line "trailer"
output_line "<<"
output_line "/Size $OBJ_ID"  # 存在する obj の數 + 1
output_line "/Root 2 0 R"
output_line ">>"
output_line "startxref"
output_line "$XREF_OFFSET"
output_line "%%EOF"
```
</details>