🥨

ブラウザ上で自動改行されたテキスト表示の各行を取得したメモ

に公開


ブラウザ上で自動改行により適切に表示されているテキストの各行を、諸事情により抽出する必要が出てきたのでそれを実装したメモです。そのようなWebAPIが提供されていないので無理やり実現した感が否めませんが、申し訳程度にパフォーマンス向上策を盛り込んだので良しとする事にします。今のところChromeでのみ動作検証しているため、Firefox/Safariでは結果が少し異なる可能性があります。

前提と方針

実現したい事

幅が制限されたブロック要素の中に表示されている長いテキストは通常、要素内に収まるようにブラウザによって自動改行される。この自動改行されたテキストの各行を把握した上で何らかの処理を行うために、各行のテキスト内容をstring[]として抽出する。

対応方針

RangeオブジェクトのgetClientRectsメソッドを用いてブラウザがどのようにテキストを行分けしてレンダリングしているか(する予定か)を取得して判定する。テキストを任意の長さに何度も切り取り、その都度getClientRectsで得られる行数が変化するかを確認することで自動改行が存在する位置を検知する。

制約

  • 抽出対象テキスト(テキストノード)はインライン要素の子である必要がある
    • テキストノードがブロック要素の子である場合、自動改行の無い結果しか取得できない
  • テキストノードを指定したRangegetClientRectsメソッドを適用する必要がある
    • インライン要素自体を指定するとテキストを任意長に切り取ることができない

実装

デモ

