Лучший алгоритм переноса слов?

Перенос слов - одна из обязательных функций в современном текстовом редакторе.

Вы знаете, как обрабатывать перенос слов? Какой лучший алгоритм для переноса слов?

обновлено: если текст состоит из нескольких миллионов строк, как я могу сделать перенос слов очень быстрым?

обновлено: зачем мне решение? Потому что мои проекты должны рисовать текст с разным уровнем масштабирования и одновременно красивым внешним видом.

обновлено: рабочая среда - устройства Windows Mobile. Максимальная скорость 600 МГц с очень маленьким объемом памяти.

обновлено: как мне обрабатывать информацию о линиях? Давайте предположим, что исходные данные состоят из трех строк.

THIS IS LINE 1.
THIS IS LINE 2.
THIS IS LINE 3.

После переноса слова текст будет отображаться так:

THIS IS
LINE 1.
THIS IS
LINE 2.
THIS IS
LINE 3.

Стоит ли выделять еще 3 строки? Или какие-либо другие предложения?

12 ответов

Вот алгоритм переноса слов, который я написал на C#. Это должно быть довольно легко переводить на другие языки (кроме, возможно, для IndexOfAny).

static char[] splitChars = new char[] { ' ', '-', '\t' };

private static string WordWrap(string str, int width)
{
    string[] words = Explode(str, splitChars);

    int curLineLength = 0;
    StringBuilder strBuilder = new StringBuilder();
    for(int i = 0; i < words.Length; i += 1)
    {
        string word = words[i];
        // If adding the new word to the current line would be too long,
        // then put it on a new line (and split it up if it's too long).
        if (curLineLength + word.Length > width)
        {
            // Only move down to a new line if we have text on the current line.
            // Avoids situation where wrapped whitespace causes emptylines in text.
            if (curLineLength > 0)
            {
                strBuilder.Append(Environment.NewLine);
                curLineLength = 0;
            }

            // If the current word is too long to fit on a line even on it's own then
            // split the word up.
            while (word.Length > width)
            {
                strBuilder.Append(word.Substring(0, width - 1) + "-");
                word = word.Substring(width - 1);

                strBuilder.Append(Environment.NewLine);
            }

            // Remove leading whitespace from the word so the new line starts flush to the left.
            word = word.TrimStart();
        }
        strBuilder.Append(word);
        curLineLength += word.Length;
    }

    return strBuilder.ToString();
}

private static string[] Explode(string str, char[] splitChars)
{
    List<string> parts = new List<string>();
    int startIndex = 0;
    while (true)
    {
        int index = str.IndexOfAny(splitChars, startIndex);

        if (index == -1)
        {
            parts.Add(str.Substring(startIndex));
            return parts.ToArray();
        }

        string word = str.Substring(startIndex, index - startIndex);
        char nextChar = str.Substring(index, 1)[0];
        // Dashes and the likes should stick to the word occuring before it. Whitespace doesn't have to.
        if (char.IsWhiteSpace(nextChar))
        {
            parts.Add(word);
            parts.Add(nextChar.ToString());
        }
        else
        {
            parts.Add(word + nextChar);
        }

        startIndex = index + 1;
    }
}

Это довольно примитивно - оно разбивается на пробелы, табуляции и тире. Он гарантирует, что тире придерживаются слова перед ним (так что вы не получите переполнение стека \ n-overflow), хотя он не способствует перемещению небольших переносимых слов на новую строку, а не разбивает их. Это разделяет слова, если они слишком длинные для строки.

Это также довольно специфично с точки зрения культуры, так как я мало знаю о правилах переноса слов в других культурах.

Дональд Э. Кнут проделал большую работу над алгоритмом разрыва строки в своей системе набора текста TeX. Возможно, это один из лучших алгоритмов переноса строк - "лучший" с точки зрения визуального отображения результата.

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

Эффективный алгоритм может быть реализован с использованием динамического программирования.

Документ о разрыве строк TeX.

Я не знаю, будет ли кто-нибудь когда-нибудь читать это, видя, сколько лет этому вопросу, но у меня была возможность недавно написать функцию переноса слов, и я хочу поделиться тем, что я придумал. Я использовал подход TDD, почти такой же строгий, как и в примере с Go. Я начал с теста, заключающего строку "Привет, мир!" на ширине 80 должен возвращаться "Hello, World!" Очевидно, что самое простое, что работает, - это вернуть входную строку без изменений. Исходя из этого, я проводил все более сложные тесты и получил рекурсивное решение, которое (по крайней мере, для моих целей) довольно эффективно справляется с задачей.

