Для начала я хочу показать некоторые скриншоты очень ранних версий виджета ENinja (в заключительной части будет кое-что поинтереснее):

For starting point I want to show some screenshots of very early version of ENinja (at the end of post you could find something more interesting):

Alpha version - Ugly Duckling
Beta Buttons of ENinja

Итак, первым делом нам расширение должно позволить добавить PDF-документ в БД, разбив его предварительно на страницы и преобразовав в другой формат.

Ok! First of all our extension should let us upload a PDF document, split it to pages, convert them to another image format and deploy to database.

Для решения поставленной задачи Linux и многие другие *nix дистрибутивы предлагают нам воспользоваться такими программами, как ghostscript, convert (часть библиотеки imagemagick), wvHtml. Грех отказываться от проверенного временем ПО, ими мы и воспользуемся.

To solve our problem about converting different images to different formats we can use time-proved *nix programs and libraries, such as ghostscript, convert (part of imagemagick library).

Реализацию начнём с общего алгоритма:

Let's think about future algorithm:

  1. Файл принимается сервером и временно сохраняется (на время текущего запуска приложения).

    Web-server gets a pdf-file and temporarily saves it (for a current php runtime).

  2. Мы выясняем тип этого файла (нас интересуют PDF-файлы).

    We need to determine a filetype of uploaded file (More interesting are PDF files for us).

  3. Начнём транзакцию.

    Starting transaction.

    1. Преобразуем полученный файл в набор PCX изображений - для каждой из страниц документа своё изображение. Все картинки сохраняются во временной папке (/tmp и созданная внутри неё папка нам вполне подойдет).

      Convert uploaded file to a set of PCX images - one for each page of a document. All pictures are stored in temporary directory (/tmp, for example).

    2. Сортируем полученные изображения в соответствии с их номерами страниц.

      Sort images with right page order.

    3. Конвертируем их в формат PNG (эти действием в качестве мы почти ничего не теряем, а размер итоговых файлов раза в 4—5 будет меньше).

      Convert pages (PCX files) to PNG format (size of end file will be at 4—5 times smaller).

    4. Заносим каждое изображение в базу данных.

      INSERT each image-file to database.

    5. Для пущей верности занесём в БД и оригинал полученного файла.

      For future purposes we insert original document to database too.

    6. Если вставка данных в базу завершилась успешно, то коммитим транзакцию и «облегченно вздыхаем».

      If inserting data to DB was completed successfully, we commit transaction and say "Ohh!..Yeah!"

Приведём реализацию этого алгоритма для случая с PostgreSQL:

