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

Пример кода в проекте сделан на Flutter, но сама идея не привязана к Dart.

Весь код и текст этой статьи можно найти тут на GitHub.

Работа приложения, написанного на Flutter и Dart
Работа приложения, написанного на Flutter и Dart

Какая цель?

  • Выделение должно идти по контуру текста, а не простым прямоугольником.

  • На стыках строк не должно быть ломаных "ступенек".

  • Углы должны быть скруглены, чтобы форма выглядела естественно.

1) Получаем геометрию выделяемого фрагмента

Сначала превращаем массив сегментов в единый TextSpan, отрисовываем его через TextPainter, и вычисляем диапазон символов для нужного сегмента.

Ищем индексы начала и конца нужного нам отрезка текста
Ищем индексы начала и конца нужного нам отрезка текста
final textPainter = TextPainter(
  text: TextSpan(children: inlineSpans),
  textDirection: textDirection,
)..layout(maxWidth: maxWidth);

int selectionStart = 0;
for (int i = 0; i < segmentIndex; i++) {
  selectionStart += textSegments[i].length;
}
final int selectionEnd = selectionStart + textSegments[segmentIndex].length;

Для индексов работает простая формула:

s_i = \sum_{k=0}^{i-1} |t_k|,\quad e_i = s_i + |t_i|

- t_i — i-й текстовый сегмент,

- s_i — начало сегмента в общей строке,

- e_i — конец сегмента.

Дальше используем getBoxesForSelection:

final selectionBoxes = textPainter.getBoxesForSelection(
  TextSelection(baseOffset: selectionStart, extentOffset: selectionEnd),
);

Если боксы есть — конвертируем их в HighlightBounds.

Если нет (редкий крайний случай) — берем caret-позиции начала/конца и строим fallback-контур.

2) Нормализуем особый кейс с "уехавшим" первым боксом

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

if (boundsGroup.length > 1 &&
    boundsGroup[0].startX > (boundsGroup[1].endX - 10)) {
  normalizedBoundsGroups.add([boundsGroup[0]]);
  boundsGroup.removeAt(0);
  normalizedBoundsGroups.add(boundsGroup);
}

Практически это убирает артефакты в переходах между строками.
10px - это простая эвристика. Добавляет визуально красоты

Рассматриваем мы тут только первую строчку, потому что только она может попасть в такую ситуацию. Ведь все последующие строки будут расположены так, что выровнены по левому краю. То есть нам нужно рассмотреть только вариант с первой и второй строкой.

3) Строим контур через матрицу точек

Идея: собрать все узловые точки боксов в "таблицу" координат и пройти ее по периметру по часовой стрелке. Это позволит а) идти по границе и б) замечать, когда происходят смещения "вправо" и "влево" и учитывать это

Визуализация того, как мы создаем и заполняем таблицу
Визуализация того, как мы создаем и заполняем таблицу

3.1) Уникальные оси X и Y

Берем все x и y из прямоугольников, оставляем уникальные и сортируем:

X = \mathrm{sort}\left(\mathrm{unique}(\{x_{left}, x_{right}\})\right),\quad Y = \mathrm{sort}\left(\mathrm{unique}(\{y_{top}, y_{bottom}\})\right)
final uniqueXList = uniqueX.toList()..sort();
final uniqueYList = uniqueY.toList()..sort();

final List<List<Offset?>> matrix = List.generate(
  uniqueYList.length,
  (index) => List.generate(uniqueXList.length, (index) => null),
);

3.2) Заполнение матрицы

Для каждой точки каждого бокса ищем индекс по x и y, после чего кладем ее в matrix[yIndex][xIndex].

Обход по часовой стрелке

Контур собирается так:

  1. Верхняя грань: слева направо.

  2. Правая грань: сверху вниз.

  3. Нижняя грань: справа налево.

  4. Левая грань: снизу вверх.

Формально:

P = T \,\Vert\, R \,\Vert\, B \,\Vert\, L

где (T, R, B, L) — списки точек соответствующих сторон, а || — конкатенация.

Выравниваем переходы на боковых гранях

Когда соседние точки на правой/левой стороне имеют разный dx, вертикальный переход может получиться "косым".

Поэтому dy усредняется попарно:

\Delta = \frac{|y_{i+1} - y_i|}{2}

Для правой стороны:

y_i' = y_i + \Delta,\quad y_{i+1}' = y_{i+1} - \Delta

Для левой — зеркально:

y_i' = y_i - \Delta,\quad y_{i+1}' = y_{i+1} + \Delta

Чистим лишние точки

  • Удаляем дубликаты.

  • Удаляем точки, лежащие на одной прямой:

    • если x_{i-1} = x_i = x_{i+1}, точка не нужна;

    • если y_{i-1} = y_i = y_{i+1}, точка не нужна.

После этого остаются в основном угловые вершины контура.

4) Скругляем углы через векторы

Слева: обход таблицы. Справа: расчет скругления угла
Слева: обход таблицы. Справа: расчет скругления угла

Для каждой вершины p_i берем соседние точки p_{i-1} и p_{i+1}, считаем два единичных вектора:

\hat{v}_{prev} = \frac{p_{i-1} - p_i}{\|p_{i-1} - p_i\|},\quad \hat{v}_{next} = \frac{p_{i+1} - p_i}{\|p_{i+1} - p_i\|}

Радиус ограничиваем сверху базовым значением и снизу геометрией отрезка:

r = \min\left(r_0,\ \frac{\|p_{i+1} - p_i\|}{2}\right),\quad r_0 = 6

Строим две точки рядом с углом:

p_{closePrev} = p_i + r \cdot \hat{v}_{prev},\quad p_{closeNext} = p_i + r \cdot \hat{v}_{next}

В коде это выглядит так:

final prevVector = (prevPoint - point).normalized();
final nextVector = (nextPoint - point).normalized();
final radius = min(6.0, (nextPoint - point).length / 2);

final pointCloseToNext = (nextVector * radius) + point;
final pointCloseToPrev = (prevVector * radius) + point;

5) Определяем направление дуги через векторное произведение

Нужно понять, как рисовать arcToPoint: по или против часовой.

a = p_i - p_{closePrev},\quad b = p_{closeNext} - p_{closePrev}

2D-векторное произведение (z-компонента):

b \times a = b_x a_y - b_y a_x

Если знак положительный — поворот считаем "clockwise" (в терминах внутренней геометрии контура), иначе — обратный.

final vectorToCurrent = point - pointCloseToPrev;
final vectorToNext = pointCloseToNext - pointCloseToPrev;
final crossProduct = vectorToNext.cross(vectorToCurrent);
final isClockwise = crossProduct > 0;

Важно: в экранных координатах Flutter ось \(Y\) направлена вниз, поэтому при передаче флага в arcToPoint в коде используется инверсия (clockwise: ... != true), чтобы визуально дуга закручивалась правильно.

6) Рисуем итоговый путь

После скругления получаем пары точек:

(точка_входа_в_угол, флаг_направления) и (точка_выхода_из_угла, null).

path.moveTo(roundedContourPoints.first.$1.dx, roundedContourPoints.first.$1.dy);
drawArc(0);
for (int i = 2; i < roundedContourPoints.length; i = i + 2) {
  path.lineTo(roundedContourPoints[i].$1.dx, roundedContourPoints[i].$1.dy);
  drawArc(i);
}
path.close();
canvas.drawPath(path, Paint()..color = highlightColor);

7) Текст рисуем поверх контура

Контур и текст складываются в Stack: сначала CustomPaint, затем RichText.

Stack(
  children: [
    CustomPaint(...),
    IgnorePointer(
      ignoring: true,
      child: RichText(text: ...),
    ),
  ],
)