Псевдокод для рекурсивного решения:

Функция WordWrap (inputString, ширина)
    Обрезать входную строку начальных и конечных пробелов.

    Если длина обрезанной строки <= ширина,
        Верните урезанную строку.
    В противном случае,
        Найти индекс последнего пробела в обрезанной строке, начиная с ширины

        Если пробелов нет, используйте ширину в качестве индекса.

        Разделите обрезанную строку на две части по указателю.

        Обрезать конечные пробелы от части до индекса,
        и начальные пробелы из части после индекса.

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

Это переносит только пробелы, и если вы хотите обернуть строку, которая уже содержит разрывы строк, вам нужно разбить ее на разрывы строк, отправить каждый фрагмент этой функции и затем снова собрать строку. Тем не менее, в VB.NET, работающем на быстрой машине, это может обрабатывать около 20 МБ / с.

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

Я не знаю каких-либо конкретных алгоритмов, но следующее не будет грубым описанием того, как это должно работать:

  1. Для текущего размера текста, шрифта, размера экрана, размера окна, полей и т. Д. Определите, сколько символов может уместиться на строке (если используется фиксированный тип) или сколько пикселей может уместиться на строке (если не фиксированный тип).
  2. Проходите строку за символом, вычисляя, сколько символов или пикселей было записано с начала строки.
  3. Когда вы пропустите максимальный размер символов / пикселей для строки, вернитесь к последнему знаку пробела / пунктуации, переместите весь текст на следующую строку.
  4. Повторяйте, пока не пройдете весь текст в документе.

Вопрос: В.net функция переноса слов встроена в такие элементы управления, как TextBox. Я уверен, что подобная встроенная функциональность существует и для других языков. Есть ли причина, по которой вы не хотите использовать готовое решение? Это похоже на то, как изобретать велосипед.

С или без переносов?

без этого легко. Просто инкапсулируйте ваш текст как wordobjects для каждого слова и дайте им метод getWidth(), затем начните с первого слова, добавляя длину строки, пока она не станет больше доступного пространства. если это так, оберните последнее слово и начните считать снова для следующего ряда, начиная с этого ecetera.

При использовании переносов вам нужны правила переноса в общем формате, например: hy-phen-a-tion

Затем то же самое, что и выше, за исключением того, что вам нужно разделить последнее слово, вызвавшее переполнение.

Хороший пример и учебное пособие о том, как структурировать ваш код для превосходного текстового редактора, приведен в книге "Банды четырех шаблонов дизайна". Это один из основных образцов, на которых они показывают образцы.

Я задавался вопросом, то же самое для моего собственного редактора проекта. Мое решение состояло из двух этапов:

  1. Найдите концы строк и сохраните их в массиве.
  2. Для очень длинных линий найдите подходящие точки разрыва примерно с интервалом 1K и сохраните их также в массиве строк. Это для того, чтобы поймать "4MB текст без единого переноса строки".

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

Если вы можете, загрузите / проанализируйте весь текст в фоновом потоке. Таким образом, вы уже можете отобразить первую страницу текста, пока остальная часть документа все еще проверяется. Самое простое решение здесь - вырезать первые 16 КБ текста и запустить алгоритм на подстроке. Это очень быстро и позволяет вам визуализировать первую страницу мгновенно, даже если ваш редактор все еще загружает текст.

Вы можете использовать аналогичный подход, когда курсор изначально находится в конце текста; просто прочитайте последние 16 КБ текста и проанализируйте это. В этом случае используйте два буфера редактирования и загрузите все, кроме последних 16 КБ, в первый, пока пользователь заблокирован во втором буфере. И вы, вероятно, захотите вспомнить, сколько строк имеет текст при закрытии редактора, поэтому полоса прокрутки не выглядит странно.

Это становится проблематично, когда пользователь может запустить редактор с курсором где-то посередине, но, в конечном счете, это только расширение конечной проблемы. Только вам нужно запомнить позицию байта, текущий номер строки и общее количество строк из последнего сеанса, плюс вам нужно три буфера редактирования или вам нужен буфер редактирования, где вы можете вырезать 16 КБ в середине.

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

Вот мой, над которым я работал сегодня для развлечения в Си:

Вот мои соображения:

1) Нет копирования символов, просто печать на стандартный вывод. Поэтому, поскольку я не люблю изменять аргументы argv[x], и поскольку мне нравится вызов, я хотел сделать это без его изменения. Я не пошел на идею вставки '\n',