Here is a part of code (for PostgreSQL RDBMS):

  1.  public function upload($update, $document_id, $uploadedImage)
  2.  {
  3.      $this->documentId = $document_id;
  4.   
  5.      $ghostscript_cmd = "nice " . exec("which ghostscript") . " -sDEVICE=pcx256 -r150x150 -dNOPAUSE";
  6.      $convert_cmd = "nice " . exec("which convert");
  7.      $wvhtml_cmd = "nice " . exec("which wvHtml");
  8.      $file_cmd = "nice " . exec("which file");
  9.      $tmp_dir = "/tmp";
  10.   
  11.      if ($uploadedImage->hasError)
  12.      {
  13.          switch($uploadedImage->getError())
  14.          {
  15.              case 1:
  16.                  return false;
  17.                  break;
  18.              default:
  19.                  break;
  20.          }
  21.      }
  22.      else
  23.      {
  24.          $out_dir = $tmp_dir . "/isodc" . md5(uniqid('adsf'));
  25.   
  26.          if (strstr(exec($file_cmd . " " . $uploadedImage->getTempName()), "PDF"))
  27.          {
  28.              $filetype = 'pdf';
  29.          }
  30.          else if (strstr(exec($file_cmd . " " . $uploadedImage->getTempName()), "Microsoft Office"))
  31.          {
  32.              $filetype = 'doc';
  33.          }
  34.          else
  35.          {
  36.              $filetype = 'unknown';
  37.          }
  38.   
  39.          switch($filetype)
  40.          {
  41.              case 'pdf':
  42.                  mkdir($out_dir);
  43.                  $cmd = $ghostscript_cmd . " -sOutputFile=\"$out_dir/%d.pcx\" \"" . $uploadedImage->getTempName() . "\"";
  44.                  exec($cmd);
  45.                  $dir_handle = @opendir($out_dir) or die("Unable to open $out_dir");
  46.                  $pages = array();
  47.                  while ($file = readdir($dir_handle))
  48.                  {
  49.                      if (strstr($file, '.pcx'))
  50.                      {
  51.                          array_push($pages, $file);
  52.                      }
  53.                  }
  54.                  sort($pages);
  55.                  $pageNumber = 1;
  56.   
  57.                  try
  58.                  {
  59.                      $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
  60.                      $this->pdo->beginTransaction();
  61.   
  62.                      if ($update)
  63.                      {
  64.                          $this->removePages();
  65.                          $this->removeOriginal();
  66.                      }
  67.   
  68.                      foreach($pages as $page)
  69.                      {
  70.                          $imageFile = $out_dir . "/" . $page;
  71.                          //convert PCX to PNG format
  72.                          $params = " \"$imageFile\" \"$imageFile\".png";
  73.                          exec($convert_cmd . $params);
  74.                          unlink($imageFile);
  75.   
  76.                          $imageFile .= ".png";
  77.                          $this->insertPage($imageFile, $pageNumber);
  78.                          $pageNumber++;
  79.                          unlink($imageFile);
  80.                      }
  81.   
  82.                      $this->insertOriginal($uploadedImage->getTempName());
  83.   
  84.                      $this->pdo->commit();
  85.                  }
  86.                  catch (Exception $e)
  87.                  {
  88.                      $this->pdo->rollBack();
  89.                  }
  90.   
  91.                  closedir($dir_handle);
  92.                  rmdir($out_dir);
  93.                  break;
  94.   
  95.              default:
  96.              return false;
  97.          }
  98.      }
  99.  }
  100.   
  101.  private function removePages()
  102.  {
  103.      $stmt = $this->pdo->prepare("DELETE FROM " . self::PAGES_TABLE_NAME . " WHERE document_id = ?");
  104.      $stmt->execute(array($this->documentId));
  105.  }
  106.   
  107.  private function removeOriginal()
  108.  {
  109.      $stmt = $this->pdo->prepare("DELETE FROM " . self::DOCUMENTS_TABLE_NAME . " WHERE document_id = ?");
  110.      $stmt->execute(array($this->documentId));
  111.  }
  112.   
  113.  private function insertOriginal($documentFile)
  114.  {
  115.      $oid = $this->pdo->pgsqlLOBCreate();
  116.      $stream = $this->pdo->pgsqlLOBOpen($oid, 'w');
  117.      $local = fopen($documentFile, 'rb');
  118.      if (!$local) die('couldnot open original: ' . $documentFile);
  119.      stream_copy_to_stream($local, $stream);
  120.      $local = null;
  121.      $stream = null;
  122.      $stmt = $this->pdo->prepare("INSERT INTO " . self::DOCUMENTS_TABLE_NAME . " (document_id, oid) VALUES (?, ?)");
  123.      $stmt->execute(array($this->documentId, $oid));
  124.  }
  125.   
  126.  private function insertPage($imageFile, $page_number)
  127.  {
  128.      $oid = $this->pdo->pgsqlLOBCreate();
  129.      $stream = $this->pdo->pgsqlLOBOpen($oid, 'w');
  130.      $local = fopen($imageFile, 'rb');
  131.      if (!$local) die('couldnot open page: ' . $imageFile);
  132.      stream_copy_to_stream($local, $stream);
  133.      $local = null;
  134.      $stream = null;
  135.      $stmt = $this->pdo->prepare("INSERT INTO " . self::PAGES_TABLE_NAME .
  136.              " (document_id, page_number, page_oid) VALUES (?, ?, ?)");
  137.      $stmt->execute(array($this->documentId, $page_number, $oid));
  138.  }

Код контроллера, в котором осуществляется валидация и загрузка файла в БД

Controller code for validation and uploading file to Database

  1.  public function actionCreate()
  2.  {
  3.      $document=new Document;
  4.      if(isset($_POST['Document']))
  5.      {
  6.          $document->attributes=$_POST['Document'];
  7.   
  8.          $uploadedFile = CUploadedFile::getInstance($document, 'content');
  9.          if ($uploadedFile->hasError)
  10.          {
  11.              switch($uploadedFile->getError())
  12.              {
  13.                  // UPLOAD_ERR_INI_SIZE
  14.                  // Value: 1; The uploaded file exceeds the upload_max_filesize directive in php.ini.
  15.                  case 1:
  16.                      $document->filesize = Document::EXCESS_MAX_FILESIZE_ERROR;
  17.                      break;
  18.                  default:
  19.                      break;
  20.              }
  21.          }
  22.          else
  23.          {
  24.              $document->filename = $uploadedFile->getName();
  25.              $document->filetype = $uploadedFile->getType();
  26.              $document->filesize = $uploadedFile->getSize();
  27.   
  28.              if($document->save())
  29.              {
  30.                  $ninja = Yii::createComponent('application.extensions.ninja.ENinja');
  31.                  $ninja->upload(false, $document->id, $uploadedFile);
  32.   
  33.                  $this->redirect(CController::createUrl('show',array('document'=>$document)));
  34.              }
  35.          }
  36.      }
  37.      $this->render('create',array('document'=>$document));
  38.  }

Разработка формы виджета

Widget form developing

Ремарка. У виджета меняются кнопки:

Remark. A new buttons on widget:

New view of widget

Форма отображения ENinja разработана с использованием XHTML и jQuery, на ней располагается несколько кнопок для управления содержимым документа, такие как: переход "в начало" документа, переход на "следующую" страницу, переход к "предыдущей" странице, "увеличение/уменьшение" масштаба, а также магическая кнопка, которая будет разворачивать отображение поля, через которое будет виден документ (чтобы не загромождать экран необязательной всё время информацией). Про метод создания кнопок, который я использовал в виджете, я рассказывал в прошлом посте, он называется Custom Buttons и является одной из первоначальный версий кнопок, которые используется в интерфейсе GMail.

Widget form for the ENinja component developed with XHTML+jQuery. We have some control buttons on its interface, such as: go "start" of document, go to the "next" page, go to the "previous" page, "zoomIn/zoomOut/zoomDefault", and of course a magic button which toggles displaying document window (so we don't have to show the document content all the time concealing the screen place). Method that I'm used for buttons in the interface of ENinja widget I wrote in previous post. In previous post i described the method I used for buttons of interface of ENinja widget which is used in GMail.

Несколько слов о коде, который отвечает за отрисовку формы просмотра документа. В него передаются параметры из PHP-класса компонента, такие как идентификатор подгружаемого документа, количество страниц (maximumPage) и многие другие. После первичной реализации почти сразу выяснилось, что несколько документов на одной странице приложения существовать не могут, т.к. имена переменных внутри каждого виджета одинаковые. Найденное решение, может, быть не столь изысканно, но работает. Я сделал так, что имя каждой переменной и функции, а также идентификатора элемента интерфейса внутри виджета получает идентификатор документа, который виджет отображает. Но у меня получился такой страшный код в секции java-скриптов:

I want to say a few words about code of widget. It takes parameters from parent PHP class of component, such as id of document, count of pages in document (maximumPage) and others. After first alpha release we had a problem with several documents on the same page of application. It happened because each widget had variables named like others. My solution may be not so refined, but it works. Now every variable, element identificator of function name in widget component adds an random parameter from PHP-class. But we got so ugly code in javascript section:

var documentId<?php echo $this->myHash; ?> = <?php echo $this->documentId; ?>

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

I am not going to post here the code of widget, because it would take too much place. At the end of post I put links to full extension package.

Рендеринг хранимого в БД изображения

Rendering image from Database

Сама отрисовка изображения будет осуществляться простой подменой атрибута src элемента img, которому будет присваиваться путь до контроллера с передачей параметров (идентификатора документа и номера страницы) в строке запроса.

Image rendering will be implement with a simple changing of src atribute of img object in our ENinja widget. In the src value we write a route to needed controller, its method and send GET parameters which reflect document identifier and page number.

Наш виджет должен знать о каком документе и его странице «идёт речь», когда его «просят» отрисовать изображение. То есть нам необходимо передвать эту информацию в запросах отрисовки изображения.

Our widget need to know what document it show to end user. We need to send this information in render requests.

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

We implement this with an additional method of controller:

  1.  public function actionNinja()
  2.  {
  3.      if(isset($_GET['document_id']) && isset($_GET['page_number']))
  4.      {
  5.          $ninja = Yii::createComponent('application.extensions.ninja.ENinja');
  6.          echo $ninja->renderImage($_GET['document_id'], $_GET['page_number']);
  7.      }
  8.  }

Скринкаст демо-версии просмотрщика ENinja

Screencast of demo-version of ENinja Viewer

Напоследок, как я и обещал, видео того, как работает этот виджет:

At last, as I say at start of post, I make an video, which describe how our widget works:

Скачать расширение

Extension download

Download image Скачать архив tar.gz с расширением ENinjaDownload extension package (.tar.gz)