Честная генерация DOCX файлов на PHP. Часть 2

в 6:57, , рубрики: docx, php, метки: ,

image Здравствуйте, уважаемое читатели!
Продолжаем историю про генерацию DOCX средствами PHP.

Что нас ждет сегодня:

  • Мы узнаем, как вставлять изображения в документ;
  • Просветимся на счет English Metric Units;
  • Сделаем задел на будущую генерацию Exel.

Тем, кто не в курсе, рекомендуется прочитать первую часть. Ну а кто в теме – прошу под кат.

Ещё раз

Но сначала обо всем по порядку. С момента публикации прошлой статьи было написано достаточное количество комментариев: эмоциональных и по делу; у проекта PHPDocx на гитхабе появилось несколько форков. Всё это говорит о том, что эта тема достаточно актуальна. Но некоторые разработчики не понимают самой сути моего подхода. А подход этот заключается в использовании наследования: класс генератор должен быть наследником ZipArchive. Послушайте, ну если не хотите Вы использовать наследование, установите PHP 5.4 и используйте traits, в конце концов! Этот подход несравненно лучше, чем работать постоянно через одно свойство:

$this->archive->open( … );
$this->archive->addFile( … );
$this->archive->close( .. );

Для чего вообще нужно генерить DOCX на PHP? Некоторые разработчики не понимают, зачем вообще это нужно. Я ориентировался на то, чтобы сделать возможность сохранить web-страницу в формате Word. Лично я использую свой класс для сохранения отчетов Яндекс.Метрики в формате DOCX. Пользователь seriyPS спросил, зачем я разбивал текст на строки? Я это делал, предполагая, что текст является полем из БД, а перенос строки — новый абзац. В общем, не будем этого делать для ясности. Сделайте сами разбивку на абзацы.
Кроме того, наш генератор должен иметь максимально удобный API. Я думаю, мне удалось его реализовать. API состоит всего из трех методов: конструктора, assign, create.
Ну что ж, поговорили, и хватит. Приступим.

Что нового

Во-первых, я существенно изменил код, используемый в той статье, и оформил это всё в полноценную OpenSource библиотеку. Ссылки в конце. А сейчас по пунктам:

1. Класс OfficeDocument и WordDocument

Как мы уже поняли, в корне архива хранятся файлы, необходимые документу MS Office в целом. В папке word/ хранятся документы, необходимые документу MS Office Word непосредственно. Решение напрашивается само собой: сделать класс общий для документов MS Office, и класс-наследник для Word-документов непосредственно.
Сразу опишу структуру:

// Общий класс для создания генераторов MS Office документов
class OfficeDocument extends ZipArchive{

__construct($filename, $template_path = '/template/' );
protected function add_rels( $filename, $rels, $path = '' );
protected function pparse( $replace, $content );
}
// Класс для создания документов MS Word
class WordDocument extends OfficeDocument{

public function __construct( $filename, $template_path = '/template/' )

// Обращаю внимание, это метод API
public function assign( $content = '', $return = false );
public function create();
}

Зачем я это сделал. Это задел на будущее, в котором мы будем генерить файлы MS Excel классом XlsxDocument.
Давайте разберем внутренности.

2. Динамическое создание связей

Внутри docx-файла существуют файлы _rels/.xml и word/_rels/document.xml.rels. Они подключают файлы в документ. Если не описать какой-либо файл в этих структурах, то он просто окажется лишним весом в docx-документе. Таким образом можно просто прятать инфу внутри docx. Мы же в конструкторах создадим массивы внутренних связей между XML-документами. Вот, например, связи для документа MS Office:

      // Описываем связи для документа MS Office
      $this->rels = array_merge( $this->rels, array(
        'rId3' => array(
          'http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties',
          'docProps/app.xml' ),
        'rId2' => array(
          'http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties',
          'docProps/core.xml' ),
      ) );

Идентификатором подключаемого файла является запись «rIdN». Файлы app.xml и core.xml являются статичными. Мы их просто будем упаковывать в архив методом add_rels, параллельно создавая XML-файл описания связей _rels.xml:

    // Генерация зависимостей
    protected function add_rels( $filename, $rels, $path = '' ){

      // Шапка XML
      $xmlstring = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';

      // Добавляем документы по описанным связям
      foreach( $rels as $rId => $params ){

        // Если указан путь к файлу, берем. Если нет, то берем из репозитория
        $pathfile = empty( $params[2] ) ? $this->path . $path . $params[1] : $params[2];

        // Добавляем документ в архив
        if( $this->addFile( $pathfile ,  $path . $params[1] ) === false )
          die('Не удалось добавить в архив ' . $path . $params[1] );

        // Прописываем в связях
        $xmlstring .= '<Relationship Id="' . $rId . '" Type="' . $params[0] . '" Target="' . $params[1] . '"/>';
      }

      $xmlstring .= '</Relationships>';

      // Добавляем в архив
      $this->addFromString( $path . $filename, $xmlstring );
}