https://svelte.dev/playground/untitled?#H4sIAAAAAAAACqVXbW8bxxH-K5OLP9zFFGk5zReKRyNpnKaA4wC2-kknQEfeklz4uEfcLiW5NAEu6zqxZcNvchw7MazUtqTajZQ2Nhq_VPkvWZGWP7WA_0Cxu3fHO4kynBaiqN2d2ZlnnpmdXXUM4jaRUTT-QBhmPvKMnFHDPqJGcaZjsNMtKZMLRi7W_LDVytN55DO5VnEpGrdeDQhDhFGjaJRoNcQtBs3Aa_sIfJfUbcdg1DHKDnEYbraCkEEHKohUG003PJWDOmKf4JCyabTIjgceUitH3WrjGCYIulALgyY4Rr5wDFcin44xJa1VA0IZeO1m8zTY0JFLDqONIGRFmHu5xgXfdByy_fzC8Ns7gm8J3hf8uuAbg-9XX_58TfQuHgtC1ATcou0meIEfhKJ3SfANwe-KPhc97hgUM7eJGEMSOApdxjCpYq9NWLTNZfCfu5cfv17ha69X-F9er_AHr1f4RjT_2TEGf18S_J7gq9Jw_5rgNwV_KPrnBV8Tvf5cTqP2A1Ivwpzo9fZ-ho-Xht9ekmN-8eXyuuBn1fabgl9WNvtyhV8W_fPDW_1XX10X_KLgmzIIqbMqHcnxU8EvSai_9C798vXKv3-6LHp9pfGD_h68eCL4DdG_Lvijwdbj7a3vBr37gt8R_Nzwxq2d_j8EX3OMY7iCQiABIFdThmjydx6FASCWzClmINmD-cBvs5Yrc4UjdfCw20x91VC7jl0GvlsJQpR3jGHvjuLt9uDFXYViY_un59vPnslx_6LgS69Wf9x58oOKTtO7vHNufefsU7VyS_AV9bkrRT2uLQzOXxJ8U1XG0vazpeGD74e9NcGXYj6l5uD-2vDexuDKN6oQNgR_NOzdGf7pR8E31MY1Rfg9wdfVYHn4Ynnn4d8EX99-dlUl4qqC91fR779c4ztfPBx8eU5BWh4u_Xn7-XNVAw9Ff0n0vxT8nOCPBP9GGVxSCV3e_qm3sy5zPXhxQ-akf0Hqy0DWVSDfpbzfVx4fvVy-O3ysLZ9VUV-MkMhdMoQYsyTkTeUqS2JcCUYf3pe4-VlILW2OO0fw_54bdRC3FP5NwS8Mv34g-KNXX9xTUWwKflt9y9oc3F8b3D8vCeG3FPMrUT3ESR9-9XRw5aokp98fXO4rPldU1T8abG7pvA_-dX1wZVX0r-08-acKcXWwdVMNbr-BseT8dqccAqDb0QL2WOOEyxAFG2Z-k4PDOZicnXJIqaB7Y9khcZfc3R59xAD5qCm3HqDMZaj06fRnx062XHLUR01E2Mxs2ZyZtaZi7RDRts9S-h3w2qHLcECKQNrNCgpzkVIRKAsxqc_Mdndbka0_MWE6huqhjqEV5G-tTarSJgSk6uPqKdOK220tCMGUNjDYcGgKMJTg_SnABw8mKnGjZmiRgb2n35sq4hkc4ZE_uAbmO1LdkqQyTNookUURz-BZsEfXiGlaYJfTN4ep9sc2uypNmSTINCAfVRlUMPGK867fRnZHUtGVYTZcUkd2R9sd0Twz21WpKgUtSUhZUVUqRLO0RLb0lKBU0N7ksNJmLCCKdtsx9MwxoBOx2y17iElgPiaIlgpaQUHuvIvcaiNdZC6F0GUoBxhMObBUqCUPz0PVdymVDoJFQItus-UjxwDKTvvI7swpG8UDnQ_ek9u6IWpOzenYHFaiLZdoXlgDU7sT56hb7qj7dkZin-2WClJRh13w8Lwadd7FNRil6Ug-rshuZFwqfpxUaWfuQGeknSjnWfAJXkSeedjqNulcN2W_gGs6yMCP8Ea0pJ3qsaRHstiNy6fk4zQvjlHuKHmp4OPYWEEa0x4KkYtSI4RC2SEjGSkpIpU0H7EbV3zTDeuYTFT8oHqqCJOS2ozARzWWWQ_mUVjzg4UizGOKK74udwUhL7MX2a0EoYfCItDAx95Uem1CZxMmW4ujrYEfb2y5nodJfbdjfSCiMIycIY-MUWRhG3Vz-zwIR4-w7IMws_72D0K0qB6ESX9JDnRpumzWSBH0-Zu2ivCmzjYNXRkqQNyFKXND2WxaKKwFYdMlVZQnwYJpTaW1ohqxoUYSSYhYOySj3jVyuscWTGg_uWxviqb6UujKr91R7mmBJPDQkSLIsVUEuQxngLR9P45KdUSpZcUApTQTDKaxObBBWUwMyglgqg3bZTXPy69p1fhtG96PTKl2ru1VG9j3IKhpbTWT5qgVY9KoRm5NpWMlCNU0stvNkJtg34-dTBPXhFij-yub6qBWo0j1ZorQKbnroxC5pz7Xy_oayOa2jphU07JcbMAaIUqg7GsxwaQrcTemDoTy-sjpDp4DylALumADJpidkKIMLr2pIp3QlEl526Tzoq5Ze3IKcEnZHV2zOh1pPsAGRBluugydCNr1xnG0yHQAUdQRQKzBxUggQpFvtWnDDFEtG_yevdqZZY1NszY1htYsCxGXWQaVC_1akNL9OFM8gK3V83XEfutjRNgJVGXUtPI-InXW2HOydaQ6inimMxXPJCdFqIbIZehT1699jKru6d8hIp-yQWh-5rJGnoVtUlWYIj9Q0FYsK7LTHRP7noCSUlJL41kgaEGLR21KBUwROyk70EeoFoRZfhKFo8T7sMZQOO4kKKUxIPcNXGbueLsZF6lVBLWEXR__UXawShD4yCWqaWuVbEBSGWw4rkR5TH9PGKqjxK4FR0Bx61boaO09OAxFmIygy1Pgu5RFpykbUAqNwpBtVSnZXgsQrykAVYR9Uy0U4PDodERupGBq_xy_6eTpdEdnKEp6DqrtMESEjW42XYAK8lhak9YThyhpSY6-3G3Ke1ySF9mO8C40sI_AfAdTCUw2t8_Jght6NHu2o02jEz6iMvJyMHKz6-xH9rXzXa3JQz5zYdc-nRstKsEHlm4bGeGvwwoT2pFlJQu2XhnbpbTOmCyO735vmT9ttbhftrxgN58TEzG6mMJfm6G3i2q81f81qqgw42DSTSdzu47rO_u1ayhnSnZP-xzd3THq6AYfXZ37Phb2b6ip-yS1OXUFZztuFN0ha8-7Kaq5oAYz-Xw-gpaD1D0xa-26hN7EGIDbaiHinUDEQyHy1LMoelbozSw4qeCaVqpPjQObNZw9BfpBsZfv_ZyPONKPnHhupV-sUpBnIW6aFpw5o33ESS7ZcCjFg5apV4ccJiAB-RTtUpvJGJqASZknveYyc2LSgoNqmg40-79_9n-d2ZzBXOwvYOIZxZrrU9T9LwTGCPIrFwAA

