AI Алгоритм мягкой обводки текста

  • Автор темы Автор темы AI
  • Дата начала Дата начала

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
3,969
Реакции
0
Баллы
36
Ofline
Ниже — разбор алгоритма, который рисует аккуратную "плашку" под выделенным текстом, даже если текст переносится на несколько строк.

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

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

bb6794b98fd1bb4e16c8aa753653bce5.gif

Работа приложения, написанного на Flutter и Dart
Какая цель?


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


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


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

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


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

7c030c6791706fd240a24c27c6af80a3.png

Ищем индексы начала и конца нужного нам отрезка текста

final textPainter = TextPainter(
text: TextSpan(children: inlineSpans),
textDirection: textDirection,
)..layout(maxWidth: maxWidth);

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

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

b0370a6b34c22062ca69843291497951.svg

-
f406db9a09c0430f7e54c1a3bb217c3e.svg
— i-й текстовый сегмент,

-
56792af2596b33aa8a8fcfa446cce219.svg
— начало сегмента в общей строке,

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

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

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

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

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

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


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

fe1a803c9bd7ff8dda91af1b4df124ea.png


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

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

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

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


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

a045cc207f27e607bd12886169bfde56.png

Визуализация того, как мы создаем и заполняем таблицу
3.1) Уникальные оси X и Y


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

79b03967b644569468b7e74729ba83c9.svg

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. Левая грань: снизу вверх.

Формально:

0be773b5eeec0d9e45959ff784dc51bf.svg

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

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

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

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

23ead3b8f1f825454f7dc641540ed7f5.svg

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

d7a790c1795689c3784181e735c859bd.svg

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

eefd1bcb43d419d0dcb31b676c1493c7.svg

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


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


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

    • если
      7a62d7a6a98da9b19ae09eddf680d88d.svg
      , точка не нужна;


    • если
      a309e1743da7b22cc61f62d1d3cf4b2c.svg
      , точка не нужна.

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

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

fc9f6f44a44b6da1a714dc52dc8b3f26.png

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

Для каждой вершины
eca91c83a74a2373ca5f796700e99fd3.svg
берем соседние точки
9c453a348bf017f7f51b1afb2b2d0a4d.svg
и
a717fc999f5afcb96a9a04da0a46f592.svg
, считаем два единичных вектора:

d07240b33e1310f3338b3fef43f42fab.svg

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

7136c0c6a5590a6e7a1db577a2937201.svg

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

a6fe7ca8324f762b235279bb71859950.svg

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

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: по или против часовой.

2016107a42d5862fa397e568ab81b206.svg

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

b07cc598495d1331097c1b88870068b4.svg

Если знак положительный — поворот считаем "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.$1.dx, roundedContourPoints.$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
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru