Работая над очередным проектом, у нас возникла необходимость генерации docx-документов по шаблону. И все бы ничего, но требовалось, чтобы часть полей обязательно заполнялись вручную. И, казалось бы, можно заполнить данные частично, а пользователи уже сами дополнят необходимое. Но нет, шаблонов было много, да еще и валидацию нужно продумать, и чтобы введенные данные можно было сохранять и восстанавливать.

Так мы решили разработать механизм, который по docx-документу генерировал бы нам веб-форму, которую пользователь заполнял бы через браузер, а в ответ получал заполенный docx-документ.

Собственно, в этой статье я хочу рассмотреть изюминку этого проекта. Мы напишем простое flask приложение, которое реализует нашу задумку.

Заполнять будем такой документ, в качестве плэйсхолдеров будем использовать конструкции вида {{key}}:

Вот наше заявление form.docx:

Разбиремся с Pandoc

Для ковертации будем использовать утилиту pandoc, которая позволят осуществлять пребразования между различными форматами, включая необходимые нам docx и html.

Скачиваем релиз github.com/jgm/pandoc/releases/latest под свою систему и устанваливаем.

Попробуем сконвертировать наш docx-форму, для этого выполним комманду:

pandoc -o form.html form.docx

файл form.html будет содержать примерно следующий текст (для наглядности я его отформотировал):

<table>
    <tbody>
    <tr class="odd">
        <td></td>
        <td><p>Председателю жилищно-строительного кооператива «КУ»</p>
            <p>Господину ПЖ</p>
            <p>от {{second_name}} {{first_name}} {{patronymic}}</p>
            <p>проживающего по адресу: <strong>{{address}}</strong>,</p>
            <p>телефон: <strong>{{phone}}</strong></p></td>
    </tr>
    </tbody>
</table>
<p><strong>Заявление</strong></p>
<table>
    <tbody>
    <tr class="odd">
        <td>{{text}}</td>
    </tr>
    <tr class="even">
        <td><p>«{{day}}» <em>{{month_verbose}}</em> {{year}} года ___________________ _____________________</p>
            <p>(подпись) (ФИО)</p></td>
    </tr>
    </tbody>
</table>

Пишем бэкенд на Flask

Так как мы планируем писать наше приложение на python, нам потребуется либо дергать вручную утилиту pandoc, либо воспользоваться уже реализованной оберткой pypi.python.org/pypi/pypandoc.

Установим ее, а также Flask и библиотеку для использования Jinja-подобного синтаксиса в docx:

pip install pypandoc Flask docxtpl

Создадим две папки, client и forms:

mkdir client
mkdir forms

В папку forms поместим наш docx-шаблон, и давайте сделаем копию этого шаблона, назовем ее form2.docx, отредактриуем этот файл для отличия от первого шаблона. Я, например, просто заменил слово "Заявление" на "Второе заявление".

Также добавим наш бэкенд файл app.py со следующим содержимым:

import os

import pypandoc
from flask import Flask, jsonify

app = Flask(__name__)

# путь к папке где лежат наши шаблоны
FORMS_FOLDER = os.path.join(app.root_path, "forms")


# вовзвращает список шаблонов
@app.route('/api/form')
def forms():
    form_list = []
    for f in os.listdir(FORMS_FOLDER):
        if f.endswith('.docx'):
            form_list.append(f)

    return jsonify({
        'forms': form_list
    })


# вовзвращает содержимое шаблона в html формате
@app.route("/api/form/<name>")
def form(name):
    # вообще так делать не рекомендуется,
    # будет лучше если доступ к шаблонам будет осуществлятся по идентификатору
    # но для примера пойдет
    filename = os.path.join(FORMS_FOLDER, name)

    # сконвертируем файл с помощью pandoc
    output = pypandoc.convert_file(filename, "html")

    return jsonify({
        "html": output
    })


if __name__ == '__main__':
    # я поставил тут debug чтобы приложение
    # автоматически перезапускалось при изменениях кода
    app.run(debug=True)

Итого будем иметь следующую организацию файлов:

Попробуем, как работает наш запрос, localhost:5000/api/form должен вернуть нам:

{
  "forms": [
    "form2.docx",
    "form.docx"
  ]
}

А запрос localhost:5000/api/form/form.docx отдаст нас содержимаое шаблона в html формате:

{
  "html": "<table>\n<tbody>\n<tr cl..."
}

Подключаем Vue.js

Устанавливаем утилиту для генерации vue-приложений:

npm install -g vue-cli

Переходим в папку client и генерируем шаблон приложения:

cd client
vue-init webpack-simple .

Отвечаем на вопросы генератора, далее устанавливаем зависимости.

Я преподчитаю использовать yarn в качестве менеджера пакетов, т.к. он кеширует эти самые пакеты. Поэтому, если он у вас не установлен, сейчас самое время:

npm install -g yarn

А вот теперь точно устанавливаем зависимости фронта:

yarn install

Настраиваем прокси webpack-dev-server для более удобной разработки фронта. Для этого добавим узел proxy в узел devServer файла webpack.config.js:

...
devServer: {
    historyApiFallback: true,
    noInfo: true,
    overlay: true,
    proxy: {
      '/api': 'http://localhost:5000'
    }
}
...

Итого, имеем следущую организацию файлов:

Пишем клиент

Добавим себе библиотеку axios для осуществоения ajax запросов:

yarn add axios

