Пользователь
0,0
рейтинг
27 февраля 2012 в 14:33

Разработка → Честная генерация DOCX на PHP. Часть 1 из песочницы

PHP*
image Здравствуйте, уважаемое хабрасообщество! Как-то раз был на хабре интересный материал про генерацию doc-файлов средствами PHP. К сожалению, больше на хабре ничего на эту тему я не нашел. На тот момент я разработал собственное решение.
Оно состояло в том, чтобы генерировать .docx файлы. Аргументы были следующие:

  • На дворе 2012 год, а этот формат появился аж в 2007-м
  • Генерить .docx несомненно проще, чем .doc, поскольку .docx = .zip, а .doc — бинарный файл
  • Костыль с генерацией HTML и переименованием в doc не подойдет для более-менее уважающих себя проектов
  • С помощью приведенного ниже метода мы с легкостью сгенерируем Excel, и вообще всё что угодно.

Подробности под катом.

Структура файла


image image Возьмите ваш любой файл .docx и переименуйте его в .zip, а затем откройте. И вы увидите структуру docx-файла. Да, да! Это обычный zip-архив. Кратко скажу, что самое интересное для нас лежит в папке word. Здесь-же в корне находятся общие настройки документа.
Самое же интересное для нас в папке word — файл document.xml, который представляет из себя файл с содержимым Office Open XML. Именно он содержит в себе непосредственно содержимое документа. Подробнее об этом формате можно почитать на английской Википедии. В папке _rels находится файл document.xml.rels. Он нам пригодится в будущем, чтобы описывать связи прикрепленных файлов внутри документа. Может еще существовать папка media, если в вашем документе присутствуют изображения. Имена остальных файлов вроде-бы говорят за себя.

Учимся генерить .docx


Итак, как мы уже определились, .docx это просто обычный zip-архив, поэтому решение напрашивается само собой: класс-генератор документов должен быть наследником класса ZipArchive, который доступен «из коробки». А остальное — дело техники. Ниже приведен класс для создания пустого .docx-файла (не забываем включить zlib и использовать кодировку UTF-8).
class Word extends ZipArchive{

    // Файлы для включения в архив
    private $files;

    // Путь к шаблону
    public $path;

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

      // Путь к шаблону
      $this->path = dirname(__FILE__) . $template_path;

      // Если не получилось открыть файл, то жизнь бессмысленна.
      if ($this->open($filename, ZIPARCHIVE::CREATE) !== TRUE) {
        die("Unable to open <$filename>\n");
      }


      // Структура документа
      $this->files = array(
        "word/_rels/document.xml.rels",
        "word/theme/theme1.xml",
        "word/fontTable.xml",
        "word/settings.xml",
        "word/styles.xml",
        "word/document.xml",
        "word/stylesWithEffects.xml",
        "word/webSettings.xml",
        "_rels/.rels",
        "docProps/app.xml",
        "docProps/core.xml",
        "[Content_Types].xml" );

      // Добавляем каждый файл в цикле
      foreach( $this->files as $f )
        $this->addFile($this->path . $f , $f );
    }

    // Упаковываем архив
    public function create(){

      $this->close();
    }
}


$w = new Word( "Example.docx" );

$w->create();

Возле скрипта должен появиться файл Example.docx При этом не забываем создать саму структуру файлов. Для её получения пользуемся пресловутым MS Office и Winrar'ом. После сборки пробуем открыть в через MS Office. В случае незначительных ошибок в XML ворд выдаст предупреждение, что в документе содержатся ошибки, но и предложит их исправить. Если же документ собран совсем неправильно, ворд лишь ругнется и откажется открывать.

Вставляем текст


Для получения требуемого XML текста я использовал тот же подход ламера: печатал текст в ворде, извлекал внутренности и изучал. Вот какой XML у меня получился для обычного абзаца:
<w:p w:rsidR="00BB20FC" w:rsidRPr="00357A74" w:rsidRDefault="00357A74" w:rsidP="00BB20FC">
<w:pPr>
<w:jc w:val="left"/>
<w:rPr>
<w:sz w:val="28"/>
<w:lang w:val="en-US"/>
</w:rPr>
</w:pPr>
<w:r w:rsidRPr="00357A74">
<w:rPr>
<w:sz w:val="28"/>
<w:lang w:val="en-US"/>
</w:rPr>
<w:t>{TEXT}</w:t>
</w:r>
</w:p>

Нетрудно понять, что нужно изменить, чтобы получить требуемое выравнивание и размер текста. В тег w:t вставляем наш текст, но без переноса строк!
Вводим в наш класс метод assign, и генератор становится таким:
class Word extends ZipArchive{

    // Файлы для включения в архив
    private $files;

    // Путь к шаблону
    public $path;

    // Содержимое документа
    protected $content;

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

      // Путь к шаблону
      $this->path = dirname(__FILE__) . $template_path;

      // Если не получилось открыть файл, то жизнь бессмысленна.
      if ($this->open($filename, ZIPARCHIVE::CREATE) !== TRUE) {
        die("Unable to open <$filename>\n");
      }


      // Структура документа
      $this->files = array(
        "word/_rels/document.xml.rels",
        "word/theme/theme1.xml",
        "word/fontTable.xml",
        "word/settings.xml",
        "word/styles.xml",
        "word/stylesWithEffects.xml",
        "word/webSettings.xml",
        "_rels/.rels",
        "docProps/app.xml",
        "docProps/core.xml",
        "[Content_Types].xml" );

      // Добавляем каждый файл в цикле
      foreach( $this->files as $f )
        $this->addFile($this->path . $f , $f );
    }

    // Регистрируем текст
    public function assign( $text = '' ){

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

      // Нам нужно разбить текст по строкам
      $text_array = explode( "\n", $text );

      foreach( $text_array as $str )
        $this->content .= str_replace( '{TEXT}', $str, $p );
    }

    // Упаковываем архив
    public function create(){

      // Добавляем содержимое
      $this->addFromString("word/document.xml", str_replace( '{CONTENT}', $this->content, file_get_contents( $this->path . "word/document.xml" ) ) );

      $this->close();
    }
}

$w = new Word( "Пример.docx" );

$w->assign('Пример текста.
Будущее не предопределено.');

$w->create();

Вот в принципе и всё. В следующий раз мы научимся вставлять изображения.
Просто, не правда ли? Весь код с примером.
UPD. Сделал подсветку кода.
UPD 2. Читайте продолжение.
@alexios
карма
8,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (19)

  • +20
    <source> в помощь.
  • +12
    Все, конечно, супер. Спасибо за статью. Но пожалуйста, отформатируйте исходные коды… Прям вырви глаз :)
  • +5
  • 0
    Еще с нетерпением жду описание цифровой подписи docx.
    Всегда мечтал генерить при помощи пхп подписанные документы.
  • +3
    Можно попридираться к коду?

    — Название класса Word не однозначно. Увидев где-то в его клиенте $word = new Word('Пример.docx'), я бы решил что какое-то слово извлекается из документа. Предлагаю DocxDocument или или даже DocxDocumentFromTemplate.

    — Вызывает большое сомнение необходимость наследоваться от ZipArchive. Не буду заниматься излишними абстракциями вроде: «Хорошо бы иметь общий интерфейс для .txt, .doc, .docx, .odt, .rtf, пускай эта функциональность пока и не требуется и неизвестно потребуется ли когда либо». Поступлю проще, спрошу: «Вы уверены, что в классе Word вам нужны все методы ZipArchive? Его клиентам нужен к ним доступ?». Может лучше сделать ZipArchive свойством (приватным/защищенным)?

    — параметр конструктора $template = '/template/' меня как-то ступорит. Привык, что template это файл, а путь начинающийся с '/' абсолютный от корня ФС. Может вообще передавать в конструктор нормальный шаблон (подготовленный в редакторе файл с полями {{CONTENT}}, а класс его будет разархивировать, заменять содержимое, а потом архивировать?

    В общем придирки не к реализации, а к интерфейсу. Не слишком ли много клиенту нужно знать об устройстве и самого формата, и вашего класса?

    • 0
      Можно попридираться к коду?
      Да, конечно.

      Название класса — да, согласен. DocxDocument будет понятнее.

      Наследование от ZipArchive — это концепция этого топика, я уверен, что именно так можно (не скажу что нужно, т.к. могут быть другие пути) генерить MSWord. Причина — в упаковщике. Если DocxDocument — генератор, то он должен быть и упаковщиком.

      Вопрос про шаблон template — отдельная тема. В топике я реализовал лишь самое главное, применив str_replace. Но у каждого свои тараканы в голове — можно хоть Smarty применить.

      Самое главное, что меня интересует — правильная ли реализация генерации контента при помощи метода assign? Ведь можно сделать assign методом регистрации переменных, как принято в Template-движках, а парсинг — методом pparse в конце. Что-то такое уже проглядывается в текущей реализации, поскольку весь контент регистрируется в одну переменную {CONTENT}, а в конце она одна парсится str_replac'ом. Но с другой стороны, мы изобретаем велосипед. Можно просто прикрутить готовый шаблонизатор в свойство и скармливать ему шаблон. Вопрос — какой шаблонизатор?
      • +1
        Я имел в виду код вроде:
        class DocxDocument {
          protected $archive;
        
          public function __construct($filename, $template_path = '/template/' ){
        
              // Путь к шаблону
              $this->path = dirname(__FILE__) . $template_path;
        
              // Если не получилось открыть файл, то жизнь бессмысленна.
              if ($this->archive->open($filename, ZIPARCHIVE::CREATE) !== TRUE) {
                die("Unable to open <$filename>\n");
              }
              // Структура документа
              $this->archive->files = array(
                "word/_rels/document.xml.rels",
                "word/theme/theme1.xml",
                "word/fontTable.xml",
                "word/settings.xml",
                "word/styles.xml",
                "word/document.xml",
                "word/stylesWithEffects.xml",
                "word/webSettings.xml",
                "_rels/.rels",
                "docProps/app.xml",
                "docProps/core.xml",
                "[Content_Types].xml" );
              // Добавляем каждый файл в цикле
              foreach( $this->archive->files as $f )
                $this->addFile($this->archive->path . $f , $f );
            }
            //...
        }
        

        • –1
          Не вижу смысла, т.к. получается, что везде где есть $this, вы приписали ->archive.
          Забыли приписать возле $this->addFile(…
          Если еще путь к шаблону поместить в ->archive, тогда вообще вся работа будет идти только через одно свойство.

          В принципе, если DocxDocument должен будет являться наследником какого-то другого класса, тогда ваша реализация — выход.
  • +9
    Люблю тонкий юмор в камментах)))

    // Если не получилось открыть файл, то жизнь бессмысленна.
    if ($this->open($filename, ZIPARCHIVE::CREATE) !== TRUE) {
    die("Unable to open <$filename>\n");
    }
  • –2
  • +7
    Быдлокод детектед!

    Я думал тут хотябы пара слов про DOM или XPath будет. А тут «str_replace()»…
    Да и про наследование ZipArchive правильно заметили.
    Нафига разбили строки по переносам строки не понятно.
  • 0
    Вот хорошая штука — OpenTBS.
  • 0
    Большое спасибо! Давно искал подобную статью.
  • 0
  • –1
    Садись, пять! Только, как это было уже замечено, оформление храмает.

    Трилогия MS Office X:
    1) .docx
    2) .xlsx
    3) .pptx

    Ждем продолжение!
  • +1
    Я это выкладывал сюда год назад.
    • 0
      не «это», а несколько другой подход. Кроме этого, alexios в этой статье лучше заржевывает свою реализацию.
  • 0
    > аж в 2007-м
    да, а в MacOS X XML вовсю использовался еще в далеком 2000-м. Microsoft считало его не перспективным и им понадобилось 7 лет, чтобы до него дорасти))

    когда-то городил на PHP генерацию .doc-файлов в html-формате, но мне там не нравилось отсутствие массы фишек из оригинального .doc-формата.
    спасибо за статью.
  • 0
    Спасибо!

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.