本文章是由機器翻譯。

UI 最前線

分頁原則

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,

但是,列印原始程式碼時,這些技巧都不是很好用。

Splitting the 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中下載該工具包。在左側點擊或點按可進入下一頁面;在右側點按可返回上一頁面。

圖 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 這一步驟,並且如果段落從一頁延伸到下一頁,則會非常頻繁地執行這一步驟)造成了負面影響。我將在以後的專欄中探討一些速度更快的方法。

但是,程式不必一次對整本書進行分頁。在您開始閱讀一本書的開頭,然後再一頁一頁地翻閱的過程中,程式一次只需分一頁。

Features and Needs

我最初認為,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