UI 前沿技术

分页的原则

Charles Petzold

下载代码示例

Charles Petzold
几十年来,专门研究计算机图形的编程人员已经认识到最艰难的任务与位图或矢量图无关,而与那些普普通通的文本有关。

文本处理起来棘手有多种原因,大部分原因与通常要求文本具有较强的可读性有关。此外,一段文本的实际高度只是偶尔与其字号有关,而字符宽度随字符的不同而异。字符通常组成字词(字符必须合在一起)。字词又组成段落,而段落必须分成多行。段落又组成文档,文档必须既可以滚动,又可以分成多页。

在上一期中,我讨论了 Silverlight 4 中的打印支持,现在我想讨论一下打印文本。在屏幕上显示文本与在打印机上打印文本之间最重要的区别可以用一个适合设想的简单事实来概括:打印机页面无滚动条。

需要打印超出一页的文本的程序必须将文本分成多页。这是一项重要编程任务,称为分页。我发现了一个十分有趣的现象,分页事实上在最近几年里已经变得越来越重要了,而在此期间,打印却变得不怎么重要了。还有一个您可以想象并可写下来贴到墙上的简单事实是:分页 — 它不再仅仅针对打印机。

拿起任何电子书阅读器 — 或任何可以让您阅读期刊、书籍的小型设备,甚至是桌面读书软件 — 您会发现文档已经整理成页面。有时,这些页面预先经过格式化并且格式固定(例如 PDF 和 XPS 文件格式),但是在许多情况下,可以对页面进行动态重排(如 EPUB 或专有电子书格式的文件)。对于可以重排的文档,有时像更改字号这样简单的操作都可能需要对文档的某个部分进行动态重新分页,此时用户只能等待,而且用户可能会不耐烦。

动态分页 — 并且快速完成 — 将一项重要的编程工作转换成了一项特别具有挑战性的工作。但是,我们不要太恐慌。我迟早会攻克这一难题的,至于现在,我会从非常简单的工作开始。

堆栈 TextBlock

Silverlight 提供了几种显示文本的类:

  • Glyphs 元素可能是大多数 Silverlight 编程人员最不熟悉的一种类。Glyphs 中使用的字体必须通过 URL 或 Stream 对象指定,这使得该元素在非常依赖特定字体的、具有固定页面的文档或文档包中很有用。关于 Glyphs 元素,此处不作讨论。
  • Paragraph 是 Silverlight 4 中的新类,它模拟了 Windows Presentation Foundation (WPF) 的文档支持中的一个重要类。但是,Paragraph 主要与 RichTextBox 结合使用,Silverlight for Windows Phone 7 不支持此类。
  • 然后还有 TextBlock,通常,通过设置 Text 属性便可轻松地使用它 — 但是它也可以使用其 Inlines 属性合并不同格式的文本。当文本超出允许的宽度时,TextBlock 还具有将文本折为多行的重要功能。

TextBlock 具有 Silverlight 编程人员所熟悉并且适合我们的需求的功效,这就是我使用它的原因。

SimplestPagination 项目(与本文中的可载代码一起提供)旨在打印纯文本文档。程序将文本的各行视为可能需要折为多行的段落。但是,程序隐式假设这些段落都不太长。我们可以从程序禁止跨页显示段落来推断出这种假设。(这是 SimplestPagination 名称的 Simplest 部分。)如果某个段落太长,无法在同一页面上显示,则整个段落就会移动到下一页面,并且如果该段落太大,以至于一个页面容不下该段落的内容,那么该段落就会被截断。

您可以运行 bit.ly/elqWgU 上的 SimplestPagination 程序。它只有两个按钮:“Load”(加载)和“Print”(打印)。“Load”(加载)按钮显示的 OpenFileDialog 可使您从本地存储中选择文件。“Print”(打印)按钮可对您选择的文件进行分页,并将其打印出来。

OpenFileDialog 会返回一个 FileInfo 对象。FileInfo 的 OpenText 方法将返回一个 StreamReader,它有一个用于读取文本的所有行的 ReadLine 方法。图 1 显示了 PrintPage 处理程序。

图 1 SimplestPagination 的 PrintPage 处理程序

void OnPrintDocumentPrintPage(
  object sender, PrintPageEventArgs args) {

  Border border = new Border {
    Padding = new Thickness(
      Math.Max(0, desiredMargin.Left - args.PageMargins.Left),
      Math.Max(0, desiredMargin.Top - args.PageMargins.Top),
      Math.Max(0, desiredMargin.Right - args.PageMargins.Right),
      Math.Max(0, desiredMargin.Bottom - args.PageMargins.Bottom))
  };

  StackPanel stkpnl = new StackPanel();
  border.Child = stkpnl;
  string line = leftOverLine;

  while ((leftOverLine != null) || 
    ((line = streamReader.ReadLine()) != null)) {

    leftOverLine = null;

    // Check for blank lines; print them with a space
    if (line.Length == 0)
      line = " ";

    TextBlock txtblk = new TextBlock {
      Text = line,
      TextWrapping = TextWrapping.Wrap
    };

    stkpnl.Children.Add(txtblk);
    border.Measure(new Size(args.PrintableArea.Width, 
      Double.PositiveInfinity));

    // Check if the page is now too tall
    if (border.DesiredSize.Height > args.PrintableArea.Height &&
      stkpnl.Children.Count > 1) {

      // If so, remove the TextBlock and save the text for later
      stkpnl.Children.Remove(txtblk);
      leftOverLine = line;
      break;
    }
  }

  if (leftOverLine == null)
    leftOverLine = streamReader.ReadLine();

  args.PageVisual = border;
  args.HasMorePages = leftOverLine != null;
}

与通常一样,打印页面是一个可视的树形结构。 此特定可视树的根是 Border 元素,该元素获得了一个 Padding 属性以获取 48 个单位(半英寸)的边距(在 desiredMargins 字段中显示)。 事件参数的 PageMargins 属性提供了页面的不可打印边距的尺寸,因此 Padding 属性需要指定额外的空间来使总空间最大达到 48。

然后,使 StackPanel 成为 Border 的子项,并将 TextBlock 元素添加到 StackPanel 中。 完成各项操作后,调用 Border 的 Measure 方法,其中页面的可打印宽度具有水平限制,而竖直方向上可以无限长。 然后,DesiredSize 属性会揭示 Border 所需的大小。 如果超出了 PrintableArea 的高度,则必须从 StackPanel 中删除 TextBlock(但是,如果只有一个 TextBlock,则不必删除)。

leftOverLine 字段存储页面上没有打印的文本。 我还通过最后一次调用 StreamReader 上的 ReadLine 来使用它指示文档是完整的。 (显然,如果 StreamReader 包含 PeekLine 方法,则不需要此字段。)

可下载代码包含一个 Documents 文件夹,以及一个名为 EmmaFirstChapter.txt 的文件。 这是专门为此程序准备的 Jane Austen 的小说《爱玛》的第一章:所有段落都是单行,并且它们都由空白行隔开。 使用默认的 Silverlight 字体,长度大约有四页。 打印页面阅读起来有些费劲,不过这只是因为行对于字号来说太宽了。

此文件也反映了程序的一个小问题:某个空白行是页面的第一段。 如果是这种情况,不应该打印此空白行。 这背后另有道理。

对于包含实际段落的打印文本,您可以在段落间使用空白行,或者您可能更愿意通过设置 TextBlock 的 Margin 属性施加更多的控制。 您也可以通过更改由以下代码指定 TextBlock 的 Text 属性的语句来实现首行缩进:

Text = line,
 
to this:
Text = "     " + line,

但是,打印源代码时,这些技巧都不是很好用。

拆分 TextBlock

体验过 SimplestPagination 程序之后,您可能会得出一个结论:该程序最大的缺陷是跨页时无法断开段落。

BetterPagination 程序中介绍了一种可以解决这一问题的方法,您可以在 bit.ly/ekpdZb 上运行此程序。 将 TextBlock 添加到 StackPanel 时会导致总高度超出页面范围,除此情况外,此程序与 SimplestPagination 非常相似。 在 SimplestPagination 中,此代码直接从 StackPanel 中删除了整个 TextBlock:

// Check if the page is now too tall
if (border.DesiredSize.Height > args.PrintableArea.Height &&
  stkpnl.Children.Count > 1) {

  // If so, remove the TextBlock and save the text for later
  stkpnl.Children.Remove(txtblk);
  leftOverLine = line;
  break;
}
BetterPagination now calls a method named RemoveText:
// Check if the page is now too tall
if (border.DesiredSize.Height > args.PrintableArea.Height) {
  // If so, remove some text and save it for later
  leftOverLine = RemoveText(border, txtblk, args.PrintableArea);
  break;
}

RemoveText 如图 2 所示。 此方法一次仅从 TextBlock 的 Text 属性的末尾删除一个字,并检查这样做是否有助于 TextBlock 适合页面。 所有删除的文本都累积在 StringBuilder 中,PrintPage 处理程序将其另存为下一页面的 leftOverLine。

图 2 BetterPagination 的 RemoveText 方法

string RemoveText(Border border, 
  TextBlock txtblk, Size printableArea) {

  StringBuilder leftOverText = new StringBuilder();

  do {
    int index = txtblk.Text.LastIndexOf(' ');

    if (index == -1)
      index = 0;

    leftOverText.Insert(0, txtblk.Text.Substring(index));
    txtblk.Text = txtblk.Text.Substring(0, index);
    border.Measure(new Size(printableArea.Width, 
      Double.PositiveInfinity));

    if (index == 0)
      break;
  }
  while (border.DesiredSize.Height > printableArea.Height);

  return leftOverText.ToString().TrimStart(' ');
}

虽然看起来复杂一点儿,但确实有效。 请记住,如果您要处理带格式的文本(不同的字体、字号,以及粗体和斜体),则您不能使用 TextBlock 的 Text 属性,只能使用 Inlines 属性,这极大地增加了处理过程的复杂性。

是的,实现这一目的确实有更快速的方法,不过这些方法肯定更复杂。 例如,可以实现二进制算法:可以删除一半的字,如果适合页面,则可以恢复所删除的那一半字,如果不适合页面,则可以删除所恢复的那一半字,依此类推。

但是,请记住,这是为打印编写的代码。 打印的瓶颈在于打印机本身,因此,当代码可能会多花几秒钟时间来测试每个 TextBlock 时,这个瓶颈可能并不会引起注意。

但是,当您对根元素调用 Measure 时,您可能开始想知道在后台执行的操作究竟有多少。 当然,所有单个 TextBlock 元素都将获取 Measure 调用,并使用 Silverlight 内部信息确定以特殊字体和字号呈现的文本字符串实际占用的空间大小。

您可能不知道此类代码是否可以对文档进行分页以便在慢速设备上进行视频显示。

那么我们就来试一下吧。

Windows Phone 7 上的分页

我的目标(在本文中不会完成)是创建一个用于 Windows Phone 7 的、适合读取从 Project Gutenberg (gutenberg.org) 下载的纯文本书籍文件的电子书阅读器。 您可能知道,Project Gutenberg 始于 1971 年,是最早的数字图书馆。 多年来,它专注于以纯文本 ASCII 格式提供公共领域图书(通常是英国古典文学名著)。 例如,Jane Austen 的小说《爱玛》的完整版本是文件 gutenberg.org/files/158/158.txt

每本书都由一个正整数来标识,作为其文件名。 正如您在这里看到的,《爱玛》是 158,其文本版本在文件 158.txt 中。 近年来,Project Gutenberg 还提供了其他格式(如 EPUB 和 HTML),但是为了力求简单,对于本项目,我将一直使用纯文本格式。

针对 Windows Phone 7 的 EmmaReader 项目将 158.txt 添加为一项资源,使您可以在手机上阅读整本书。 图 3 是运行在 Windows Phone 7 仿真器上的程序。 对于手势支持,该项目需要 Silverlight for Windows Phone 工具包,可从 silverlight.codeplex.com 中下载该工具包。 在左侧点击或点按可进入下一页面;在右侧点按可返回上一页面。

EmmaReader Running on the Windows Phone 7 Emulator

图 3 在 Windows Phone 7 仿真器上运行的 EmmaReader

