WordIndex.cs
// 完毕:
using System;
using System.IO;
using System.Drawing;
using System.Linq;
using System.Collections.Generic;
using GrapeCity.Documents.Pdf;
using GrapeCity.Documents.Pdf.TextMap;
using GrapeCity.Documents.Text;
using GrapeCity.Documents.Common;
using GrapeCity.Documents.Pdf.Annotations;

namespace DsPdfWeb.Demos.Basics
{
    // 此示例加载现有的 PDF,并使用预定义的关键字列表,
    // 建立链接到它们出现的页面的那些单词的字母索引
    // 在文件中。生成的索引页附加到原始文档中,
    // 并保存在新的 PDF 中。
    // 使用以下技术将索引呈现在两个平衡列中
    // 在BalancedColumns示例中进行了演示。
    // 
    // 注意:如果您下载此示例并在您自己的系统上本地运行它
    // 没有有效的 GcDocs.Pdf 许可证,只有示例 PDF 的前五页
    // 将被加载,并且只会为这五个页面生成索引。
    public class WordIndex
    {
        // 字体集合来保存我们需要的字体:
        private FontCollection _fc = new FontCollection();
        // 本示例中使用的字体系列(不区分大小写):
        private const string _fontFamily = "segoe ui";

        // 主要样本条目:
        public int CreatePDF(Stream stream)
        {
            // 使用我们需要的字体设置字体集合:
            _fc.RegisterDirectory(Path.Combine("Resources", "Fonts"));

            // 获取要添加索引的 PDF:
            string tfile = Path.Combine("Resources", "PDFs", "CompleteJavaScriptBook.pdf");

            // 我们将在其上构建索引的单词列表:
            var words = _keywords.Distinct(StringComparer.InvariantCultureIgnoreCase).Where(w_ => !string.IsNullOrEmpty(w_));

            // 加载 PDF 并添加索引:
            using (var fs = File.OpenRead(tfile))
            {
                var doc = new GcPdfDocument();
                doc.Load(fs);
                //
                int origPageCount = doc.Pages.Count;
                // 构建并添加索引:
                AddWordIndex(doc, words);
                // 默认在第一个索引页打开文档
                // (可能无法在浏览器查看器中工作,但可以在 Acrobat 中工作):
                doc.OpenAction = new DestinationFit(origPageCount);
                // 完毕:
                doc.Save(stream);
                return doc.Pages.Count;
            }
        }

        // 用于构建索引的单词列表:
        private readonly string[] _keywords = new string[]
        {
            "JavaScript", "Framework", "MVC", "npm", "URL", "CDN", "HTML5", "CSS", "ES2015", "web",
            "Node.js", "API", "model", "view", "controller", "data management", "UI", "HTML",
            "API", "function", "var", "component", "design pattern", "React.js", "Angular", "AJAX",
            "DOM", "TypeScript", "ECMAScript", "CLI", "Wijmo", "CoffeeScript", "Elm",
            "plugin", "VueJS", "Knockout", "event", "AngularJS", "pure JS", "data binding", "OOP", "GrapeCity",
            "gauge", "JSX", "mobile", "desktop", "Vue", "template", "server-side", "client-side",
            "SPEC", "RAM", "ECMA",
        };

        // 在文档或页面上调用 FindText() 会动态为每个页面构建文本映射。
        // 重用缓存的文本地图可以大大加快速度。
        private SortedSet<int> FindTextPages(ITextMap[] maps, FindTextParams tp)
        {
            var finds = new SortedSet<int>();
            int currPageIdx = -1;
            foreach (var map in maps)
            {
                currPageIdx = map.Page.Index;
                map.FindText(tp, (fp_) => finds.Add(currPageIdx));
            }
            return finds;
        }

        // 将单词索引添加到传递的文档的末尾:
        private void AddWordIndex(GcPdfDocument doc, IEnumerable<string> words)
        {
            var tStart = Common.Util.TimeNow();

            // 为所有页面构建文本映射以加速 FindText() 调用:
            var textMaps = new ITextMap[doc.Pages.Count];
            for (int i = 0; i < doc.Pages.Count; ++i)
                textMaps[i] = doc.Pages[i].GetTextMap();

            // 单词和出现的页面索引,按单词排序:
            SortedDictionary<string, List<int>> index = new SortedDictionary<string, List<int>>();

            // 这里构建索引的主循环是针对关键词的。
            // 另一种方法是循环页面。
            // 取决于关键字词典与关键字词典的相对大小
            // 文档的页数,其中之一可能更好,
            // 但这超出了本示例的范围。
            foreach (string word in words)
            {
                bool wholeWord = word.IndexOf(' ') == -1;
                var pgs = FindTextPages(textMaps, new FindTextParams(word, wholeWord, false));
                // 查找复数的一种非常简单的方法:
                if (wholeWord && !word.EndsWith('s'))
                    pgs.UnionWith(FindTextPages(textMaps, new FindTextParams(word + "s", wholeWord, false)));
                if (pgs.Any())
                    index.Add(word, pgs.ToList());
            }

            // 准备渲染索引。整个索引建立完毕
            // 在单个 TextLayout 实例中,设置为渲染它
            // 每页两栏。
            // 主渲染循环使用 TextLayout.SplitAndBalance 方法
            // 使用BalancedColumns示例中演示的方法。
            // 这里的复杂之处在于我们需要将链接关联到
            // 相关页面及其呈现的每个页码,请参阅下面的链接索引。
            // 设置文本布局:
            const float margin = 72;
            var pageWidth = doc.PageSize.Width;
            var pageHeight = doc.PageSize.Height;
            var cW = pageWidth - margin * 2;
            // 标题(索引字母)格式:
            var tfCap = new TextFormat()
            {
                FontName = _fontFamily,
                FontBold = true,
                FontSize = 16,
                LineGap = 24,
            };
            // 索引字及页码格式:
            var tfRun = new TextFormat()
            {
                FontName = _fontFamily,
                FontSize = 10,
            };
            // 页眉/页脚:
            var tfHdr = new TextFormat()
            {
                FontName = _fontFamily,
                FontItalic = true,
                FontSize = 10,
            };
            // FirstLineIndent = -18 设置悬挂缩进:
            var tl = new TextLayout(72)
            {
                FontCollection = _fc,
                FirstLineIndent = -18,
                MaxWidth = pageWidth,
                MaxHeight = pageHeight,
                MarginLeft = margin,
                MarginRight = margin,
                MarginBottom = margin,
                MarginTop = margin,
                ColumnWidth = cW * 0.46f,
                TextAlignment = TextAlignment.Leading,
                ParagraphSpacing = 4,
                LineGapBeforeFirstLine = false,
            };

            // 为页码创建的文本运行列表:
            List<Tuple<TextRun, int>> pgnumRuns = new List<Tuple<TextRun, int>>();
            // 此循环在 TextLayout 上构建索引,保存文本运行
            // 为呈现的每个页码创建。请注意,此时
            // (在 PerformLayout(true) 调用之前)文本运行不包含任何信息
            // 关于它们的代码点和渲染位置,因此我们只能在此处保存文本运行。
            // 稍后它们将用于添加指向 PDF 中引用页面的链接:
            char litera = ' ';
            foreach (KeyValuePair<string, List<int>> kvp in index)
            {
                var word = kvp.Key;
                var pageIndices = kvp.Value;
                if (Char.ToUpper(word[0]) != litera)
                {
                    litera = Char.ToUpper(word[0]);
                    tl.Append($"{litera}\u2029", tfCap);
                }
                tl.Append(word, tfRun);
                tl.Append("  ", tfRun);
                for (int i = 0; i < pageIndices.Count; ++i)
                {
                    var from = pageIndices[i];
                    var tr = tl.Append((from + 1).ToString(), tfRun);
                    pgnumRuns.Add(Tuple.Create(tr, from));
                    // 我们将连续的页面合并为“..-M”:
                    int k = i;
                    for (int j = i + 1; j < pageIndices.Count && pageIndices[j] == pageIndices[j - 1] + 1; ++j)
                        k = j;
                    if (k > i + 1)
                    {
                        tl.Append("-", tfRun);
                        var to = pageIndices[k];
                        tr = tl.Append((to + 1).ToString(), tfRun);
                        pgnumRuns.Add(Tuple.Create(tr, to));
                        // 快进:
                        i = k;
                    }
                    if (i < pageIndices.Count - 1)
                        tl.Append(", ", tfRun);
                    else
                        tl.AppendLine(tfRun);
                }
            }
            // 这将计算字形并布置整个索引。
            // 下面循环中的 tl.SplitAndBalance() 调用不需要重做布局:
            tl.PerformLayout(true);

            // 现在我们准备分割和渲染文本布局,并添加页码的链接。

            // Split 区域和选项 - 有关详细信息,请参阅BalancedColumns:
            var psas = new PageSplitArea[] {
                new PageSplitArea(tl) { MarginLeft = tl.MarginLeft + (cW * 0.54f) },
            };
            var tso = new TextSplitOptions(tl)
            {
                KeepParagraphLinesTogether = true,
            };

            // 当前列中的第一个原始代码点索引:
            int cpiStart = 0;
            // 当前列中的 Max+1 原始码点索引:
            int cpiEnd = 0;
            // pgnumRuns 中的当前索引:
            int pgnumRunsIdx = 0;
            // 将索引拆分并呈现为两列:
            for (var page = doc.Pages.Add(); ; page = doc.Pages.Add())
            {
                var g = page.Graphics;
                // 添加一个简单的页眉:
                g.DrawString($"Index generated by DsPdf on {tStart:R}", tfHdr,
                    new RectangleF(margin, 0, pageWidth - margin * 2, margin),
                    TextAlignment.Center, ParagraphAlignment.Center, false);
                // 'rest' 将接受不适合此页面的文本:
                var splitResult = tl.SplitAndBalance(psas, tso, out TextLayout rest);
                // 渲染文本:
                g.DrawTextLayout(tl, PointF.Empty);
                g.DrawTextLayout(psas[0].TextLayout, PointF.Empty);
                // 添加从页码到页面的链接:
                linkIndices(tl, page);
                linkIndices(psas[0].TextLayout, page);
                // 我们完成了吗?
                if (splitResult != SplitResult.Split)
                    break;
                tl = rest;
            }
            // 完毕:
            return;

            // 在当前列的页码上添加指向实际页面的链接的方法:
            void linkIndices(TextLayout tl_, Page page_)
            {
                cpiEnd += tl_.CodePointCount;
                for (; pgnumRunsIdx < pgnumRuns.Count; ++pgnumRunsIdx)
                {
                    var run = pgnumRuns[pgnumRunsIdx];
                    var textRun = run.Item1;
                    int cpi = textRun.CodePointIndex;
                    if (cpi >= cpiEnd)
                        break;
                    cpi -= cpiStart;
                    var rects = tl_.GetTextRects(cpi, textRun.CodePointCount);
                    System.Diagnostics.Debug.Assert(rects.Count > 0);
                    page_.Annotations.Add(new LinkAnnotation(rects[0].ToRectangleF(), new DestinationFit(run.Item2)));
                }
                cpiStart += tl_.CodePointCount;
            }
        }