code
App.svelte
<script module lang="ts">
  import { benchmark, getFirstTextNode, getEachLine } from "./Lib.svelte";
  const dummy = {
    short: `私は\n今朝まあこの学習「Lorem ipsum dolor」のため、"sitamettemporerattincidunt ipsum at 🏴󠁧󠁢󠁥󠁮󠁧󠁿"屋ってのをしならで。`,
    long: `       洋服 が用いらしくのもいくら時間がはたしてでたべき🏴‍☠️。しばしば張さんに徴価値そう教育で"Liber no ea dolores dolores vero et dolores sit amet volutpat duis dolor diam diam diam feugiat labore."思っず弟その主人それか馳走でってご膨脹ですだだたて、その前は私か事業性からして、大森君ののに思想の私でもっともご演説と云いけれども私自分でご担任をなるようにせっかくご一言が引張りなたと、ちっともついに生活をいだがいけたのにもっだた🏴󠁧󠁢󠁥󠁮󠁧󠁿。         あるい    は「Lorem ipsum dolor sitamettemporerattincidunt ipsum at 🏴󠁧󠁢󠁥󠁮󠁧󠁿」またはお春に釣っはずはそう大変とすれだって、その方向をも参りだばに対して当否を起るて得るずた🏴󠁧󠁢󠁥󠁮󠁧。`,
  };
  const widthRates = [4, 2, 1];
</script>
<script lang="ts">
  let elems = $state<HTMLSpanElement[]>([]);
  let results = $state<{ duration: number, result: string[]}[]>([]);
  let type = $state("short");
  
  function onclick() {
    for (let i = 0; i < 3; i++) {
      const text = getFirstTextNode(elems[i]);
      if (!text) continue;
      results[i] = benchmark(() => getEachLine(text));
    }
  }
</script>

<select bind:value={type} onchange={() => results = []}>
  <option>short</option>
  <option>long</option>
</select>
<button type="button" {onclick}>detect lines</button>

