Делаем PDF-книгу из веб-комикса при помощи C# на примере xkcd

Sony PRS-650 и xkcdРассматривая новый выпуск xkcd, я взглянул на свою свежеприобретённую электрокнигу Sony PRS-650, и немедленно подумал — хочу смотреть комиксы на ней! Xkcd как раз чёрно-белые и, обычно, небольшие по размеру. Слегка погуглив, нашел лишь сборник картинок на TPB, и скрипт на bash, который должен делать PDF. Решил слегка поразмяться в программировании и сделать граббер комиксов на любимом C#.

Можно было бы обойтись консольным приложением, но, для наглядности, сделал простой интерфейс на WPF.


Полный разбор кода будет излишним, поэтому объясню основные моменты. Рекомендую сразу открыть/скачать полный код приложения с Google Code.





1. Получаем картинки, названия и alt-текст с сайта


На xkcd комиксы удобно лежат по адресам вида xkcd.com/n, где n=1…
Первая мысль была выдирать нужное из кода страницы, но обнаружилось, что можно получить всю информацию в JSON по адресу вида xkcd.com{0}/info.0.json

Для JSON в .NET существует DataContractJsonSerializer
Создаём соответствующий DataContract:

   [DataContract]
   public class XkcdComic
   {
      #region Public properties and indexers

      [DataMember]
      public string img { get; set; }

      [DataMember]
      public string title { get; set; }

      [DataMember]
      public string month { get; set; }

      [DataMember]
      public string num { get; set; }

      [DataMember]
      public string link { get; set; }

      [DataMember]
      public string year { get; set; }

      [DataMember]
      public string news { get; set; }

      [DataMember]
      public string safe_title { get; set; }

      [DataMember]
      public string transcript { get; set; }

      [DataMember]
      public string day { get; set; }

      [DataMember]
      public string alt { get; set; }

      #endregion
   }


… и используем:
      private static XkcdComic GetComic(string url)
      {
         var stream = new WebClient().OpenRead(url);
         if (stream == null) return null;
         var serializer = new DataContractJsonSerializer(typeof (XkcdComic));
         return serializer.ReadObject(stream) as XkcdComic;
      }


По адресу xkcd.com/info.0.json можно получить последний комикс, и, взяв его номер из поля num, узнать их общее количество.
Осталось выкачать саму картинку, тут всё просто:
var imageBytes = WebRequest.Create(comicInfo.img).GetResponse().GetResponseStream().ToBytes();

… где comicInfo — это наши данные из JSON, а ToBytes() — простой extension-метод, который считывает данные из потока в массив.

Для представления комикса (комик-стрип, или как его правильно называть в единственном числе?) используется класс Comic. Чтобы валидировать полученные байты картинки (а скачать мы могли что-нибудь не то, сервер мог вернуть ошибку, и т.п.) конструктор класса сделан приватным, и добавлен фабричный метод Create, который вернёт null в случае ошибки декодирования. Для декодирования используется BitmapImage, который, в случае успеха, будет использован как thumbnail для предпросмотра результата:
   public static Comic Create(byte[] imageBytes)
   {
     try
     {
      // Validate image bytes by trying to create a Thumbnail.
      return new Comic {ImageBytes = imageBytes};
     }
     catch
     {
      // Failure, cannot decode bytes
      return null;
     }
   }

   public byte[] ImageBytes
   {
     get { return _imageBytes; }
     private set
     {
      _imageBytes = value;
      var bmp = new BitmapImage();
      bmp.BeginInit();
      bmp.DecodePixelHeight = 100; // Do not store whole picture
      bmp.StreamSource = new MemoryStream(_imageBytes);
      bmp.EndInit();
      bmp.Freeze();
      Thumbnail = bmp;
     }
   }


Собрав всё воедино, получим метод для закачки комик-стрипа по его номеру:
      protected override Comic GetComicByIndex(int index)
      {
         // Download comic JSON
         var comicInfo = GetComic(string.Format(UrlFormatString, index + 1));
         if (comicInfo == null) return null;

         // Download picture
         var imageStream = WebRequest.Create(comicInfo.img).GetResponse().GetResponseStream().ToMemoryStream();
         var comic = Comic.Create(imageStream.GetBuffer());
         if (comic == null) return null;

         comic.Description = comicInfo.alt;
         comic.Url = comicInfo.link;
         comic.Index = index + 1;
         comic.Title = comicInfo.title;

         // Auto-rotate for best fit
         var t = comic.Thumbnail;
         if (t.Width > t.Height)
         {
            comic.RotationDegrees = 90;
         }

         return comic;
      }


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

Распараллеливание закачек


