Просмотрщик XPS


В свое время, вместе с выходом висты, если я не ошибаюсь, компания Microsoft ввела такой формат как xps. Этот формат вроде был призван потеснить адобовский pdf, но микрософт не утрудили себя каким-либо его продвижением. В целом формат достаточно неплох для такого рода вещей, как всякие отчёты, мануалы и прочие документы не требующие модификации. Вот только присутствует один большой недостаток. У данного формата отсутствует толковый просмотрщик, поскольку просматривать его предлагают в интернет эксплорере, с помощью плагина который шёл в комплекте с вистой, а в xp и 2003 ставился вместе с 3-им дотнет фреймворком, что крайне неудобно, плюс просмотрщик внутри ie не поддерживает, ни структуры документа, ни внутрених ссылок. В общем достаточно хороший формат был бездарно загублен убогим просмотрщиком. Чуть позже в микрософте опомнились и сварганили Xps Essentials Pack, который несмотря на пафосность названия и представлял собой вполне нормальный (за исключением того, что этого просмотрщика существует как минимум 4 разных версии, под разные версии windows) просмотрщик для этого самого xps-а. Плюс в windows 7 такой просмотрщик(явный наследник essential pack) включён по умолчанию, плюс плагин для ie серьезно доработан.
Так вот, чему я это все. А к тому, что в 3-ем дотнет фреймворке встроена функциональность для работы с этим самым xps-ом. И сейчас я покажу, как написать свой просмотрщик для него.
Просмотрщик XPS

Для начала создадим пустое окно на которое кинем TreeView для отображения оглавления, DocumentViewer – это такой специальный контрол для работы с FixedDocument (а документы xps и являются FixedDocument, точнее сказать в xps может быть несколько FixedDocument, но на данный момент это не существенно), GridSplitter, чтоб изменять размеры TreeView-а, ну и тулбарчик с кнопкой:
<Window x:Class="XPSViewer.Window1"
    xmlns="[url:http://schemas.microsoft.com/winfx/2006/xaml/presentation|http://schemas.microsoft.com/winfx/2006/xaml/presentation]"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="XPSViewer" Height="480" Width="800">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="144*" MinWidth="75"/>
            <ColumnDefinition Width="540*" MinWidth="200"/>
        </Grid.ColumnDefinitions>
        <ToolBar Height="33" HorizontalAlignment="Stretch" Margin="-8,0,5,0" Name="toolBar1" VerticalAlignment="Top">
            <Button Height="23" Margin="0,0,0,0" Name="button1" VerticalAlignment="Center" Click="OnOpenClick">Открыть</Button>
        </ToolBar>
        <TreeView Margin="0,33,5,0" Name="treeView1" />
        <DocumentViewer HorizontalAlignment="Left" Margin="0,0,0,0" Name="documentViewer1" Grid.Column="1" />
        <GridSplitter HorizontalAlignment="Right" Margin="0,0,0,0" Name="gridSplitter1" VerticalAlignment="Stretch" Width="5" />        
    </Grid>
</Window>

По клику на кнопку будем открывать наш xps-ный файл. Примерно так:
// При нажатии кнопки Открыть
private void OnOpenClick(object sender, RoutedEventArgs e)
{
    OpenFileDialog dlg = new OpenFileDialog();
    dlg.Filter = "Файлы XPS|*.xps|Все файлы|*.*";
    if(dlg.ShowDialog(this)==true)
    {
        LoadXPS(dlg.FileName);
    }
}

Теперь напишем сам метод для загрузки xps-а:
/// <summary>
/// Загрузка xps из файла
/// </summary>
/// <param name="fileName">Имя файла</param>
private void LoadXPS(string fileName)
{
    // Загружаем документ
    XpsDocument doc = new XpsDocument(fileName, FileAccess.Read);
    // Загружаем документ в вьювер
    documentViewer1.Document = docs;            
    // Закрываем документ
    doc.Close();
}
Запускаем, смотрим. Вроде бы все неплохо, но есть ряд недостатков:
  • во-первых ссылки внутри документа не работают
  • во-вторых оглавление отсутствует (что впрочем неудивительно, мы же его не загрузили)
Итак начнем по порядочку.
Добавим поддержу ссылок в документе. Для этого изменим метод загрузки следующим образом:
/// <summary>
/// Загрузка xps из файла
/// </summary>
/// <param name="fileName">Имя файла</param>
private void LoadXPS(string fileName)
{
    // Загружаем документ
    XpsDocument doc = new XpsDocument(fileName, FileAccess.Read);

    FixedDocumentSequence docs = doc.GetFixedDocumentSequence();
    // Подписываемся на событие загрузки страницы, чтобы повесить событие на ссылки
    docs.DocumentPaginator.GetPageCompleted += OnGetPageComplete;
    // Загружаем документ в вьювер
    documentViewer1.Document = docs;            
    // Закрываем документ
    doc.Close();
}
DocumentViewer не загружает сразу весь документ, а держит в памяти только видимые страницы, подгружая новые по мере прокрутки. За загрузку новых страниц по мере прокрутки отвечает класс DocumentPaginator. Подписавшись у этого класса на событие GetPageCompleted мы может отредактировать страницу сразу после загрузки:
// При загрузке страницы
private void OnGetPageComplete(object sender, GetPageCompletedEventArgs e)
{
    if (e.Cancelled || (e.Error != null)) return;
    FillLinks(((FixedPage)e.DocumentPage.Visual).Children);            
}

e.DocumentPage.Visual – это мы получаем визуальную составляющую загруженной страницы и с помощью метода FillLinks ищем все ссылки на ней и подписываемся на нажатие их мышью:
/// <summary>
/// Проверяем все элементы страницы и заполняем ссылки
/// </summary>
/// <param name="collection">Коллекция элементов страницы</param>
private void FillLinks(UIElementCollection collection)
{
    foreach (UIElement element in collection)
    {
        // Если это ссылка, то подписываемся на клик
        Uri navigateUri = FixedPage.GetNavigateUri(element);
        if (navigateUri != null)
        {
            element.PreviewMouseUp += OnUrlMouseUp;
        }
        // Если элемент имеет поделементы то проверяем их
        if (element is Canvas)
        {
            FillLinks((element as Canvas).Children);
        }
    }
}

По нажатию мышью на них переходим по ссылке:
/// <summary>
/// Переход по ссылке
/// TODO: сделать корректную версию, чтоб учитывались внешение ссылки
/// </summary>
/// <param name="url">Ссылка</param>
private void GoToLink(string url)
{
    int pos = url.LastIndexOf('#');
    if (pos != -1)
    {
        // Выдираем якорь
        url = url.Substring(pos + 1);
        Match match = Regex.Match(url, "PG_(.*?)_LNK_(.*?)");
        if (match.Groups.Count > 2)
        {
            // Выдираем номе рстраницы
            int pageIndex = Convert.ToInt32(match.Groups[1].Value) - 1;
            // Загружаем страницу
            DocumentPage page =
                (documentViewer1.Document as FixedDocumentSequence).DocumentPaginator.GetPage(pageIndex);
            // Находим на странице элемент на который указывает ссылка
            UIElement element = FindVisual(url, (page.Visual as FixedPage).Children);
            // Скролимя к этому элементу
            if (element != null) (element as FrameworkElement).BringIntoView();
        }
    }
}