        // 创建一个包含 100 页“lorem ipsum”的示例文档:
        private string MakeDocumentToIndex()
        {
            const int N = 100;
            string tfile = Path.GetTempFileName();
            using (var fsOut = File.OpenRead(tfile))
            {
                var tdoc = new GcPdfDocument();
                // 有关StartDoc/EndDoc模式的详细信息请参见StartEndDoc:
                tdoc.StartDoc(fsOut);
                // 准备一个 TextLayout 来保存/格式化文本:
                var tl = new TextLayout(72);
                tl.FontCollection = _fc;
                tl.DefaultFormat.FontName = _fontFamily;
                tl.DefaultFormat.FontSize = 12;
                // 使用 TextLayout 布局整个页面,包括边距:
                tl.MaxHeight = tdoc.PageSize.Height;
                tl.MaxWidth = tdoc.PageSize.Width;
                tl.MarginAll = 72;
                tl.FirstLineIndent = 72 / 2;
                // 生成文档:
                for (int pageIdx = 0; pageIdx < N; ++pageIdx)
                {
                    tl.Append(Common.Util.LoremIpsum(1));
                    tl.PerformLayout(true);
                    tdoc.NewPage().Graphics.DrawTextLayout(tl, PointF.Empty);
                    tl.Clear();
                }
                tdoc.EndDoc();
            }
            return tfile;
        }
    }
}