Как рассчитать высоту текста без рендеринга в DOM?

Я использую виртуализированный список (react-virtualized), где требуется высота элементов моего списка и может сильно отличаться. Из-за больших различий любая оценка высоты, которую я даю библиотеке, дает плохой опыт.

Обычный метод расчета высоты выглядит примерно так:

const containerStyle = {
  display: "inline-block",
  position: "absolute",
  visibility: "hidden",
  zIndex: -1,
};

export const measureText = (text) => {
  const container = document.createElement("div");
  container.style = containerStyle;

  container.appendChild(text);

  document.body.appendChild(container);

  const height = container.clientHeight;
  const width = container.clientWidth;

  container.parentNode.removeChild(container);
  return { height, width };
};

К сожалению, когда вы имеете дело с очень большими списками с элементами разного размера, это неэффективно. Хотя кеш может использоваться, даже это не работает так хорошо, когда вам нужно знать общую высоту (высоту всех элементов вместе) в самом начале.

Второе часто используемое решение - использование холста HTML 'measureText. Производительность сродни описанной выше манипуляции с DOM.

В моем случае я знаю следующее:

  • Ширина контейнера
  • Шрифт
  • Размер шрифта
  • Вся обивка
  • Все поля
  • Любые и все остальные стили, например line-height

Я ищу математическое решение, которое может вычислить высоту (или чрезвычайно точную оценку), так что мне не нужно полагаться на какие-либо манипуляции с DOM, и я могу получить высоту всякий раз, когда я пожалуйста.

Я предполагаю, что это выглядит примерно так:

const measureText = (text, options) => {
  const { width, font, fontSize, padding, margins, borders, lineHeight } = options;

  // Assume this magical function exists
  // This all depends on width, stying and font information
  const numberOfLines = calculateLines(text, options);

  const contentHeight = numberOfLines * lineHeight;

  const borderHeight = borders.width * 2 // (this is all pseudo-code... but somehow get the pixel thickness. 

  const marginsHeight = margins.top + margins.bottom
  const paddingHeight = padding.top + padding.bottom

  return marginsHeight + paddingHeight + borderHeight + contentHeight;
}

Выше нам не хватает функции calculateLines, которая кажется основной тяжестью работы. Как двигаться дальше на этом фронте? Нужно ли мне делать некоторую предварительную обработку для определения ширины символов? Поскольку я знаю шрифт, который использую, это не должно быть большой проблемой, верно?

Существуют ли проблемы с браузером? Как расчет может отличаться в каждом браузере?

Есть ли другие параметры, которые следует учитывать? Например, если у пользователя есть какая-то системная настройка, которая увеличивает для него текст (доступность), сообщает ли мне это браузер через какие-либо полезные данные?

Я понимаю, что рендеринг в DOM - это простейший подход, но я готов приложить усилия к шаблонному решению, даже если это означает, что каждый раз, когда я меняю поля и т. Д. Мне нужно обеспечить обновление входных данных функции.

Обновление: это может помочь на пути к поиску ширины символа: Карта ширины статического символа, откалиброванная с помощью ограничивающей рамки SVG < / а>. Ниже приведена дополнительная информация: Демо и подробности. Кредиты переходят на Toph

Обновление 2: благодаря использованию моноширинных шрифтов вычисление ширины становится еще более упрощенный, поскольку вам нужно измерить ширину только одного символа. Удивительно, но в списке есть несколько очень хороших и популярных шрифтов, таких как Menlo и Monaco.

Большое обновление 3: Это было довольно продолжительное время, но благодаря вдохновению с помощью метода SVG в обновлении 1 я придумал нечто, что прекрасно работает при вычислении количества строк. К сожалению, я видел, что в 1% случаев он отключается на 1 строку. Вот примерный код:

const wordWidths = {} as { [word: string]: number };

const xmlsx = const xmlsn = "http://www.w3.org/2000/svg";

const svg = document.createElementNS(xmlsn, "svg");
const text = document.createElementNS(xmlsn, "text");
const spaceText = document.createElementNS(xmlsn, "text");
svg.appendChild(text);
svg.appendChild(spaceText);

document.body.appendChild(svg);

// Convert style objects like { backgroundColor: "red" } to "background-color: red;" strings for HTML
const styleString = (object: any) => {
  return Object.keys(object).reduce((prev, curr) => {
    return `${(prev += curr
      .split(/(?=[A-Z])/)
      .join("-")
      .toLowerCase())}:${object[curr]};`;
  }, "");
};