Так мы получаем аккуратную цветную подложку и тот же текст сверху.

Короткий итог алгоритма

  1. Из текста получаем TextBox-прямоугольники выделяемого сегмента.

  2. Нормализуем особые случаи многострочных переходов.

  3. По уникальным x/y строим матрицу и обходим ее по периметру по часовой стрелке.

  4. Чистим дубликаты и коллинеарные точки.

  5. Скругляем углы через единичные векторы и ограниченный радиус.

  6. Направление дуги определяем знаком векторного произведения.

  7. Рисуем путь и накладываем текст сверху.

Что можно улучшить дальше

  • Разделить стили обычного и выделенного текста без расхождения метрик.

  • Кэшировать рассчитанный контур, чтобы не пересчитывать его на каждый build.

  • Улучшить объединение "особых" групп, чтобы сохранять больше семантики цельного блока.

  • По-другому обходить таблицу (возможно, совсем без таблицы)


Спасибо вам за прочтение статьи! Надеюсь, вы найдете ее полезной. А если будут какие-то замечания или улучшения, обязательно напишите. Если это будет кому-то полезно, можно будет сделать простой пакет в pub.dev

Комментарии (4)


  1. savostin
    15.02.2026 23:18

    Немного SVG магии:

    <svg width="0" height="0" style="position:absolute">
      <filter id="goo">
        <feGaussianBlur in="SourceGraphic" stdDeviation="4" result="b"/>
        <feColorMatrix in="b" mode="matrix"
          values="
            1 0 0 0 0
            0 1 0 0 0
            0 0 1 0 0
            0 0 0 22 -10" result="g"/>
        <feComposite in="SourceGraphic" in2="g" operator="atop"/>
      </filter>
    </svg>
    <style>
    .container {
      width: 14em;
      font-size: 1.2em;
      line-height: 1.5em;
    }
    
    .hl{
      --c: #f59e0b;
      --py: .14em;     /* vertical padding */
      --px: .50em;     /* horizontal padding */
      --join: .2em;   /* how much to “bleed” into the line gap */
    
      background: var(--c);
      padding: var(--py) var(--px);
      border-radius: 9999px;
    
      -webkit-box-decoration-break: clone;
      box-decoration-break: clone;
    
      box-shadow:
        0  var(--join) 0 var(--c),
        0 calc(-1 * var(--join)) 0 var(--c);
      filter: url(#goo);
    }
    </style>
    <div class="container">
      Lorem ipsum dolor sit <b class="hl">amet, consectetur adipiscing elit, 
      sed do eiusmod</b> tempor incididunt ut labore et 
      dolore magna aliqua. Ut enim ad minim veniam, 
      quis nostrud exercitation ullamco laboris nisi 
      ut aliquip ex ea commodo consequat.
    </div>
    


    1. koptehe Автор
      15.02.2026 23:18

      Да, согласен, что такие фильтры есть. И я даже думал сделать это через эффект gooey, или через шейдеры (где тоже gooey). Но для flutter нельзя напрямую использовать SVG-фильтры, а с шейдерами чуть запарно.

      А еще есть с gooey есть проблема, что при малом радиусе скругления уголки могу "неправильно" отрисовываться. "Неправильно" - это как раз не перетекать, а создавать острые углы в месте стыка (каждый gooey "обнимает" свой текст) . Но тут я могу ошибаться


  1. aamonster
    15.02.2026 23:18

    Сделайте ширину чуть больше (начинайте выделение чуть левее, а заканчивайте чуть правее – примерно на радиус закругления), а то плохо смотрится.


  1. RodionGork
    15.02.2026 23:18

    С одной стороны - круто, плюсую :)

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

    читатель пробегая текст "по диагонали" не будет особо разбираться как именно закругляли уголки. а уж если текст будет разбирать LLM как сейчас модно, чтобы представить "выжимку" то тем более :)