Обращаю внимание, что add_rels описан в OfficeDocument, а используется в обоих классах: OfficeDocument и WordDocument, поскольку внутри docx файла существует два документа _rels.xml, описывающих зависимости. Это выйгрыш ООП подхода, который я предложил, и здесь методология, предложенная VolCh, точно не подойдет.
В результате получаем такой типовой _rels:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
 <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
 <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
 <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>

Файл word/document.xml мы сгенерим и подключим динамически. Надеюсь, с динамическим созданием связей понятно. Теперь со вставкой изображения.

Учимся вставлять изображения

Сначала приведу XML-фрагмент, полученный экспериментальным методом, для вставки в document.xml, чтобы получить изображение в Word-документе:

<w:p w:rsidR="000E3348" w:rsidRDefault="00CD6FED">
 <w:r>
  <w:rPr>
   <w:noProof/>
   <w:lang w:eastAsia="ru-RU"/>
  </w:rPr>
  <w:drawing>
   <wp:inline distT="0" distB="0" distL="0" distR="0">
    <wp:extent cx="{WIDTH}" cy="{HEIGHT}"/>
    <wp:effectExtent l="19050" t="0" r="0" b="0"/>
    <wp:docPr id="2" name="Рисунок 2"/>
    <wp:cNvGraphicFramePr>
     <a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>
    </wp:cNvGraphicFramePr>
    <a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
     <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
      <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
       <pic:nvPicPr>
        <pic:cNvPr id="0" name="image.jpg"/>
       <pic:cNvPicPr/>
      </pic:nvPicPr>
      <pic:blipFill>
       <a:blip r:embed="{RID}">
        <a:extLst>
         <a:ext uri="{28A0092B-C50C-407E-A947-70E740481C1C}">
          <a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"/>
         </a:ext>
        </a:extLst>
       </a:blip>
       <a:stretch>
        <a:fillRect/>
       </a:stretch>
      </pic:blipFill>
      <pic:spPr>
       <a:xfrm>
        <a:off x="0" y="0"/>
        <a:ext cx="{WIDTH}" cy="{HEIGHT}"/>
       </a:xfrm>
       <a:prstGeom prst="rect">
        <a:avLst/>
       </a:prstGeom>
       <a:noFill/>
       <a:ln>
        <a:noFill/>
       </a:ln>
      </pic:spPr>
     </pic:pic>
    </a:graphicData>
   </a:graphic>
  </wp:inline>
 </w:drawing>
 </w:r>
</w:p>

Нам нужно будет заменить {RID} на идентификатор подключенного изображения, а также прописать {WIDTH} и {HEIGHT}.
За вставку изображения, как и за вставку текста отвечает один метод API — assign:

    public function assign( $content = '', $return = false ){

      // Проверяем, является ли $text файлом. Если да, то подключаем изображение
      if( is_file( $content ) ){

        // Берем шаблон абзаца
        $block = file_get_contents( $this->path . 'image.xml' );

        list( $width, $height ) = getimagesize( $content );

        $rid = "rId" . count( $this->word_rels ) . 'i';
        $this->word_rels[$rid] = array(
          "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
          "media/" . $content,

          // Указываем непосредственно путь к файлу
          $content
        );

        $xml = $this->pparse( array(
          '{WIDTH}' => $width * $this->px_emu,
          '{HEIGHT}' => $height * $this->px_emu,
          '{RID}' => $rid,
        ), $block );
      }
      else{

        // Берем шаблон абзаца
        $block = file_get_contents( $this->path . 'p.xml' );

        $xml = $this->pparse( array(
          '{TEXT}' => $content,
        ), $block );
      }

      // Если нам указали, что нужно возвратить XML, возвращаем
      if( $return )
        return $xml;
      else
        $this->content .= $xml;
    }

Кто умеет читать код, заметит, что в методе используется хитрая метрическая система. Называется она English Metric Units (EMU). Почитать об этом можно на английской википедии. Кратко: можно получить EMU из px умножением на число. Только вот на википедии написано, что это число равно 12700. Я же экспериментально выяснил, что оно равно 8625. При этом множителе картинка отображалась пиксель в пиксель.
Ну и конечно, подключаем непосредственно файл изображения в структуру связей:

        $rid = "rId" . count( $this->word_rels ) . 'i';
        $this->word_rels[$rid] = array(
          "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
          "media/" . $content,

          // Указываем непосредственно путь к файлу
          $content
        );

В результате

В результате мы получили полноценную библиотеку. Теперь мы можем использовать её вот так:

// Подключаем класс
include 'PHPDocx_0.9.2.php';

// Создаем и пишем в файл. Деструктор закрывает
$w = new WordDocument( "Пример.docx" );

// Использование метода assign
/******************************
/
/ $w->assign( 'text' );
/ $w->assign( 'image.png' );
/ $xml = $w->assign( 'image.png', true );
/ $w->assign( $w->assign( 'image.png' ) );
/
/******************************/

$w->assign('image.jpg');
$w->assign('Кто узнал эту женщину - тот настоящий знаток женской красоты.');

$w->create();

Вот в принципе и всё.
В планах: генерация таблиц.
Ссылки:
PHPDocx на гитхабе.
Домашняя страница PHPDocx.
Скачать PHPDocx (все исходники из статьи и пример).

Автор: alexios

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


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