WordIndex.cs
// 
// このコードは、DioDocs for PDF のサンプルの一部として提供されています。
// © MESCIUS inc. All rights reserved.
// 
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 サンプルで示されている手法を使用して、2つの
    // バランスの取れた列に描画されます。
    // 
    // 注意:このサンプルをダウンロードし、有効な DsPdf ライセンスなしでローカルシステム上で
    // 実行すると、サンプル PDF の最初の5ページだけが読み込まれ、その5ページのインデックス
    // のみが生成されます。
    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);
                // PDF ドキュメントを保存します。
                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
            // インスタンスに組み込まれ、1ページあたり2列で描画されるように設定されています。
            // メインの描画ループでは、BalancedColumns サンプルで示された手法を使用して
            // TextLayout.SplitAndBalance メソッドを使用します。
            // ここで問題となるのは、関連するページへのリンクを描画した各ページ番号に関連付ける
            // 必要があることです。以下の linkIndices を参照してください。
            // 
            // TextLayout を設定します。
            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.SplitAndBalanc() 呼び出しでは、レイアウトをやり直す必要はありません。
            tl.PerformLayout(true);

            // 
            // これで、テキストレイアウトを分割して描画し、ページ番号へのリンクを追加する準備が整いました。
            // 

            // 分割された領域とオプション - 詳細は 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;
            // インデックスを2列に分割して描画します。
            for (var page = doc.Pages.Add(); ; page = doc.Pages.Add())
            {
                var g = page.Graphics;
                // シンプルなページヘッダを追加します。
                g.DrawString($"Index generated by DsPdf on {tStart}", 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;
            }
            // PDF ドキュメントを保存します。
            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;
        }
    }
}