Monotouch OpenSource библиотека для отображения PDF документов

в 15:35, , рубрики: iOS, mobile development, monotouch, open source, PDF, reader, метки: , , ,

Ну вот, наконец-то я решил сделать один из своих проектов открытым и доступным для общественности, т.е. превратить его в open source, ну а рассказать об этом событии захотелось на Хабре.
Далее речь пойдет о платформе Monotouch и отображению PDF документов в iOS. В теме я не буду приводить кучу исходников библиотеки, кому надо, тот найдет их на GitHub, а постараюсь сконцентрировать внимание на сложных вопросах, возникщих при построении библиотеки.

Наверняка многие разработчики под iOS использующие Monotouch, сталкивались с задачей отображения PDF документов в своих программах. Некоторое время назад, и я столкнулся с такой задачей при разработке своей программы. Исследовав предлагаемые стандартные возможности для отображения PDF документов в iOS (см. сравнительную таблицу ниже), я понял, что без написания своей библиотеки не обойтись. Сразу же хочу сказать, когда я создавал свою библиотеку, iOS 5 еще не вышла, и у меня не было под рукой нового компонента PageControl.

  • UIWebView – несомненно хорошая вещь, но подходит только для демонстрационных проектов. Контрол хоть и может отображать кучу разных форматов документов, но качество отображения очень сильно зависит от содержимого документа. Документы Word, Excel обрезает без зазрения совести, а некоторые PDF документы не открывают вовсе, сообщая об InnerException.
  • QLPreviewController – судя по названию штука предназначенная для предварительного отображения документов, используется в MacOS. Имеет те же проблемы с документами MS Office, что и UIWebView. По поводу PDF, открывает все (ну скажем так — 99%), Но не подлежит визуальной настройке и прикручиванию новых фич, т.е. нормально встроить его в свою программу не получится.
  • Сторонние Objective-C библиотеки для отображения PDF документов – мощные библиотеки, в которых реализованы чуть ли не все фичи нужные просмоторщику PDF на iOS. Но, опять же встает проблема с настройкой под себя, очень высокая их стоимость (> 500 EUR) и необходимость создавать обертки для использования в Monotouch, а для библиотеки с большим количеством открытых классов это задача не из самых тривиальных.

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

  • Два варианта “переворачивания” страниц.
  • Авто масштабирование страницы.
  • Качественное отображение страницы.
  • Детальное масштабирование страницы.
  • Закладки.
  • Заметки.
  • Миниатюры.
  • Настройка опций.
  • Определение ссылок.
  • Поиск.
  • Определение содержания или структуры документа.
  • Использование только классов Cocoa Touch.
  • Отображение “больших” документов, свыше 100Mb.

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

Перелистывание страниц

Первый, и пожалуй, главный вопрос был в том, как реализовать визуальное “перелистывание” страниц? Как уже говорилось выше, компонента PageControl еще не было, так что пришлось изобретать свое решение. Подумав, я решил использовать стандартный ScrollView с большой областью контента и несколькими View в нем. ScrollView позволяет добавлять в него несколько View, при перемещении которых, они визуально заменяются (вот и эффект перелистывания). Сколько View было нужно? Ответ прост – три (один для предыдущей, второй – для текущей, третий – для следующей страницы). Область контента в этом случае, равна 3 * View.Width, т.е. тройной ширине представления страницы.

image

Я реализовал горизонтальное и вертикальное перелистывание страниц. Собственно перелистывание происходит при перетаскивании текущего View влево или вверх (для перелистывания вперед) и вправо или вниз (для перелистывания назад).

Алгоритм работы прост. Все это дело реализована в DocumentViewController, наследнике UIScrollViewController. Для хранения View я использую список (а не массив). При перемещении текущего View (когда следующая страница не первая и не последняя), создается новый View и добавляется в список, в новый View загружается нужная PDF страница, а из списка удаляется неиспользуемый View (первый в списке). Кроме того, SrollView.Position смещается на позицию отображения центрального, т.е. второго в списке View.

public virtual void OpenDocumentPage(int pageNumber)
{
	if (PDFDocument.DocumentHasLoaded && (pageNumber != PDFDocument.CurrentPageNumber)) {
		if ((pageNumber < 1) || (pageNumber > PDFDocument.PageCount)) {
			return;
		}

		// Set current page
		PDFDocument.CurrentPageNumber = pageNumber;

		// Calc min, max page	
		int minValue;
		int maxValue;
		if (PDFDocument.PageCount <= MaxPageViewsCount) {
			minValue = 1;
			maxValue = PDFDocument.PageCount;
		} else {
			minValue = PDFDocument.CurrentPageNumber - 1;
			maxValue = PDFDocument.CurrentPageNumber + 1;
			if (minValue < 1) {
				minValue++;
				maxValue++;
			} else if (maxValue > PDFDocument.PageCount) {
				minValue--;
				maxValue--;
			}
		}
				
		// Create/update page views for displayed pages				
		var unusedPageViews = new List<PageView>(mPageViews);
		RectangleF viewRect = GetScrollViewSubViewFrame();
		for (int i = minValue, j = 0; i <= maxValue; i++,j++) {
			PageView pageView = mPageViews.FirstOrDefault(v => v.PageNumber == i);
			if (pageView == null) {
				pageView = new PageView(viewRect, i);
				mScrollView.AddSubview(pageView);
				mPageViews.Add(pageView);
			} else {
				pageView.Frame = viewRect;
				pageView.PageNumber = i;
				pageView.ZoomReset();
				unusedPageViews.Remove(pageView);
			}
			viewRect = CalcFrameForNextPage(viewRect);
			if (i == PDFDocument.CurrentPageNumber) {
				mCurrentPageView = pageView;
			}
		}
		// Clear unused page views
		foreach (var view in unusedPageViews) {
			view.RemoveFromSuperview();
			mPageViews.Remove(view);					
		}
		
		// Update scroll view content offset
		UpdateScrollViewContentOffset();
	}
}

