こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトの記事は縦書きでも読めるようにしていて、本文中の漢字にはすべてふりがな(<ruby>)が付きます。記事に画像があるときは、画像を float させて本文を回り込ませます。
その回り込みが、記事によってはまるごと効かないことがありました。画像の横が上から下まで空白のままになります。
症状
縦書きで画像を float すると、画像と同じ高さの帯だけ本文の列が短くなります。そこに本文が回り込む、というのが狙いです。
ところが一部の記事では、画像の横の列に文字が 1 つも入りません。列が丸ごと空いたまま、本文は画像より先の列から始まります。
iPhone 相当の幅(440x956)で、長崎の記事を使って測った空き幅がこれです。
| 本文の高さ | 画像の横に空いた幅 |
|---|---|
| 400px | 166px |
| 440px | 230px |
| 480px | 262px |
本文の高さを変えると空き幅も変わります。つまり「入らないから送られている」のは間違いなさそうでした。何が入らないのかが分かりません。
外れた仮説
最初は改行の設定を疑いました。日本語の禁則処理や単語単位の折り返しが効きすぎて、行の途中で切れなくなっているのだろうと考えたのです。
そこで本文に line-break と word-break を指定しました。結果として、この記事の空き幅は 1px も変わりませんでした。
後で分かったことですが、この指定は「かぎ括弧とルビの境目で切れる」ぶんには効いていました。効いていなかったのは、ルビの内側です。プロパティが無視されたのではなく、そもそも問題の場所に届いていませんでした。
次に疑ったのが、float と縦書きの組み合わせそのものです。しかし画像の横に入る記事もあるので、組み合わせが壊れているわけではありません。入る記事と入らない記事の違いを探すほうが早いと気づきました。
違いは、長いふりがなでした。
<ruby>長崎原爆犠牲者慰霊平和祈念式典<rt>ながさきげんばくぎせいしゃいれいへいわきねんしきてん</rt></ruby>
親文字が 15 文字あります。この 1 つのルビが 1 行に収まらないと、行ごと画像の先へ送られます。そして送られた行の後ろも当然そちらへ続くので、画像の横がまるごと空きます。
原因
ブラウザで実際に測って確かめました。ルビの要素が持つ矩形の数を数えると、改行されているかどうかが分かります。
document.querySelector('ruby').getClientRects().length
Chromium では本文の高さに応じて 2 から 3 になります。ルビの途中で折り返しています。
WebKit では、どの高さでも常に 1 でした。ルビは 1 つの塊として扱われ、途中では改行されません。iOS のブラウザは Chrome を含めてすべて WebKit なので、iPhone では必ずこの挙動になります。
float と縦書きの相互作用ではなく、ルビが分割不可能な塊であることが原因でした。横書きなら 1 行が長いのであまり目立ちませんが、縦書きで画像の横に作られた短い列では、15 文字のルビは絶対に入りません。
対処
CSS で切れないなら、切れる形で HTML を出すことにしました。長いルビは、生成の段階で 1 文字ずつのルビに割ります。
<ruby>長<rt>なが</rt></ruby><ruby>崎<rt>さき</rt></ruby><ruby>原<rt>げん</rt></ruby>…
問題は読みの割り当てです。「ながさきげんばくぎせいしゃいれいへいわきねんしきてん」を 15 文字にどう配るかは、そのままでは決まりません。
そこで、常用漢字表(2010 年の内閣告示)の音訓を持っておいて、そこから逆算しました。「長」に「なが」「ちょう」がある、「崎」に「さき」がある、といった表を引いて、読み全体を過不足なく分けられる組み合わせを探します。連濁と促音便は許します。
そのうえで、割ってよい条件を厳しくしました。
export function splitLongRuby(
html: string, minLen = LONG_RUBY_MIN_LEN,
): { html: string; splits: RubySplit[] } {
const splits: RubySplit[] = [];
const next = html.replace(RUBY_ELEMENT_RE, (whole, rubyAttr: string | undefined, base: string,
rtAttr: string | undefined, reading: string) => {
const chars = Array.from(base);
if (chars.length < minLen) return whole;
const parts = decomposeReading(base, reading);
if (!parts || parts.length !== chars.length) return whole;
// つなぎ直して元に戻ることを必ず確かめる(DP の取りこぼしをここで止める)。
if (parts.join('') !== reading) return whole;
splits.push({ base, reading, parts: chars.map((c, i) => [c, parts[i]]) });
return chars
.map((c, i) => `<ruby${rubyAttr ?? ''}>${c}<rt${rtAttr ?? ''}>${parts[i]}</rt></ruby>`)
.join('');
});
return { html: next, splits };
}
要点は 3 つです。
- 割り方が 1 通りに決まるときだけ割る。2 通り以上あるものは触りません。
- 割った後の読みをつなぎ直して、元の読みと一致することを確かめる。1 モーラでも落ちる書き換えはしません。
- 短いルビは対象外にする(8 文字以上だけ)。短いものは元々列に入ります。
データベースにある親文字 8 文字以上のルビは 134 種類でした。このうち機械で一意に割れたのが 116 種、割れなかったのが 18 種で、2 通り以上に割れてしまったものは 0 種でした。
割れなかった 18 種は、固有名詞混じり(人名と役職がつながったもの)や熟字訓です。これらは残します。読みを間違えるくらいなら、画像の横が空くほうがましです。
修正後に同じ記事を測り直すと、空き幅は 3 つの本文高すべてで 3px になりました。行間ぶんなので、実質ゼロです。
一般化できる部分
既存の記事にも同じ書き換えを掛ける必要があったので、バックフィルのコマンドを書きました。このとき決めたことが 1 つあります。
割った内容を必ずレポートに出す、そしてデータベースを書く前に保存する、というものです。割らずに残したものも理由付きで並べます。
これが効きました。レポートを読んでいて、読みが途中で切れている既存データが見つかったのです。
神戸大学大学院農学研究科 こうべだいがくだいがくいん
「農学研究科」ぶんの読みがありません。ふりがなを生成する側の古いバグで作られたものが残っていました。「割れなかったもの」の一覧は、そのまま「データがおかしいもの」の一覧でもあったわけです。
書き換える処理を作るときに、変更点の一覧を副産物として出しておくと、こういう拾い物があります。差分を確認するためだけのものだと思っていましたが、既存データの検査を兼ねていました。
もうひとつは、CSS で解決しようとする前に測る、という順番です。line-break を指定した時点で「これで直っているはず」と思い込みかけました。実際には 1px も変わっていません。getClientRects().length を数えるのは 1 行で、それを先にやっていれば「ルビは 1 個の矩形のままだ」とすぐ分かりました。
まとめ
- WebKit はルビの途中で改行しない。矩形は常に 1 個で、Chromium は 2〜3 に割れる
- 縦書きで画像を
floatすると横の列が短くなり、長いルビが入らずに行ごと送られる -
line-break/word-breakはルビの内側には届かない - 対処は生成側で 1 文字ずつのルビに割ること。読みは常用漢字表の音訓から求める
- 1 通りに決まるときだけ割り、つなぎ直して一致することを確かめる
- 書き換えの一覧をレポートに出すと、既存データの不備がそこで見つかる