Аннотации

140

В WPF имеется возможность вставки в потоковые и фиксированные документы аннотаций, т.е. комментариев и выделений цветом. Эти аннотации могут использоваться для обозначения различных редакций, выделения ошибок или пометки важных фрагментов.

Во многих продуктах имеется множество различных видов аннотаций. Например, Adobe Acrobat позволяет вставлять в документ отметки о редакциях и геометрические фигуры. В WPF используются лишь два вида аннотаций:

Выделение цветом

Можно выделить часть текста, задав цвет ее фона. (В принципе при этом в WPF применяется частично прозрачный цвет поверх текста, но в результате пользователю кажется, что изменен фон.)

"Наклейки"

Можно выделить часть текста и прикрепить к ней плавающее окошко, содержащее дополнительную текстовую или рукописную информацию.

На рисунке ниже приведен пример, который мы создадим в этой статье. На нем показан потоковый документ с выделенной областью текста и двумя наклейками: с рукописным и текстовым содержимым:

Аннотации в потоковом документе

Все четыре контейнера документов WPF — FlowDocumentReader, FlowDocumentScrollViewer, FlowDocumentPageViewer и DocumentViewer — поддерживают аннотации. Но для их использования нужно выполнить два действия. Во-первых, необходимо вручную разрешить службу аннотирования с помощью небольшого кода инициализации. Во-вторых, потребуется добавить элементы управления (кнопки инструментальной панели), которые позволят пользователям добавлять поддерживаемые виды аннотаций.

Классы аннотаций

Система аннотаций в WPF основана на нескольких классах из пространств имен System.Windows.Annotations и System.Windows.Annotations.Storage. Вот основные из них:

Классы аннотаций
Класс Описание
AnnotationService Этот класс управляет функцией аннотаций. Чтобы использовать аннотации, программист должен самостоятельно создать этот объект.
AnnotationStore Этот класс управляет хранением аннотаций. Он определяет несколько методов для создания и удаления отдельных аннотаций. Кроме того, в нем имеются события, позволяющие реагировать на создание или изменение аннотаций. Этот класс является абстрактным, и на данный момент у него только один наследник: класс XmlStreamStore. Этот класс преобразует аннотации в формат XML и позволяет сохранить их в любом потоке.
AnnotationHelper Данный класс предоставляет небольшой набор статических методов для работы с аннотациями. Эти методы заполняют нишу между хранящимися аннотациями и контейнером документа. Большинство методов класса AnnotationHelper работают с текстом, выделенным в данный момент времени в контейнере документа (позволяя выделять его цветом, вставлять аннотации к нему или удалять существующие аннотации). Этот класс позволяет также находить в документе аннотированные места.

В последующих разделах мы используем каждый из этих трех основных компонентов.

Включение службы аннотаций

Перед работой с аннотациями необходимо включить службу аннотаций с помощью объектов AnnotationService и MemoryStream.

В примере, показанном ниже, имеет смысл создать объект AnnotationService во время первоначальной загрузки окна. Создание службы выполняется просто: нужно лишь создать объект AnnotationService для программы чтения документа и вызвать метод AnnotationService.Enable(). Однако при вызове метода Enable() ему необходимо передать объект AnnotationStore. Объект AnnotationService управляет информацией аннотаций, a AnnotationStore управляет хранением этих аннотаций.

Ниже показан код, который создает и включает аннотации:

// Поток для хранения аннотаций
private MemoryStream annotationStream;

// Служба аннотирования
private AnnotationService service; 

protected void window_Loaded(object sender, RoutedEventArgs e) 
{ 
            // Создание AnnotationService для контейнера документов
            service = new AnnotationService(docReader); 
            
            // Создание хранилища для аннотаций
            annotationStream = new MemoryStream(); 
            AnnotationStore store = new XmlStreamStore(annotationStream); 
            
            // Включение аннотирования
            service.Enable(store); 
} 

Обратите внимание: в этом примере аннотации хранятся в потоке MemoryStream. Поэтому они будут удалены сразу после сборки мусора MemoryStream. Если нужно сохранить аннотации, чтобы повторно применять их к исходному документу, то это можно сделать двумя способами.

Вместо MemoryStream можно создать экземпляр FileStream, и тогда данные аннотаций будут записываться при их применении. Или же можно после закрытия документа скопировать данные, хранящиеся в MemoryStream, в другое место (например, в файл или запись базы данных).

Если вы не уверены, было ли включено аннотирование для контейнера документов, можно использовать статический метод AnnotationService.GetService() и передать ему ссылку на контейнер документа. Если аннотирование еще не включено, этот метод возвращает пустую ссылку.

В какой-то момент понадобится закрыть поток приложения и отключить службу аннотирования. В данном примере эти задачи выполняются, когда пользователь закрывает окно (событие Unloaded):

protected void window_Unloaded(object sender, RoutedEventArgs e) 
{ 
            if (service != null && service.IsEnabled) 
            { 
                // Сброс аннотаций в поток
                service.Store.Flush(); 

                // Отключение аннотирования
                service.Disable(); 
                annotationStream.Close(); 
            }
}

Вот и все, что потребуется сделать, чтобы включить аннотирование в документе. Если при вызове метода AnnotationService.Enable() в объекте потока уже определены какие-либо аннотации, то они тут же появятся. Однако все равно нужно будет добавить элементы управления, с помощью которых пользователь сможет добавлять или удалять аннотации.

Каждый контейнер документа может иметь один экземпляр AnnotationService. Каждый документ должен иметь свой экземпляр AnnotationStore. При открытии нового документа нужно отключить AnnotationService, сохранить и закрыть текущий поток аннотаций, создать новый экземпляр AnnotationStore, а затем снова включить AnnotationService.

Создание аннотаций

Существуют два способа работы с аннотациями. Можно использовать один из методов класса AnnotationHelper, который позволяет создавать аннотации (CreateTextStickyNoteForSelection() и CreateInkStickyNoteForSelection()), удалять их (DeleteTextStickyNotesForSelection() и DeleteInkStickyNotesForSelection()) и выделять текст цветом (CreateHighlightsForSelection() и ClearHighlightsForSelection()). Часть ForSelection в имени метода означает, что эти методы применяют аннотацию к тексту, выделенному в данный момент.

Методы AnnotationHelper работают замечательно, но гораздо проще использовать соответствующие команды, предлагаемые классом AnnotationService. Эти команды можно связать непосредственно с кнопками пользовательского интерфейса. Именно такой подход и будет задействован в следующем примере.

Чтобы использовать класс AnnotationService в XAML, нужно отобразить пространство имен System.Windows.Annotations на пространство имен XML, поскольку оно не является одним из основных пространств имен в WPF. Можно добавить следующее отображение:

<Window x:Class="Wpf_Documents.Annotations"
        ...
        xmlns:annot="clr-namespace:System.Windows.Annotations;assembly=PresentationFramework"
        ...

Теперь можно создать кнопку, которая создает текстовое примечание для выделенной в данный момент части документа:

<Button Content="Создать аннотацию" 
      Command="annot:AnnotationService.CreateTextStickyNoteCommand"/>

Если теперь пользователь щелкнет на этой кнопке, на экране появится зеленое окошко примечания. Пользователь может ввести текст внутри этого окошка. (Если с помощью команды CreateInkStickyNoteCommand создать рукописное примечание, пользователь сможет рисовать внутри окошка.)

В данном элементе Button не задано свойство CommandTarget. Это объясняется тем, что кнопка располагается в панели инструментов (внутри ToolBar). Класс Toolbar может автоматически задать свойство CommandTarget для элемента, находящегося в фокусе. Естественно, если использовать ту же команду для кнопки, находящейся за пределами панели инструментов, нужно будет установить свойство CommandTarget так, чтобы оно указывало на средство просмотра документов.

Наклейки не обязательно держать видимыми. Если щелкнуть на кнопке сворачивания в правом верхнем углу окошка примечания, оно исчезнет. Останется лишь выделенная часть документа, для которой создано примечание. Если навести указатель мыши на эту выделенную область, появится значок примечания — щелчок на нем восстановит окошко наклейки:

Скрытая аннотация

Объект AnnotationService сохраняет позицию каждого окошка примечания, поэтому если перетащить одно из них в какое-то другое место в документе, закрыть его, а затем открыть снова, окошко появится на прежнем месте.

В предыдущем примере аннотация создана без информации об ее авторе. Если аннотации могут добавлять несколько пользователей, то почти наверняка понадобится хранить какую-то идентификацию. Для этого нужно передать строку, идентифицирующую автора, в качестве параметра команды:

<Button Content="Создать аннотацию" Command="annot:AnnotationService.CreateTextStickyNoteCommand"
       CommandParameter="{StaticResource AuthorName}"/>

Здесь предполагается, что имя автора задано в виде ресурса:

<Window x:Class="Wpf_Documents.Annotations"
        ...
        xmlns:sys="clr-namespace:System;assembly=mscorlib"
        ...>
    <Window.Resources>
        <sys:String x:Key="AuthorName">Вася Пупкин</sys:String>
    </Window.Resources>

Это позволяет указать имя автора при первоначальной загрузке окна, когда производится инициализация службы аннотирования. Можно использовать имя, указанное пользователем, которое неплохо было бы хранить в файле .config в виде параметра настройки приложения.

Потребуется также создать кнопки, использующие команды CreateInkStickyNoteCommand (для создания окошка рукописного примечания) и DeleteStickyNotesCommand (для удаления ранее созданных наклеек):

<Button Content="Графическое примечание" Command="annot:AnnotationService.CreateInkStickyNoteCommand" 
        CommandParameter="{StaticResource AuthorName}"/>
<Button Content="Удалить примечания"
        Command="annot:AnnotationService.DeleteStickyNotesCommand"/>

Команда DeleteStickyNotesCommand удаляет все наклейки в выделенном в данный момент тексте. Но даже без такой кнопки пользователь сможет удалить аннотации с помощью меню Edit (Правка) в окне примечания (если только в окне примечания не используется другой шаблон управления, не включающий данную функцию).

Осталось создать кнопки для выделения цветом. Чтобы добавить выделение, используется команда CreateHighlightCommand с объектом Brush в качестве параметра CommandParameter. При этом нужно обязательно использовать кисть с частично прозрачным цветом. Иначе выделенное содержимое окажется совершенно невидимым, как показано на рисунке:

Выделение содержимого с помощью непрозрачного цвета

Например, если вы хотите использовать для выделения текста сплошной цвет #FF32CD32 ("лимонно-зеленый"), необходимо уменьшить значение альфа-канала, которое задается первыми двумя шестнадцатеричными цифрами. (Оно может быть от 0 до 255, где 0 соответствует полной прозрачности, а 255 — полной непрозрачности.)

Например, цвет #54FF32CD32 позволяет получить полупрозрачный вариант лимонно-зеленого цвета, со значением альфа-канала 84 (то есть 54^16x).

Следующая разметка определяет две кнопки: одну для выделения желтым цветом, и одну для выделения зеленым. Сама кнопка вместо текста содержит просто квадратик размером 15x15 с соответствующим цветом. CommandParameter определяет кисть SolidColorBrush, которая использует тот же цвет, но с уменьшенной непрозрачностью, что позволяет видеть текст:

<Button Background="Yellow"
         Command="annot:AnnotationService.CreateHighlightCommand" Width="15" Margin="2,0" Height="15">
         <Button.CommandParameter>
                   <SolidColorBrush Color="#54FFFF00"></SolidColorBrush>
         </Button.CommandParameter>
</Button>
<Button Background="LimeGreen"
         Command="annot:AnnotationService.CreateHighlightCommand" Width="15" Margin="2,0" Height="15">
         <Button.CommandParameter>
                   <SolidColorBrush Color="#5432CD32"></SolidColorBrush>
         </Button.CommandParameter>
</Button>

И, наконец, добавим последнюю кнопку, чтобы удалять выделение цветом в выбранной области:

<Button Command="annot:AnnotationService.ClearHighlightsCommand" Content="Очистить выделения "/>

При печати документа с аннотациями с помощью команды ApplicationCommands.Print аннотации выглядят так же, как и на экране. То есть свернутые аннотации будут выглядеть свернутыми, видимые аннотации будут напечатаны поверх содержимого (и могут закрывать другие части документа) и т.д. Если нужно распечатать документ без аннотаций, просто отключите службу аннотирования перед печатью.

Просмотр аннотаций

Иногда бывает нужно просмотреть все аннотации, прикрепленные к документу. На это может быть много разных причин: вывести сводный отчет по всем аннотациям, напечатать список аннотаций, экспортировать текст аннотации в файл и т.п.

Объект AnnotationStore позволяет относительно легко получить список всех содержащихся аннотаций с помощью метода GetAnnotations(). После этого можно просмотреть каждую аннотацию в виде объекта Annotation:

IList<Annotation> annotations = service.Store.GetAnnotations(); 
foreach (Annotation annotation in annotations) 
{
   ...
}

Теоретически можно найти аннотации в некоторой части документа с помощью перегруженной версии метода GetAnnotations(), который принимает объект ContentLocator. Но на практике это оказывается довольно сложной задачей, поскольку объект ContentLocator не очень удобен в использовании, да еще и нужно точно определить начальную позицию аннотации.