Открываем файл ./client/src/App.vue и приводим его к следующему виду:

<template>
  <div>
    <select v-model="selectedForm">
      <option :value="f" v-for="f in forms">{{f}}</option>
    </select>
    <div v-html="htmlForm"></div>
  </div>
</template>

<script>
  import axios from 'axios';

  export default {
    data() {
      // данные приложения
      return {
        selectedForm: null,
        htmlForm: null,
        forms: []
      }
    },
    created() {
      // при инициализации компонента делаем запрос всех docx форм
      axios("/api/form")
        .then(response => this.forms = response.data.forms) // и сохраняем список в переменную forms
    },
    watch: {
      selectedForm() {
        // остлеживаем изменения выпадающего списка
        // и при его изменении запрашиваем его html содержимое
        axios(`/api/form/${this.selectedForm}`).then(response => {
          this.htmlForm = response.data.html; // и сохраняем в переменную htmlForm
        })
      },
    },
  }
</script>

Отлично, теперь давайте запустим паралельно flask-сервер и webpack-dev-server.

В одном терминале запустите команду:

python app.py

В другом:

cd client
yarn dev

открываем браузер по адресу http://localhost:8080/ и, если мы все сделали правильно, там нас ожидает выпадающий список с двумя значениями:

Выбираем любое и видим:

Да, это выглядит не очень красиво, но зато мы имеем функциональное приложение.

Заменим все плейсхолдеры на тэги input, отредактируем:

watch: {
  selectedForm() {
    // остлеживаем изменения выпадающего списка
    // и при его изменении запрашиваем его html содержимое
    axios(`/api/form/${this.selectedForm}`).then(response => {
      // заменяем плейсхолдеры в html на input`ы
      let html = response.data.html.replace(
        /\{\{(\w+)\}\}/g,
        '<input ref="$1" placeholder="$1">'
      );
      this.htmlForm = html; // и сохраняем в переменную htmlForm
    })
  },
},

Вот наши инпуты появились:

Как вы заметили, я использую ref - это нужно, чтобы впоследствии было проще ссылаться на элемент input. К сожалению, vue не обрабатывает код вставленный через v-html, поэтому наши ссылки ref работать не будут.

Для решения этой проблемы, я создам динамическую компоненту, которая будет создаваться на основе значения htmlForm. Добавим узел computed:

export default {
  ...
  watch: { ... },
  computed: {
    // наш динамичский компонент который формируется на основе html
    formComponent() {
      return {
        // пришлось обернуть в div, так как шаблон
        // может иметь только один корневой тэг
        template: `<div>${this.htmlForm}</div>`,
      }
    },
  }
}

А шаблон отредактируем следующим образом:

<template>
  <div>
    <select v-model="selectedForm">
      <option :value="f" v-for="f in forms">{{f}}</option>
    </select>
    <component ref="htmlFormComponent" v-if="htmlForm" :is="formComponent"></component>
  </div>
</template>

Отлично, теперь мы сможем получать доступ к инпутам нашей html-формы через конструкцию:

this.$refs.htmlFormComponent.$refs

Давайте проверим, что наша конструкция корректно работает и добавим кнопку в наш шаблон:

<template>
  <div>
    <select v-model="selectedForm">
      <option :value="f" v-for="f in forms">{{f}}</option>
    </select>
    <button  v-if="htmlForm" @click="onDownloadClick">Download</button>
    <component ref="htmlFormComponent" v-if="htmlForm" :is="formComponent"></component>
  </div>
</template>

А также соответствующий обработчик события в код скрипта:

export default {
  ...
  computed: { ... },
  methods: {
    onDownloadClick () {
      console.log(this.$refs.htmlFormComponent.$refs);
    }
  }
}

Попробуйте кликнуть на кнопку Download и загляните в консоль, там вы увидите подобное:

Заполнение docx-шаблона

Переключимся на наш app.py файл и добавим в него следующий обработчик, который будет формировать docx-документ на основе шаблона и передаваемых значений:

from io import BytesIO
from docxtpl import DocxTemplate

...

@app.route("/api/form/print/<name>")
def print_form(name):
    filename = os.path.join(FORMS_FOLDER, name)

    # открываем шаблон
    doc = DocxTemplate(filename)
    # передаем параметры запроса как значени плейсхолеров для шаблона
    doc.render(request.args.to_dict())

    # сохраняем результат заполнения docx в память
    stream = BytesIO()
    doc.get_docx().save(stream)
    stream.seek(0)

    # возвращаем файл
    return send_file(stream, mimetype='docx')

Теперь давайте поправим наш обработчик onDownloadClick, чтобы он передавал нам значения заполненых полей, а в ответ открывал новосозданный docx-файл:

export default {
  ...
  computed: { ... },
  methods: {
    onDownloadClick () {
      let data = this.$refs.htmlFormComponent.$refs;

      // формируем строку параметров get запроса
      let params = Object.keys(data).map(function (key) {
        return [key, data[key].value].map(encodeURIComponent).join("=");
      }).join("&");

      // формируем url, передавать параметры get запросом не очень хорошая идея,
      // но для тестового приложения пойдет
      let url = `/api/form/print/${this.selectedForm}?${params}`;
      window.open(url);
    }
  }
}

Отлично, теперь давайте заполним форму:

И нажмем кнопку download, в ответ придет созданный docx-документ:

Вот так достаточно элегантно можно работать с docx-шаблонами и заполнять их через веб-формы.