const getWordWidth = (character: string, style: any) => {
  const cachedWidth = wordWidths[character];
  if (cachedWidth) return cachedWidth;

  let width;

  // edge case: a naked space (charCode 32) takes up no space, so we need
  // to handle it differently. Wrap it between two letters, then subtract those
  // two letters from the total width.
  if (character === " ") {
    const textNode = document.createTextNode("t t");
    spaceText.appendChild(textNode);
    spaceText.setAttribute("style", styleString(style));
    width = spaceText.getBoundingClientRect().width;
    width -= 2 * getWordWidth("t", style);
    wordWidths[" "] = width;
    spaceText.removeChild(textNode);
  } else {
    const textNode = document.createTextNode(character);
    text.appendChild(textNode);
    text.setAttribute("style", styleString(style));
    width = text.getBoundingClientRect().width;
    wordWidths[character] = width;
    text.removeChild(textNode);
  }

  return width;
};

const getNumberOfLines = (text: string, maxWidth: number, style: any) => {
  let numberOfLines = 1;

  // In my use-case, I trim all white-space and don't allow multiple spaces in a row
  // It also simplifies this logic. Though, for now this logic does not handle
  // new-lines
  const words = text.replace(/\s+/g, " ").trim().split(" ");
  const spaceWidth = getWordWidth(" ", style);

  let lineWidth = 0;
  const wordsLength = words.length;

  for (let i = 0; i < wordsLength; i++) {
    const wordWidth = getWordWidth(words[i], style);

    if (lineWidth + wordWidth > maxWidth) {
      /**
       * If the line has no other words (lineWidth === 0),
       * then this word will overflow the line indefinitely.
       * Browsers will not push the text to the next line. This is intuitive.
       *
       * Hence, we only move to the next line if this line already has
       * a word (lineWidth !== 0)
       */
      if (lineWidth !== 0) {
        numberOfLines += 1;
      }

      lineWidth = wordWidth + spaceWidth;
      continue;
    }

    lineWidth += wordWidth + spaceWidth;
  }

  return numberOfLines;
};

Первоначально я делал это посимвольно, но из-за кернинга и того, как они влияют на группы букв, слово за словом более точное. Также важно отметить, что хотя стиль используется, заполнение должно быть учтено в параметре maxWidth. CSS Padding не повлияет на текстовый элемент SVG. Он неплохо справляется со стилем регулировки ширины letter-spacing (он не идеален, и я не уверен, почему).

Что касается интернационализации, казалось, что она работает так же хорошо, как и с английским, за исключением того случая, когда я перешел на китайский. Я не знаю китайского, но, похоже, он следует другим правилам для перехода на новые строки, и это не учитывает эти правила.

К сожалению, как я уже сказал ранее, я заметил, что время от времени это происходит постепенно. Хотя это редко, но не идеально. Я пытаюсь понять, что вызывает крошечные несоответствия.

Тестовые данные, с которыми я работаю, генерируются случайным образом и составляют от 4 до 80 строк (а я генерирую 100 за раз).

Обновление 4: я больше не думаю, что у меня есть отрицательные результаты. Изменение тонкое, но важное: вместо getNumberOfLines(text, width, styles) вам нужно использовать getNumberOfLines(text, Math.floor(width), styles) и убедиться, что Math.floor(width) - это ширина, также используемая в DOM. Браузеры несовместимы и по-разному обрабатывают десятичные пиксели. Если мы сделаем ширину целым числом, то нам не о чем беспокоиться.


person David    schedule 16.06.2020    source источник
comment
Я не думаю, что когда-либо видел достойную реализацию, в которой не использовался бы скрытый элемент DOM. Даже они обычно остаются верным предположением и не идеальны. Поделитесь, если кто-нибудь найдет его.   -  person user120242    schedule 16.06.2020
comment
@ user120242 мне тоже. В настоящее время я возился со своим калькулятором ширины. Сообщу результаты.   -  person David    schedule 16.06.2020
comment
@ user120242 Редактировал с апдейтом. Хотя технически он находится в DOM, я должен сказать ... метод SVG чрезвычайно эффективен. Даже не замечаю всплеска, а я имею дело с большим набором данных.   -  person David    schedule 16.06.2020
comment
А как насчет Z̷̧̢̩̫̟͖̟͇͙̫̟͚̦̓͌̍̐̌̊̓ä̴̭̼̹̫͎͕̲͙͈͊̌̈̕̕͜ͅl̸̻̹̦̬͕͍͉͗̓̌͐̄̃̎͂̈̄̚͘͝͠g̴̹̽̆͌͋͗̏̌̀͆̆̕ŏ̸̱͎͕̥̹͔̱̺̗̽̅̂̀̆͐̀̚͜ͅ?   -  person Kaiido    schedule 17.06.2020
comment
@Kaiido Я не думаю, что что-то может хорошо справиться с этим переполнением - тестирование в Chrome, оно никоим образом не подтверждает этот текст и не учитывает его высоту.   -  person David    schedule 17.06.2020
comment
Метод холста и measureText. Однако на самом деле это не относится к этой теме.   -  person user120242    schedule 19.06.2020
comment
Итак, в конце (обновление 4) кажется, что вы смогли получить свой результат, не так ли?   -  person Daniele Ricci    schedule 20.06.2020
comment
@DanieleRicci потенциально, да. Я не уверен, что он работает с letter-spacing CSS, и мне нужно провести более обширное тестирование. Также ... Мне нужно выяснить, почему некоторые языки, такие как китайский, не работают.   -  person David    schedule 20.06.2020


Ответы (2)


Я нашел алгоритм измерения текста, который приблизительно определяет ширину строк, не касаясь DOM.

Я немного изменил его, чтобы рассчитать количество строк (где вы застряли).

Вы можете рассчитать количество строк, как показано ниже:

/**
 * @param text : <string> - The text to be rendered.
 * @param containerWidth : <number> - Width of the container where dom will be rendered. 
 * @param fontSize : <number> - Font size of DOM text
**/

function calculateLines(text, containerWidth, fontSize = 14) {
  let lines = 1;  // Initiating number of lines with 1

// widths & avg value based on `Helvetica` font.
  const widths = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.278125,0.278125,0.35625,0.55625,0.55625,0.890625,0.6671875,0.1921875,0.334375,0.334375,0.390625,0.584375,0.278125,0.334375,0.278125,0.303125,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.55625,0.278125,0.278125,0.5859375,0.584375,0.5859375,0.55625,1.015625,0.6671875,0.6671875,0.7234375,0.7234375,0.6671875,0.6109375,0.778125,0.7234375,0.278125,0.5,0.6671875,0.55625,0.834375,0.7234375,0.778125,0.6671875,0.778125,0.7234375,0.6671875,0.6109375,0.7234375,0.6671875,0.9453125,0.6671875,0.6671875,0.6109375,0.278125,0.35625,0.278125,0.478125,0.55625,0.334375,0.55625,0.55625,0.5,0.55625,0.55625,0.278125,0.55625,0.55625,0.2234375,0.2421875,0.5,0.2234375,0.834375,0.55625,0.55625,0.55625,0.55625,0.334375,0.5,0.278125,0.55625,0.5,0.7234375,0.5,0.5,0.5,0.35625,0.2609375,0.3546875,0.590625]
  const avg = 0.5293256578947368

  text.split('')
    .map(c => c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg)
    .reduce((cur, acc) => {
      if((acc + cur) * fontSize  > containerWidth) {
          lines ++;
          cur = acc;
      }
      return acc + cur;
    }); 

  return lines;
}

Примечание

Я использовал Helvetica как font-family, вы можете получить значение widths & avg из Измерьте текст в соответствии с font-family у вас есть.

person harish kumar    schedule 19.06.2020
comment
Если вы посмотрите мои «Обновление» и «Большое обновление 3», вы заметите, что я действительно ссылаюсь на упомянутый вами алгоритм. Я также начал работу над функцией getNumberOfLines. Я не уверен, что моя функция работает во всех случаях (но в последнее время я видел, что она стабильно работает для английского языка). Необходимо провести дополнительное тестирование с другими переданными свойствами стиля. Кроме того, мой метод вообще не обрабатывает китайский язык (хотя я не знаю китайских правил переполнения). Кроме того, это привет, но мое решение не обрабатывает текст, в котором некоторые слова могут быть выделены жирным шрифтом (т. Е. Разные стили). - person David; 19.06.2020
comment
Что касается вашего алгоритма, вы должны проверить мой. У вас есть ошибка, при которой любое переполнение ширины будет представлять собой новую строку, но на самом деле это не так. Иногда текст переполняется, но браузер не переносит его на новую строку. - person David; 19.06.2020
comment
Я понял ваше беспокойство здесь. Хотя этот алгоритм не идеален, но он может дать вам близкое к правильному значение. Попробую придумать еще что-нибудь. - person harish kumar; 19.06.2020
comment
Какую информацию о стиле вы предоставили? Позже проверю. - person David; 19.06.2020
comment
Нашел, что ваш алгоритм работает намного лучше, чем предыдущий. - person harish kumar; 19.06.2020

ИМХО суть этого вопроса в нескольких словах:

К сожалению, когда вы имеете дело с очень большими списками с элементами разного размера, это неэффективно. Хотя кеш может использоваться, даже это не работает так хорошо, когда вам нужно знать общую высоту (высоту всех элементов вместе) в самом начале.

Это сильно контрастирует с природой и философией JavaScript: объединение очень больших списков в самом начале - это то, что не работает в JavaScript.

Вероятно, вы сможете добиться лучших результатов с меньшими усилиями, если сосредоточитесь на том, что заставляет вас говорить в самом начале, а не искать фактический ответ на этот вопрос. Независимо от того, насколько производительным является решение, которое вы можете найти, когда чрезвычайно большие списки продолжают расти, ваше решение неизбежно вызовет блокировку пользовательского интерфейса.

Это всего лишь мои два цента.

person Daniele Ricci    schedule 24.06.2020