Pet проект. Разделитель PDF документов

f74395b91d408737d58af8e4d38f9de5.jpeg

Привет, молодые успешные!

Как часто вы работаете с PDF документами? Случалось ли вам сталкиваться с проблемой монолитности этого формата? Я часто сталкивался с такой проблемой, когда мне требовалось лишь несколько листов из всего документа, например чтобы отправить их по почте. Да можно воспользоваться бесплатными онлайн сервисами которых полно в интернете…

ee58f8c76d1c6f02afeb3361cba85af3.png

Но если документ важен и хранит в себе коммерческую тайну, или в 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, папка промежуточных классов, они нужны для того чтобы взаимодействовать со внешними слоями для того чтобы мы могли инкапсулировать нашу основную логику

833a19bc6cc227a510ecd3c0056c843c.png

Представление

Слой будет отвечать за внешний вид нашей программы, здесь будет основа реализации паттерна MVVM, то есть здесь будет определенны папки Model, View, ViewModel.

  • Model: Здесь будут представлены сущности предоставляющие данные для слоя бизнес логики

  • View: В этой части будет определенно наше основное окно (на данный момент задумано одно окно)

  • ViewModel: Место основной логики взаимодействия между Model и View, отсюда будет прямое обращение к слою бизнес логики

76378c135931e9afa237f624f8c8b96d.png

Данные

Так как у нас не будет подключения к базам данных, слой данных будет представлен файловой системой нашей операционной системы Windows, где будут храниться наши файлы pdf и куда они будут сохранятся.

И так, предлагаю закончить на этом планирование и приступить уже к конкретной реализации

Реализация

Начнем с создания WPF проекта

710ca19b2b955bf68fe7f276662582c2.png76567958002de5b9d64adf2269480840.png79825843df5236d5bc792a5b0c6b8a32.png

В итоге имеем такое окно

31aa6ad301db1cc6f8c2b06cd779d2b1.png

Приведем к описанной выше структуре

1e71fc3a79ed373563a7258f91864309.png

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

Бизнес логика. Реализация

Бизнес логика будет сосредоточена в классической библиотеке классов, поэтому без лишних слов ПКМ => Добавить => Создать проект…

72b4d8577e3dbe3b23eb702551969ff2.png925bae39eeefecd0c878085cb1c91b5f.png

Прошу заметить, что желательно располагать связанные проекты в одном месте сразу, для упрощения создания репозиториев.

После создания проекта, безотлагательно приводим его структуру к описанной в разделе планирования.

f43060ad55e586059fa43842618bc4f4.png

Как только мы справились с этой задачей, можно подключать самый главный инструмент данного проекта, а именно nuget пакет iTextSharp 5.5.13.3. Зависимости => ПКМ => Управление пакетами Nuget, далее переключаемся на вкладку «Обзор» и в строку поиска вводим заветное «iTextSharp». 

Нужный нам вариант должен быть на первой строчке в результате поиска.

24acc43b5b02ab975daab53080fc624a.png

Подготовительные мероприятия закончены, теперь мы наконец то приступаем к программированию. Добавим класс в папку «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();

}

Теперь по порядку что здесь происходит.

  1. Здесь инициализируется объект класса PdfReader в конструктор которого передается параметр метода sourcePDFpath.

  2. В этом месте инициализируется  объект типа Document, в конструктор которого передается метод объекта класса PdfReader «reader.GetPageSizeWithRotation (startpage)», из этого метода задается размер страниц.

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

  4. И наконец то мы начинаем процесс переноса заданного диапазона страниц, он начинаются с открытия документа с помощью метода 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

}

Как обеспечивается доступ к нашей логике.

  1. В начале объявим закрытое свойство класса нашей логики «SplitPage» это обеспечит доступ к нему из всего текущего класса

  2. Проинициализируем наше свойство в конструкторе класса, это гаранируем что наше свойство никогда не будет null

  3. Метод ExtractPageFromTo дублирует сигнатуру метода логики класса «SplitPage» и просто вызывает соответствующий метод, а также название метода информирует о том что делает этот метод.

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

Бизнес логика. Тестирование

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

По решению «PDFSlicer» ПКМ => Добавить => Новый проект… Здесь нам нужно выбрать «Тестовый проект xUnit»

8abba9b50af4bca8918537e7698a4bc9.png

Далее называем как нам удобно, я обычно называю его именем решения которое он будет тестировать, приписывая в конце .Test

64087a803e71fa9eece450fb70553edf.png

После того как тестовый проект будет добавлен, следует добавить в зависимости ссылку на тестируемую библиотеку, ПКМ => Зависимости => Добавить ссылку на проект, после чего выбрать нашу библиотеку бизнес логики.

febd1bb31279fa5e72b2ccd4898359d7.png

Теперь можно приступить к самому тестированию, в тестовом проекте по умолчанию  реализован класс «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);

    }

}

И выполним тест ПКМ по коду => Выполнить тест, в результате видим следующее окно.

71b85a7b582624de86b3a39d1099160b.png

В результате видим по указанному пути два файла, один создан нашим методом, другой изначальный.

4009374cf0ec6ae10263c4beebfa22ba.png

Проверим количество страниц, мы видим что в документе источнике 10 страниц.

98a12802ba33e194fb67878a347ea871.png

А в нашем производном от теста файле 4 страницы, что соответствует введенным нами параметрам, от второй страницы по пятую.

21a8db930bee55b46d88ebdd407b4432.png

Также в результате теста выяснилось несколько вещей.

  • Если файл с изъятыми страницами сохраняется в ту же папку где хранится исходный документ,   следует проследить что имя производного файла не совпадает с исходным файлам (приводит к исключению).

  • Также указывая нужный диапазон страниц следует иметь ввиду реальные индексы страниц. Условно говоря если в документе 10 страниц, а вы укажите диапазон до 11 страницы, то мы получим исключение.

С бизнес логикой мы наконец закончили, теперь можно приступать к реализации пользовательского интерфейса.

Представление. Реализация

Прежде чем реализовывать паттерн MVVM, нужно примерно спланировать как будет выглядеть наш интерфейс. Нарисуем примерный макет (я пользуюсь draw.io). Я не дизайнер заранее извиняюсь.

На основе нашего макета мы также сможем прикинуть примерную схему нашей привязки.

6d1b161455450e9798c93a7390f51ba1.png

Здесь от 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» мы должны дать ссылку на наш проект бизнес логики для нашей презентации, «Зависимости => ПКМ => Добавить ссылку на проект…» и выбираем наш проект бизнес логики.

0b9f5bc218180c757bb6628f5fc46998.png

В начале мы объявляем основные свойства 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 execute;

    private Func canExecute;

    public event EventHandler CanExecuteChanged

    {

        add { CommandManager.RequerySuggested += value; }

        remove { CommandManager.RequerySuggested -= value; }

    }

    public RelayCommand(Action execute, Func canExecute = null)

    {

        this.execute = execute;

        this.canExecute = canExecute;

    }

    public bool CanExecute(object parameter)

    {

        return this.canExecute == null || this.canExecute(parameter);

    }

    public void Execute(object parameter)

    {

        this.execute(parameter);

    }

}

Для хранения создадим ему папку «Command» внутри папки «ViewModel».

Для полей «RelayCommand» определенные свойства которые будут реагировать на событие внутри View, в нашем случае это, выбор файла источника, выбор папки сохранения, и запуск процесса извлечения страниц.

О всех этих событиях подробно далее.

Выбор файла источника.

27e7e9a5109b17be340c289433b060ed.png

  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».

Выбор папки сохранения

b300380119d261c893692e240d3d159d.png

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»

Запуск процесса извлечения страниц

b65e8f763879244318babbd498a53ed8.png

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 документов, а нижнюю я оставлю для будущих статей.

bff97133aad411d1b5ed77b08f285ab1.png



     

        

        

    

    

        

            

        

        

            

        

        

            

    

    

        

Здесь код представлен без блока 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», либо выбрав файл в диалоговом окне. Пользователь без выбора, хороший пользователь.