/// <summary>
/// Поиск элемента с нужным именем на странице
/// </summary>
/// <param name="name">Имя элемента</param>
/// <param name="collection">Список элементов</param>
/// <returns>Найденный элемент либо null</returns>
private UIElement FindVisual(string name, UIElementCollection collection)
{
    foreach (UIElement element in collection)
    {
        // Это нужный элемент, то возвращаем его
        if ((element as FrameworkElement).Name == name)
        {
            return element;
        }
        // Если элемент имеет поделементы то проверяем их
        if (element is Canvas)
        {
            UIElement findElement = FindVisual(name, (element as Canvas).Children);
            if (findElement != null) return findElement;
        }
    }
    return null;
}
В данном методе я выдираю идущее после # имя элемента в формате PG<номер страницы>LNK<индекс ссылки>. Потом выдираю оттуда номер страницы, загружаю эту страницу и нахожу на ней элемент с нужным именем. На самом деле я тут жутко схалтурил, ибо во-первых, ссылка может даваться не только на элемент страницы но и на что-то внешнее, например на веб-сайт, на е-мейл и т.п., а во-вторых, xps поддерживает возможность запихивания в него нескольких FixedDocument-ов и ссылка может вести на любой их них, ну и в третьих, то что имя элемента будет записан в формате PG<номер страницы>LNK<индекс ссылки> никем не гарантируется, оно может быть каким угодно, просто MS Word сохраняет его так. В общем данный метод будет работать отнюдь не со всеми документами и соответственно нуждается в серьёзной доработке…
Теперь о том, как загружать структуру документа. В микрософте почему-то не включили возможность загрузки оглавления в классы для работы с xps(почему, мне совсем непонятно), поэтому придётся идти обходным путем.
Сначала чуть-чуть о формате файла. Файл xps представляет собой специально сформированный zip-архив(как впрочем и все документы офис 2007, все это называется Open Document Format), в котором содержится всяческие файлы(страницы документа, картинки и т.п.), в частности там содержится xml файл с оглавлением. Более подробно формат я описывать не буду, благо про это всегда можно посмотреть в документации.
Итак для работы с такими хитрыми zip-файлами в .NetFramework есть подсистема System.IO.Packaging. Ей-то мы и воспользуемся. Для начала добавим в метод загрузки файла вызов метода загрузки оглавления:
/// <summary>
/// Загрузка xps из файла
/// </summary>
/// <param name="fileName">Имя файла</param>
private void LoadXPS(string fileName)
{
    // Загружаем документ
    XpsDocument doc = new XpsDocument(fileName, FileAccess.Read);
    // Загружаем оглавление
    LoadOutline(fileName);

    FixedDocumentSequence docs = doc.GetFixedDocumentSequence();
    // Подписываемся на событие загрузки страницы, чтобы повесить событие на ссылки
    docs.DocumentPaginator.GetPageCompleted += OnGetPageComplete;
    // Загружаем документ в вьювер
    documentViewer1.Document = docs;            
    // Закрываем документ
    doc.Close();
}
Затем создадим структуру, в которой мы будем хранить элементы оглавления(структура идентична той, в которой оглавление хранится в xml-е) :
// Элемент структуры документа
struct OutlineItem
{
    /// <summary>
    /// Уровень(степень вложености) в дереве
    /// </summary>
    public int Level;
    /// <summary>
    /// Описание
    /// </summary>
    public string Description;
    /// <summary>
    /// Ссылка
    /// </summary>
    public string Target;
}
Теперь напишем метод загрузки оглавления:
/// <summary>
/// Загрузка оглавления
/// TODO: Сделать выдирание правильного имени файла с оглавлением и поддержку многодокументового xps-а
/// </summary>
/// <param name="fileName">Имя файла</param>
private void LoadOutline(string fileName)
{
    // Очищаем дерево
    treeView1.Items.Clear();
    // Добываем пакет с документом
    Package package = PackageStore.GetPackage(new Uri(fileName, UriKind.Absolute));
    // Выдираем
    if (package.PartExists(new Uri("/Documents/1/Structure/DocStructure.struct", UriKind.Relative)))
    {
        PackagePart structurePart = package.GetPart(new Uri("/Documents/1/Structure/DocStructure.struct", UriKind.Relative));
        using (Stream structureStream = structurePart.GetStream())
        {
            XmlDocument doc = new XmlDocument();
            // Загружаем документ
            doc.Load(structureStream);
            // Выдираем структуру документа
            var items = doc.GetElementsByTagName("OutlineEntry").Cast<XmlElement>().Select(x => new OutlineItem() { Level = Convert.ToInt32(x.Attributes["OutlineLevel"].Value), Description = x.Attributes["Description"].Value, Target = x.Attributes["OutlineTarget"].Value });
            // Заполняем дерево
            FillTree(items);
        }
    }
}
Так как метод LoadOutline вызывается после загрузки файла в XpsDocument, то тут дабы избежать повторной загрузки файла с документом я вместо загрузки пакета использую PackageStore для того, чтоб извлечь имеющийся документ, именно поэтому я не делаю потом Сlose данного документа, потому что он будет закрыт в XpsDocument-е. Нужно заметить, что тут я опять схалтурил: во-первых, как я уже говорил, FixedDocument-ов в xps-е может быть несколько, и соответственно оглавлений тоже будет несколько, а во-вторых я здесь жестко прописал имя файла с оглавлением, что тоже неверно ибо оно может разниться.
Ну и методом FillTree я заполняю дерево:
/// <summary>
/// Заполнение дерева по структуре документа
/// </summary>
/// <param name="items">Элементы структуры документа</param>
private void FillTree(IEnumerable<OutlineItem> items)
{
    TreeViewItem currentItem = null;
    int lastLevel = 1;
    foreach (var item in items)
    {
        // Создаем элемент дерева
        TreeViewItem newItem = new TreeViewItem();
        // В заголовок пишем описание
        newItem.Header = item.Description;
        // В тэг пишем ссылку, чтоб по ней переходить
        newItem.Tag = item.Target;
        // При выделение - переход на ссылку
        newItem.Selected += OnOutlineTreeItemSelected;
        int level = item.Level;
        // Добавляем элемент в нужнео место
        if (level == 1) treeView1.Items.Add(newItem);
        else if (item.Level > lastLevel) currentItem.Items.Add(newItem);
        else
        {
            TreeViewItem parentItem = currentItem.Parent as TreeViewItem;
            while (level != lastLevel)
            {
                parentItem = parentItem.Parent as TreeViewItem;
                lastLevel--;
            }
            parentItem.Items.Add(newItem);
        }
        currentItem = newItem;
        lastLevel = item.Level;
    }
}
В общем все. Осталось только подправить методы GoToLink и LoadOutline для их корректной работы (я опищу это как-нибудь в другой раз) и просмотрщик xps файлов готов.
Полный код (xaml приведен в самом начале):
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Packaging;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Xps.Packaging;
using System.Xml;
using Microsoft.Win32;