Качественное отображение и масштабирование страницы

image

View для отображения страницы, на самом деле состоит из целых трех View:

  • PageView (наследуется от UIScrollView) добавляется на DocumentViewController.
  • PageContentView (наследуется от UIView) добавляется на PageView.
  • ThumbView (наследуется от UIPageView) добавляется на PageView.

Первый View нужен для масштабирования страницы, второй – собственно для качественного отображения страницы, третий – нужен для временного изображения страницы.

Для качественного отображения страницы при масштабировании, был использован наследник класса CATiledLayer. Этот класс позволяет отображать детализированный контент при увеличении масштаба View. Но, за все нужно платить – использование CATiledLayer привело к очень длительной первичной отрисовке страницы (несколько секунд), что вызывало неприятное мерцание и пошаговое замещение старой картинки на новую. Чтобы избежать данного негативного эффекта, я добавил еще один View – ThumbView, в который делал первичный вывод PDF страницы с низким разрешением, а уже затем формирование и вывод основного изображения страницы:

Собственно отображения самой PDF страницы очень даже простое:

private void Draw(CGContext context)
{
	if (!PDFDocument.DocumentHasLoaded) {
		return;
	}	
	// Draw page
	context.SetFillColor(1.0f, 1.0f, 1.0f, 1.0f);
	using (CGPDFPage pdfPage = PDFDocument.GetPage(mPageNumber)) {
		context.TranslateCTM(0, Bounds.Height);
		context.ScaleCTM(1.0f, -1.0f);		
		context.ConcatCTM(pdfPage.GetDrawingTransform(CGPDFBox.Crop, Bounds, 0, true));
		context.SetRenderingIntent(CGColorRenderingIntent.Default);
		context.InterpolationQuality = CGInterpolationQuality.Default;
		context.DrawPDFPage(pdfPage);
	}
}

Закладки и заметки

image
При проектировании классов (менеджеров) для работы с закладками и заметками документа, я хотел предоставить возможность разработчикам реализовать собственные механизмы сохранения данных: в xml, БД или др. В своем приложению использующем библиотеку, я сохраняю данные о закладках и заметках в БД Sqlite. В демо-приложение, данные хранятся в памяти во время работы приложения.

Собственно оба менеджера похожи между собой. В них есть методы для загрузки, сохранения и удаления данных. Большинство методов виртуальные. Метода оперируют с дата – объектами DocumentBookmark и DocumentNote соответсвенно.

Т.к., в Monotouch нет такой замечательной штуки как Generic types, механизм активации менеджеров, мне не совсем нравится. Я создал класс ObjectActivator, в котором имеются методы для создания экземпляров менеджеров при старте приложения. Соответственно в случае наследования классов менеджеров, новые экземпляры должны быть зарегистрированы в ObjectActivator:

public class MyObjectsActivator : ObjectsActivator
{
	/// <summary>
	/// Returns DocumentNoteManager instance
	/// </summary>
	/// <returns></returns>
	protected override DocumentNoteManager CreateDocumentNoteManager()
	{
		return new MyDocumentNoteManager();
	}
	
	/// <summary>
	/// Returns DocumentBookmarkManager instance
	/// </summary>
	/// <returns></returns>
	protected override DocumentBookmarkManager CreateDocumentBookmarkManager()
	{
		return new MyDocumentBookmarkManager();
	}	
}

Миниатюры страниц

image

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

Честно говоря, с отображением миниатюр пришлось повозится. Сперва я пытался реализовать алгоритм, при котором количество миниатюр равнялось бы количеству отображаемых, плюс одна слева и одна справа (т.е. алгоритм, в точности аналогичный отображению страниц). При этом, для более быстрой отрисовки, более ненужные миниатюры (т.е. скрытые), располагающиеся с противополжной от перемещения, стороны, не удалялись бы, а перемещались в сторону направления перемещения. Т.е. получался бы некоторый круговорот. От данной задумки пришлось отказаться, т.к. при быстрой прокрутки миниатюр событие Scrolled вызывалось с пропусками в 100 и более пикселей, что приводило к “разрывам” между миниатюрами, т.е. к неверному их расположению.

В итоге я остановился на алгоритме “буфера”, при котором отображаемые в данный момент миниатюры всегда остаются на своих местах, а в случае перемещения, замещаются только при заполнении буфера. Размер буфера задается в опциях, так что для большей производительности следует его увеличить (конечно за счет дополнительного расхода памяти).

Итог

В общем вот и все, если будут вопросы/замечания/пожеланию пишите в комментариях, с радостью (не всегда конечно) отвечу.

Исходники библиотеки на GitHub: github.com/AlexanderMac/mTouch-PDFReader

Автор: AlexMac

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js