Skip to content

コンセプト

作者はThinreportsの帳票テンプレートを長年使ってきました。当時、Railsで帳票を出す現実的な選択肢はこれだけでした。道具として便利に使ってきた一方で、運用していくうえで注意し続ける点が六つありました。どれも根はフォーマットの構造にあります。Shojikuは、この六つに注意しなくていい構造を選び直したものです。

よく似た仕組みのJS製ライブラリもあります。ただ、作者はRailsやPythonを使っていて、それらを採用できませんでした。こういうことが起きないように、Shojikuはどんな言語からでも使えるよう本体をRustで書き、各言語のSDKはそのバイナリを呼び出すだけにしています。

使えるキーを探す手間がかかる

Thinreportsのレイアウトはデザイナーで作ります。キャンバス上のフィールドがIDを持ち、値を流し込むRubyコードがそのIDを文字列で参照します。形はこうです(簡略化した例です)。

json
{ "type": "s-tblock", "id": "customer_address", "x": 96.0, "y": 153.5 }
ruby
report.page.item(:customer_address).value(customer.address)

このIDに何があるかの一覧は、コードの側にありません。使えるキーの集合はレイアウトファイルの中に散らばっていて、値を流し込む側は、デザイナーで開いて確かめるか、別の資料を参照するしかありませんでした。型もサンプル値も、そこには付いていません。

存在しないIDを参照すると、その帳票を生成した時点でUnknownItemIdのエラーになります。ただし検出されるのはレンダリングを実行した時なので、その帳票を生成するテストがなければ、本番で見つかることになります。逆に、レイアウトにあるのにコードが値を入れなかった欄は、エラーにならないまま空白で出ます。一度も値が入らなかった欄と、今回たまたま値が空だった欄は紙の上で区別できないため、最後は誰かがPDFを目視して確認していました。

この二つを解決するのがdefinitions.ymlです。データ項目の台帳をファイルにして、テンプレートはそこに宣言されたキーしか参照できないことにしました。使えるキーの一覧は台帳を開けば全部あり、型も制約もサンプル値もそこに書いてあります。未宣言のキーを参照すると、レンダリングの前に診断コードつきのエラーになります。同じチェックが作成時、CI、CLI、MCPサーバ、Designerで走ります。下にいるエンジンが一つだからです。

表示形のぶんだけ、キーが増えていく

Thinreportsのフィールドには書式設定の欄もあります。それでも実務の要求は、金額の桁区切りだけでなく単位や丸め、和暦、電話番号のマスキングへと続き、結局Ruby側に表示用のValueオブジェクトを揃えて、文字列に整形してからテンプレートに流し込む形になりました。形はこうです(擬似コード)。

ruby
# 日付のValueオブジェクト。表示形の数だけキーを書き込む
def render(renderer:, key:)
  renderer.set_value(key,         I18n.l(value, format: '%Y/%m/%d(%a)'))
  renderer.set_value("#{key}_jp", I18n.l(value, format: '%Y年%-m月%-d日(%a)'))
end

整形の結果は文字列なので、表示形が一つ増えるたびにレイアウトのキーも一つ増えます。ordered_atの隣に和暦のordered_at_jp、日付だけのordered_at_date、マスキング済みのtel_maskedという具合です。数年後には、よく似たキーがレイアウトに数十個並び、選び間違えても出力は一見それらしく見える状態になっていました。

Shojikuでは、フォーマットは表示の瞬間に適用され、値は数値のままです。通貨スタイルをコンテナに一度書けば、その下の金額はすべて従い、変えたい場所だけ上書きします。規則はCSSの継承と同じで、西暦で出すか和暦で出すかも表示側の指定です。キーは増えません。ロケールの情報はpacks/locale/にデータとして置きました。同じ領収書テンプレートが日本語、繁体字、簡体字、英語でレンダリングできるのは、通貨や日付、税表記、フォントフォールバックがパック側で差し替わるだけで、レイアウトの数値は変わりません。ギャラリーに四つを並べてあります。

一つ直すと、全部直すことになる

旧フォーマットのレイアウトは、全項目が絶対座標でした。こういう形です(簡略化した例です)。

