Пытаемся сделать PDF-книгу из веб-комикса при помощи Haskell на примере xkcd

    Прочитав статью решил проверить, насколько пригоден для этого Haskell. Сразу скажу, сам Haskell пригоден весьма неплохо, но вот, пробежавшись по hackage.haskell.org, я сразу обнаружил проблемы с библиотеками для работы с PDF, что и поставило крест на полноценной реализации.
    Но я решил всё же проделать часть работы, дабы показать, как та же задача могла бы быть сделана на Haskell, если бы да кабы…

    Получаем информацию о комиксе

    Так как нам придётся запрашивать информацию в виде JSON, сразу напишем полезную функцию:
    rethrow :: (Show e) => Exceptional e a -> IO a<br>
    rethrow = switch (throwIO . userError . show) return<br>
    <br>
    jsonAt url = simpleHTTP (getRequest url) >>= getResponseBody >>= rethrow . decode_ json<br>
    <br>
    str s = member_ (literal s) JSON.string<br>
    num n = member_ (literal n) JSON.number<br>


    Функция rethrow выкинет исключение при ошибочном парсинге, а str и num упростят указание необходимых полей в JSON.

    Тогда код для получения последнего комикса и комикса по номеру будет выглядеть так:
    comics = jsonAt "http://xkcd.com/info.0.json" >>= fmap (fromIntegral . numerator) . rethrow . decode_ (num "num")<br>
    <br>
    comic n = jsonAt (concat ["http://xkcd.com/", show n, "/info.0.json"]) >>= rethrow . decode_ (str "img" <&> str "title")<br>

    Функция decode_ парсит полученные данные в соответствии с указанным паттерном. В случае num "num"
    мы получаем из JSON числовой член с именем num, в случае str "img" <&> str "title"
    — тупл из двух строк для картинки и названия соответственно.

    Код для получения картинки по URL:
    image url = simpleHTTP (getRequest url) >>= getResponseBody<br>


    Поток закачки

    Запишем закачку одного комикса в отдельную функцию.
    retrieve ch l i = tryGet `onException` onFail where<br>
        onFail = do<br>
            writeChan ch (i, Nothing)<br>
            writeLogger l Error $ "Comic " ++ show i ++ " failed to download"<br>
        tryGet = do<br>
            (imgUrl, title) <- comic i<br>
            imgData <- image imgUrl<br>
            jpg <- writeBinaryFile fname imgData >> readJpegFile fname >>= either (throwIO . userError . show) return<br>
            writeChan ch (i, Just (jpg, title))<br>
            writeLogger l Info $ "Comic " ++ show i ++ " downloaded"<br>
        fname = show i ++ ".jpg"<br>


    Здесь мы воспользовались каналом ch (Control.Concurrent.Chan), в который будем отсылать результаты закачек, а так же потокобезопасным логом l.

    Из-за кривой библиотеки HPDF приходится сначала сохранять картинку в файл, а потом грузить её оттуда вновь. Мне совершенно не ясно, почему автор написал парсинг JPEG с нуля сам (да ещё и только из файла), а не воспользовался готовой библиотекой

    Генерация PDF

    Теперь стоит написать функцию, которая по списку картинок сгенерирует нам результирующий PDF.
    pdf imgs = runPdf "Xkcd.pdf" doc (PDFRect 0 0 800 600) $ forM_ imgs genPage where<br>
        genPage (jpeg, title) = do<br>
            img <- createPDFJpeg jpeg<br>
            page <- addPage Nothing<br>
            drawWithPage page (drawText (text (PDFFont Times_Roman 12) 0 0 (toPDFString title)) >> drawXObject img)<br>
        doc = PDFDocumentInfo {<br>
            author = toPDFString "xkcd",<br>
            subject = toPDFString "xkcd",<br>
            pageMode = UseNone,<br>
            pageLayout = OneColumn,<br>
            viewerPreferences = standardViewerPrefs,<br>
            compressed = False }<br>

    В общем-то, в этой функции ничего интересного. Зовём соответственные функции из библиотек. Важен только тот нюанс, что список картинок ленивый, поэтому работать функция начинает сразу, как только появляется первая картинка.

    Собираем воедино

    В основной функции мы инициализируем лог, создаём канал, в который легковесные потоки будут писать результат, и вызываем генерацию PDF с ленивым списком картинок из этого канала.
    main = bracket (newLogger Console) closeLogger $ \l -> do<br>
        n <- comics<br>
        writeLogger l Info $ "Number of comics to download: " ++ show n<br>
        ch <- newChan<br>
        mapM_ (fork . retrieve ch l) [1..n]<br>
        cts <- fmap (take n) $ getChanContents ch<br>
        let imgs = catMaybes $ mapMaybe (`lookup` cts) [1..n]<br>
        pdf imgs `onException` (writeLogger l Error "Unable to generate PDF")<br>
        writeLogger l Info "PDF generated."<br>

    Функция bracket аналогична using, гарантируя закрытие лога.
    Строкой mapM_ (fork . retrieve ch l) [1..n]<br>
    мы на каждый номер создаём поток закачивания, т.е. вызываем retrieve ch l i в отдельном потоке.
    fmap (take n) $ getChanContents ch<br>
    вернёт нам ленивый список с первыми n результатами. Брать весь список не имеет смысла, так как канал бесконечен.
    Затем мы применяем функцию lookup, с каждым индексом по порядку, от 1 до n. Это необходимо, чтобы в результате получить тоже ленивый список, но в котором картинки идут строго по порядку. Таким образом мы всегда будем писать картинки в нужном порядке.

    Полный листинг

    main = bracket (newLogger Console) closeLogger $ \l -> do<br>
        n <- comics<br>
        writeLogger l Info $ "Number of comics to download: " ++ show n<br>
        ch <- newChan<br>
        mapM_ (fork . retrieve ch l) [1..n]<br>
        cts <- fmap (take n) $ getChanContents ch<br>
        let imgs = catMaybes $ mapMaybe (`lookup` cts) [1..n]<br>
        pdf imgs `onException` (writeLogger l Error "Unable to generate PDF")<br>
        writeLogger l Info "PDF generated."<br>
    <br>
    retrieve ch l i = tryGet `onException` onFail where<br>
        onFail = do<br>
            writeChan ch (i, Nothing)<br>
            writeLogger l Error $ "Comic " ++ show i ++ " failed to download"<br>
        tryGet = do<br>
            (imgUrl, title) <- comic i<br>
            imgData <- image imgUrl<br>
            jpg <- writeBinaryFile fname imgData >> readJpegFile fname >>= either (throwIO . userError . show) return<br>
            writeChan ch (i, Just (jpg, title))<br>
            writeLogger l Info $ "Comic " ++ show i ++ " downloaded"<br>
        fname = show i ++ ".jpg"<br>
    <br>
    pdf imgs = runPdf "Xkcd.pdf" doc (PDFRect 0 0 800 600) $ forM_ imgs genPage where<br>
        genPage (jpeg, title) = do<br>
            img <- createPDFJpeg jpeg<br>
            page <- addPage Nothing<br>
            drawWithPage page (drawText (text (PDFFont Times_Roman 12) 0 0 (toPDFString title)) >> drawXObject img)<br>
        doc = PDFDocumentInfo {<br>
            author = toPDFString "voidex",<br>
            subject = toPDFString "xkcd",<br>
            pageMode = UseNone,<br>
            pageLayout = OneColumn,<br>
            viewerPreferences = standardViewerPrefs,<br>
            compressed = False }<br>
    <br>
    rethrow :: (Show e) => Exceptional e a -> IO a<br>
    rethrow = switch (throwIO . userError . show) return<br>
    <br>
    jsonAt url = simpleHTTP (getRequest url) >>= getResponseBody >>= rethrow . decode_ json<br>
    <br>
    str s = member_ (literal s) JSON.string<br>
    num n = member_ (literal n) JSON.number<br>
    <br>
    comics = jsonAt "http://xkcd.com/info.0.json" >>= fmap (fromIntegral . numerator) . rethrow . decode_ (num "num")<br>
    <br>
    comic n = jsonAt (concat ["http://xkcd.com/", show n, "/info.0.json"]) >>= rethrow . decode_ (str "img" <&> str "title")<br>
    <br>
    image url = simpleHTTP (getRequest url) >>= getResponseBody<br>
    <br>
    writeBinaryFile fname str = withBinaryFile fname WriteMode (\h -> hPutStr h str)<br>


    Ругань

    К сожалению, из-за отсутствия достойной библиотеки для работы с PDF, результат не оправдал себя.
    Большую часть картинок HPDF отказывается принимать (благодаря очередной велосипедной реализации загрузки JPEG), с масштабированием картинок я даже и не стал разбираться.

    Дифирамбы

    Было очень удобно прямо в GHCi протестировать запрос, затем разобрать один из них, скачать и сохранить картинку. Вся разработка велась там, а затем код был перенесён в файл. Многопоточность была прикручена без добавлений интерфейсов и какого-либо лишнего кода. Вместо возврата результата мы просто пишем его в канал, на другом конце которого обработчик. А к асинхронной функции дописываем fork. В общем случае не всё так просто, конечно, но по своему опыту скажу, что ни разу не приходилось менять архитектуру для этого.

    В общем, смотрите на hackage.haskell.org, ищите нужные библиотеки, и коли нашли, не упускайте шанс написать всё на Haskell!
    Share post

    Similar posts

    Comments 17

      +12
      Ну что, кто возьмется написать на Брейнфаке?
        +1
        Чтобы написать это на любом языке программирования нужно как минимум два потока данных, один сетевой и один файловый. А bf может работать только с одним.
          +3
          Он шутит в честь сегодняшнего «дня брейнфака»:
          habrahabr.ru/blogs/crazydev/112919/
          habrahabr.ru/blogs/crazydev/112989/
            0
            На bf можно написать программу, которая будет управлять мультиплексором потоков. В качестве такого мультиплексора можно использовать обычный шелл.
              0
              Шелл можно считать как отдельный ЯП, так что это будет уже не чистый bf.
          +11
          Хм, хаскель одного меня пугает или есть ещё дурики?
            +1
            Да ничего страшного там нету. Мне хаскель нравится, но такая задача, скажем на питоне решается гораздо менее трудоемко. Типизация в таких рутинных задачках только мешает. Ничего лучше языков с динамической типизацией для таких скриптиков нету.
            0
            Только первая пара сотен комиксов идут в jpg, остальные в png. Может быть, в этом проблема? Вы ведь только jpg в коде ожидаете.
              0
              Дифирамбы.
                +3
                > как та же задача могла бы быть сделана на Haskell, если бы да кабы

                Ничего личного, но ИМХО это типичный сценарий использования Haskell'a :-)

                Пишется «тёплое ламповое» всё-такое-функциональное ядро/алгоритм, а потом выясняется, что либо в нужной базе данных varchar поддерживается, либо реализация какого-нибудь RPC протокола «готова на 80%», либо ещё что-нибудь в этом роде.

                После чего всё берется и переписывается на любимый «ремесленный» язык программирования ;)
                  0
                  Это весьма сильный вывод. Чем подкрепите?
                    0
                    Собственно, данным постом.

                    Я не говорю что «всё плохо, ничего не работает», просто качество+количество библиотек еще не доросло до «продакшен» уровня.

                    Пример: берем задачу, пишем её на haskell, всё хорошо.
                    через месяц появляется требование читать данные из excel файлов.
                    ещё через месяц — выводить отчёты в pdf
                    ещё через месяц — работать с другой системой через soap
                    ещё через месяц — добавить красивый web-интерфейс.

                    Просто выше шанс того, что на Java/C#/Python/whatever под новые требования найдётся библиотека, которая проверена временем и работает.
                      +1
                      У Haskell на удивление простой и удобный интероп с C, так что реальной проблемой при достаточно серьёзном проекте эти требования стать вряд ли смогут. В данной конкретной задаче можно было вызывать сишную функцию, передавая raw pointer на данные картинки и название.
                      Хотя, конечно, отсутствие библиотеки из коробки не есть хорошо.
                      В оправдание можно лишь сказать, что библиотек-то на самом деле много на почти все случаи жизни.
                        0
                        > Собственно, данным постом.

                        Ну, я не уверен в том, что можно судить по разработке на Haskell в целом по отдельно взятому посту. :)

                        > Я не говорю что «всё плохо, ничего не работает», просто качество+количество библиотек еще не доросло до «продакшен» уровня.

                        Это зависит от сферы применения. Например, для компиляторов Haskell подходит даже сейчас. Какую сферу применения вы имеете в виду?

                        > Пример: берем задачу, пишем её на haskell, всё хорошо.
                        > через месяц появляется требование читать данные из excel файлов.
                        > ещё через месяц — выводить отчёты в pdf
                        > ещё через месяц — работать с другой системой через soap
                        > ещё через месяц — добавить красивый web-интерфейс.

                        Похоже на задачу из бизнеса. Конечно, вряд ли кто-нибудь станет выполнять такие на Haskell. Хотя, кто его знает. :)
                          0
                          > Ну, я не уверен в том, что можно судить по разработке на Haskell в целом по отдельно взятому посту. :)

                          Просто оборот «вот как можно было бы сделать на haskell, но...» показался знакомым… ;)

                          >Это зависит от сферы применения. Например, для компиляторов Haskell подходит даже сейчас. Какую сферу применения вы имеете в виду?

                          Разумеется. Сфера применения — general purpose programming, скажем так. Хороший пример — как раз в топике. Понадобилось, например, скачать картинки и сделать из них PDF…

                          > Похоже на задачу из бизнеса.
                          Ну да. Не всё же компиляторы писать или задачки из Эйлера решать :)
                    0
                    > Было очень удобно прямо в GHCi протестировать запрос, затем разобрать один из них, скачать и сохранить картинку. Вся разработка велась там, а затем код был перенесён в файл.

                    Скажите спасибо придумавшим это лисперам. ;)
                      0
                      Огромное скажу. Я теперь без REPL нормального программирования не представляю.

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