📖

読書アシストの実装方法について考えてみる

17 min read

はじめに

まず、読書アシストについては、こちらを参照してください。

この読書アシストの「4. 冒頭文字を階段状に字下げする表示方式」をどのように実装するのかについて考えてみました。

読書アシストとは
画像は読書アシストより

レギュレーション

こちらのサンプルを確認するとわかるように、

  • 段落 (HTML でいうと p タグ) ごとに字下げはクリアされ
  • 1行ずつ字下げが増えていき
  • 7行ぐらい (作品によっては9行のものもある) で字下げが2文字ぐらいに戻り
  • 以降5行ごとぐらいに字下げが2文字ぐらいに戻る。

図にすると以下のような感じ。

読書アシストイメージ

実装

現時点で以下の値が設定できると良さそうな気がします。

  • 最大字下げ
  • 最初の字下げ行数
  • 続きの字下げ行数

これらを CSS カスタムプロパティとして以下のように設定しておきます。

css
:root {
  --assist-indent-max: 7em; /* 最大字下げ */
  --assist-lines-first: 7; /* 最初の字下げ行数 */
  --assist-lines-after: 5; /* 続きの字下げ行数 */
}

CSS シェイプについて知る

では、どのように実装したらよいか。結論から言いますと、CSS シェイプ (shape-outside プロパティ) を使うと実現できそう。shape-outside<basic-shape> データ型で図形を作ることで、テキストを図形に従って回り込ませることができます。

例:

html
<p>Lorem ipsum dolor ...</p>
css
p::before {
  content: "";
  float: left;
  height: 15em;
  shape-outside: circle();
  width: 15em;
}

 例のスクリーンショット

実装中に今どのような形になっているのかわかりにくいので、色を付けたくなるでしょう。残念ながら background を入れるだけでは、要素全体の四角形に色が付きます。

css
 p::before {
+  background: #0080ff33;
   content: "";
   float: left;
   height: 15em;
   shape-outside: circle();
   width: 15em;
 }

 に  のみを追加した場合スクリーンショット

下記のように clip-path プロパティに shape-outside と同じ図形を設定すれば確認することができます。

css
 p::before {
   background: #0080ff33;
+  clip-path: circle();
   content: "";
   float: left;
   height: 15em;
   shape-outside: circle();
   width: 15em;
 }

 にさらに  を追加した場合スクリーンショット

図形をカスタムプロパティにしておけば、実装中の確認が楽になります。

css
 p::before {
+  --shape: circle();
   background: #ed5565;
-  clip-path: circle();
+  clip-path: var(--shape);
   content: "";
   float: left;
   height: 15em;
-  shape-outside: circle();
+  shape-outside: var(--shape);
   width: 15em;
 }

polygon() で多角形を作る

今回は polygon() を使って多角形を作ることになりそうです。形は下図のようになるでしょう。それぞれの頂点座標がどのようになるのかについて考えてみます。

 で作りたい形

  • 頂点 A の座標は 0 0 であることは自明です。
  • 頂点 B, D, F の x 座標は、図形の width--assist-indent-max にすれば、 100% を使うことができます。

 のそれぞれの頂点位置の計算

  • 三角形 △ABZ△CDE は相似しているので、w' = w \times \frac{h'}{h} が成り立ちます。 頂点 C, E の x 座標は w - w' なので、w =100\% も代入して計算すると以下になります。

    (1 - \frac{続きの字下げ行数}{最初の字下げ行数}) \times 100\%
  • 頂点 B, C の y 座標は、行の高さ×最初の字下げ行数

  • 頂点 D, E の y 座標は、行の高さ×(最初の字下げ行数+続きの字下げ行数)

  • 頂点 F, G の y 座標は、行の高さ×(最初の字下げ行数+続きの字下げ行数×2)

  • 以上のことから y 座標は以下のように表すことができます。

    行の高さ \times (最初の字下げ行数 + 続きの字下げ行数 \times i) \\

    i0 から始まり、1 ずつ増えていきます。虚数という意味ではないのであしからず。

  • ここで、行の高さを知る必要があることがわかりました。lh という、行の高さを表す新しい単位がありますが、残念ながら現時点ではまだどのブラウザもサポートしていません。ということで行の高さもカスタムプロパティとして設定しておきます。

    css
    :root {
    +  --line-height: 2em;
      --assist-indent-max: 7em;
      --assist-lines-first: 7;
      --assist-lines-after: 5;
    }
    

    --line-height: 2; とすると、高さなどに使いたいとき calc(1em * var(--line-height)) のように 1em をかけてあげる必要があるので、それを省くため予め em 単位をつけておきます。