Любой объект Annotation имеет свойства, перечисленные в таблице ниже:

Свойства объекта Annotation
Имя Описание
Id Глобальный идентификатор (GUID), который однозначно идентифицирует данную аннотацию. Если известен GUID аннотации, то соответствующий объект Annotation можно получить с помощью метода AnnotationStore.GetAnnotation().

Естественно, невозможно знать GUID существующей аннотации, если он предварительно не получен с помощью вызова метода GetAnnotations() или события AnnotationStore при создании или изменении аннотации.
AnnotationType Имя элемента XML, который определяет данный тип аннотации, в формате пространство_имен:локальное_имя
Anchors Коллекция, содержащая ноль или более объектов AnnotationResource, которые показывают, к какому тексту относится аннотация
Cargos Коллекция, содержащая ноль или более объектов AnnotationResource, которые хранят пользовательские данные аннотации. Это текст текстового примечания или штрихи рукописного примечания
Authors Коллекция, содержащая ноль или более строк, указывающих, кто создал аннотацию
CreationTime Дата и время создания аннотации
LastModificationTime Дата и время последнего изменения аннотации

Объект Annotation — это просто тонкая оболочка для данных XML, которые хранят саму аннотацию. Поэтому, в частности, трудно получить информацию из свойств Anchors и Cargos. Например, если нужно получить текст аннотации, потребуется просмотреть второй элемент в коллекции Cargos. Однако содержащийся в нем текст хранится в кодировке Base64 (это позволяет избежать проблем, если примечание содержит символы, запрещенные в содержимом элементов XML). Чтобы все-таки просмотреть этот текст, необходим примерно такой нудный код:

// Проверка текстовой информации
if (annotation.Cargos.Count > 1)
{
        // Декодирование текста примечания
        string base64Text = annotation.Cargos[1].Contents[0].InnerText;
        byte[] decoded = Convert.FromBase64String(base64Text);

        // Запись декодированного текста в поток
        MemoryStream m = new MemoryStream(decoded);

        // Преобразование байтов текста в осмысленнную строку 
        // с помощью объекта StreamReader
        StreamReader r = new StreamReader(m);
        string annotationXaml = r.ReadToEnd();
        r.Close();

        // Вывод содержимого аннотации
        MessageBox.Show(annotationXaml);
}

Этот код получает текст аннотации, упакованный в элементе XAML <Section>. Открывающий дескриптор <Section> содержит атрибуты, задающие множество различных типографических подробностей. Внутри элемента <Section> находится несколько элементов <Paragraph> и <Run>:

Исходный XAML-код аннотации

Подобно текстовой аннотации, рукописная аннотация тоже имеет коллекцию Cargos с более чем одним элементом. Однако в этом случае коллекция Cargos содержит данные о штрихах, а не декодируемый текст. Если предыдущий код применить к рукописной аннотации, окно сообщения будет пустым. Значит, если документ содержит текстовые и рукописные аннотации, необходимо проверять свойство Annotation.AnnotationType, чтобы убедиться, что это именно текстовая аннотация, и уже затем использовать данный код.

Если нужно просто получить текст без сопутствующего кода XML, можно использовать класс XamlReader, чтобы выполнить его преобразование (и не использовать StreamReader). XML можно преобразовать в объект Section посредством следующего кода:

// Проверка текстовой информации
if (annotation.Cargos.Count > 1)
{
        // Декодирование текста примечания
        string base64Text = annotation.Cargos[1].Contents[0].InnerText;
        byte[] decoded = Convert.FromBase64String(base64Text);

        // Запись декодированного текста в поток
        MemoryStream m = new MemoryStream(decoded);

        // Обратное преобразование XML в объект Section
        Section sec = XamlReader.Load(m) as Section;
        m.Close();

        // Получение текста внутри объекта Section. 
        TextRange range = new TextRange(sec.ContentStart, sec.ContentEnd);

        // Вывод содержимого аннотации
        MessageBox.Show(range.Text);
}
Вывод текста аннотации

Хранение аннотаций в фиксированном документе

В предыдущих примерах использовались аннотации в потоковых документах. В таких случаях аннотации можно сохранять для работы с ними в будущем, однако их нужно хранить отдельно — например, в отдельном файле XML.

Для фиксированного документа можно применять тот же подход, но есть дополнительная возможность — хранить аннотации прямо в файле документа XPS. Более того, в одном документе можно хранить даже несколько наборов разных аннотаций. Нужно лишь использовать поддержку пакетов в пространстве имен System.IO.Packaging.