2) я не хочу

This line breaks     here

становиться

This line breaks
     here

так что изменение персонажей в '\n' это не вариант, учитывая эту цель.

3) Если ширина линии установлена, скажем, на 80, а 80-й символ находится в середине слова, все слово должно быть помещено в следующую строку. Поэтому, когда вы сканируете, вы должны помнить позицию конца последнего слова, которое не превышало 80 символов.

Так вот мой, он не чистый; В течение последнего часа я ломал голову, пытаясь заставить его работать, кое-что добавляя. Это работает для всех крайних случаев, о которых я знаю.

#include <stdlib.h>
#include <string.h>
#include <stdio.h>

int isDelim(char c){
   switch(c){
      case '\0':
      case '\t':
      case ' ' :
         return 1;
         break; /* As a matter of style, put the 'break' anyway even if there is a return above it.*/
      default:
         return 0;
   }
}

int printLine(const char * start, const char * end){
   const char * p = start;
   while ( p <= end ) putchar(*p++);
   putchar('\n');
}

int main ( int argc , char ** argv ) {

   if( argc <= 2 ) exit(1);

   char * start = argv[1];
   char * lastChar = argv[1];
   char * current = argv[1];
   int wrapLength = atoi(argv[2]);

   int chars = 1;
   while( *current != '\0' ){
      while( chars <= wrapLength ){
         while ( !isDelim( *current ) ) ++current, ++chars;
         if( chars <= wrapLength){
            if(*current == '\0'){
               puts(start);
               return 0;
            }
            lastChar = current-1;
            current++,chars++;
         }
      }

      if( lastChar == start )
         lastChar = current-1;

      printLine(start,lastChar);
      current = lastChar + 1;
      while(isDelim(*current)){
         if( *current == '\0')
            return 0;
         else
            ++current;
      }
      start = current;
      lastChar = current;
      chars = 1;
   }

   return 0;
}

Так что в основном у меня есть start а также lastChar что я хочу установить в качестве начала строки и последний символ строки. Когда они установлены, я вывожу на стандартный вывод все символы от начала до конца, а затем выводю '\n'и перейдите к следующей строке.

Сначала все указывает на начало, затем я пропускаю слова с while(!isDelim(*current)) ++current,++chars;, Когда я это делаю, я помню последний символ, который был до 80 символов (lastChar).

Если в конце слова я пропустил число символов (80), то я выхожу из while(chars <= wrapLength) блок. Я вывожу все символы между start а также lastChar и newline,

Тогда я установил current в lastChar+1 и пропустить разделители (и если это приведет меня к концу строки, мы закончили, return 0). Задавать start, lastChar а также current к началу следующей строки.

if(*current == '\0'){
    puts(start);
    return 0;
}

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

Я чувствую, что это может быть выполнимо более элегантным способом. Если кому-то есть что предложить, я бы с удовольствием попробовал.

И когда я писал это, я спрашивал себя: "Что произойдет, если у меня будет строка, которая на одно слово длиннее моей длины"? Ну, это не работает. Поэтому я добавил

if( lastChar == start )
     lastChar = current-1;

перед printLine() заявление (если lastChar не сдвинулся, у нас есть слово, которое слишком длинное для одной строки, так что нам все равно нужно просто поставить все в строку).

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

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

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

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

public static void WordWrap(this StringBuilder sb, int tabSize, int width)
{
    string[] lines = sb.ToString().Replace("\r\n", "\n").Split('\n');
    sb.Clear();
    for (int i = 0; i < lines.Length; ++i)
    {
        var line = lines[i];
        if (line.Length < 1)
            sb.AppendLine();//empty lines
        else
        {
            int indent = line.TakeWhile(c => c == '\t').Count(); //tab indents 
            line = line.Replace("\t", new String(' ', tabSize)); //need to expand tabs here
            string lead = new String(' ', indent * tabSize); //create the leading space
            do
            {
                //get the string that fits in the window
                string subline = line.Substring(0, Math.Min(line.Length, width));
                if (subline.Length < line.Length && subline.Length > 0)
                {
                    //grab the last non white character
                    int lastword = subline.LastOrDefault() == ' ' ? -1 : subline.LastIndexOf(' ', subline.Length - 1);
                    if (lastword >= 0)
                        subline = subline.Substring(0, lastword);
                    sb.AppendLine(subline);

                    //next part
                    line = lead + line.Substring(subline.Length).TrimStart();
                }
                else  
                {
                    sb.AppendLine(subline); //everything fits
                    break;
                }
            }
            while (true);
        }
    }
}