ここまでをまとめると、以下のような感じです。

css
p {
  line-height: var(--line-height);
}

p::before {
  content: "";
  float: left;
  shape-outside: polygon(
    /* 頂点 A */
    0 0, 

    /* 頂点 B */
    100% 
    calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 0)),
    
    /* 頂点 C */
    calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%)
    calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 0)),
    
    /* 頂点 D */
    100%
    calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 1)),

    /* 頂点 E */
    calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%)
    calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 1)),

    /* 頂点 F */
    100%
    calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 2)),

    /* 頂点 G */
    0
    calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * 2))
  );
  width: var(--assist-indent-max);
}

汎用化について考える

各段落がこんな都合のいい行数になるわけではないし、レスポンシブのことも考えると、段落ごとに頂点の数や位置を計算するのは現実的ではありません。

幸いなことに、CSS シェイプに絶対値を使うと、要素のサイズを超えた場合は、図形が縮んだりするのではなく、単純にクロップされます。例えば height: 320px; とすると以下の3つ目の図のようになります。

シェイプが要素の高さを超えた場合の見え方

ということで、十分な高さがある図形を用意して、段落ごとの高さに合わせて要素に height を設定すれば大丈夫そう。

十分な高さがある図形を作る

十分な高さがある図形は、これまでの計算で i を増やしていけばいいということがわかっています。ここで Sass の @for の力を借りて、i100 になるまで、最初と最後以外の頂点をループしていきます。これは初期設定で507行までの段落に対応するということです。

scss
$polygon: null;
$end: 100
@for $i from 0 through $end {
  $polygon:
    $polygon,
    100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
    #{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
}

--polygon: 0 0, #{$polygon}, 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end}));

$polygon 変数を予め宣言しておき、$i0 から 100 になるまで、B, C のような対になる頂点を追加していきます。最後の頂点 (上図でいうと G ) の x 座標は 0 なので、最初は分岐して 0 にしていましたが、後述右側の図形のため、ここはあえて最後の頂点を出力しないようにします。

こう見ると簡単にたどり着いたように見えますが、実は Sass は最後の , を出力しないので、, の位置を変えたり、',' で強制的に出力したり、試行錯誤した末、これが一番きれいな書き方だと思います。

$polygon の最後に , がつくように見えますが、前述のように Sass はループの途中では , を出力しますが、最後の , は出力しないので、後ろになにか追加したい場合は $polygon, … のようにカンマをつける必要があります。むしろそのほうがわかりやすいので好都合ではありますが。

段落に合わせて要素に height を設定する

最終的に JS で高さを算出することになるでしょう。通常のタグであれば何の問題もありませんが、今回は疑似要素を使っていますので、JS から直接スタイルを設定することができません。実は CSS カスタムプロパティを利用すれば、スマートに実現することができます。

https://twitter.com/ixkaito/status/1361916562773471232?s=20

下記のように、CSS 内でカスタムプロパティを設定し、HTML 側の style 属性でそれぞれに値をセットすれば大丈夫です。

css
p::before {
  height: var(--p-height, 0);
}
html
<p style="--p-height: 2em;">吾輩は猫である。名前はまだ無い。</p>
<p style="--p-height: 26em;">どこで生れたかとんと見当がつかぬ。...</p>

段落の高さに合わせて図形の  を設定した場合のスクリーンショット

右側の図形について考える

左側と同様に考えれば右側も同じように実装できますが、実は上図の赤い線はまったく同じ頂点を通ることに気づきます。最後にそれぞれ左下と右上の頂点を追加すれば図形は完成します。先ほどの Sass で最後の頂点を出力しなかったのはこれのためです。また、この共通部分をカスタムプロパティに登録しておけば、最終的な CSS の量は約半分で済みます。

scss
  $polygon: null;
  $end: 100
  @for $i from 0 through $end {
    $polygon:
      $polygon,
      100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
      #{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
  }

- --polygon: 0 0, #{$polygon}, 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end}));
+ --polygon: 0 0, #{$polygon};
+ --polygon-left: polygon(var(--polygon), 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end})));
+ --polygon-right: polygon(var(--polygon), 100% 0);

右側の図形を出力する

これまでは、p タグの before 疑似要素で左側を出力していました。右側は after を使いたいところですが、残念ながら float で回り込ませるため、HTML 上では右側もテキストの前にある必要があります。

html
<p>
  <!-- 左側 float: left; -->
  <!-- 右側 float: right; -->
  テキスト
</p>

ということで仕方なく、テキストの前に <span> を挿入し、その ::before ::after に左右の図形を設定することにします。<span> にクラスをつけてもいいんですが、衝突のリスクを減らすためここはあえてデータ属性 data-assist をつけてあげます。ここまでをまとめるとコードは以下のような感じです。

html
<p style="--p-height: 2em;"><span data-assist></span>吾輩は猫である。名前はまだ無い。</p>
<p style="--p-height: 28em;"><span data-assist></span>どこで生れたかとんと見当がつかぬ。...</p>
scss
:root {
  --line-height: 2em;
  --assist-indent-max: 7em;
  --assist-lines-first: 7;
  --assist-lines-after: 5;
}

p {
  line-height: var(--line-height);
  
  [data-assist] {
    $polygon: null;
    $end: 100;
    @for $i from 0 through $end {
      $polygon:
        $polygon,
        100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
        #{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
    }

    --polygon: 0 0, #{$polygon};
    --polygon-left: polygon(var(--polygon), 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end})));
    --polygon-right: polygon(var(--polygon), 100% 0);
    
    &::before,
    &::after {
      content: "";
      height: var(--p-height, 0);
      width: var(--assist-indent-max);
    }

    &::before {
      float: left;
      shape-outside: var(--polygon-left);
    }

    &::after {
      float: right;
      shape-outside: var(--polygon-right);
    }
  }
}

読書アシストの「冒頭文字を階段状に字下げする表示方式」で表示されている状態のスクリーンショット

ちゃんと読書アシストの「冒頭文字を階段状に字下げする表示方式」で表示されました 🎉

CSS を軽量化する

--assist-lines-first などのカスタムプロパティはループでそのまま CSS として出力されるので、カスタムプロパティ名が長いと、ファイルサイズが一気に大きくなってしまいます。しかし、カスタムプロパティは設定の上書きで使いたいので、わかりやすい名前にしておく必要があります。そこで、内部的に短い名前のカスタムプロパティに再代入することで、この問題を解決できます。さらに、共通部分を予め計算したものをカスタムプロパティとして設定しておくことで、さらにサイズを軽量化できます。

scss
 :root {
   --line-height: 2em;
   --assist-indent-max: 7em;
   --assist-lines-first: 7;
   --assist-lines-after: 5;
 }

 p {
   line-height: var(--line-height);
 
   [data-assist] {
+    --l1: calc(var(--line-height) * var(--assist-lines-first));
+    --l2: calc(var(--line-height) * var(--assist-lines-after));
+    --in: calc((1 - var(--assist-lines-after) / var(--assist-lines-first) ) * 100%);
+
     $polygon: null;
     $end: 100;
     @for $i from 0 through $end {
       $polygon:
         $polygon,
-        100% calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})),
-        #{if( $i == $end, '', calc((1 - var(--assist-lines-after) / var(--assist-lines-first)) * 100%) calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$i})) )};
+        100% calc(var(--l1) + var(--l2) * #{$i}),
+        #{if( $i == $end, '', var(--in) calc(var(--l1) + var(--l2) * #{$i}) )};
     }
 
     --polygon: 0 0, #{$polygon};
