最好的自动换行算法? [关闭]

Posted

技术标签:

【中文标题】最好的自动换行算法? [关闭]【英文标题】:Best word wrap algorithm? [closed] 【发布时间】:2010-09-06 06:21:36 【问题描述】:

自动换行是现代文本编辑器的必备功能之一。

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

如果文本有几百万行,我怎样才能使自动换行非常快?

为什么我需要解决方案?因为我的项目必须绘制具有不同缩放级别的文本,同时还要美观。

运行环境为 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.

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

【问题讨论】:

关于你的更新和速度问题,记得稍后优化。首先,编写你的自动换行算法。如果是文本,则运行一百万行。如果且仅当对于您的要求而言速度太慢,则进行优化。 问题没有明确指定它用于固定宽度的字体,尽管示例和“文本编辑器”中的使用暗示了这一点。只有 Yaakov Ellis 的回答提到了非固定宽度字体的文本换行。 以什么方式最好?最漂亮、最快、最小、最简单、最聪明…… 【参考方案1】:

带或不带连字符?

没有它很容易。只需将您的文本封装为每个单词的 wordobjects 并给它们一个方法 getWidth()。然后从第一个单词开始累加行长度,直到它大于可用空间。如果是这样,则将最后一个单词换行并重新开始计算以该单词开头的下一行,依此类推。

使用断字,您需要采用通用格式的断字规则,例如:hy-phen-a-tion

那么和上面一样,只是需要拆分导致溢出的最后一个单词。

Gang of Four Design Patterns 一书中提供了一个很好的示例和教程,说明如何为优秀的文本编辑器构建代码。这是他们展示模式的主要样本之一。

【讨论】:

为什么会被选为-1?当然贪心算法不是最优的,但是...... 打败了我。我也很惊讶。 因为说它“容易”是不正确的,所以即使你忽略连字符,为这项工作编写一个有效的算法也不是一件容易的事。也很难创建对固定宽度和可变宽度字体都有效的任何版本。 Easy 不正确,因此投反对票。【参考方案2】:

我不知道任何具体的算法,但以下可能是它应该如何工作的粗略概述:

    对于当前的文本大小、字体、显示大小、窗口大小、边距等,确定一行可以容纳多少个字符(如果是固定类型),或者一行可以容纳多少像素(如果不是固定类型)。 逐个字符遍历该行,计算自该行开始以来记录了多少个字符或像素。 当您超过该行的最大字符数/像素数时,移回最后一个空格/标点符号,并将所有文本移至下一行。 重复,直到浏览完文档中的所有文本。

在 .NET 中,自动换行功能内置于 TextBox 等控件中。我确信其他语言也存在类似的内置功能。

【讨论】:

【参考方案3】:

这是我用 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;
    

它相当原始 - 它以空格、制表符和破折号分隔。它确实确保破折号坚持它之前的单词(所以你不会以 stack\n-overflow 结束),尽管它不赞成将带连字符的小单词移动到换行符而不是拆分它们。如果单词太长而不是一行,它会拆分单词。

这也是相当特定的文化,因为我不太了解其他文化的自动换行规则。

【讨论】:

非常简洁明了。小错误:如果字符串包含换行符,则 curLineLength 应设置为零(最简单的方法是在断字符中添加 '\n',然后测试 word 是否等于 '\n')。 另外,最好不要在拆分长词时尝试使用连字符,而是将它们分开。正确的行尾连字符是一个难题,即使对于英语(不是英语或英语)也是如此。 其中一个错误是非空格字符。例如,如果您的用户输入了 LATIN SMALL LETTER E 后跟 COMBINING BREVE,并且只有 50 个单词,那么您将每行的 2/3 到 1/2 留空。规范化为 FormC 将限制组合的单个代码点变体,但通常您需要扫描并检查每个字形以查看它是否是间距字符。小问题通常,一些输入的大问题。【参考方案4】:

Donald E. Knuth 在他的 TeX 排版系统中对换行算法做了很多工作。这可以说是最好的换行算法之一——就结果的视觉外观而言是“最好的”。

他的算法避免了贪婪线填充的问题,在这种情况下,你可能会得到一条非常密集的线,然后是一条非常松散的线。

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

A paper on TeX's line breaking.

【讨论】:

【参考方案5】:

最近有机会写一个自动换行功能,想分享一下自己的想法。

我使用的TDD 方法几乎与Go example 中的方法一样严格。我从包装字符串“Hello, world!”的测试开始。宽度为 80 时应返回“Hello, World!”。显然,最简单的方法是原封不动地返回输入字符串。从那开始,我进行了越来越复杂的测试,最终得到了一个递归解决方案,它(至少对我而言)非常有效地处理了任务。

递归解决方案的伪代码:

函数 WordWrap(输入字符串,宽度) 修剪前导和尾随空格的输入字符串。 如果修剪后的字符串的长度

这仅在空格处换行,如果要换行已经包含换行符的字符串,则需要在换行符处将其拆分,将每个部分发送到此函数,然后重新组合字符串。尽管如此,在快速机器上运行的 VB.NET 中,这可以处理大约 20 MB/秒。

【讨论】:

【参考方案6】:

我对自己的编辑器项目也有同样的想法。我的解决方案分为两步:

    找到行尾并将它们存储在一个数组中。 对于很长的线,以大约 1K 的间隔找到合适的断点,并将它们也保存在线阵列中。这是为了捕捉“4 MB 文本,没有一个换行符”。

当您需要显示文本时,找到有问题的行并即时换行。在缓存中记住此信息以便快速重绘。当用户滚动整个页面时,刷新缓存并重复。

如果可以,请在后台线程中加载/分析整个文本。这样,您就可以在文档的其余部分仍在检查时显示文本的第一页。这里最简单的解决方案是删除前 16 KB 的文本并在子字符串上运行算法。这非常快,即使您的编辑器仍在加载文本,您也可以立即呈现第一页。

当光标最初位于文本末尾时,您可以使用类似的方法;只需阅读最后 16 KB 的文本并对其进行分析。在这种情况下,使用两个编辑缓冲区并将除最后 16 KB 之外的所有内容加载到第一个缓冲区中,而用户被锁定在第二个缓冲区中。当你关闭编辑器时,你可能想记住文本有多少行,这样滚动条就不会看起来很奇怪。

当用户可以将光标放在中间某处启动编辑器时,它会变得很棘手,但最终它只是最终问题的扩展。只需要记住字节位置、当前行号和上次会话的总行数,另外还需要三个编辑缓冲区,或者需要一个可以在中间切掉 16 KB 的编辑缓冲区。

或者,在文本加载时锁定滚动条和其他界面元素;允许用户在完全加载时查看文本。

【讨论】:

【参考方案7】:

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

我没有成功使用它,但我想出了另一个解决方案。如果对此有任何兴趣,请随时使用: WordWrap function in C#。来源是on GitHub。

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

【讨论】:

【参考方案8】:

我不能声称它没有错误,但我需要一个包含单词并遵守缩进边界的东西。除了到目前为止它对我有用之外,我对这段代码没有任何要求。这是一种扩展方法,违反了 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);
        
    

【讨论】:

【参考方案9】:

我不妨加入我制作的 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);


【讨论】:

【参考方案10】:

这是我今天为了好玩而在 C 中工作的:

以下是我的考虑:

    不复制字符,只打印到标准输出。因此,由于我不喜欢修改 argv[x] 参数,并且因为我喜欢挑战,所以我想在不修改的情况下进行。我没有考虑插入'\n'

    我不想

     This line breaks     here
    

    成为

     This line breaks
          here
    

    因此,鉴于此目标,将字符更改为 '\n' 不是一种选择。

    如果线宽设置为 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;
    
    

    所以基本上,我想将startlastChar 设置为行的开头和行的最后一个字符。设置好后,我将所有字符从头到尾输出到标准输出,然后输出'\n',然后继续下一行。

    最初一切都指向开始,然后我跳过带有while(!isDelim(*current)) ++current,++chars; 的单词。当我这样做时,我记得最后一个字符在 80 个字符之前 (lastChar)。

    如果在一个单词的末尾,我已经传递了我的字符数 (80),那么我就会退出 while(chars &lt;= wrapLength) 块。我输出startlastCharnewline 之间的所有字符。

    然后我将current 设置为lastChar+1 并跳过分隔符(如果这导致我到达字符串的末尾,我们就完成了,return 0)。将startlastCharcurrent 设置为下一行的开头。

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

    part 用于太短而无法包装一次的字符串。我在写这篇文章之前添加了这个,因为我尝试了一个短字符串但它不起作用。

    我觉得这可能以更优雅的方式可行。如果有人有什么建议,我很乐意尝试。

    当我写这篇文章时,我问自己“如果我有一个比我的 wraplength 长的单词的字符串会发生什么” 好吧,它不起作用。所以我添加了

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

    printLine() 语句之前(如果lastChar 没有移动,那么我们有一个单词对于单行来说太长了,所以我们只需要把整个东西放在一行上)。

    自从我写这篇文章以来,我就从代码中删除了 cmets,但我真的觉得肯定有比我不需要 cmets 的更好的方法来做到这一点。

    这就是我如何写这个东西的故事。我希望它可以对人们有用,我也希望有人对我的代码不满意,并提出一种更优雅的方式。

    需要注意的是,它适用于所有边缘情况:单词对于一行来说太长,字符串短于一个 wrapLength,以及空字符串。

【讨论】:

以上是关于最好的自动换行算法? [关闭]的主要内容,如果未能解决你的问题,请参考以下文章

新人报道(小程序文本换行自动算法)

如何在 Visual Studio 中切换自动换行?

请检查当我在控制台中输入时它自动换行的程序[关闭]

记事本的自动换行功能和显示状态栏不能同时打开

禁止 git 自动转换换行符

UITextView 在换行符/自动换行符后显示空格