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

    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', true ) );
    /
    /******************************/
    
    $w->assign('image.jpg');
    $w->assign('Кто узнал эту женщину - тот настоящий знаток женской красоты.');
    
    $w->create();
    

    Вот в принципе и всё.
    В планах: генерация таблиц.
    Ссылки:
    PHPDocx на гитхабе.
    Страница проекта PHPDocx.
    Скачать исходники.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 19

      0
      профи скажите плиз
      если docx, образно говоря файл xml + zip, то в чём сложности при открытии сложноформатированных документов при открытии в LibreOffice?
        +1
        Напрашивается логичный ответ, что в LibreOffice спецификация docx реализована не полностью
          +1
          Могу предположить что в типографике. Довольно сложно отрендерить 1-в-1. Есть же множество правил, переносов в случае обтекания изображений, идентичность рендеринга шрифтов и прочего, прочего
            0
            Плюс LibreOffice довольно сумбурно написан. Видно что его писало огромное множество людей. Когда понадобилось вставить поддержку DOCX, ее вставили, а рендер остался старым. Как старый рендер работал по-другому, нежели MS, так и работает. Поддержка формата тут, IMHO, не при чем
            0
            У формата есть синтаксис и семантика рендеринга. OpenOffice реализует только семантику рендеринга ODF. Для работы с другими форматами просто используются конвертеры из/в ODF.
              +2
              У меня аналогичный вопрос: почему мои сложные документы odt не открываются корректно M$ Office?
                0
                не могу вам ответить… я убунтоид и меня интересует как можно более корректное открытие форматов MS в LO
                vice versa меня не интересует… извините
                  +3
                  Коллега. Тогда вы точно знаете, что документация docx содержит множество неточностей судя по новостям. Ну а пока другие разбираются в стандарте M$, сама M$ клепает новые продукты с новыми «особенностями».
                    0
                    просто «открытость» docx давала надежду, что наконец-то можно нормально обмениваться документами без косяков
                      +2
                      Ага. docx — спецификация 6 тысяч страниц. odf — тысяча страниц на все.
              +3
              Вы правильно заметили в прошлом посте, что WordDocument может быть наследником другого класса. Я имел в виду, что он может быть наследником/реализацией какого-то абстрактного класса/интерфейса Document, а от него наследоваться WordDocument (вернее DocxDocument), DocDocument, OdtDocument, PdfDocument, RtfDocumnt и т. п., чтобы пользователь мог прозрачно выбрать соответствующий формат.

              У вас же, видимо другие задачи. У вас получается, что OfficeDocument, WordDocument и будущий ExcelDocument все реализуют интерфейс ZipArchive, давая, кроме всего прочего пользователю нарушить целостность документов, сформировать их так, что они открываться не будут. Да и вообще, мне как пользователю класса WordDocument неинтересно представлен он физически zip-файлом или в каком-то бинарном формате, а вы на меня эту информацию вываливаете без всякой надобности (хотя бы в куче методв при автодополнении в IDE). Получая выгоду от наследования, вы нарушаете инкапсуляцию.
                –2
                Спасибо за хорошие комментарии.
                Я вас, кажется, понимаю. У всех этих документов (doc, odt, pdf) есть что-то общее, а именно, создание файла, добавление контента, упаковка. Ключевое слово — инкапсуляция. Здесь вы совершенно правы.
                Сдружить наследование с инкапсуляцией, как мне видится, можно двумя способами:

                1. Использовать геттеры/сеттеры

                2. Написать костылей:

                protected function addFile( $params ){
                  return $this->addFile( $params );
                }
                
                  –2
                  Я хотел сказать
                  protected function addFile( $params ){
                    return parent::addFile( $params );
                  }
                  
                    +1
                    class OfficeDocument {
                    protected $zip;
                    function __construct($filename, $template_path = '/template/' ) {
                        $this->path = dirname(__FILE__) . $template_path;
                        $this->zip = ZipArchive();
                        $this->zip->open($this->path);
                        #.....
                    }
                    }

                    $this->zip доступен и в дочерних классах.
                    +2
                    Мне кажется, что VolCh имел ввиду, что пользователю совершенно необязательно знать и иметь представление, что обьект имеет методы работы с zip ахивом.
                    $storge = new DocFileStorage('temp/file.doc'); // Этот класс реализует механизм сохранения/создания/модификации файла
                    $doc = new WordDocument($storage); // получает аргументом что-то вроде Interface IDocStorage
                    $doc->assign('image.jpg');
                    $doc->assign('Кто узнал эту женщину - тот настоящий знаток женской красоты.');
                    

                    Туда бы еще try{}catch(){} добавить и вообще шоколадно было бы.

                    И теперь мы можем хоть в БД их писать(просто подменяя IDocStorage), хоть в файл, хоть в гугл докс отправлятью.
                      0
                      Более того — опасно ему давать эти знания :)

                      Но вообще немного другую схему имел в виду, о выделении storage не думал, думал только о том чтобы docx, doc, odt, pdf, rtf, txt,… имели общий интерфейс вроде IDocument с assign и т. п., но ничего больше. Если ещё и с вашей стороны смотреть, то это нам наверное мост нужен будет, который будет определять какую имплементацию IDocumentStorage нам подсовывать в конкретную имплементацию IDocument. Без чёткого указания на это в ТЗ ябы не спешил с этим, достаточно для начала IDocument::getСontent(), которое выдаёт бинарное представление, а уж его хоть прямо в вывод (с getMimeType соответствующим), хоть в файл, хоть в БД блобом.
                  +1
                  > Кратко: можно получить EMU из px умножением на число. Только вот на википедии написано, что это число равно 12700. Я же экспериментально выяснил, что оно равно 8625.

                  Потому что в википедии написано про pt, а не про px
                    0
                    Тут тоже. Вполне возможно число правильное, а вот DPI у вас другой. Хотя может я не так понял о чем речь
                    +4
                    XML — парсером так и не научились пользоваться…
                    $w->assign('> < > < > pwned!!!111 < > < > < ');

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