namespace XPSViewer
{
    // Элемент структуры документа
    struct OutlineItem
    {
        /// <summary>
        /// Уровень(степень вложености) в дереве
        /// </summary>
        public int Level;
        /// <summary>
        /// Описание
        /// </summary>
        public string Description;
        /// <summary>
        /// Ссылка
        /// </summary>
        public string Target;
    }
    /// <summary>
    /// Главное окно
    /// </summary>
    public partial class Window1 : Window
    {
        #region Инициализация

        /// <summary>
        /// Конструктор по умолчанию
        /// </summary>
        public Window1()
        {
            InitializeComponent();
        }

        #endregion

        #region Обработка событий

        // При нажатии кнопки Открыть
        private void OnOpenClick(object sender, RoutedEventArgs e)
        {
            OpenFileDialog dlg = new OpenFileDialog();
            dlg.Filter = "Файлы XPS|*.xps|Все файлы|*.*";
            if(dlg.ShowDialog(this)==true)
            {
                LoadXPS(dlg.FileName);
            }
        }
        // При загрузке страницы
        private void OnGetPageComplete(object sender, GetPageCompletedEventArgs e)
        {
            if (e.Cancelled || (e.Error != null)) return;
            FillLinks(((FixedPage)e.DocumentPage.Visual).Children);            
        }
        // При нажатии на ссылку
        private void OnUrlMouseUp(object sender, MouseButtonEventArgs e)
        {
            if ((e.ChangedButton == MouseButton.Left) &amp;&amp; (e.ClickCount == 1))
            {
                string url = FixedPage.GetNavigateUri(sender as UIElement).ToString();
                GoToLink(url);
            }
        }
        // При выделение элемента дерева
        private void OnOutlineTreeItemSelected(object sender, RoutedEventArgs e)
        {
            GoToLink((treeView1.SelectedItem as TreeViewItem).Tag as string);
        }

        #endregion

        #region Внутрение методы

        /// <summary>
        /// Загрузка xps из файла
        /// </summary>
        /// <param name="fileName">Имя файла</param>
        private void LoadXPS(string fileName)
        {
            // Загружаем документ
            XpsDocument doc = new XpsDocument(fileName, FileAccess.Read);
            // Загружаем оглавление
            LoadOutline(fileName);

            FixedDocumentSequence docs = doc.GetFixedDocumentSequence();
            // Подписываемся на событие загрузки страницы, чтобы повесить событие на ссылки
            docs.DocumentPaginator.GetPageCompleted += OnGetPageComplete;
            // Загружаем документ в вьювер
            documentViewer1.Document = docs;            
            // Закрываем документ
            doc.Close();
        }

        /// <summary>
        /// Проверяем все элементы страницы и заполняем ссылки
        /// </summary>
        /// <param name="collection">Коллекция элементов страницы</param>
        private void FillLinks(UIElementCollection collection)
        {
            foreach (UIElement element in collection)
            {
                // Если это ссылка, то подписываемся на клик
                Uri navigateUri = FixedPage.GetNavigateUri(element);
                if (navigateUri != null)
                {
                    element.PreviewMouseUp += OnUrlMouseUp;
                }
                // Если элемент имеет поделементы то проверяем их
                if (element is Canvas)
                {
                    FillLinks((element as Canvas).Children);
                }
            }
        }

        /// <summary>
        /// Переход по ссылке
        /// TODO: сделать корректную версию, чтоб учитывались внешение ссылки
        /// </summary>
        /// <param name="url">Ссылка</param>
        private void GoToLink(string url)
        {
            int pos = url.LastIndexOf('#');
            if (pos != -1)
            {
                // Выдираем якорь
                url = url.Substring(pos + 1);
                Match match = Regex.Match(url, "PG_(.*?)_LNK_(.*?)");
                if (match.Groups.Count > 2)
                {
                    // Выдираем номе рстраницы
                    int pageIndex = Convert.ToInt32(match.Groups[1].Value) - 1;
                    // Загружаем страницу
                    DocumentPage page =
                        (documentViewer1.Document as FixedDocumentSequence).DocumentPaginator.GetPage(pageIndex);
                    // Находим на странице элемент на который указывает ссылка
                    UIElement element = FindVisual(url, (page.Visual as FixedPage).Children);
                    // Скролимя к этому элементу
                    if (element != null) (element as FrameworkElement).BringIntoView();
                }
            }
        }

        /// <summary>
        /// Поиск элемента с нужным именем на странице
        /// </summary>
        /// <param name="name">Имя элемента</param>
        /// <param name="collection">Список элементов</param>
        /// <returns>Найденный элемент либо null</returns>
        private UIElement FindVisual(string name, UIElementCollection collection)
        {
            foreach (UIElement element in collection)
            {
                // Это нужный элемент, то возвращаем его
                if ((element as FrameworkElement).Name == name)
                {
                    return element;
                }
                // Если элемент имеет поделементы то проверяем их
                if (element is Canvas)
                {
                    UIElement findElement = FindVisual(name, (element as Canvas).Children);
                    if (findElement != null) return findElement;
                }
            }
            return null;
        }

        /// <summary>
        /// Загрузка оглавления
        /// TODO: Сделать выдирание правильного имени файла с оглавлением и поддержку многодокументового xps-а
        /// </summary>
        /// <param name="fileName">Имя файла</param>
        private void LoadOutline(string fileName)
        {
            // Очищаем дерево
            treeView1.Items.Clear();
            // Добываем пакет с документом
            Package package = PackageStore.GetPackage(new Uri(fileName, UriKind.Absolute));
            // Выдираем
            if (package.PartExists(new Uri("/Documents/1/Structure/DocStructure.struct", UriKind.Relative)))
            {
                PackagePart structurePart = package.GetPart(new Uri("/Documents/1/Structure/DocStructure.struct", UriKind.Relative));
                using (Stream structureStream = structurePart.GetStream())
                {
                    XmlDocument doc = new XmlDocument();
                    // Загружаем документ
                    doc.Load(structureStream);
                    // Выдираем структуру документа
                    var items = doc.GetElementsByTagName("OutlineEntry").Cast<XmlElement>().Select(x => new OutlineItem() { Level = Convert.ToInt32(x.Attributes["OutlineLevel"].Value), Description = x.Attributes["Description"].Value, Target = x.Attributes["OutlineTarget"].Value });
                    // Заполняем дерево
                    FillTree(items);
                }
            }
        }
        /// <summary>
        /// Заполнение дерева по структуре документа
        /// </summary>
        /// <param name="items">Элементы структуры документа</param>
        private void FillTree(IEnumerable<OutlineItem> items)
        {
            TreeViewItem currentItem = null;
            int lastLevel = 1;
            foreach (var item in items)
            {
                // Создаем элемент дерева
                TreeViewItem newItem = new TreeViewItem();
                // В заголовок пишем описание
                newItem.Header = item.Description;
                // В тэг пишем ссылку, чтоб по ней переходить
                newItem.Tag = item.Target;
                // При выделение - переход на ссылку
                newItem.Selected += OnOutlineTreeItemSelected;
                int level = item.Level;
                // Добавляем элемент в нужнео место
                if (level == 1) treeView1.Items.Add(newItem);
                else if (item.Level > lastLevel) currentItem.Items.Add(newItem);
                else
                {
                    TreeViewItem parentItem = currentItem.Parent as TreeViewItem;
                    while (level != lastLevel)
                    {
                        parentItem = parentItem.Parent as TreeViewItem;
                        lastLevel--;
                    }
                    parentItem.Items.Add(newItem);
                }
                currentItem = newItem;
                lastLevel = item.Level;
            }
        }

        #endregion        
    }
}

Скачать исходники:

КОММЕНТАРИИ


НОВЫЙ КОММЕНТАРИЙ


*жирный*
_курсив_
+подчеркнутый+
! заголовок 1
!! заголовок 2
* список
** список 2
# нумерованый список
## нумерованый список 2
[url:http://www.example.com]
{"без форматирования"}
Полное описание синтаксиса