Как уже было сказано, каждый документ XPS на самом деле является архивом ZIP, который содержит несколько файлов. При сохранении аннотации в документе XPS на самом деле внутри архива ZIP создается еще один файл.

Сначала необходимо выбрать URI для идентификации аннотаций. Ниже показан пример, в котором используется имя AnnotationStream:

Uri annotationUri = PackUriHelper.CreatePartUri(
   new Uri("AnnotationStream", UriKind.Relative));

Затем нужно получить пакет Package для документа XPS с помощью статического метода PackageStore.GetPackage():

Package package = PackageStore.GetPackage(doc.Uri);

После этого можно создать часть пакета, в которой будут храниться аннотации из документа XPS. Только нужно проверить, существует ли уже такая часть пакета аннотаций (если документ уже загружался, и в него добавлялись аннотации). Если она не существует, можно создать ее сейчас:

PackagePart annotationPart = null;
if (package.PartExists(annotationUri))
{                    
       annotationPart = package.GetPart(annotationUri);
}
else                
{                    
       annotationPart = package.CreatePart(annotationUri, "Annotations/Stream");
}

И, наконец, необходимо создать объект AnnotationStore, который будет содержать часть пакета аннотации, а затем, как обычно, разрешить работу службы аннотирования:

AnnotationStore store = new XmlStreamStore(annotationPart.GetStream());
service = new AnnotationService(docViewer);
service.Enable(store);

Для работы этой технологии необходимо открыть файл XPS в режиме FileMode.ReadWrite, а не в режиме FileMode.Read, чтобы аннотации можно было записывать в файл XPS. По той же причине во время работы службы аннотирования документ XPS должен быть открыт. Закрыть документ XPS можно тогда, когда будет закрыто окно (или открыт новый документ).

Настройка внешнего вида наклеек

Окошки примечаний, которые появляются при создании текстового или рукописного примечания, являются экземплярами класса StickyNoteControl, который определен в пространстве имен System.Windows.Controls. Как и для всех элементов управления WPF, внешний вид StickyNoteControl можно настроить с помощью средств настройки стилей или применив новый шаблон элемента управления.

Например, нетрудно создать стиль, который можно применять ко всем экземплярам StickyNoteControl с помощью свойства Style.TargetType. Вот пример изменения цвета фона для всех экземпляров StickyNoteControl:

<Style TargetType="{x:Type StickyNoteControl}">
            <Setter Property="Background" Value="LightBlue"/>
</Style>
Новый стиль для StickyNoteControl

Для создания более динамичной версии StickyNoteControl можно написать триггер стиля, который будет реагировать на свойство StickyNoteControl.IsActive, которое равно true, если наклейка имеет фокус ввода.

Чтобы получить дополнительные возможности для управления, можно использовать совершенно другой шаблон элемента управления для StickyNoteControl. Единственная хитрость заключается в том, что шаблон StickyNoteControl меняется в зависимости от того, что он хранит: текстовое примечание или рукописное. Если пользователю разрешено создавать оба типа примечаний, понадобится триггер, который будет выбирать один из двух шаблонов. Рукописные примечания должны содержать элемент InkCanvas, а текстовые — RichTextBox. В обоих случаях этот элемент должен иметь имя PART_ContentControl.

Ниже показан стиль, который применяет простейший шаблон элемента управления для примечаний обоих типов. Он задает размеры окошка примечания и выбирает нужный шаблон на основе типа содержимого примечания:

<Style TargetType="{x:Type StickyNoteControl}">
            <Setter Property="OverridesDefaultStyle" Value="true" />
            <Setter Property="Width" Value="100" />
            <Setter Property="Height" Value ="100" />
            <Style.Triggers>
                <Trigger Property="StickyNoteControl.StickyNoteType" Value="{x:Static StickyNoteType.Ink}">
                    <Setter Property="Template">
                        <Setter.Value>
                            <ControlTemplate>
                                <InkCanvas Name="PART_ContentControl" />
                            </ControlTemplate>
                        </Setter.Value>
                    </Setter>
                </Trigger>
                <Trigger Property="StickyNoteControl.StickyNoteType" Value="{x:Static StickyNoteType.Text}">
                    <Setter Property="Template">
                        <Setter.Value>
                            <ControlTemplate>
                                <RichTextBox Name="PART_ContentControl" Background="Yellow"/>
                            </ControlTemplate>
                        </Setter.Value>
                    </Setter>
                </Trigger>
            </Style.Triggers>
</Style>
Пройди тесты
Лучший чат для C# программистов