.NET
C#
1 February 2011

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

From Sandbox
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

+67
4.5k 83
Comments 36