-    --polygon-left: polygon(var(--polygon), 0 calc(var(--line-height) * (var(--assist-lines-first) + var(--assist-lines-after) * #{$end})));
+    --polygon-left: polygon(var(--polygon), 0 calc(var(--l1) + var(--l2) * #{$end}));
     --polygon-right: polygon(var(--polygon), 100% 0);
 
     &::before,
     &::after {
       content: "";
       height: var(--p-height, 0);
       width: var(--assist-indent-max);
     }
 
     &::before {
       float: left;
       shape-outside: var(--polygon-left);
     }
 
     &::after {
       float: right;
       shape-outside: var(--polygon-right);
     }
   }
 }

軽量化前と後を比較すると、ファイルサイズを約1/3に減らすことができます。さらに、以下は1段落500行ぐらいまで対応していますが、100行ぐらいで十分であれば、$end: 20; にすることで最終的な CSS のサイズは約2.4KBになります。十分実用的なレベルですね。

軽量化前と後のファイルサイズの比較画像

JavaScript で各段落の高さを計算する

CSS の部分はほぼ完成しました。あとは JS を使ってそれぞれの段落の高さを計算すればよい。

HTML では、手動で記述した高さや data-assist 要素を削除し、data-reading-assist 要素にまとめておきます。

html
-<p style="--p-height: 2em;"><span data-assist></span>吾輩は猫である。名前はまだ無い。</p>
-<p style="--p-height: 28em;"><span data-assist></span>どこで生れたかとんと見当がつかぬ。...</p>
+<div data-reading-asssit>
+  <p>吾輩は猫である。名前はまだ無い。</p>
+  <p>どこで生れたかとんと見当がつかぬ。...</p>
+</div>

CSS もそれに合わせて [data-reading-assit] の中に入れます。

scss
+[data-reading-assist] {
   p {
     ...
   }
+}

JS ではまず各段落を取得して data-assist 要素を挿入します。段落内のイベントなどを壊さないために insertAdjacentHTML() メソッドを利用します。次に各段落の高さを取得して、インラインスタイルでカスタムプロパティ --p-height にセットします。

js
const paragraphs = document.querySelectorAll('[data-reading-assist] p');

paragraphs.forEach(p => {
  p.insertAdjacentHTML('afterbegin', '<span data-assist />');

  const height = getComputedStyle(p).height;
  p.style.cssText = `
    ${p.style.cssText};
    --p-height: ${height};
  `;
});

しかし、これはまだ完成ではありません。なぜなら図形を挿入したことで、段落の高さが変わるからです。

段落の高さを1回のみ取得した場合のスクリーンショット

段落の高さを再度取得して --p-height に設定し直す必要があります。それによって段落の高さがさらに変わる可能性があるので、--p-height が段落の高さより低い間これを繰り返す必要があります。初回は必ず実行するので do...while でループしていきます。

js
 const paragraphs = document.querySelectorAll('[data-reading-assist] p');

 paragraphs.forEach(p => {
   p.insertAdjacentHTML('afterbegin', '<span data-assist />');

+  let height;
+  do {
-    const height = getComputedStyle(p).height;
+    height = getComputedStyle(p).height;
     p.style.cssText = `
       ${p.style.cssText};
       --p-height: ${height};
     `;
+  } while (parseFloat(height) < parseFloat(getComputedStyle(p).height));
 });

段落の高さを繰り返し取得した場合のスクリーンショット

レスポンシブ対応する

レスポンシブ対応のために、ウィンドウサイズが変更されたとき、段落の高さを再取得します。段落の高さを取得する処理を関数にして、初期化のときとウィンドウがリサイズされたときのどちらでも実行されるようにすれば完成です。

js
 const paragraphs = document.querySelectorAll('[data-reading-assist] p');
 
-paragraphs.forEach(p => {
-  p.insertAdjacentHTML('afterbegin', '<span data-assist />');
-
+const getPHeight = p => {
   let height;
   do {
     height = getComputedStyle(p).height;
     p.style.cssText = `
       ${p.style.cssText};
       --p-height: ${height};
     `;
   } while (parseFloat(height) < parseFloat(getComputedStyle(p).height));
+};
+
+paragraphs.forEach(p => {
+  p.insertAdjacentHTML('afterbegin', '<span data-assist />');
+  getPHeight(p);
+});
+
+let timeout;
+window.addEventListener('resize', () => {
+  if (timeout) {
+    window.cancelAnimationFrame(timeout);
+  }
+
+  timeout = window.requestAnimationFrame(() => {
+    paragraphs.forEach(p => {
+      getPHeight(p);
+    });
+  });
 });

カスタマイズする

以下のようにカスタムプロパティを上書きすることで、行間・字下げの字数や行数をカスタマイズすることができます。また、メディアクエリを使って --assist-indent-max を書き換えれば、狭い画面では字下げなし、広い画面では字下げありにすることもできます。

css
body {
  --line-height: 1.75em;
  --assist-indent-max: 0;
  --assist-lines-first: 9;
  --assist-lines-after: 7;
}

@media (min-width: 640px) {
  body {
    --assist-indent-max: 7em;
  }
}

完成版デモ

おわりに

こんな超マイナーな内容、果たして誰かの何かに役に立つのだろうか。記事を書きながら、心の中のヤナギブソンが「誰が興味あんねん!」ってずっとツッコんでいました。でもほかの誰もやらないだろうし書かないことなので、逆にオンリーワンなわけです。

文章を読むときに、次の行を見失ってしまう方は一定数いると思います。ウェブアクセシビリティの観点でこれが役に立つ可能性はあるかもしれません。

また、ここで利用している技術や手法そして僕の思考を楽しんでいただければうれしく思います。

追記

実は形態素解析して、単語の途中で改行しないようにしたものもあります。ただ、JS によるフロントでの形態素解析は、処理が重すぎてあまり使いものになりませんでした。

形態素解析したもののスクリーンショット

Discussion

ログインするとコメントできます