Вот решение в C#. Разлито единственное слово с превышением заданного лимита, а остальные слова остаются как обычно.

        /// <summary>
        /// Word wraps the given text to fit within the specified width.
        /// </summary>
        /// <param name="text">Text to be word wrapped</param>
        /// <param name="width">Width, in characters, to which the text
        /// should be word wrapped</param>
        /// <returns>The modified text</returns>
        public static string WordWrap(string text, int width)
        {
            int pos, next;
            StringBuilder sb = new StringBuilder();

            // Lucidity check
            if (width < 1)
                return text;

            // Parse each line of text
            for (pos = 0; pos < text.Length; pos = next)
            {
                // Find end of line
                int eol = text.IndexOf(Environment.NewLine, pos);
                if (eol == -1)
                    next = eol = text.Length;
                else
                    next = eol + Environment.NewLine.Length;

                // Copy this line of text, breaking into smaller lines as needed
                if (eol > pos)
                {
                    do
                    {
                        int len = eol - pos;
                        if (len > width)
                            len = BreakLine(text, pos, width);
                        sb.Append(text, pos, len);
                        sb.Append(Environment.NewLine);

                        // Trim whitespace following break
                        pos += len;
                        while (pos < eol && Char.IsWhiteSpace(text[pos]))
                            pos++;
                    } while (eol > pos);
                }
                else sb.Append(Environment.NewLine); // Empty line
            }
            return sb.ToString();
        }

        /// <summary>
        /// Locates position to break the given line so as to avoid
        /// breaking words.
        /// </summary>
        /// <param name="text">String that contains line of text</param>
        /// <param name="pos">Index where line of text starts</param>
        /// <param name="max">Maximum line length</param>
        /// <returns>The modified line length</returns>
        private static int BreakLine(string text, int pos, int max)
        {
            // Find last whitespace in line
            int i = max;
            while (i >= 0 && !Char.IsWhiteSpace(text[pos + i]))
                i--;

            // If no whitespace found, break at maximum length
            if (i < 0)
                return max;

            // Find start of whitespace
            while (i >= 0 && Char.IsWhiteSpace(text[pos + i]))
                i--;

            // Return length of text before whitespace
            return i + 1;
        }

Я также могу вмешаться в решение Perl, которое я сделал, потому что GNU fold -s оставлял конечные пробелы и другое плохое поведение. Это решение не (должным образом) обрабатывает текст, содержащий табуляции или возвратные пробелы, или встроенные возвраты каретки или тому подобное, хотя он обрабатывает CRLF-концы строк, преобразуя их все в просто LF. Он вносит минимальные изменения в текст, в частности, никогда не разбивает слово (не меняет wc -w), а для текста, содержащего не более одного пробела в строке (и без CR), он не изменяется wc -c (потому что он заменяет пространство на LF вместо вставки LF).

#!/usr/bin/perl

use strict;
use warnings;

my $WIDTH = 80;

if ($ARGV[0] =~ /^[1-9][0-9]*$/) {
  $WIDTH = $ARGV[0];
  shift @ARGV;
}

while (<>) {

s/\r\n$/\n/;
chomp;

if (length $_ <= $WIDTH) {
  print "$_\n";
  next;
}

@_=split /(\s+)/;

# make @_ start with a separator field and end with a content field
unshift @_, "";
push @_, "" if @_%2;

my ($sep,$cont) = splice(@_, 0, 2);
do {
  if (length $cont > $WIDTH) {
    print "$cont";
    ($sep,$cont) = splice(@_, 0, 2);
  }
  elsif (length($sep) + length($cont) > $WIDTH) {
    printf "%*s%s", $WIDTH - length $cont, "", $cont;
    ($sep,$cont) = splice(@_, 0, 2);
  }
  else {
    my $remain = $WIDTH;
    { do {
      print "$sep$cont";
      $remain -= length $sep;
      $remain -= length $cont;
      ($sep,$cont) = splice(@_, 0, 2) or last;
    }
    while (length($sep) + length($cont) <= $remain);
    }
  }
  print "\n";
  $sep = "";
}
while ($cont);

}

@ICR, спасибо, что поделились примером C#. Мне не удалось его использовать, но я нашел другое решение. Если есть какой-либо интерес к этому, пожалуйста, не стесняйтесь использовать это: http://johan.andersson.net/2010/11/03/wordwrap-function-in-c/

Я включил модульные тесты / образцы.

Спасибо!

Другие вопросы по тегам