程序除了使其合理可用所需的功能外几乎没有任何其他功能。 显然,我要增强这个程序,尤其是要使您可以阅读除《爱玛》外的其他书籍,甚至可能是您自己选择的书籍! 但是,为了确定基本功能,将精力集中在单本书籍上会更简单。

如果您检查 158.txt,您会发现纯文本 Project Gutenberg 文件的最显著的特征:各个段落分别包含一个或多个连续的、由空白行分隔的 72 个字符的行。 为了将此转换成适合 TextBlock 折行的格式,需要进行一些预处理,以便将这些单独的连续行合并成一行。 此操作在 EmmaReader 中的 PreprocessBook 方法中执行。 然后,将整本书(包括用于分隔段落的长度为零的行)存储为一个以段落类型 List<string> 命名的字段。 此版本的程序不会尝试将书籍划分为若干章节。

由于已经对书籍进行过分页,每页都会被视为仅有两个整数属性的 PageInfo 类型的对象:ParagraphIndex 是段落列表的一个索引,而 CharacterIndex 则是相应段落的字符串的一个索引。 这两个索引指示页面起始处的段落和字符。 首页的两个索引显然都是零。 由于已经对各个页面进行过分页,因此下一页面的索引已确定。

程序不会尝试一次对整本书进行分页。 使用我已经定义过的页面布局和 Silverlight for Windows Phone 7 的默认字体,《爱玛》分成了 845 页,在实际设备上运行时,要实现这样的分页需要 9 分钟的时间。 显然,我所使用的分页方法(要求 Silverlight 对每页执行 Measure 这一步骤,并且如果段落从一页延伸到下一页,则会非常频繁地执行这一步骤)造成了负面影响。 我将在以后的专栏中探讨一些速度更快的方法。

但是,程序不必一次对整本书进行分页。 在您开始阅读一本书的开头,然后再一页一页地翻阅的过程中,程序一次只需分一页。

功能和需求

我最初认为,EmmaReader 第一版除了这些从头到尾阅读书籍所需的功能外几乎没有任何其他功能。 而这真的会很残酷。 例如,假设您正在看书,您已经看到了 100 页左右,然后您关闭屏幕,并将手机放到了您的口袋里。 那时,程序已被逻辑删除了,这意味着它实际上已经终止了。 当您重新打开屏幕时,程序会重新启动,您又回到了第一页。 然后,您必须按 99 页才能从上次停止的位置继续阅读!

因此,当程序被逻辑删除或终止后,程序会将当前的页码保存在独立存储中。 您始终都能跳转回上次停止时所在的页面。 (如果您在仿真器或实际手机上的 Visual Studio 中运行程序时体验此功能,请确保通过按“Back”(返回)按钮来终止程序,而不要在 Visual Studio 中停止调试。 停止调试并不会使程序正确终止并访问独立存储。)

将页码保存在独立存储中实际上并不够。 如果仅保存页码,则程序会为了显示第 100 页而不得不对前 99 页进行分页。 程序至少需要此页的 PageInfo 对象。

但是,只有一个 PageInfo 对象也是不够的。 假设程序重新加载,它使用 PageInfo 对象显示第 100 页,然后您决定点按手指右侧的位置,进入上一页。 程序没有第 99 页的 PageInfo 对象,因此它需要对前 98 页进行重新分页。

因此,当您逐步阅读书籍,并且程序对每页进行分页时,程序会保留一个 List<PageInfo> 类型的列表,该列表包含到目前为止已确定的所有 PageInfo 对象。 整个列表保存在独立存储中。 如果您试用程序的源代码(例如,更改布局、字号,或用另一本书替换此整本书),请记住,任何影响分页的更改都会导致此 PageInfo 对象列表失效。 您需要使用手指按住启动列表中的程序名称,然后选择“Uninstall”(卸载),将程序从手机(或仿真器)中删除。 这是当前从独立存储中擦除存储的数据的唯一途径。

下面是 MainPage.xaml 中的 Grid 的内容:

<Grid x:Name="ContentPanel" 
  Grid.Row="1" Background="White">
  <toolkit:GestureService.GestureListener>
  <toolkit:GestureListener 
    Tap="OnGestureListenerTap"
    Flick="OnGestureListenerFlick" />
  </toolkit:GestureService.GestureListener>
            
  <Border Name="pageHost" Margin="12,6">
    <StackPanel Name="stackPanel" />
  </Border>
</Grid>

在分页期间,程序获取了 Border 元素的 ActualWidth 和 ActualHeight,并以在打印程序中使用 PrintableArea 属性的方式使用它们。 将各个段落的 TextBlock 元素(以及段落间的空白行)添加到 StackPanel 中。

图 4 显示了 Paginate 方法。 您可以看到,除了访问基于 paragraphIndex 和 characterIndex 的字符串对象列表以外,此方法与打印程序中使用的方法非常相似。 此方法还为下一页面更新这些值。

图 4 EmmaReader 中的 Paginate 方法

void Paginate(ref int paragraphIndex, ref int characterIndex) {
  stackPanel.Children.Clear();

  while (paragraphIndex < paragraphs.Count) {
    // Skip if a blank line is the first paragraph on a page
    if (stackPanel.Children.Count == 0 &&
      characterIndex == 0 &&
      paragraphs[paragraphIndex].Length == 0) {
        paragraphIndex++;
        continue;
    }

    TextBlock txtblk = new TextBlock {
      Text = 
        paragraphs[paragraphIndex].Substring(characterIndex),
      TextWrapping = TextWrapping.Wrap,
      Foreground = blackBrush
    };

    // Check for a blank line between paragraphs
    if (txtblk.Text.Length == 0)
      txtblk.Text = " ";

    stackPanel.Children.Add(txtblk);
    stackPanel.Measure(new Size(pageHost.ActualWidth, 
      Double.PositiveInfinity));

    // Check if the StackPanel fits in the available height
    if (stackPanel.DesiredSize.Height > pageHost.ActualHeight) {
      // Strip words off the end until it fits
      do {
        int index = txtblk.Text.LastIndexOf(' ');

        if (index == -1)
          index = 0;

        txtblk.Text = txtblk.Text.Substring(0, index);
        stackPanel.Measure(new Size(pageHost.ActualWidth, 
          Double.PositiveInfinity));

        if (index == 0)
          break;
      }
      while (stackPanel.DesiredSize.Height > pageHost.ActualHeight);

      characterIndex += txtblk.Text.Length;

      // Skip over the space
      if (txtblk.Text.Length > 0)
        characterIndex++;

      break;
    }
    paragraphIndex++;
    characterIndex = 0;
  }

  // Flag the page beyond the last
  if (paragraphIndex == paragraphs.Count)
    paragraphIndex = -1;
}

正如您在图 3 中看到的那样,程序显示了一个页码。 但是请注意,它并未显示页数,因为只有对整本书进行分页后才能确定页数。 如果您熟悉商务电子书阅读器,您可能知道页码和页数的显示是一个大问题。

用户发现电子书阅读器中的一种必要功能是能够更改字体或字号。 但是,从程序的角度考虑,这会导致可怕的后果:必须丢弃到目前为止累积的所有分页信息,并且需要对书籍的当前页面之前的所有页面进行重新分页,而此当前页面甚至会发生变化。

电子书阅读器中的另一个出色功能是能够导航到各个章节的起始位置。 将书籍分成章节实际上有助于程序处理分页。 各个章节都从新的页面开始,因此,可以使各个章节中的页面独立于其他章节来进行分页。 跳转到新章节的起始位置非常简单。 (但是,如果用户之后点按右侧,进入上一章节的最后一页,则必须对上一章节的全部内容进行重新分页!)

您也可能认为此程序需要具备更好的页面转换功能。 只是让新页面进入正确的位置是不能令人满意的,因为这并不能提供关于页面实际上已经转换,或者只转换了一个页面而非多个页面的足够反馈信息。 我们确实需要对程序做一些改进工作。 同时,也让我们享受一下小说的乐趣吧。

Charles Petzold MSDN 杂志*的长期特约编辑。*他的新书《Programming Windows Phone 7》(Microsoft Press,2010 年)可从 bit.ly/cpebookpdf 免费下载获得。

衷心感谢以下技术专家对本文的审阅:Jesse Liberty