Pet проект. Разделитель PDF документов
Привет, молодые успешные!
Как часто вы работаете с PDF документами? Случалось ли вам сталкиваться с проблемой монолитности этого формата? Я часто сталкивался с такой проблемой, когда мне требовалось лишь несколько листов из всего документа, например чтобы отправить их по почте. Да можно воспользоваться бесплатными онлайн сервисами которых полно в интернете…
Но если документ важен и хранит в себе коммерческую тайну, или в PDF документе отсканирован паспорт? Нет никаких гарантий что документ не попадет к злоумышленникам.
Именно эту задачу и должен решить мой проект! Это должно быть простое, быстрое решение, работающее локально на вашей машине.
Также этот проект может подойти студентам колледжей и университетов.
С исходниками проекта вы можете ознакомиться в моем git репозитории https://github.com/BakaLaver/PDFSplitter, а так же релиз https://github.com/BakaLaver/PDFSplitter/releases/tag/1.0.1.
Да там уже реализована возможность объединять документы, но это для следующих статей.
Инструменты
Как сказано в заголовке, мы будем использовать язык программирования c#, а конкретно пользовательский интерфейс WPF.
Для работы с PDF документами нам понадобиться библиотека itextsharp, обширная библиотека позволяющая работать с широким спектром электронных документов. Конкретно в этом проекте я использовал версию 5.5.13.3, выяснилось что есть версия 8.0.3 уже после того как я разобрался в версии 5.
Ну и сам факт использования WPF склоняет нас к паттерна MVVM.
Планирование
Для удобного управления проектом следует разделить ответственность на слои, бизнес-логика, презентация, и слой данных.
Бизнес-логика
На этот слой возлагается ответственность работы с pdf документами. Здесь будет проходить основная работа над документом. Так же здесь будет объявлен сервис дающий доступ к нашим моделям бизнес логики.
Слой бизнес логики будет иметь следующую структуру:
Папка BusinessModels, будет хранить классы основной логики для работы с pdf документами
Папка Services, папка промежуточных классов, они нужны для того чтобы взаимодействовать со внешними слоями для того чтобы мы могли инкапсулировать нашу основную логику
Представление
Слой будет отвечать за внешний вид нашей программы, здесь будет основа реализации паттерна MVVM, то есть здесь будет определенны папки Model, View, ViewModel.
Model: Здесь будут представлены сущности предоставляющие данные для слоя бизнес логики
View: В этой части будет определенно наше основное окно (на данный момент задумано одно окно)
ViewModel: Место основной логики взаимодействия между Model и View, отсюда будет прямое обращение к слою бизнес логики
Данные
Так как у нас не будет подключения к базам данных, слой данных будет представлен файловой системой нашей операционной системы Windows, где будут храниться наши файлы pdf и куда они будут сохранятся.
И так, предлагаю закончить на этом планирование и приступить уже к конкретной реализации
Реализация
Начнем с создания WPF проекта
В итоге имеем такое окно
Приведем к описанной выше структуре
Начальный порядок навели, теперь можно приступать к реализации бизнес логики
Бизнес логика. Реализация
Бизнес логика будет сосредоточена в классической библиотеке классов, поэтому без лишних слов ПКМ => Добавить => Создать проект…
Прошу заметить, что желательно располагать связанные проекты в одном месте сразу, для упрощения создания репозиториев.
После создания проекта, безотлагательно приводим его структуру к описанной в разделе планирования.
Как только мы справились с этой задачей, можно подключать самый главный инструмент данного проекта, а именно nuget пакет iTextSharp 5.5.13.3. Зависимости => ПКМ => Управление пакетами Nuget, далее переключаемся на вкладку «Обзор» и в строку поиска вводим заветное «iTextSharp».
Нужный нам вариант должен быть на первой строчке в результате поиска.
Подготовительные мероприятия закончены, теперь мы наконец то приступаем к программированию. Добавим класс в папку «BusinessModels», назовем его «SplitPage» В нем определим несколько свойств.
internal class SplitPage
{
private PdfReader reader { get; set; }
private Document sourceDocument { get; set; }
private PdfCopy pdfCopyProvider { get; set; }
private PdfImportedPage importedPage { get; set; }
}
О каждом подробней
PdfReader reader. Будет отвечать за чтение PDF документа источника, через него мы будем получать характеристики документа
Document sourceDocument. Сюда будет сохраняться «парсенный» документы источник, то есть документ откуда будут добываться нужный диапазон страниц
PdfCopy pdfCopyProvider. Делает копию PDF документа, такой объект полностью открыт для редактирования.
PdfImportedPage importedPage. Класс представляет собой нечто типа промежуточного состояния передаваемой страницы, когда страница уже не является частью документа источника, и еще не добавлена в новый документ.
Со свойствами разобрались, теперь нужно описать сердце нашей бизнес логики, а именно метод в котором будет происходить вся магия. Метод будет называться «ExtractPages» и будет иметь следующую сигнатуру.
public void ExtractPages(string sourcePDFpath, string outputPDFpath, int startpage, int endpage)
Метод не будет ничего возвращать и будет иметь следующие параметры
string sourcePDFpath. строковый параметр передающий полный путь (то есть и имя файла то же) документа источника
string outputPDFpath. строковый параметр передающий полный путь для нового документа содержащий в себе нужный диапазон страниц. Обратите внимание что нужно передать путь с именем конечного файла и его расширением .pdf
int startpage и int endpage. целочисленные параметры указывающие номера страниц, с какой и по какую нужно достать страницы.
В теле цикла будет определена следующая логика.
public void ExtractPages(string sourcePDFpath, string outputPDFpath, int startpage, int endpage)
{
reader = new PdfReader(sourcePDFpath); //1
sourceDocument = new Document(reader.GetPageSizeWithRotation(startpage));//2
pdfCopyProvider = new PdfCopy(sourceDocument, new System.IO.FileStream(outputPDFpath, System.IO.FileMode.Create));//3
sourceDocument.Open();
for (int i = startpage; i <= endpage; i++)
{
importedPage = pdfCopyProvider.GetImportedPage(reader, i); //4
pdfCopyProvider.AddPage(importedPage);
}
sourceDocument.Close();
reader.Close();
}
Теперь по порядку что здесь происходит.
Здесь инициализируется объект класса PdfReader в конструктор которого передается параметр метода sourcePDFpath.
В этом месте инициализируется объект типа Document, в конструктор которого передается метод объекта класса PdfReader «reader.GetPageSizeWithRotation (startpage)», из этого метода задается размер страниц.
Тут самый сложный момент для моего понимания, по идее конструктор класса PdfCopy должен копировать переданный ему документ в первом параметре, но по факту создается пустой pdf документ (если после этой строчки что то идет не так, не забудьте очистить папку назначения, потому что при каждой неудачной попытке будет создаваться пустой документ). Так или иначе, тут в конструкторе первым параметром передается объект документа источника, вторым инициализируеться файловый поток создающий файл по заданному пути (параметр outputPDFpath).
И наконец то мы начинаем процесс переноса заданного диапазона страниц, он начинаются с открытия документа с помощью метода Open (), после этого запускается цикл for где начальная точка это первая заданная страница, а последняя…последняя. Во время каждой итерации в свойство importedPage будет передаваться страница из документа источника через метод GetImportedPage, куда передается объект класса PdfReader, а также индекс нужной страницы. После чего полученная страница добавляется в новый pdf документ. Когда все страницы будут добавлены в новый документ, все использованные документы следует закрыть, а задачу по вытягиванию страниц из pdf документа можно считать выполненной.
Теперь. когда основная логика готова, нужно обеспечить вызов нашей логики внешними сборками, для этого создадим класс в папке «Service» и назовем его «PDFService».
private SplitPage _SplitPageCommand;
public PDFService()
{
_SplitPageCommand = new SplitPage();//1
}
public void ExtractPageFromTo(string sourcePDFpath, string outputPDFpath, int startpage, int endpage)
{
_SplitPageCommand.ExtractPages(sourcePDFpath, outputPDFpath, startpage, endpage);//2
}
Как обеспечивается доступ к нашей логике.
В начале объявим закрытое свойство класса нашей логики «SplitPage» это обеспечит доступ к нему из всего текущего класса
Проинициализируем наше свойство в конструкторе класса, это гаранируем что наше свойство никогда не будет null
Метод ExtractPageFromTo дублирует сигнатуру метода логики класса «SplitPage» и просто вызывает соответствующий метод, а также название метода информирует о том что делает этот метод.
Такой способ построения кажется мне самым удобным, он обеспечивает расширяемость, если я захочу расширить свой проект новыми свойствами, то такое построение проекта обеспечит мне это.
Бизнес логика. Тестирование
Чтобы убедиться в работоспособности нашего решения здесь и сейчас, до реализации пользовательского интерфейса, мы можем создать проект unit тест и обратиться к нашей логике оттуда.
По решению «PDFSlicer» ПКМ => Добавить => Новый проект… Здесь нам нужно выбрать «Тестовый проект xUnit»
Далее называем как нам удобно, я обычно называю его именем решения которое он будет тестировать, приписывая в конце .Test
После того как тестовый проект будет добавлен, следует добавить в зависимости ссылку на тестируемую библиотеку, ПКМ => Зависимости => Добавить ссылку на проект, после чего выбрать нашу библиотеку бизнес логики.
Теперь можно приступить к самому тестированию, в тестовом проекте по умолчанию реализован класс «UnitTest1», с методом внутри «Test1()», для наших целей этого вполне достаточно, для теста возьмем тестовый многостраничный PDF документ взятый из интернета по запросу «test pdf» (https://axiomabio.com/pdf/test.pdf), в теле метода напишем следующий код.
public class UnitTest1
{
[Fact]
public void Test1()
{
PDFService split = new PDFService();
split.ExtractPageFromTo(@"D:\\TestPDF\test.pdf", @"D:\\TestPDF\test1.pdf", 2,5);
}
}
И выполним тест ПКМ по коду => Выполнить тест, в результате видим следующее окно.
В результате видим по указанному пути два файла, один создан нашим методом, другой изначальный.
Проверим количество страниц, мы видим что в документе источнике 10 страниц.
А в нашем производном от теста файле 4 страницы, что соответствует введенным нами параметрам, от второй страницы по пятую.
Также в результате теста выяснилось несколько вещей.
Если файл с изъятыми страницами сохраняется в ту же папку где хранится исходный документ, следует проследить что имя производного файла не совпадает с исходным файлам (приводит к исключению).
Также указывая нужный диапазон страниц следует иметь ввиду реальные индексы страниц. Условно говоря если в документе 10 страниц, а вы укажите диапазон до 11 страницы, то мы получим исключение.
С бизнес логикой мы наконец закончили, теперь можно приступать к реализации пользовательского интерфейса.
Представление. Реализация
Прежде чем реализовывать паттерн MVVM, нужно примерно спланировать как будет выглядеть наш интерфейс. Нарисуем примерный макет (я пользуюсь draw.io). Я не дизайнер заранее извиняюсь.
На основе нашего макета мы также сможем прикинуть примерную схему нашей привязки.
Здесь от 1 по 5 пункт, представлены свойства модели, 6 пункт представляет привязку кнопки к свойству команды, нажатие по кнопке вызывает обращение к методам нашей логики извлечения документа.
Представление. Модель
Начнем мы с реализации класса модели. Модель будет представлена классом «SplitPDFFromTo» и реализовывать интерфейс «INotifyPropertyChanged», он поможет нам реализовать полноценную привязку, и благодаря ему мы можем оповещать нашу ViewModel о изменениях. Все свойства используют полноценную реализацию (приватное поле, публичное свойство), для того чтобы иметь возможность вызывать методы «INotifyPropertyChanged».
public class SplitPDFFromTo : INotifyPropertyChanged
{
private string _inPutPath;
private string _outPutPath;
private string _newDocumentName;
private int _from;
private int _to;
public int From
{
get { return _from; }
set
{
_from = value;
OnPropertyChanged("From");
}
}
public int To
{
get { return _to; }
set
{
_to = value;
OnPropertyChanged("To");
}
}
public string NewDocumentName
{
get { return _newDocumentName; }
set
{
_newDocumentName = value;
OnPropertyChanged("NewDocumentName");
}
}
public string InPutPath
{
get { return _inPutPath; }
set
{
_inPutPath = value;
OnPropertyChanged("InPutPath");
}
}
public string OutPutPath
{
get { return _outPutPath; }
set
{
_outPutPath = value;
OnPropertyChanged("OutPutPath");
}
}
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged([CallerMemberName] string prop = "")
{
if (PropertyChanged != null)
PropertyChanged(this, new PropertyChangedEventArgs(prop));
}
}
С моделью закончили, можно переходить к реализации VIewModel.
Представление.ViewModel
Пока что у нас только один View это основное окно MainWindow, для него мы и создадим наш ViewModel, он будет представлен классом «MainWindowsModel».
Прежде чем начать работать над «ViewModel» мы должны дать ссылку на наш проект бизнес логики для нашей презентации, «Зависимости => ПКМ => Добавить ссылку на проект…» и выбираем наш проект бизнес логики.
В начале мы объявляем основные свойства ViewModel.
public class MainWindowsModel
{
private PDFService TakePagesService { get; set; }
private SplitPDFFromTo FromToModel { get; set; }
public MainWindowsModel()
{
FromToModel = new SplitPDFFromTo();
TakePagesService = new PDFService();
}
private RelayCommand _selectFromToInFileCommand;
private RelayCommand _selectFromToOutFileCommand;
private RelayCommand _takePagesFromToCommand;
}
Здесь объявляется свойства классов PDFService и SplitPDFFromTo, а так же идет их инициализация в конструкторе класса, тем самым методы класса могут обращаться к свойствам из любой части кода, а когда создается объект класса конструктор гарантирует нам что эти свойства не будут «null».
Также у нас объявлены поля типа «RelayCommand» для взаимодействия с событиями в нашем View (нажатие на кнопку и тд.). Сам класс реализован в классическом виде (и взят отсюда https://metanit.com/sharp/wpf/22.3.php).
public class RelayCommand : ICommand
{
private Action
Для хранения создадим ему папку «Command» внутри папки «ViewModel».
Для полей «RelayCommand» определенные свойства которые будут реагировать на событие внутри View, в нашем случае это, выбор файла источника, выбор папки сохранения, и запуск процесса извлечения страниц.
О всех этих событиях подробно далее.
Выбор файла источника.
public RelayCommand SelectFromToInFileCommand
{
get
{
return _selectFromToInFileCommand ??
(_selectFromToInFileCommand = new RelayCommand(obj =>
{
FromToModel.InPutPath = SelectSourceFile();
}));
}
}
private string SelectSourceFile()
{
string path = "" ;
FileSelection.OpenFileDialog op = new FileSelection.OpenFileDialog();
op.Filter = "PDFfile|*.pdf";
op.DefaultExt = "pdf";
if (op.ShowDialog() == true)
{
path = op.FileName;
}
return path;
}
Здесь в свойстве «SelectFromToInFileCommand» идет реакция на нажатие кнопки которая вызывает метод «SelectSourceFile» открывающий окно выбора файла исходника. В результате работы метода идет возврат строки пути выбранного файла, передающийся в свойство модели «FromToModel.InPutPath».
Выбор папки сохранения
public RelayCommand SelectFromToOutFileCommand
{
get
{
return _selectFromToOutFileCommand ??
(_selectFromToOutFileCommand = new RelayCommand(obj =>
{
FromToModel.OutPutPath = SelectOutFlder();
}));
}
}
private string SelectOutFlder()
{
string path = "";
var dialog = new CommonOpenFileDialog();
dialog.IsFolderPicker = true;
CommonFileDialogResult result = dialog.ShowDialog();
if (result == CommonFileDialogResult.Ok)
{
path = dialog.FileName;
}
return path;
}
Логика вызова команды «SelectFromToOutFileCommand» схожа с вызовом «SelectFromToInFileCommand» за тем исключением что здесь вызывается метод «SelectOutFlder» открывающий диалоговое окно для выбора папки, и результатом работы метода будет строка пути к папке назначения, которая передается в свойство модели «FromToModel.OutPutPath».
В методе «SelectOutFlder» используются компоненты nuget пакета «WindowsAPICodePack-Shell»
Запуск процесса извлечения страниц
public RelayCommand TakePagesFromToCommand
{
get
{
return _takePagesFromToCommand ??
(_takePagesFromToCommand = new RelayCommand(obj =>
{
FromToCall();
var fullPath = FromToModel.OutPutPath + @"\" + FromToModel.NewDocumentName + ".pdf";
OpenFolerQuestion(fullPath);
}));
}
}
private void FromToCall()
{
string outPath = FromToModel.OutPutPath + @"\" + FromToModel.NewDocumentName + ".pdf";
TakePagesService.ExtractPageFromTo(FromToModel.InPutPath, outPath, FromToModel.From, FromToModel.To);
}
private void OpenFolerQuestion(string path)
{
var dialogResult = MessageBox.Show("Открыть папку с документом?", "Готово!", MessageBoxButton.YesNo);
string fullPath = path;
if (dialogResult == MessageBoxResult.Yes)
{
fullPath = System.IO.Path.GetFullPath(fullPath);
System.Diagnostics.Process.Start("explorer.exe", string.Format("/select,\"{0}\"", fullPath));
}
}
Эта команда отвечает за финальный этап пользовательского опыта, когда все поля заполнены и требуется получить результат. В блоке get свойства «TakePagesFromToCommand» первым делом вызывается метод «FromToCall», в теле метода в строковой переменной «outPath» собирается полный путь нового документа, из полей модели «OutPutPath» и «NewDocumentName» (не забываем дополнить путь расширением файла ».pdf»), в этот документ будет сохраняться изъятые страницы.
Далее вызывается основная логика через свойство «TakePagesService» метод ExtractPageFromTo, куда мы передаем данные полученные от нашего View.
После того как метод «FromToCall» выполнил свою работу, мы возвращаемся к нашей команде, где я решил ввести инструмент демонстрации выполненной работы нашей бизнес логики, а именно предложить открыть расположение нового файла, чтобы иметь возможность сразу проверить результат.
За это отвечает метод «OpenFolerQuestion», вызов которого вызывает окно с вопросом, нужно ли открыть папку с новым документом.
Это все что связанно с нашей ViewModel, теперь я предлагаю наконец вспомнить о пользователе и заняться интерфейсом в нашем View.
Представление.View
Сразу предупрежу, я не большой спец в верстке или разметке страницы, поэтому я заострю внимание на основных аспектах окна. Также я не буду указывать код целиком, он однотипный и его много, с полной версией вы можете ознакомиться в моем git репозитории.
В начале я разделяю страницу по горизонтали, в верхней части будет реализован интерфейс для разделения pdf документов, а нижнюю я оставлю для будущих статей.
Здесь код представлен без блока Window внутри которого он должен быть.
Начнем с разметки наших полей, под каждую строку мы выделяем свой блок. Так же все блоки будут находиться внутри блока «StackPanel», он нужен для того чтобы реализовать функцию «drag and drop file» для этого указывается параметр » AllowDrop=«True» » это должно облегчить жизнь пользователю.
Для взаимодействия нашего «View» и «ViewModel» используем обычный «Binding» внутри параметров тэгов, здесь привязка будет применяться либо в тэге «TextBox», либо в «Button» возьмем по одному из них для примера.
Здесь идет привязка к свойству пути к исходному файлу в нашем «Model», мы получаем к нему доступ через свойство «FromToModel» в нашем «ViewModel» тем самым «View» нечего не знает о «Model», также мы указываем параметр «Mode=TwoWay» что указывает на двухстороннее движение данных как от «View» к «Model», так и наоборот, и «UpdateSourceTrigger=PropertyChanged» этот параметр будет требовать обновления реагировать на изменения свойства.
Да, и я отключил прямой ввод пути, путь можно ввести только через функцию «drag and drop file», либо выбрав файл в диалоговом окне. Пользователь без выбора, хороший пользователь.
У кнопки мы привязываем свойство команды к нашей к…нашей команде в нашем «ViewModel» и в принципе всё.
Теперь осталось финишная прямая, задать логику окна в ».cs» файле.
private MainWindowsModel ViewModel { get; set; }
public MainWindow()
{
InitializeComponent();
ViewModel = new MainWindowsModel();
DataContext = ViewModel;
}
Здесь мы объявляем приватное свойство типа «MainWindowsModel» нашего «ViewModel». Инициализация новым объектом «MainWindowsModel» проводиться в конструкторе класса, сразу после инициализации компонентов окна, после чего ссылка на объект передается в «DataContext» что связывает «View» и «ViewModel».
private void SplitPageDropFile_Drop(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
string[] file = (string[])e.Data.GetData(DataFormats.FileDrop);
if (CheckDropedFile(file[0]))
{
ViewModel.FromToModel.InPutPath = file[0];
}
else
{
MessageBox.Show("Допустимы только PDF файлы");
}
}
}
private bool CheckDropedFile(string path)
{
bool result = false;
FileInfo fileInf = new FileInfo(path);
if (fileInf.Extension == ".pdf")
{
result = true;
}
return result;
}
В событии «SplitPageDropFile_Drop» элемента «StackPanel» идет проверка «дропнутого» файла, сама проверка проходит в приватном методе «CheckDropedFile» куда передается путь к файлу, сам метод возвращает «true» или «false» исходя из результата проверки.
Финальная проверка
Теперь когда все находиться на своих местах мы готовы проверить наше решение в действии.
В начале нужно указать путь к нашему файлу, за основу возьмем тестовый файл из блока тестирования бизнес логики.
Также напоминаю что вы можете просто перетащить нужный вам файл в эту область.
Далее вводим название нового документа, куда попадут наши изымаемые страницы, и место его сохранения, в моем случае это та же папке где хранится документ источник.
Теперь указываем нужный нам диапазон, от какой страницы и до какой нужно изъят страницы. Не забываем о важности реальной нумерации страниц, если указать страницу которой нет в документе, программе станет плохо.
После того как мы все ввели мы готовы начать извлечение, для этого нажмем на кнопку «Извлечь».
По завершению операции мы получим сообщение о готовности, и предложение открыть папку с новым документом.
Как видим файл действительно там где нам и нужно, и страниц в нем сколько мы и просили (страницы изымаются включительно с теми которые были указаны).
Итог
В ходе проекта мы применили паттерн MVVM, а так же разделили структуру проекта на слои, что обеспечило легкую тестируемость нашего кода.
В итоге мы получили легкий инструмент для такой редкой задачи.
Спасибо что дочитали до сюда, проект еще не закончен в нем еще много чего можно сделать, я также буду рад вашим отзывам и предложениям по фичам.