🖋️

Illustrator 上でルビを振るスクリプト illustrator-ruby を公開しました

2022/08/17に公開

Adobe Illustrator でルビ振りを行うスクリプト illustrator-ruby を公開しました。

Illustrator は標準でルビ機能に対応しておらず、ルビを振るには手動で一文字ずつテキストを調節するなど、極めて煩雑な作業が要求されます。本スクリプトを用いることでルビのサイズや揃え位置、進入処理・アキ挿入の自動実施や熟語ルビといった高度な指定を交えながら、ルビ処理を一括で実施することができます。

https://github.com/inaniwaudon/illustrator-ruby

illustrator-ruby で夏目漱石「夢十夜」にルビを自動で振った例。Illustrator 上のスクリーンショット。
図 1:illustrator-ruby で夏目漱石「夢十夜」にルビを自動で振った例

主に、以下の機能に対応しています。

  • 縦書き・横書きテキストでのモノルビ、熟語ルビ、グループルビ
  • ルビの揃え位置の調整(肩付き、中付き、JIS(1-2-1)、右・下揃え)
  • ルビの長体・平体による自動調整、進入処理・アキ挿入の有無
  • ルビのサイズ、オフセット、フォントの指定
  • 捨て仮名への自動変換

スクリプトの使用方法

GitHub の Releases から最新の ruby.jsx をダウンロードし、「ファイル → スクリプト → その他スクリプト」から実行します。Illustrator にスクリプトを追加する際には、以下の記事が参考になります。

ルビの付与

  1. ルビを振るテキストを用意し、オブジェクト名を finish を含む名称にリネームする。
  2. もう一つテキストを作成し、1. の内容をコピーする(スタイルは同一でなくても構いません)。オブジェクト名は base を含む名称にリネームする。
  3. (2) で作成したテキストに対して、後述の記法を用いてルビや詳細設定を記述する。
  4. (1), (2) で作成した 2 つのテキストフレームを選択する。
  5. Illustrator 上で ruby.jsx を実行する。

また、下記動画でも手順を説明しています。

https://www.youtube.com/watch?v=FNwUFGg1Vf0&ab_channel=あいうえお

そのほか、基本的な使い方に関しては Readme に記述しています。本稿では、ルビ処理の基本や実装上の工夫等に焦点を当てて解説を進めます。

ルビとは?

印刷物等における振り仮名をルビといいます。日本語組版では、十三(じゅうそう)、先斗町(ぽんとちょう)、蕨(わらび)に代表される難解地名や、時間(とき)、運命(さだめ)等の特殊な例など、千差万別の読み方を有する漢字に対して、ルビを用いて処理することで対処してきました。
以下に解説するルールは、W3C によって策定された日本語組版処理の要件Requirements for Japanese Text Layout.以下、JLReq)に準拠するものとします。

カラオケの画面。「変わる自分に戸惑いながら」のすべての漢字にルビを振ってある。
図 2:ルビは書籍や印刷物にとどまらず、カラオケ等にも活用されている

ルビの基本

例えば「思春期」といった単語にルビを振る場合、複数のルビの振り方が存在します(括弧内はルビ)。「思春期」のような漢字は親文字と呼びます。

  • モノルビ:親文字一つ一つにルビを振る。
    • 例:思(し) 春(しゅん) 期(き) の時期
  • グループルビ:単語をグループとみなしてルビを振る。
    • 例:思春期(ししゅんき) の時期
  • 熟語ルビ: 熟語としてのまとまりを重視しつつ親文字とルビを対応させる。
    • 例:思(し) 春(しゅ) 期(んき) の時期

モノルビ・グループルビ・熟語ルビの配置例。「思春期」の文字にルビを振っている。
図 3:モノルビ・グループルビ・熟語ルビの配置例

熟語ルビを活用すると、モノルビやグループルビでは不用意なアキが生じるケースに対しても、体裁良く組み上げることが可能となります。例えば図 3 に示した「思春期」は、モノルビで組むと「春」「期」の間に半角のアキが生じてしまい不格好です。一方の熟語ルビでは、「ん」「き」のルビがそれぞれ「期」「の」に掛かるように配置を行うなど、JLReq に規定される複雑なルール(詳細は後述)に基づいて処理を行います。

illustrator-ruby はこれら全ての組み方に対応しており、角括弧 [ ] で囲った場合はモノルビ・グループルビ、山括弧 < > で囲った場合は熟語ルビとして処理を行います。読みの区切りはスラッシュで表現します。

# モノルビ(2つの処理結果は同一)
[|えっ][|ちゅう][|じま]
[越中島|えっ/ちゅう/じま]
# 熟語ルビ
<越中島|えっ/ちゅう/じま>
# グループルビ
[越中島|えっちゅうじま]

捨て仮名

拗音や促音等に使用される、小さな仮名(ぁぃぅぇぉっゃゅょ 等)を「捨て仮名」と呼びます。
ルビはそもそもが小さなサイズであるため、伝統的な組版ではルビに捨て仮名を使用してきませんでした[1]。今日ではルビに捨て仮名を使用する例も散見されますが、JLReq では「可読性を考慮すると,小書きの仮名のルビは,特定の読みを限定したい固有名詞などに限り使用するのが望ましい」とされています。
illustrator-ruby は、既定値として捨て仮名を大文字へと自動変換します。図 3 の例示でも、「ししゅんき」と指定したにも関わらず、ルビは「ししゆんき」と表記されていました。また以下の指定によって、捨て仮名からの変換の有無を明示的に指定できます。

# 自動変換を実施する(捨て仮名を使用しない)
# 「よつかいち」となる
(sute|true)<四日市|よっ/か/いち>
# 自動変換を実施しない(捨て仮名を使用する)
# 「よっかいち」となる
(sute|false)<四日市|よっ/か/いち>

揃え位置

親文字に対するルビの揃え位置として「肩付き」「中付き」「1-2-1(JIS)」「右・下揃え」を指定することが出来ます。モノルビにおいては肩付き(縦組み)、中付き(横組み)が、グループルビでは1-2-1ルール等が推奨されます。

  • 肩付き:親文字とルビの上端を揃える配置方法で、縦組みの文章で頻繁に使用されます。横組みでは基本的に使用されません。
  • 中付き:親文字の中央にルビを揃えます。
  • 右・下揃え:一般には使用されません。
  • 1-2-1(JIS) ルール:日本語文書の組版方法を規定する JIS X 4051 にて定義されるルールです。ルビと親文字の幅に差がある際は、字間を 1:2:1(3 文字の場合)の比率に調節するため、体裁良く配置することが出来ます。

肩付き・中付き・1-2-1ルールによるルビの配置例。「紫陽花」の文字にルビを振っている。
図 4:肩付き・中付き・1-2-1ルールによるルビの配置例

illustrator-ruby では次のように指定します。既定値は 1-2-1(JIS) です。

# 肩付き
(align|kata)[清澄白河|きよすみしらかわ]
# 中付き
(align|naka)[葛西臨海公園|かさいりんかいこうえん]
# 右・下揃え
(align|shita)[門前仲町|もんぜんなかちょう]
# 1-2-1(JIS) ルール
(align|jis)[海浜幕張|かいひんまくはり]

ルビがはみ出す処理

噂(うわさ)、懐(ふところ)等の親文字よりも長いルビを振る場合は以下のような処理が考えられます。

  • 進入処理[2]:親文字の前後の文字クラス[3]に応じて、ある程度までルビを前後の文字に掛ける処理です。
  • 長体・平体:ルビの水平方向(縦組みの場合は垂直方向)の縮尺を縮め、親文字の長さにルビを収める方法です。

進入処理では、親文字の前後が「かな」である場合はルビを 1 文字まで掛けることが許容されますが、前後に漢字が位置する場合はルビを進入させることができません。その場合は、親文字の前後に均等にアキを入れることで対処します。
長体・平体処理に関しては、JLReq では親文字の 1/3 の文字幅である三分ルビに関する言及がありますが、「使用例は少ない」と補足されています。親文字のベタ組みを崩さないという点では体裁が保たれますが、文字を縮めすぎると可読性を失う恐れがあります。

上から、進入処理・アキ挿入を行う、長体処理を行う、何の処理も行わない場合の例。「噂好きなひとは懐具合もよく」という文章にルビを振っている。
図 5:上から、進入処理・アキ挿入を行う、長体処理を行う、何の処理も行わない場合の例 [4]

illustrator-ruby では進入処理・アキ挿入、長体・平体処理のほかに、手動での調整を前提に「何の処理も行わない」という選択も可能です。

# 進入処理・アキ挿入を行う
(overflow|shinyu)[|うわさ]好きなひとは[|ふところ]具合もよく
# 長体・平体処理を行う
(overflow|narrow)[|うわさ]好きなひとは[|ふところ]具合もよく
# 何の処理も行わない
(overflow|false)[|うわさ]好きなひとは[|ふところ]具合もよく

本稿で紹介した以外に、ルビを親文字の両側に振る、ルビのフォントやサイズを変更するといった指定も可能です。詳細は GitHub 上のドキュメント を参照ください。

実装おもしろ話:熟語ルビ

ここからは Zenn(Qiita かもしれない)の場に相応しく、実装の話をしてみます。
日本語組版の中でも複雑な処理の一つに、熟語ルビが存在します。これは InDesign 等でも実装されていない[5]曲者です。熟語ルビは JIS X 4051 と 日本語組版処理の要件(JLReq)で異なる配置方法が規定されており、今回のスクリプトでは個々の親文字の読みをより重視する後者に挑戦してみました。

なお、LaTeX や SATySFi でルビを実装するパッケージは、JIS X 4051 に規定される熟語ルビの処理(親文字に対してルビが2字以下の場合は親文字とルビを対応させ、3字以上の場合はグループルビとする)を採用していますが[6][7]、これは簡潔化された仕様であると後に明かされています[8]