{#each widthRates as rate, i (rate)}
  <div class="box example" style={`width:${5*rate}rem;`}>
    <span bind:this={elems[i]}>{dummy[type]}</span>
  </div>
  {#if results[i]?.duration}
    <div>Duration: {`${results[i].duration.toFixed(2)}ms`}</div>
  {/if}
  <ol>
    {#each results[i]?.result as line}
      <li class="box">{line}</li>
    {/each}
  </ol>
  <hr />
{/each}

<style>
  .example {
    margin-block: 1rem;
    margin-left: 1rem;
    overflow: visible;
  }
  .box {
    border: solid;
    border-width: 1px;
  }
  ol {
    padding-left: 1rem;
  }
</style>
Lib.svelte
<script module lang="ts">
  export function benchmark<T>(fn: () => T): { duration: number, result: T } {
    const start = performance.now();
    const result = fn();
    return {
      duration: performance.now() - start,
      result,
    };
  }
  export function getFirstTextNode(node?: Node): Text | null {
    if (!node) return null;
    const isTextNode = (node: Node): node is Text => node.nodeType === 3;
    for (const child of node.childNodes) {
      if (isTextNode(child)) return child;
    }
    return null;
  }
  export function getEachLine(text: Text): string[] {
    const offsets = seekLineBreakOffsets(text);
    return getLines(text, offsets);
  }
  function seekLineBreakOffsets(text: Text): number[] {
    const { range, lines, step } = initRange(text);
    const breaks: number[] = [];
    for (let i=1; i<lines; i++) {
      const offset = estimateRoughNextOffset(text, range, i, step);
      breaks.push(refineBreakOffset(text, range, i, offset));
    }
    return breaks;
  }
  function initRange(text: Text) {
    const range = getTextRange(text);
    const lines = range.getClientRects().length;
    return {
      range,
      lines,
      step: createHalfDecayGenerator(Math.trunc(text.length / lines)),
    };
  }
  function getTextRange(text: Text): Range {
    const range = new Range();
    range.setStartBefore(text);
    range.setEndAfter(text);
    return range;
  }
  function createHalfDecayGenerator(initNum: number): (initialize?: boolean) => number {
    const init = Number.isInteger(initNum) ? Math.abs(initNum) * 2 : 1;
    let last = init;
    return (initialize) => {
      if (initialize) last = init;
      last = Math.ceil(last / 2);
      return last;
    };
  }
  function estimateRoughNextOffset(text: Text, range: Range, current: number, step: (init?: boolean) => number): number {
    let offset = step(true) * current;
    while (!isNextLineOnwards(text, range, current, offset)) {
      offset += step();
    }
    while (true) {
      const delta = step();
      if (delta < 5) break;
      if (isNextLineOnwards(text, range, current, offset - delta)) offset -= delta;
    }
    return offset;
  }
  function refineBreakOffset(text: Text, range: Range, current: number, offset: number): number {
    do {
      offset--;
    } while (isNextLineOnwards(text, range, current, offset))
    return offset;
  }
  function isNextLineOnwards(text: Text, range: Range, current: number, offset: number): boolean {
    range.setEnd(text, offset);
    return range.getClientRects().length > current;
  }
  function getLines(text: Text, offsets: number[]): string[] {
    const range = new Range();
    const lines: string[] = [];
    range.setStart(text, 0)
    for (const offset of [...offsets, text.length]) {
      range.setEnd(text, offset);
      appendRenderedLine(lines, range.toString());
      range.setStart(text, offset);
    }
    return lines;
  }
  function appendRenderedLine(lines: string[], line: string) {
    if (line.trim() || lines.length <= 0) {
      lines.push(line);
    } else {
      lines[lines.length - 1] = lines.at(-1) + line;
    }
  }
</script>

コード

getEachLine(textNode)を実行することで各行に分割された配列を取得可能。

code
function getEachLine(text: Text): string[] {
  const offsets = seekLineBreakOffsets(text);
  return getLines(text, offsets);
}

function seekLineBreakOffsets(text: Text): number[] {
  const { range, lines, step } = initRange(text);
  const breaks: number[] = [];
  for (let i=1; i<lines; i++) {
    const offset = estimateRoughNextOffset(text, range, i, step);
    breaks.push(refineBreakOffset(text, range, i, offset));
  }
  return breaks;
}
function initRange(text: Text) {
  const range = getTextRange(text);
  const lines = range.getClientRects().length;
  return {
    range,
    lines,
    step: createHalfDecayGenerator(Math.trunc(text.length / lines)),
  };
}
function getTextRange(text: Text): Range {
  const range = new Range();
  range.setStartBefore(text);
  range.setEndAfter(text);
  return range;
}
function createHalfDecayGenerator(initNum: number): (initialize?: boolean) => number {
  const init = Number.isInteger(initNum) ? Math.abs(initNum) * 2 : 1;
  let last = init;
  return (initialize) => {
    if (initialize) last = init;
    last = Math.ceil(last / 2);
    return last;
  };
}
function estimateRoughNextOffset(text: Text, range: Range, current: number, step: (init?: boolean) => number): number {
  let offset = step(true) * current;
  while (!isNextLineOnwards(text, range, current, offset)) {
    offset += step();
  }
  while (true) {
    const delta = step();
    if (delta < 5) break;
    if (isNextLineOnwards(text, range, current, offset - delta)) offset -= delta;
  }
  return offset;
}
function refineBreakOffset(text: Text, range: Range, current: number, offset: number): number {
  do {
    offset--;
  } while (isNextLineOnwards(text, range, current, offset))
  return offset;
}
function isNextLineOnwards(text: Text, range: Range, current: number, offset: number): boolean {
  range.setEnd(text, offset);
  return range.getClientRects().length > current;
}
function getLines(text: Text, offsets: number[]): string[] {
  const range = new Range();
  const lines: string[] = [];
  range.setStart(text, 0)
  for (const offset of [...offsets, text.length]) {
    range.setEnd(text, offset);
    appendRenderedLine(lines, range.toString());
    range.setStart(text, offset);
  }
  return lines;
}
function appendRenderedLine(lines: string[], line: string) {
  if (line.trim() || lines.length <= 0) {
    lines.push(line);
  } else {
    lines[lines.length - 1] = lines.at(-1) + line;
  }
}

簡単な解説

単純化した流れ概要

  1. テキスト全体が何行か取得する
  2. 1行あたりのテキスト目安長(テキスト長/行数)を取得する
  3. 行始め位置から目安長を足したテキストが次の行になるか検査する
    • 次の行にならない場合、再度目安長を足して検査する
  4. 次の行になった位置から1文字ずつ減らして都度検査し、改行が無くなる位置を特定する
  5. 3,4の操作を各行に対して行い、テキストの改行位置を取得する
  6. テキストの改行位置から各行のテキスト内容を配列化する

工夫ポイント

  • コストが高いと思われるgetClientRectsを呼び出す回数を極力減らせるようにした
    • 目安長を何度も足す場合、足す値を都度半減して改行に近い位置になる可能性を高めた
    • 次の行になった位置からさらに半減させた値を用いて改行に近い位置に調整するようにした
  • 改行直前の半角スペースは表示されていないように見えるが、結果には含めることにした
    • 使用時に必要に応じてtrimすれば良いだけのため

雑記

テスト中抽出1回に50ms程度かかった場合もあり、処理的にかなり重いので乱用はできませんが、スポットで使用する分にはある程度耐えるぐらいの処理内容になっている気がします。こんな処理がどうしても必要になるケースは相当稀だと思うので、そもそも乱用する機会自体ない気もします。
また、文字描画方向の左右や文字コード等によって不具合が出ないように影響がありそうな処理はRangeオブジェクトに任せています。そのためどんな言語でも問題なく使用可能なはずです。・・・はずです。

参考文献

Discussion