Буду использовать Task Parallel Library, так как давно собирался попробовать, а повода не было. На первый взгляд всё просто, в цикле вместо прямого вызова GetComicByIndex(i) делаем var task = Task.Factory.StartNew(() => GetComicByIndex(i)). Записываем все запущенные задачи в массив tasks и делаем Task.WaitAll(tasks), после чего получаем результаты каждой задачи из task.Result. Но такой подход не позволит нам отслеживать прогресс и показывать уже загруженные стрипы пользователю. Для решения этой проблемы будем использовать WaitAny и yield return, чтобы возвращать результат каждой задачи сразу по завершении:
   public IEnumerable<Comic> GetComics()
   {
     var count = GetCount();
     var tasks = Enumerable.Range(0, count).Select(GetTask).ToList();

     while (tasks.Count > 0) // Iterate until all tasks complete
     {
      var task = tasks.WaitAnyAndPop();
      if (task.Result != null) yield return task.Result;
     }
   }


Здесь метод GetTask возвращает задачу GetComicByIndex(i), плюс обработка ошибок и кеширование (это выходит за рамки данной статьи). WaitAnyAndPop — extension метод, который ждёт завершения одной из задач, удаляет её из списка и возвращает:
WaitAnyAndPop — extension метод, который ждёт завершения одной из задач, удаляет её из списка и возвращает:
   public static Task<T> WaitAnyAndPop<T>(this List<Task<T>> taskList)
   {
     var array = taskList.ToArray();
     var task = array[Task.WaitAny(array)];
     taskList.Remove(task); 
     return task;
   }


Теперь в коде ViewModel (архитектурные вопросы в этой статье я не рассматриваю, но MVVM (Model-View-ViewModel) — это стандарт де-факто для WPF приложений, а код для выкачивания, экспорта и других вещей, разумеется, разбит по соответствующим классам) мы можем в фоновом потоке итерировать по результату метода GetComics и показывать пользователю стрипы по мере поступления:
   private readonly Dispatcher _dispatcher;
   private readonly ObservableCollection<Comic> _comics = new ObservableCollection<Comic>();

   private void StartGrabbing()
   {
     _dispatcher = Dispatcher.CurrentDispatcher;  // ObservableCollection modifications should be performed on the UI thread
     ThreadPool.QueueUserWorkItem(o => DoGrabbing());
   }

   private void DoGrabbing()
   {
     var grabber = new XkcdGrabber();
     foreach (var comic in grabber.GetComics())
     {
      var c = comic;
      _dispatcher.Invoke((Action) (() => Comics.Add( c )), DispatcherPriority.ApplicationIdle);
     }
   }


2. Отображаем комиксы в WPF


В XAML коде нам остаётся лишь сделать Binding на нашу ObservableCollection, и подготовить соответствующий DataTemplate, чтобы наблюдать процесс загрузки и сами комиксы, с альт-текстом в Tooltip:
    <ListView ItemsSource="{Binding Comics}" ScrollViewer.VerticalScrollBarVisibility="Disabled" 
         x:Name="list" Margin="5,0,5,0"
         ScrollViewer.HorizontalScrollBarVisibility="Visible" Grid.Row="1">
      <ItemsControl.ItemTemplate>
        <DataTemplate>
          <Border BorderBrush="Gray" CornerRadius="5" Padding="5" Margin="5" BorderThickness="1">
            <StackPanel Orientation="Vertical">
              <StackPanel Orientation="Horizontal">
                <TextBlock Text="{Binding Index}" FontWeight="Bold" />
                <TextBlock Text="{Binding Title}" FontWeight="Bold" Margin="10,0,0,0" />
              </StackPanel>
              <Image Source="{Binding Thumbnail}" ToolTip="{Binding Description}"                
              Height="{Binding Thumbnail.PixelHeight}" Width="{Binding Thumbnail.PixelWidth}" />
            </StackPanel>
          </Border>
        </DataTemplate>
      </ItemsControl.ItemTemplate>
      <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
          <StackPanel Orientation="Horizontal" />
        </ItemsPanelTemplate>
      </ItemsControl.ItemsPanel>
    </ListView>


3. Создаём PDF-книгу


PDF выбран по причине популярности и хорошей поддержки в электрокнигах Sony. Для работы с PDF в .NET есть удобная библиотека c открытым кодом iTextSharp (вам понадобится скачать её отдельно, чтобы собрать проект). Тут всё достаточно бесхитростно. Опуская exception handling, подгон размера картинки и шрифты, получим следующее:
var document = new Document(PageSize.LETTER);
var wri = PdfWriter.GetInstance(document, new FileStream(fileName, FileMode.Create));
document.Open();
foreach (var comic in comics.OrderBy(c => c.Index).ToList())
{
  var image = Image.GetInstance(new MemoryStream(comic.ImageBytes));
  var title = new Paragraph(comic.Index + ". " + comic.Title, titleFont);
  title.SetAlignment("Center");
  document.Add(title);
  document.Add(image);
  document.Add(new Phrase(comic.Description, altFont));
  document.Add(Chunk.NEXTPAGE);
}
document.Close();


Результаты


Получилось вот такое приложение, которое, кроме экспорта в PDF, позволяет достаточно удобно просматривать комиксы:

Webcomic Grabber Screenshot

Как выглядит результат на книге можно увидеть на первой картинке статьи.

Что осталось за рамками статьи


Кеширование загруженных данных между запусками приложения (сделано с использованием IsolatedStorage).
Поддержка других вебкомиксов (C этой целью я заранее выделил интерфейс IGrabber, и вынес часть функциональности в TaskParallelGrabber. Пока писал статью, добавил грабберы для WhatTheDuck и Cyanide & Happiness).

Ссылки


Код приложения (С#): Google Code
Работа с PDF на .NET: iTextSharp
Комиксы: xkcd

UPD:
Спасибо XHunter, что залил результирующий PDF и скомпилированную программу!

UPD2:
Я просто оставлю здесь ссылку на хорошую «ответную» статью, в которой подробно раскрывается тема выкачивания комиксов средствами WCF: http://darren-brown.com/?p=37
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 36

    0
    Спасибо!
      +1
      Расскажите, чем существущие решения не устроили? В начале статьи скрипт упоминается.
        +4
        Потому что автору хотелось размять мозг любимым C# )
        0
        Статья отличная. Очень понравилась картинка из первого комикса.
          +4
          Понравился первый комикс. Sudo make me a sandwich
            0
            Поясните шутку =\.
              +1
              http://ru.wikipedia.org/wiki/Sudo
              Если кратко — «команда с правами суперпользователя».
                +1
                В Linux, например, sudo означает «выполнить команду от имени суперпользователя». То есть, в этом комиксе имеется ввиду, что человек, произносящий sudo — самый главный.
                • UFO just landed and posted this here
                    +2
                    Вы что, xkcd никогда не читали? Лысый — значит мужик.
                    –2
                    Sudo уже объяснили выше. Только переведу.

                    Переведу:
                    — Сделай мне сендвич.
                    — Что? Сделай его сам!
                    — sudo сделай мне сендвич.
                    — Хорошо.
                      0
                      Английский я знаю, спасибо :).
                      +1
                      Всем спасибо :).

                      З.Ы. Поставивший минус — гори в аду, всезнайка xD.
                        +5
                        Да-Да. :) Требовать в топике про программирование на C# знание команды sudo — нонсенс.
                        Такая гордость определенно стоит бессмертия души.
                  • UFO just landed and posted this here
                      +5
                      • UFO just landed and posted this here
                          0
                          А скомпилированную программу не могли бы выложить?
                            +1
                            Тык
                            Для xkcd, cyanide и wtd
                              0
                              Большое спасибо. Именно то что нужно!
                                0
                                Спасибо! Добавил ссылки в статью!
                          0
                          Отличная статья, иллюстрирующая гибкость использования C#. Дает «пинок» к более глубокому изучению )
                            +1
                            Скажите, пожалуйста, зачем вы используете Task.Factory + городите лишнее, когда для подобных целей придумали Parallel.For? В особенности, если вам только индекс нужен.
                              0
                              Parallel.For — это первое, что я попробовал. Но он служит для распараллеливания CPU-интенсивных задач, и будет использовать количество потоков в соответствии с количеством ядер. Нам же нужно распараллелить операции ввода-вывода.
                                +1
                                Упс, похоже, я был не прав. Опция MaxDegreeOfParallelism позволяет распараллелить настолько, насколько нужно, только что проверил.
                                Значит, действительно, можно упростить.
                              +3
                                0
                                Я не сомневался, что в каментах объявится обладатель книги с WiFi :)
                                Это здорово, конечно, но таки удобней и быстрей листать готовую книгу, да и связь не везде есть.
                                  +3
                                  Это Киндл с 3Г-же =)

                                  Но в принципе да, вы правы, удобнее читать со своего аппарата.
                                +1
                                Я был слегка наивен когда пробовал открыть xkcd.ru/{0}/info.0.json
                                  0
                                  а ссылка на bash-скрипт у Вас не сохранилась?
                                  0
                                  Исходники c гугл-кода уже убрали :(
                                  This project currently has no downloads
                                    +1
                                    Исходники на месте. Зайдите в Source, а не Downloads.
                                    0
                                    Well done! Спасибо за пост.
                                      0
                                      Неплохая статья и пример по делу. Спасибо!

                                      Есть пара советов, первый — выкладывать в репозиторий бинарники используемых библиотек — а то не собирается сходу. Второй — почему все используют обычно iTextSharp при работе с PDF. Я года 4 назад когда появилась необходимость протестил их несколько штук и выбрал тоже бесплатный PDFsharp (pdfsharp.codeplex.com) — рекомендую. Насчет iTextSharp — детали уже не помню но минусов у него много нашел.
                                        0
                                        По поводу PDF. Когда делал программу — iTextSharp первый под руку попался и легко получилось его использовать.
                                        Позже уже наткнулся на статью habrahabr.ru/blogs/open_source/112707/
                                        Безусловно, если будет серьёзная задача — серьёзно подойду к выбору и учту ваш совет.

                                        По поводу бинарников в репозитории — несколько спорно.

                                      Only users with full accounts can post comments. Log in, please.