json
{
  "report": { "margin": [25, 25, 25, 25] },
  "items": [
    { "id": "title",    "x": 25, "y": 25, "width": 545, "height": 24 },
    { "id": "customer", "x": 25, "y": 65, "width": 260, "height": 18 },
    { "id": "total",    "x": 25, "y": 91, "width": 260, "height": 32 }
  ]
}

余白を25から20に変えても、項目は一つも動きません。全項目のxとyから5を引いて回るのは自分です。フォントを大きくした時も同じです。枠は文字に合わせて広がらないので、タイトルのfont-sizeを上げたら、枠のheightを広げ、その下の全項目のyをずらします。難しい作業ではありません。ただひたすら退屈で、間違えると要素が重なって印字される作業です。「ちょっと動かす」が、いつも大仕事でした。

Shojikuは相対配置が主で、絶対座標は使いたい場所でだけ使います。ページの余白はpage:の1箇所にあり、本文は上から順に積まれ、flex行では幅を書かなかった子が残り幅を等分します。

yaml
page: { size: A4, margin: 25 }    # 20に変えても、下の行は一つも変わらない
sections:
  body:
    type: flow                    # 上から順に積む
    gap: 16
    items:
      - { type: text, text: 請求書, style: { fontSize: 24 } }
      - type: container
        box: { direction: row, gap: 8 }
        items:                    # 幅を書かない子が2つ → 半分ずつ
          - { type: text, text: 請求元 }
          - { type: text, text: 請求先 }

余白の変更はmarginの一箇所で終わります。タイトルのfontSizeを32に上げれば枠は内容に合わせて伸び、下の要素はその分だけ下がります。幅を省略すれば等分、高さを省略すれば内容に応じた伸長と、省略時の挙動も仕様で決まっています。プレイグラウンドでこの挙動をそのまま触れます。

表の配置が、とにかく難しい

表は別格でした。旧フォーマットのlist(表)は、見出しと明細とフッタを帯で描く方式です。明細の中の項目はキャンバス上の絶対座標で記録され、帯側のオフセットで行の位置へ写されます(簡略化した例です)。

json
{
  "id": "order_items", "type": "list",
  "detail": {
    "height": 24.1,
    "translate": { "x": 0, "y": -62.4 },
    "items": [
      { "id": "item_name", "x": 30, "y": 352.5, "width": 235, "height": 20 }
    ]
  }
}

y: 352.5はページ上の座標で、そこから帯の-62.4で行の中へ写す、という読み方です。どこを基準に測るかを換算しながら置く作業で、思った場所に出るまで何度も試しました。明細の行数が変わる帳票では、改ページの座標計算も自分の仕事です。

もう一つ、高さが変わる要素をうまく扱えません。複数行のテキストを「あふれたら伸ばす」にすると枠は下へ伸びますが、下に置いた要素は動きません。伸びた分だけ、重なって印字されます。伸びるかもしれない要素の下には、余白を大きめに取っておくしかありませんでした。

Shojikuの表は、flowの本文に置く一つの要素です。行はparamsの配列から来て、幅を書かない列は残り幅を等分し、行が溢れたら自動で改ページして見出し行を繰り返します。

yaml
- type: table
  data: { key: order_items }   # 行はparamsの配列
  repeatHeader: true           # 改ページ後に見出し行を繰り返す(既定)
  columns:
    - { label: 品名, data: { key: name } }
    - { label: 数量, data: { key: quantity }, width: 55 }
    - { label: 金額, data: { key: amount }, width: 95 }

本文は上から積むので、表が3行なら下の要素は上へ詰まり、30行なら次のページへ送られます。伸びた要素が下と重なることは、そもそも起きません。

共通のスタイルを、要素ごとに直して回る

見出しのフォントサイズや罫線の色といった共通の見た目は、要素ひとつひとつに直接付いています。「見出しを全部1pt大きく」は、該当する要素を全部探して全部書き換える作業でした。

Shojikuには、テンプレート先頭にstyles:の名前付きレジストリがあります。GoogleドキュメントやWordの「スタイル」と同じ仕組みで、CSSのclassのように名前で参照します。

yaml
styles:
  title:       { fontSize: 20, fontWeight: bold }
  sub-section: { fontSize: 12, fontWeight: bold, borderWidth: { bottom: 0.5 } }

sections:
  body:
    type: flow
    items:
      - { type: text, text: ご請求内容, styleNames: [sub-section] }
      - { type: text, text: お支払い条件, styleNames: [sub-section] }

見出しを全部1pt大きくするのは、レジストリの1箇所を変えることです。要素の側は1行も動きません。

レイアウトファイルを人間が編集できない

旧フォーマットは機械が書き出すXMLで、人間には実質読み取り専用でした。レイアウト変更をプルリクエストでレビューすることも、マージコンフリクトを手で解決することもできません。二つのバージョンの間で何が変わったのか、聞かれても答えられませんでした。ファイルはGUIの出力物で、入口はGUIしかありませんでした。

デザイナが隣に座っているうちは、それでも回ります。AIエージェントにテンプレートを書かせようとしたとき、これが決定的な問題になりました。エージェントの仕事はテキストを読んで書くことで、あのファイルには読めるものがありませんでした。

Shojikuの文書は、テンプレートとフィールド台帳の二枚のYAMLと、データのJSONです。diffが読めて、マージもできます。エージェントは一枚を読み、数行を直し、プレビューをレンダリングし、レイアウトツリーを読み返し、診断が消えるまで反復できます。MCPサーバはこのループのために置きました。Designerも同じファイルを読み書きするだけで、所有はしません。GUIは入口の一つです。

なぜ今作ったか

六つ目の注意点の裏返しが、Shojikuを今作った理由です。テンプレートが素直なテキストであることの価値は、AIエージェントが実務に入った時点で跳ね上がりました。エージェントはMCPサーバ越しにvalidate→preview→inspectを回し、診断コードに対して修正を繰り返します。人はレンダリングされたプレビューを目で確かめます。この二重チェックの開発ループを最初から前提にできるフォーマットとして設計しました。このサイトのヒーローバナーも、そのループで作られています。

縦書きや◯つけ、チェックボックスなども対応済み

JS製のPDFライブラリでは、縦書きのようにissueの要望が積まれたまま止まっている領域があります。Shojikuはこのあたりを最初から仕様に入れてあり、テンプレートの1行とparamsの値だけで使うことができます。

縦書きwritingModeを指定します。ルビや禁則、ぶら下がり、縦中横も、縦のまま組むことができます。

yaml
- type: text
  text: 走れメロス
  style: { writingMode: vertical_rl, fontSize: 16, letterSpacing: 6 }

縦書き小説の例。ルビ付きの縦組みが改ページする

◯つけmarkを書きます。paramsのenum値が一致した選択肢に、◯を付けることができます。

yaml
# templates.yml
- { type: text, text: 一般, mark: { data: { key: application.category, equals: general } } }
- { type: text, text: 学生, mark: { data: { key: application.category, equals: student } } }
json
{ "application": { "category": "student" } }

この場合、「学生」に◯が付きます。

チェックボックスtype: checkboxです。booleanか複数選択のenumを渡すだけで、チェックを入れることができます。

yaml
- { type: checkbox, data: { key: application.agree } }
- { type: checkbox, data: { key: application.contact_methods, equals: email } }
json
{ "application": { "agree": true, "contact_methods": ["email", "mail"] } }

申込書の例。記入済は選択肢に◯とチェックが入る

Thinreportsからの移行

.tlfのインポータは実装されていません。互換性を保つと、ここまでに挙げた注意点も一緒に持ち込むことになるためです。

Thinreportsから移行したい場合は、MCPサーバとAIエージェントを使って移行してみてください。旧帳票をレンダリングした画像を渡すと、エージェントがプレビューと診断を確認しながら、定義とテンプレートを作り直します。移行用のスキルshojiku-thinreports-migratorも同梱しています(npx skills add kengos/shojikuで入ります)。手順の全体は移行ウォークスルーにまとめてあり、実際に最初から最後まで通して確認してあります。