JLReq の文書を示す画面
図 6:日本語組版処理の要件に掲載されている熟語ルビの組版例
(出典:https://www.w3.org/TR/jlreq/)

JLReq の「F. 熟語ルビの配置方法」で要求される処理手順を、以下に端的に解説します。

  1. ルビが親文字に収まる場合はモノルビとして処理する。
  2. ルビが親文字に収まらない場合は、後ろの親文字にルビを一文字掛ける。
  3. (2) でルビを後ろに掛けられなかったか、後ろに掛けてなおルビが余る(ルビが4文字以上)場合は、前の親文字にルビを一文字掛ける。
  4. ルビが熟語全体に収まりきらない場合は、熟語の後ろにルビをはみ出させる。
  5. 熟語に後続する文字が漢字等でルビを掛けられなかったか、熟語の後ろにルビをはみ出してなお収まりきらない場合は、熟語の前の文字にルビを掛ける。
  6. それでも収まりきらない場合は、親文字の前後に均等にアキを入れる。

本来、親文字の後ろの文字が漢字の場合はルビを掛けることが出来ませんが、熟語ルビに限っては熟語内の別の親文字にルビを掛けることが許容されます(3. の処理)。ただし熟語外の漢字に対してはルビを掛けることは出来ないため、5. の処理では熟語の前後のルビの文字クラスを判別する必要がある、ということです [9]

基本的には熟語を書字方向に順に見てゆけば良いのですが、一方向の処理のみでは、JLReq の図版にある「古戦場」等の処理に失敗してしまいます。熟語ルビにおいては、「古(こ)戦(せん)場(じよう)」ではなく、「古(こせ)戦(んじ)場(よう)」と書字方向に遡って食い込ませる必要があるからです。
加えて「の影響力は」に対して「の()影(いき)響(よう)力(りよ)は()」と振るなど、処理方向と逆の熟語外にルビが掛かる処理も考慮する必要があります。

「古戦場の影響力は」の文章にルビが掛かっている
図 7:マゼンタで示した文字は、親文字に隣接する文字にルビが掛かっている

実装方針

実装の前提として、①熟語内の親文字のサイズが統一されている ②ルビのサイズは親文字の 1/2 に限定することを想定します。端数が出ると処理が厄介になるためです。

今回は親文字に対応するルビの区切りをスラッシュを用いて考えます。
隣の親文字への進入が許容されるルビは 1 文字までですので、区切りを表すスラッシュの位置を前後 1 つまで動かすことができます。

例として、の<古代紫|こ/だい/むらさき>は の場合の組み合わせを求めてみます。
こ/だいのスラッシュを前に動かすと「古」に掛かるルビが消失してしまうため、こうしたルビが空になるパターンを除くと、「古代紫」に対するルビのパターンは 5 通りが考えられます。
この場合、熟語の前後がひらがなでルビを掛けることが可能なため、親文字毎に許容されるルビの文字数は 3・2・3文字です。このとき、アキが必要になる文字数を計算します。

こ/だい/むらさき -> アキ:1文字 + 熟語後に1文字掛かる
こ/だ/いむらさき -> アキ:2文字 + 熟語後に1文字掛かる
こ/だいむ/らさき -> アキ:1文字 + 熟語後に1文字掛かる
こだ/い/むらさき -> アキ:1文字 + 熟語後に1文字掛かる
こだ/いむ/らさき -> アキ:0文字 + 熟語後に1文字掛かる

従って <古代紫|こだ/いむ/らさき> が最適解で、熟語の後に「き」が一文字飛び出す形になります。

実装

TypeScript で実装してみます。

分割位置の推定

分割位置の推定は再帰関数 getSplit を用いて実装されます。
maxRubyCounts は親文字 1 つあたりに掛けることが出来るルビの文字数を表し、通常は 2 です。熟語の最初と最後の親文字は(連続する文字が漢字等でルビを掛けられない場合を除いて)3 となります。

関数内では、ルビの区切り位置を -1, 0, 1 ずつ動かして全探索を行い、i 番目のルビの文字数が maxRubyCounts[i] を超えた場合は、その差分× 10 点をペナルティとして加えます[10]。また、maxRubyCounts[i] に収まる場合でも、熟語の前にルビが突き出てしまう場合は 2 点、熟語の外に出てしまう場合は 1 点のペナルティを加えます。こうすることで、可能な限りは熟語内に収まるように努め、収まらない場合は熟語の後ろにルビを掛け、それでも収まらない場合は熟語の前に掛かるように調整されます。

main.ts
const maxRubyCounts = [...Array(middleRuby.ruby.length)].fill(2);
maxRubyCounts[0]++;
maxRubyCounts[maxRubyCounts.length - 1]++;

const getSplit = (split: number[]): [number, number[]] => {
if (split.length === middleRuby.ruby.length - 1) {
  // calculate the penalty
  let penalty = 0;
  for (let i = 0; i < middleRuby.ruby.length; i++) {
    const rubyCount =
      middleRuby.ruby[i].length -
      (i > 0 ? split[i - 1] : 0) +
      (i < split.length ? split[i] : 0);
    penalty += Math.max(rubyCount - maxRubyCounts[i], 0) * 10;
    if (maxRubyCounts[i] > 2 && rubyCount > 2) {
      penalty += i === 0 ? 2 : 1;
    }
  }
  return [penalty, split];
}
return [
  getSplit([...split, -1]),
  getSplit([...split, 0]),
  getSplit([...split, 1]),
].sort((a, b) => a[0] - b[0])[0];
};

ルビ情報の生成

あとは、分割位置を基に MiddleRubyInfo のオブジェクトを生成します。この部分のみを切り取っても解りにくいかと思いますので、GitHub 上のコードと併せてご参照ください。

main.ts
const baseSize =
  characters[middleRuby.charIndex].characterAttributes.size;
// The ruby for which the parent characters are not of the same size is not processed.
if (
  !middleRuby.ruby.every((_, index) =>
    baseSize === characters[middleRuby.charIndex + index].characterAttributes.size
  )
) {
  continue;
}
      
const [_, split] = getSplit([]);
middleRuby.ruby.forEach((ruby, index) => {
  const rubyText =
    (index > 0 && split[index - 1] === -1
      ? middleRuby.ruby[index - 1].slice(-1)
      : "") +
    ruby.slice(
      index > 0 ? Math.max(split[index - 1], 0) : 0,
      ruby.length + (index < split.length ? Math.min(split[index], 0) : 0)
    ) +
    (index < split.length && split[index] === 1
      ? middleRuby.ruby[index + 1].slice(0, 1)
      : "");

  const newMiddleRuby: MiddleRubyInfo = {
    type: "ruby",
    ruby: rubyText,
    base: middleRuby.base[index],
    starts: middleRuby.starts,
    charIndex: middleRuby.charIndex + index,
    outlineIndex: middleRuby.outlineIndex + index,
  };
  applyAttributesToMiddleRubyInfo(newMiddleRuby, middleRuby);
  let leftCount = rubyText.length;
  if (index === 0 && leftCount > 2 && maxRubyCounts[0] > 2) {
    newMiddleRuby.alignment = "shita";
    leftCount--;
  }
  if (
    index === middleRuby.ruby.length - 1 &&
    leftCount > 2 &&
    maxRubyCounts[index] > 2
  ) {
    newMiddleRuby.alignment = "kata";
    leftCount--;
  }
  if (leftCount > 2) {
    const charAttributes =
      characters[middleRuby.charIndex + index].characterAttributes;
    charAttributes.akiLeft = (leftCount - 2) / 4;
    charAttributes.akiRight = (leftCount - 2) / 4;
    newMiddleRuby.alignment = "naka";
  }
  newMiddleRuby.narrow = false;
  resultMiddleRubys.push(newMiddleRuby);
});

以上のコードを実行することで、JLReq における熟語ルビの組版例の大半は適切に処理されます。

日本語組版処理の要件(JLReq)「F.熟語ルビの配置方法」に例示された熟語ルビの配置例
図 8:PDF は jlreq-jukugo-ruby.pdf に公開しています

赤字の部分に関して、熟語外への進入を伴う処理で一部例示と異なるルビの処理結果が得られました。今後改善予定ですので、現時点では手動で調整いただければと思います。

むすびにかえて

最後に、技術的な都合によって実装が叶わなかった機能について紹介します。殆どは Illustrator において対応する API が提供されていないことに起因するもので、今後の発展に期待です。この願望は Twight のライブラリとして実装していければと思います。

  • 行分割を跨ぐルビの処理(どのように配置されるかは不明)
  • OpenType のルビ用字形(ruby)の利用
  • 行頭・行末の突出処理

実装漏れやフィードバック等ありましたら、GitHub の Issues や Twitter: @kyoto_mast21 までやさしくご報告いただけますと幸いです。

脚注
  1. 活版印刷では、物理的な制約によりルビ用の小さなポイントの活字を準備できなかったことが背景として挙げられます。 ↩︎

  2. 「進入」の用語は JLReq には登場しませんが、LaTeX/LuaTeX用パッケージである pxrubrica, luatexja-ruby パッケージの用例に準ずるものとします ↩︎

  3. 読んで字の如く、文字をクラスに分類したもの。漢字、ひらがな、句点、読点、始め括弧類、終わり括弧類等が規定されている ↩︎

  4. 一番下の例は一見美しく見えますが、隣接する文字が漢字であるため、ルビを掛けると何処の親文字にルビが掛かっているかが不明瞭になる恐れがあります ↩︎

  5. https://works014.hatenablog.com/entry/20100522 ↩︎

  6. https://qiita.com/zr_tex8r/items/42466cbcbeb670a3a2dc ↩︎

  7. https://puripuri2100.hatenablog.com/entry/2020/12/01/202913 ↩︎

  8. https://www.jagat.or.jp/past_archives/content/view/5392.html ↩︎

  9. LaTeX においてルビを振る pxrubrica パッケージでは、命令の前後にある文字クラスの判別が難しいことを理由に、進入処理を手動で処理する形態をとっています ↩︎

  10. TeX のペナルティとは異なる概念です ↩︎

Discussion