最佳的自动换行算法是什么?

35

在现代文本编辑器中,换行是必备功能之一。

如何处理自动换行?什么是最佳自动换行算法?

如果文本有几百万行,如何使自动换行速度非常快?

为什么需要此解决方案?因为我的项目必须使用各种缩放级别绘制文本,并同时具有漂亮的外观。

运行环境是 Windows Mobile 设备。最大速度为 600 MHz,内存大小非常小。

如何处理行信息?假设原始数据有三行。

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

之后,断行文本将显示为:

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

我应该再分配三行吗?或者有其他建议吗?


关于您的更新和速度问题,请记得稍后进行优化。首先,编写您的换行算法。在一百万行文本上运行它。仅当它无法满足您的要求时,才进行优化。 - Greg Hewgill
1
问题并没有明确指出它是针对固定宽度字体的,尽管示例和在“文本编辑器”中的使用暗示了这一点。只有Yaakov Ellis的答案提到了非固定宽度字体的文本换行。 - Gnubie
最好的是什么?最漂亮的、最快的、最小的、最简单的、最聪明的... - Carl Smith
10个回答

38
这是我用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 its 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 like 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;
    }
}

这个功能相对比较简单 - 它会根据空格、制表符和破折号进行分割。

它确保破折号与前面的单词连在一起 (这样你就不会出现"stack
-overflow"的情况), 但它并不倾向于将小的连字符词移到新行而不是分割它们。

如果一行太长,它会将单词分割开。

由于我对其他文化的自动换行规则了解有限,所以这个功能也相对具有文化特定性。


1
非常好而且简洁。小错误:如果字符串包含换行符,则curLineLength应设置为零(最简单的方法是将'\n'添加到断点字符中,然后测试单词是否等于'\n')。 - dbkk
2
此外,在拆分长单词时最好不要尝试加连字符,只需将它们分开即可。适当的行尾连字符是一个难题,即使对于英语(而不是Engli-sh或Engl-ish)也是如此。 - dbkk
这个程序中的一个问题是非间距字符。例如,如果用户输入了拉丁小写字母E,后面跟着COMBINING BREVE,并且有50个单词都是这样的组合,那么每行将留下2/3到1/2的空白。规范化为FormC会限制每个组合只有一个代码点变体时的情况,但通常需要扫描和检查每个字形以查看它是否是间距字符。这通常是一个小问题,在某些输入上却是一个巨大的问题。 - dhasenan

29

唐纳德·E·克努斯在他的排版系统TeX中进行了许多关于断行算法的工作。可以说,这是最好的断行算法之一,其“最佳”体现在结果的视觉效果上。

他的算法避免了贪婪式的行填充所带来的问题,该问题可能会导致非常密集的一行,接着是非常稀疏的一行。

一个有效的算法可以使用动态规划来实现。

有关TeX断行的论文


25

最近我有机会编写一个自动换行的函数,并且想分享一下我的成果。

我采用了一个TDD方法,几乎跟Go example的方法一样严格。我从测试开始,将字符串“Hello, world!”在80个字符宽度下换行返回“Hello, World!”。显然,最简单有效的方法就是不做任何处理地返回输入字符串。从这里开始,我逐渐增加了更多复杂的测试,并最终得到了一个递归解决方案,可以相当高效地处理任务(至少对于我的目的来说)。

递归解决方案的伪代码:

Function WordWrap (inputString, width)
    去除输入字符串中前后空格。
如果去除空格后的字符串长度小于或等于宽度, 返回去除空格后的字符串。 否则, 从宽度处开始查找去除空格后的字符串中最后一个空格的索引。
如果没有空格,则使用宽度作为索引。
将去除空格后的字符串分成两部分。
从索引前面的部分去除尾随空格, 从索引后面的部分去除前导空格。
连接并返回: 索引前面的修剪部分, 一个换行符, 并调用WordWrap处理索引后面的修剪部分(使用与原始调用相同的宽度)的结果。

这仅在空格处换行,如果您想换行已经包含换行符的字符串,则需要在换行符处分割它,将每个片段发送到此函数,然后重新组装字符串。即便如此,在运行速度较快的VB.NET上,它也可以处理大约20 MB/秒。


这个算法非常好,似乎是为数不多的几个能够正确处理超过一行长度的单词的算法之一。为了澄清措辞,“查找最后一个空格的索引”意味着在字符串中向后查找width前面的空格。如果您有不成比例的字体,可以从那里开始测量行,当它超过宽度并记录最后一个空格索引时进行断开。 - Ray

6
我不知道具体的算法,但以下是它应该如何工作的大致概述:
1. 对于当前文本大小、字体、显示大小、窗口大小、边距等,确定一行可以容纳多少个字符(如果是固定类型),或者一行可以容纳多少像素(如果不是固定类型)。
2. 逐个字符地遍历这一行,计算从这一行开头到现在已经记录了多少个字符或像素。
3. 当你超过了这一行的最大字符数/像素数时,回退到最后一个空格/标点符号,并将所有文本移动到下一行。
4. 重复以上步骤,直到遍历完整个文档。
在 .NET 中,单词换行功能已经内置在像 TextBox 这样的控件中。我相信其他编程语言也有类似的内置功能。

4

使用连字符还是不使用?

如果不使用,很容易,只需将您的文本封装为每个单词的wordobjects,并为它们提供一个getWidth() 方法。然后从第一个单词开始添加行长度,直到大于可用空间。如果是这样,换行并从下一行重新开始计数,以此类推。

如果使用连字号,则需要使用通用格式的连字号规则,例如:hy-phen-a-tion。

然后就与上述相同,但是需要拆分导致溢出的最后一个单词。

有关如何为优秀的文本编辑器构建代码结构的良好示例和教程在《设计模式》(Design Patterns) 一书中给出。这是他们展示模式的主要样本之一。


为什么这个被投票为-1?虽然贪心算法不是最优的,但... - ShreevatsaR
我也不知道。我也感到很惊讶。 - Sven Hecht
3
因为说“容易”是不正确的,所以即使忽略连字符,编写有效算法来完成此任务也并非易事。而且很难创建适用于固定宽度和可变宽度字体的任何有效版本。因此,“容易”是不正确的,因此被否决了。 - mjaggard

3
我对我的编辑器项目也有同样的疑惑。我的解决方案是一个两步骤的过程:
1. 找到行尾并将它们存储在一个数组中。 2. 对于非常长的行,在大约1K间隔处找到合适的断点,并将它们也保存在行数组中。这是为了捕捉“没有单个换行符的4 MB文本”。
当您需要显示文本时,查找相关的行并即时换行。在缓存中记住此信息以进行快速重绘。当用户滚动整个页面时,清除缓存并重复。
如果可以,请在后台线程中加载/分析整个文本。这样,您可以在文档的其余部分仍在被检查时已经显示第一页文本。这里最简单的解决方案是切掉前16KB的文本,并在子字符串上运行算法。这非常快速,即使您的编辑器仍在加载文本,也可以立即呈现第一页。
当光标最初位于文本末尾时,您可以使用类似的方法;只需读取最后16KB的文本并进行分析即可。在这种情况下,使用两个编辑缓冲区,将除最后16KB之外的所有内容加载到第一个缓冲区中,而用户被锁定在第二个缓冲区中。您可能还希望在关闭编辑器时记住文本的行数,以便滚动条不会出现异常。
当用户可以从中间开始启动编辑器时,情况变得复杂起来,但最终只是结束问题的扩展。您只需要记住上次会话的字节位置、当前行号和总行数,还需要三个编辑缓冲区,或者需要一个编辑缓冲区,您可以在其中在中间切掉16KB。
或者,当文本加载时锁定滚动条和其他界面元素;这允许用户在完全加载文本时查看文本。

1

我今天为了好玩写了一些C语言代码:

以下是我的考虑:

  1. No copying of characters, just printing to standard output. Therefore, since I don't like to modify the argv[x] arguments, and because I like a challenge, I wanted to do it without modifying it. I did not go for the idea of inserting '\n'.

  2. I don't want

     This line breaks     here
    

    to become

     This line breaks
          here
    

    so changing characters to '\n' is not an option given this objective.

  3. If the linewidth is set at say 80, and the 80th character is in the middle of a word, the entire word must be put on the next line. So as you're scanning, you have to remember the position of the end of the last word that didn't go over 80 characters.

    So here is mine, it's not clean; I've been breaking my head for the past hour trying to get it to work, adding something here and there. It works for all edge cases that I know of.

    #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;
    }
    

    So basically, I have start and lastChar that I want to set as the start of a line and the last character of a line. When those are set, I output to standard output all the characters from start to end, then output a '\n', and move on to the next line.

    Initially everything points to the start, then I skip words with the while(!isDelim(*current)) ++current,++chars;. As I do that, I remember the last character that was before 80 chars (lastChar).

    If, at the end of a word, I have passed my number of chars (80), then I get out of the while(chars <= wrapLength) block. I output all the characters between start and lastChar and a newline.

    Then I set current to lastChar+1 and skip delimiters (and if that leads me to the end of the string, we're done, return 0). Set start, lastChar and current to the start of the next line.

    The

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

    part is for strings that are too short to be wrapped even once. I added this just before writing this post because I tried a short string and it didn't work.

    I feel like this might be doable in a more elegant way. If anyone has anything to suggest I'd love to try it.

    And as I wrote this I asked myself "what's going to happen if I have a string that is one word that is longer than my wraplength" Well it doesn't work. So I added the

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

    before the printLine() statement (if lastChar hasn't moved, then we have a word that is too long for a single line so we just have to put the whole thing on the line anyway).

    I took the comments out of the code since I'm writing this but I really feel that there must be a better way of doing this than what I have that wouldn't need comments.

    So that's the story of how I wrote this thing. I hope it can be of use to people and I also hope that someone will be unsatisfied with my code and propose a more elegant way of doing it.

    It should be noted that it works for all edge cases: words too long for a line, strings that are shorter than one wrapLength, and empty strings.


1
我无法保证这个代码是没有bug的,但我需要一个可以自动换行并遵守缩进边界的代码。除此之外,我对这段代码没有其他要求。这是一个扩展方法,会破坏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);
        }
    }
}

0

我也来分享一下我的 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);

}

0

@ICR,感谢您分享C#示例。

我没有成功使用它,但我想出了另一个解决方案。如果有兴趣,请随意使用这个:C#中的WordWrap函数。源代码可在GitHub上获得。

我已经包含了单元测试/样例。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接