Просмотрщик XPS
28 февраля 2009 - 19:57
В свое время, вместе с выходом висты, если я не ошибаюсь, компания Microsoft ввела такой формат как xps. Этот формат вроде был призван потеснить адобовский pdf, но микрософт не утрудили себя каким-либо его продвижением. В целом формат достаточно неплох для такого рода вещей, как всякие отчёты, мануалы и прочие документы не требующие модификации. Вот только присутствует один большой недостаток. У данного формата отсутствует толковый просмотрщик, поскольку просматривать его предлагают в интернет эксплорере, с помощью плагина который шёл в комплекте с вистой, а в xp и 2003 ставился вместе с 3-им дотнет фреймворком, что крайне неудобно, плюс просмотрщик внутри ie не поддерживает, ни структуры документа, ни внутрених ссылок. В общем достаточно хороший формат был бездарно загублен убогим просмотрщиком. Чуть позже в микрософте опомнились и сварганили Xps Essentials Pack, который несмотря на пафосность названия и представлял собой вполне нормальный (за исключением того, что этого просмотрщика существует как минимум 4 разных версии, под разные версии windows) просмотрщик для этого самого xps-а. Плюс в windows 7 такой просмотрщик(явный наследник essential pack) включён по умолчанию, плюс плагин для ie серьезно доработан.
Так вот, чему я это все. А к тому, что в 3-ем дотнет фреймворке встроена функциональность для работы с этим самым xps-ом. И сейчас я покажу, как написать свой просмотрщик для него.
Для начала создадим пустое окно на которое кинем TreeView для отображения оглавления, DocumentViewer – это такой специальный контрол для работы с FixedDocument (а документы xps и являются FixedDocument, точнее сказать в xps может быть несколько FixedDocument, но на данный момент это не существенно), GridSplitter, чтоб изменять размеры TreeView-а, ну и тулбарчик с кнопкой:
По клику на кнопку будем открывать наш xps-ный файл. Примерно так:
Теперь напишем сам метод для загрузки xps-а:
Запускаем, смотрим. Вроде бы все неплохо, но есть ряд недостатков:
Добавим поддержу ссылок в документе. Для этого изменим метод загрузки следующим образом:
DocumentViewer не загружает сразу весь документ, а держит в памяти только видимые страницы, подгружая новые по мере прокрутки. За загрузку новых страниц по мере прокрутки отвечает класс DocumentPaginator. Подписавшись у этого класса на событие GetPageCompleted мы может отредактировать страницу сразу после загрузки:
e.DocumentPage.Visual – это мы получаем визуальную составляющую загруженной страницы и с помощью метода FillLinks ищем все ссылки на ней и подписываемся на нажатие их мышью:
По нажатию мышью на них переходим по ссылке:
В данном методе я выдираю идущее после # имя элемента в формате PG<номер страницы>LNK<индекс ссылки>. Потом выдираю оттуда номер страницы, загружаю эту страницу и нахожу на ней элемент с нужным именем. На самом деле я тут жутко схалтурил, ибо во-первых, ссылка может даваться не только на элемент страницы но и на что-то внешнее, например на веб-сайт, на е-мейл и т.п., а во-вторых, xps поддерживает возможность запихивания в него нескольких FixedDocument-ов и ссылка может вести на любой их них, ну и в третьих, то что имя элемента будет записан в формате PG<номер страницы>LNK<индекс ссылки> никем не гарантируется, оно может быть каким угодно, просто MS Word сохраняет его так. В общем данный метод будет работать отнюдь не со всеми документами и соответственно нуждается в серьёзной доработке…
Теперь о том, как загружать структуру документа. В микрософте почему-то не включили возможность загрузки оглавления в классы для работы с xps(почему, мне совсем непонятно), поэтому придётся идти обходным путем.
Сначала чуть-чуть о формате файла. Файл xps представляет собой специально сформированный zip-архив(как впрочем и все документы офис 2007, все это называется Open Document Format), в котором содержится всяческие файлы(страницы документа, картинки и т.п.), в частности там содержится xml файл с оглавлением. Более подробно формат я описывать не буду, благо про это всегда можно посмотреть в документации.
Итак для работы с такими хитрыми zip-файлами в .NetFramework есть подсистема System.IO.Packaging. Ей-то мы и воспользуемся. Для начала добавим в метод загрузки файла вызов метода загрузки оглавления:
Затем создадим структуру, в которой мы будем хранить элементы оглавления(структура идентична той, в которой оглавление хранится в xml-е) :
Теперь напишем метод загрузки оглавления:
Так как метод LoadOutline вызывается после загрузки файла в XpsDocument, то тут дабы избежать повторной загрузки файла с документом я вместо загрузки пакета использую PackageStore для того, чтоб извлечь имеющийся документ, именно поэтому я не делаю потом Сlose данного документа, потому что он будет закрыт в XpsDocument-е. Нужно заметить, что тут я опять схалтурил: во-первых, как я уже говорил, FixedDocument-ов в xps-е может быть несколько, и соответственно оглавлений тоже будет несколько, а во-вторых я здесь жестко прописал имя файла с оглавлением, что тоже неверно ибо оно может разниться.
Ну и методом FillTree я заполняю дерево:
В общем все. Осталось только подправить методы GoToLink и LoadOutline для их корректной работы (я опищу это как-нибудь в другой раз) и просмотрщик xps файлов готов.
Полный код (xaml приведен в самом начале):
Скачать исходники:
Так вот, чему я это все. А к тому, что в 3-ем дотнет фреймворке встроена функциональность для работы с этим самым 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(); }
// При загрузке страницы 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; }
Теперь о том, как загружать структуру документа. В микрософте почему-то не включили возможность загрузки оглавления в классы для работы с 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(); }
// Элемент структуры документа 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); } } }
Ну и методом 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; } }
Полный код (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) && (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 } }
Скачать исходники: