commit 6dafd0eca873754edeb85302c73dcc47f3ae8146 Author: gre-ilya Date: Wed Apr 29 07:15:22 2026 +0500 init diff --git a/DocumentWizard.cpp b/DocumentWizard.cpp new file mode 100644 index 0000000..43335df --- /dev/null +++ b/DocumentWizard.cpp @@ -0,0 +1,371 @@ +#include "DocumentWizard.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================ +// Парсинг шаблонных строк +// +// Формат строки: +// heading1|Введите название раздела|Вариант А;Вариант Б;Вариант В +// text|Выберите описание|Короткое;Подробное +// table|Заполните таблицу характеристик| +// image|Вставьте изображение схемы| +// +// Если choices пуст — пользователь вводит текст вручную. +// ============================================================ + +DocumentWizard::DocumentWizard(const QStringList& templateLines, QWidget* parent) + : QDialog(parent) +{ + setWindowTitle("Мастер создания документа"); + setMinimumSize(640, 520); + setupUi(); + parseTemplates(templateLines); + + if (!m_steps.isEmpty()) + showStep(0); +} + +void DocumentWizard::parseTemplates(const QStringList& lines) { + m_steps.clear(); + + static const QMap typeMap = { + {"heading1", DocElementType::Heading1}, + {"heading2", DocElementType::Heading2}, + {"heading3", DocElementType::Heading3}, + {"text", DocElementType::NormalText}, + {"table", DocElementType::Table}, + {"image", DocElementType::Image}, + }; + + for (const QString& rawLine : lines) { + QString line = rawLine.trimmed(); + if (line.isEmpty() || line.startsWith('#')) + continue; + + QStringList parts = line.split('|'); + if (parts.size() < 2) + continue; + + WizardStep step; + step.elementType = typeMap.value(parts[0].trimmed().toLower(), + DocElementType::NormalText); + step.prompt = parts[1].trimmed(); + + if (parts.size() >= 3 && !parts[2].trimmed().isEmpty()) { + for (const QString& ch : parts[2].split(';')) + step.choices << ch.trimmed(); + } + + m_steps.append(step); + } +} + +// ============================================================ +// UI +// ============================================================ + +void DocumentWizard::setupUi() { + auto* mainLayout = new QVBoxLayout(this); + mainLayout->setSpacing(12); + mainLayout->setContentsMargins(16, 16, 16, 16); + + // --- Заголовок шага --- + m_stepLabel = new QLabel(this); + QFont stepFont = m_stepLabel->font(); + stepFont.setBold(true); + stepFont.setPointSize(11); + m_stepLabel->setFont(stepFont); + m_stepLabel->setStyleSheet("color: #2c5f8a;"); + mainLayout->addWidget(m_stepLabel); + + // --- Подсказка --- + m_promptLabel = new QLabel(this); + m_promptLabel->setWordWrap(true); + m_promptLabel->setStyleSheet("background:#f0f4f8; padding:8px; border-radius:4px;"); + mainLayout->addWidget(m_promptLabel); + + // --- Стек страниц контента --- + m_contentStack = new QStackedWidget(this); + mainLayout->addWidget(m_contentStack, 1); + + // -- Страница 0: список вариантов + свободный ввод -- + auto* choicePage = new QWidget; + auto* choiceLayout = new QVBoxLayout(choicePage); + choiceLayout->setContentsMargins(0,0,0,0); + + m_choiceList = new QListWidget; + m_choiceList->setAlternatingRowColors(true); + choiceLayout->addWidget(new QLabel("Выберите вариант:")); + choiceLayout->addWidget(m_choiceList); + choiceLayout->addWidget(new QLabel("Или введите свой текст:")); + m_freeInput = new QLineEdit; + m_freeInput->setPlaceholderText("Введите текст вручную…"); + choiceLayout->addWidget(m_freeInput); + m_contentStack->addWidget(choicePage); // index 0 + + // -- Страница 1: таблица -- + auto* tablePage = new QWidget; + auto* tableLayout = new QVBoxLayout(tablePage); + tableLayout->setContentsMargins(0,0,0,0); + + tableLayout->addWidget(new QLabel("Заголовки столбцов (через ';'):")); + m_tableHeadersEdit = new QLineEdit; + m_tableHeadersEdit->setPlaceholderText("Параметр;Значение;Описание"); + tableLayout->addWidget(m_tableHeadersEdit); + + tableLayout->addWidget(new QLabel("Строки (добавьте каждую строку; значения через ';'):")); + m_tableRowsList = new QListWidget; + tableLayout->addWidget(m_tableRowsList, 1); + + auto* addRowLayout = new QHBoxLayout; + m_tableRowInput = new QLineEdit; + m_tableRowInput->setPlaceholderText("Значение1;Значение2;Значение3"); + m_addRowBtn = new QPushButton("+ Добавить строку"); + m_removeRowBtn = new QPushButton("✕ Удалить"); + addRowLayout->addWidget(m_tableRowInput, 1); + addRowLayout->addWidget(m_addRowBtn); + addRowLayout->addWidget(m_removeRowBtn); + tableLayout->addLayout(addRowLayout); + m_contentStack->addWidget(tablePage); // index 1 + + // -- Страница 2: изображение -- + auto* imgPage = new QWidget; + auto* imgLayout = new QVBoxLayout(imgPage); + imgLayout->setContentsMargins(0,0,0,0); + + imgLayout->addWidget(new QLabel("Путь к изображению:")); + auto* imgRowLayout = new QHBoxLayout; + m_imagePathEdit = new QLineEdit; + m_imagePathEdit->setPlaceholderText("/путь/к/файлу.png"); + m_browseBtn = new QPushButton("Обзор…"); + imgRowLayout->addWidget(m_imagePathEdit, 1); + imgRowLayout->addWidget(m_browseBtn); + imgLayout->addLayout(imgRowLayout); + + auto* dimLayout = new QHBoxLayout; + dimLayout->addWidget(new QLabel("Ширина (см):")); + m_imgWidthCm = new QSpinBox; m_imgWidthCm->setRange(1, 30); m_imgWidthCm->setValue(10); + dimLayout->addWidget(m_imgWidthCm); + dimLayout->addWidget(new QLabel("Высота (см):")); + m_imgHeightCm = new QSpinBox; m_imgHeightCm->setRange(1, 30); m_imgHeightCm->setValue(7); + dimLayout->addWidget(m_imgHeightCm); + dimLayout->addStretch(); + imgLayout->addLayout(dimLayout); + imgLayout->addStretch(); + m_contentStack->addWidget(imgPage); // index 2 + + // --- Прогресс --- + m_progressLabel = new QLabel(this); + m_progressLabel->setAlignment(Qt::AlignRight); + m_progressLabel->setStyleSheet("color: #888;"); + mainLayout->addWidget(m_progressLabel); + + // --- Кнопки навигации --- + auto* btnLayout = new QHBoxLayout; + m_backBtn = new QPushButton("◀ Назад"); + m_nextBtn = new QPushButton("Далее ▶"); + m_finishBtn = new QPushButton("💾 Сохранить документ"); + m_finishBtn->setStyleSheet("background:#2c5f8a; color:white; font-weight:bold; padding:6px 18px;"); + m_finishBtn->setVisible(false); + + btnLayout->addWidget(m_backBtn); + btnLayout->addStretch(); + btnLayout->addWidget(m_nextBtn); + btnLayout->addWidget(m_finishBtn); + mainLayout->addLayout(btnLayout); + + // --- Соединения --- + connect(m_choiceList, &QListWidget::currentRowChanged, + this, &DocumentWizard::onChoiceSelected); + connect(m_addRowBtn, &QPushButton::clicked, this, &DocumentWizard::onAddTableRow); + connect(m_removeRowBtn, &QPushButton::clicked, this, &DocumentWizard::onRemoveTableRow); + connect(m_browseBtn, &QPushButton::clicked, this, &DocumentWizard::onBrowseImage); + connect(m_backBtn, &QPushButton::clicked, this, &DocumentWizard::onBackClicked); + connect(m_nextBtn, &QPushButton::clicked, this, &DocumentWizard::onNextClicked); + connect(m_finishBtn, &QPushButton::clicked, this, &DocumentWizard::onFinishClicked); +} + +// ============================================================ +// Отображение шага +// ============================================================ + +void DocumentWizard::showStep(int index) { + if (index < 0 || index >= m_steps.size()) return; + m_currentStep = index; + + const WizardStep& step = m_steps[index]; + + m_stepLabel->setText(QString("Шаг %1 из %2").arg(index + 1).arg(m_steps.size())); + m_promptLabel->setText(step.prompt); + m_progressLabel->setText(QString("%1 / %2 шагов выполнено").arg(index).arg(m_steps.size())); + + m_backBtn->setEnabled(index > 0); + bool isLast = (index == m_steps.size() - 1); + m_nextBtn->setVisible(!isLast); + m_finishBtn->setVisible(isLast); + + // Настраиваем страницу в зависимости от типа + if (step.elementType == DocElementType::Table) { + m_contentStack->setCurrentIndex(1); + m_tableRowsList->clear(); + m_tableHeadersEdit->clear(); + m_tableRowInput->clear(); + } else if (step.elementType == DocElementType::Image) { + m_contentStack->setCurrentIndex(2); + m_imagePathEdit->clear(); + } else { + m_contentStack->setCurrentIndex(0); + m_choiceList->clear(); + m_freeInput->clear(); + for (const QString& ch : step.choices) + m_choiceList->addItem(ch); + // Если вариантов нет — скрываем список + m_choiceList->setVisible(!step.choices.isEmpty()); + } +} + +// ============================================================ +// Сбор данных текущего шага и добавление в DocxBuilder +// ============================================================ + +void DocumentWizard::applyCurrentStep() { + const WizardStep& step = m_steps[m_currentStep]; + + switch (step.elementType) { + case DocElementType::Heading1: + case DocElementType::Heading2: + case DocElementType::Heading3: { + QString text = m_freeInput->text().trimmed(); + if (text.isEmpty() && m_choiceList->currentItem()) + text = m_choiceList->currentItem()->text(); + if (text.isEmpty()) { + QMessageBox::warning(this, "Пусто", "Пожалуйста, введите или выберите текст заголовка."); + return; + } + int level = (step.elementType == DocElementType::Heading1) ? 1 + : (step.elementType == DocElementType::Heading2) ? 2 : 3; + m_builder.addHeading(text, level); + break; + } + case DocElementType::NormalText: { + QString text = m_freeInput->text().trimmed(); + if (text.isEmpty() && m_choiceList->currentItem()) + text = m_choiceList->currentItem()->text(); + if (text.isEmpty()) { + QMessageBox::warning(this, "Пусто", "Пожалуйста, введите или выберите текст."); + return; + } + DocStyle style; + style.font = "Times New Roman"; + style.fontSize = 24; // 12pt + m_builder.addParagraph(text, style); + break; + } + case DocElementType::Table: { + TableData td; + QString hdr = m_tableHeadersEdit->text().trimmed(); + if (!hdr.isEmpty()) + td.headers = hdr.split(';'); + for (int i = 0; i < m_tableRowsList->count(); ++i) + td.rows.append(m_tableRowsList->item(i)->text().split(';')); + if (td.headers.isEmpty() && td.rows.isEmpty()) { + QMessageBox::warning(this, "Таблица пуста", "Добавьте хотя бы одну строку в таблицу."); + return; + } + m_builder.addTable(td); + break; + } + case DocElementType::Image: { + QString path = m_imagePathEdit->text().trimmed(); + if (path.isEmpty()) { + QMessageBox::warning(this, "Нет файла", "Выберите изображение."); + return; + } + // Конвертируем сантиметры в EMU (1 см = 360000 EMU) + int wEmu = m_imgWidthCm->value() * 360000; + int hEmu = m_imgHeightCm->value() * 360000; + m_builder.addImage(path, wEmu, hEmu); + break; + } + } +} + +// ============================================================ +// Слоты навигации +// ============================================================ + +void DocumentWizard::onNextClicked() { + applyCurrentStep(); + if (m_currentStep < m_steps.size() - 1) + showStep(m_currentStep + 1); +} + +void DocumentWizard::onBackClicked() { + if (m_currentStep > 0) + showStep(m_currentStep - 1); +} + +void DocumentWizard::onFinishClicked() { + applyCurrentStep(); + + QString path = QFileDialog::getSaveFileName( + this, "Сохранить документ", "document.docx", + "Word Documents (*.docx)"); + + if (path.isEmpty()) return; + + if (m_builder.save(path)) { + m_outputPath = path; + QMessageBox::information(this, "Готово", + QString("Документ успешно сохранён:\n%1").arg(path)); + accept(); + } else { + QMessageBox::critical(this, "Ошибка", + "Не удалось сохранить документ. Проверьте путь и права доступа."); + } +} + +// ============================================================ +// Слоты работы с таблицей и изображением +// ============================================================ + +void DocumentWizard::onChoiceSelected(int row) { + if (row >= 0 && row < m_choiceList->count()) + m_freeInput->setText(m_choiceList->item(row)->text()); +} + +void DocumentWizard::onAddTableRow() { + QString rowText = m_tableRowInput->text().trimmed(); + if (!rowText.isEmpty()) { + m_tableRowsList->addItem(rowText); + m_tableRowInput->clear(); + } +} + +void DocumentWizard::onRemoveTableRow() { + auto* item = m_tableRowsList->currentItem(); + if (item) delete item; +} + +void DocumentWizard::onBrowseImage() { + QString path = QFileDialog::getOpenFileName( + this, "Выбрать изображение", QString(), + "Изображения (*.png *.jpg *.jpeg *.bmp)"); + if (!path.isEmpty()) + m_imagePathEdit->setText(path); +} diff --git a/DocumentWizard.h b/DocumentWizard.h new file mode 100644 index 0000000..9e03bc7 --- /dev/null +++ b/DocumentWizard.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include "DocxBuilder.h" + +QT_BEGIN_NAMESPACE +class QLabel; +class QListWidget; +class QPushButton; +class QLineEdit; +class QComboBox; +class QSpinBox; +class QStackedWidget; +class QGroupBox; +QT_END_NAMESPACE + +// ============================================================ +// Шаг мастера +// ============================================================ +struct WizardStep { + QString prompt; // Вопрос / подсказка пользователю + QStringList choices; // Варианты выбора (если пусто — свободный ввод) + DocElementType elementType; // Тип элемента, который создаём на этом шаге +}; + +// ============================================================ +// Диалог-мастер пошагового создания документа +// ============================================================ +class DocumentWizard : public QDialog { + Q_OBJECT + +public: + // templateLines — набор строк-шаблонов, «скармливаемых» в начале работы. + // Формат каждой строки: "type|prompt|choice1;choice2;choice3" + // type: heading1 / heading2 / heading3 / text / table / image + explicit DocumentWizard(const QStringList& templateLines, + QWidget* parent = nullptr); + + // Путь к созданному файлу + QString outputFilePath() const { return m_outputPath; } + +private slots: + void onNextClicked(); + void onBackClicked(); + void onFinishClicked(); + void onChoiceSelected(int row); + void onAddTableRow(); + void onRemoveTableRow(); + void onBrowseImage(); + +private: + void parseTemplates(const QStringList& lines); + void showStep(int index); + void buildStepWidget(int index); + void applyCurrentStep(); + + // --- Данные --- + QList m_steps; + int m_currentStep = 0; + QString m_outputPath; + DocxBuilder m_builder; + + // Промежуточные данные текущего шага + QString m_chosenText; + TableData m_tableData; + QString m_imagePath; + + // --- UI --- + QLabel* m_stepLabel; + QLabel* m_promptLabel; + QStackedWidget* m_contentStack; + + // Страница 0 — выбор из списка или ввод + QListWidget* m_choiceList; + QLineEdit* m_freeInput; + + // Страница 1 — ввод таблицы + QGroupBox* m_tableGroup; + QLineEdit* m_tableHeadersEdit; + QListWidget* m_tableRowsList; + QLineEdit* m_tableRowInput; + QPushButton* m_addRowBtn; + QPushButton* m_removeRowBtn; + + // Страница 2 — выбор изображения + QLineEdit* m_imagePathEdit; + QPushButton* m_browseBtn; + QSpinBox* m_imgWidthCm; + QSpinBox* m_imgHeightCm; + + QPushButton* m_backBtn; + QPushButton* m_nextBtn; + QPushButton* m_finishBtn; + QLabel* m_progressLabel; + + void setupUi(); +}; diff --git a/DocxBuilder.cpp b/DocxBuilder.cpp new file mode 100644 index 0000000..5f70d0d --- /dev/null +++ b/DocxBuilder.cpp @@ -0,0 +1,486 @@ +#include "DocxBuilder.h" + +#include +#include +#include +#include +#include +#include +#include + +// Qt ZIP support (входит в состав Qt5 через QtCore private или quazip). +// Здесь используем простую реализацию через QZipWriter (Qt >= 5.x, модуль QtCore/private). +// Если QZipWriter недоступен — подключите QuaZip: https://github.com/stachenov/quazip +#include +#include + +// ============================================================ +DocxBuilder::DocxBuilder() = default; + +void DocxBuilder::clear() { + m_elements.clear(); + m_images.clear(); + m_imageCounter = 0; +} + +// ============================================================ +// Добавление элементов +// ============================================================ + +void DocxBuilder::addHeading(const QString& text, int level) { + DocElement el; + el.text = text; + switch (level) { + case 1: el.type = DocElementType::Heading1; break; + case 2: el.type = DocElementType::Heading2; break; + default: el.type = DocElementType::Heading3; break; + } + el.style.font = "Times New Roman"; + el.style.bold = true; + el.style.fontSize = (level == 1) ? 36 : (level == 2) ? 30 : 26; + m_elements.append(el); +} + +void DocxBuilder::addParagraph(const QString& text, const DocStyle& style) { + DocElement el; + el.type = DocElementType::NormalText; + el.text = text; + el.style = style; + m_elements.append(el); +} + +void DocxBuilder::addTable(const TableData& table) { + DocElement el; + el.type = DocElementType::Table; + el.table = table; + m_elements.append(el); +} + +void DocxBuilder::addImage(const QString& imagePath, int widthEmu, int heightEmu) { + QFileInfo fi(imagePath); + if (!fi.exists()) { + qWarning() << "DocxBuilder::addImage — файл не найден:" << imagePath; + return; + } + m_imageCounter++; + ImageEntry entry; + entry.sourcePath = imagePath; + entry.ext = fi.suffix().toLower(); + entry.rId = QString("rId%1").arg(10 + m_imageCounter); + entry.index = m_imageCounter; + m_images.append(entry); + + DocElement el; + el.type = DocElementType::Image; + el.imagePath = imagePath; + el.imageWidthEmu = widthEmu; + el.imageHeightEmu = heightEmu; + // сохраняем индекс в fontSize (хак для передачи индекса) + el.style.fontSize = m_imageCounter; + m_elements.append(el); +} + +void DocxBuilder::addPageBreak() { + DocElement el; + el.type = DocElementType::NormalText; + el.text = "\f"; // маркер разрыва страницы + m_elements.append(el); +} + +// ============================================================ +// Сохранение +// ============================================================ + +bool DocxBuilder::save(const QString& filePath) { + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "DocxBuilder::save — не удалось открыть файл:" << filePath; + return false; + } + + QZipWriter zip(&file); + zip.setCompressionPolicy(QZipWriter::AutoCompress); + + // --- Обязательные части DOCX --- + zip.addFile("[Content_Types].xml", buildContentTypesXml().toUtf8()); + zip.addFile("_rels/.rels", buildRelsXml().toUtf8()); + zip.addFile("word/document.xml", buildDocumentXml().toUtf8()); + zip.addFile("word/_rels/document.xml.rels", buildDocumentRelsXml().toUtf8()); + zip.addFile("word/styles.xml", buildStylesXml().toUtf8()); + zip.addFile("word/settings.xml", buildSettingsXml().toUtf8()); + + // --- Изображения --- + for (const ImageEntry& img : m_images) { + QFile imgFile(img.sourcePath); + if (imgFile.open(QIODevice::ReadOnly)) { + QByteArray data = imgFile.readAll(); + zip.addFile(QString("word/media/image%1.%2").arg(img.index).arg(img.ext), data); + } + } + + zip.close(); + file.close(); + return true; +} + +// ============================================================ +// Построение XML документа +// ============================================================ + +QString DocxBuilder::buildDocumentXml() { + QString body; + + for (const DocElement& el : m_elements) { + switch (el.type) { + case DocElementType::Heading1: + body += makeParagraph(el.text, el.style, "Heading1"); + break; + case DocElementType::Heading2: + body += makeParagraph(el.text, el.style, "Heading2"); + break; + case DocElementType::Heading3: + body += makeParagraph(el.text, el.style, "Heading3"); + break; + case DocElementType::NormalText: + if (el.text == "\f") { + // Разрыв страницы + body += R"()"; + } else { + body += makeParagraph(el.text, el.style); + } + break; + case DocElementType::Table: + body += makeTable(el.table); + break; + case DocElementType::Image: { + // Найти соответствующую ImageEntry + int idx = el.style.fontSize; + ImageEntry* found = nullptr; + for (ImageEntry& img : m_images) { + if (img.index == idx) { found = &img; break; } + } + if (found) { + QString imgXml = makeImageXml( + found->rId.mid(3).toInt(), + el.imageWidthEmu, el.imageHeightEmu, idx); + body += QString("%1").arg(imgXml); + } + break; + } + } + } + + return QString( + R"()" + R"()" + "%1" + R"()" + "" + ).arg(body); +} + +// ============================================================ +// Вспомогательные генераторы XML +// ============================================================ + +QString DocxBuilder::escapeXml(const QString& text) { + QString out = text; + out.replace("&", "&"); + out.replace("<", "<"); + out.replace(">", ">"); + out.replace("\"", """); + out.replace("'", "'"); + return out; +} + +QString DocxBuilder::makeRunProps(const DocStyle& style) { + QString rpr = ""; + rpr += QString("").arg(style.font); + rpr += QString("").arg(style.fontSize); + rpr += QString("").arg(style.fontSize); + if (style.bold) rpr += ""; + if (style.italic) rpr += ""; + rpr += ""; + return rpr; +} + +QString DocxBuilder::makeParaProps(const DocStyle& style, const QString& styleId) { + QString ppr = ""; + if (!styleId.isEmpty()) + ppr += QString("").arg(styleId); + if (style.center) + ppr += ""; + // Отступы для обычного текста + if (styleId.isEmpty()) + ppr += ""; + ppr += ""; + return ppr; +} + +QString DocxBuilder::makeParagraph(const QString& text, + const DocStyle& style, + const QString& styleId) +{ + // Разбиваем по переносам строки на отдельные параграфы + QStringList lines = text.split('\n'); + QString result; + for (int li = 0; li < lines.size(); ++li) { + result += ""; + result += makeParaProps(style, styleId); + result += ""; + result += makeRunProps(style); + result += QString("%1").arg(escapeXml(lines[li])); + result += ""; + result += ""; + } + return result; +} + +QString DocxBuilder::makeTable(const TableData& table) { + // Считаем количество колонок + int cols = table.headers.isEmpty() + ? (table.rows.isEmpty() ? 1 : table.rows[0].size()) + : table.headers.size(); + + // Ширина колонки: таблица на всю ширину страницы A4 (9356 DXA) + int colWidth = (cols > 0) ? (9356 / cols) : 9356; + + QString tbl; + tbl += ""; + + // Свойства таблицы + tbl += ""; + tbl += ""; + tbl += ""; + tbl += "" + "" + "" + "" + "" + "" + "" + ""; + tbl += ""; + + // Сетка колонок + tbl += ""; + for (int c = 0; c < cols; ++c) + tbl += QString("").arg(colWidth); + tbl += ""; + + // Строка заголовков + if (!table.headers.isEmpty()) { + tbl += ""; + for (const QString& hdr : table.headers) { + tbl += ""; + tbl += QString("" + "" + "").arg(colWidth); + tbl += "" + "" + "" + "" + ""; + tbl += QString("%1").arg(escapeXml(hdr)); + tbl += ""; + } + tbl += ""; + } + + // Строки данных + for (const QStringList& row : table.rows) { + tbl += ""; + for (int c = 0; c < cols; ++c) { + QString cellText = (c < row.size()) ? row[c] : ""; + tbl += ""; + tbl += QString("").arg(colWidth); + tbl += "" + "" + "" + ""; + tbl += QString("%1") + .arg(escapeXml(cellText)); + tbl += ""; + } + tbl += ""; + } + + tbl += ""; + // Пустой параграф после таблицы (обязателен по спецификации OOXML) + tbl += ""; + return tbl; +} + +QString DocxBuilder::makeImageXml(int rId, int widthEmu, int heightEmu, int imgIdx) { + // EMU: 914400 = 1 дюйм + return QString( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ).arg(widthEmu).arg(heightEmu).arg(imgIdx).arg(rId); +} + +// ============================================================ +// Системные XML-файлы DOCX +// ============================================================ + +QString DocxBuilder::buildContentTypesXml() { + QString ct = + "" + "" + "" + "" + "" + "" + ""; + + for (const ImageEntry& img : m_images) { + QString mime = (img.ext == "png") ? "image/png" : "image/jpeg"; + ct += QString("").arg(img.ext).arg(mime); + } + + ct += ""; + return ct; +} + +QString DocxBuilder::buildRelsXml() { + return + "" + "" + "" + ""; +} + +QString DocxBuilder::buildDocumentRelsXml() { + QString rels = + "" + "" + "" + ""; + + for (const ImageEntry& img : m_images) { + rels += QString( + "" + ).arg(img.rId).arg(img.index).arg(img.ext); + } + + rels += ""; + return rels; +} + +QString DocxBuilder::buildSettingsXml() { + return + "" + "" + "" + "" + ""; +} + +QString DocxBuilder::buildStylesXml() { + // Определяем стили Times New Roman для заголовков и нормального текста + return + "" + "" + + // Normal + "" + "" + "" + "" + "" + "" + "" + + // Heading 1 — Times New Roman 18pt Bold + "" + "" + "" + "" + "" + "" + "" + "" + "" + + // Heading 2 — Times New Roman 15pt Bold + "" + "" + "" + "" + "" + "" + "" + "" + "" + + // Heading 3 — Times New Roman 13pt Bold + "" + "" + "" + "" + "" + "" + "" + "" + "" + + // TableGrid + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + + ""; +} diff --git a/DocxBuilder.h b/DocxBuilder.h new file mode 100644 index 0000000..e0661db --- /dev/null +++ b/DocxBuilder.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include + +// ============================================================ +// Структуры данных для элементов документа +// ============================================================ + +enum class DocElementType { + Heading1, + Heading2, + Heading3, + NormalText, + Table, + Image +}; + +struct DocStyle { + QString font = "Times New Roman"; + int fontSize = 24; // в половинах пунктов (24 = 12pt) + bool bold = false; + bool italic = false; + bool center = false; +}; + +struct TableData { + QList rows; // rows[i][j] = текст ячейки + QStringList headers; +}; + +struct DocElement { + DocElementType type; + QString text; // для текста / заголовков + DocStyle style; + TableData table; // для таблиц + QString imagePath; // для картинок + int imageWidthEmu = 5400000; // ~6 см + int imageHeightEmu = 3600000; // ~4 см +}; + +// ============================================================ +// Класс-строитель DOCX-документа +// ============================================================ + +class DocxBuilder { +public: + DocxBuilder(); + + // --- Добавление элементов --- + void addHeading(const QString& text, int level = 1); + void addParagraph(const QString& text, const DocStyle& style = {}); + void addTable(const TableData& table); + void addImage(const QString& imagePath, + int widthEmu = 5400000, + int heightEmu = 3600000); + void addPageBreak(); + + // --- Сохранение --- + bool save(const QString& filePath); + + // --- Очистка --- + void clear(); + +private: + QList m_elements; + int m_imageCounter = 0; + + // Генерация XML-частей + QString buildDocumentXml(); + QString buildContentTypesXml(); + QString buildRelsXml(); + QString buildDocumentRelsXml(); + QString buildSettingsXml(); + QString buildStylesXml(); + + // Вспомогательные методы генерации XML + QString makeRunProps(const DocStyle& style); + QString makeParaProps(const DocStyle& style, const QString& styleId = ""); + QString makeParagraph(const QString& text, + const DocStyle& style, + const QString& styleId = ""); + QString makeTable(const TableData& table); + QString makeImageXml(int rId, int widthEmu, int heightEmu, int imgIdx); + QString escapeXml(const QString& text); + + // Для хранения изображений (путь → данные) + struct ImageEntry { + QString sourcePath; + QString ext; // png, jpg, … + QString rId; + int index; + }; + QList m_images; +}; diff --git a/DocxWizard.pro b/DocxWizard.pro new file mode 100644 index 0000000..c67f36d --- /dev/null +++ b/DocxWizard.pro @@ -0,0 +1,37 @@ +QT += core gui widgets + +greaterThan(QT_MAJOR_VERSION, 4): QT += widgets + +CONFIG += c++17 + +TARGET = DocxWizard +TEMPLATE = app + +# Подключаем приватные заголовки Qt для QZipWriter/QZipReader +# (входят в стандартную поставку Qt5) +QT += core-private + +SOURCES += \ + main.cpp \ + DocxBuilder.cpp \ + DocumentWizard.cpp + +HEADERS += \ + DocxBuilder.h \ + DocumentWizard.h + +# Если QZipWriter недоступен как private — используйте QuaZip: +# Установка: sudo apt install libquazip5-dev (Linux) +# или скачайте https://github.com/stachenov/quazip +# +# Тогда замените в DocxBuilder.cpp: +# #include +# #include +# на: +# #include +# #include +# и адаптируйте метод save() под QuaZip API. +# +# Раскомментируйте, если используете QuaZip: +# LIBS += -lquazip5 +# INCLUDEPATH += /usr/include/quazip5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1c3aea --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# DocxWizard — Модуль пошагового создания Word-документов + +## Стек технологий + +| Компонент | Инструмент | Зачем | +|--------------------|-------------------------------|---------------------------------------------| +| Язык | C++17 | Основной язык проекта | +| GUI + логика | Qt5 (Widgets) | Уже используется в проекте | +| Генерация DOCX | Qt5 (QZipWriter) или QuaZip | DOCX — это ZIP+XML; Qt умеет это нативно | +| Изображения | Qt5 QFile | Чтение и вставка бинарных данных изображений | + +**Внешних зависимостей нет** (кроме Qt5, которая уже есть). + +--- + +## Архитектура + +``` +main.cpp + └── MainWindow ← Главное окно; редактор строк-шаблонов + └── DocumentWizard ← Пошаговый диалог (QDialog) + ├── parseTemplates() ← Читает строки шаблона → список шагов + ├── showStep() ← Показывает нужный UI для шага + ├── applyCurrentStep() ← Передаёт данные в DocxBuilder + └── DocxBuilder ← Генерирует .docx файл + ├── addHeading() + ├── addParagraph() + ├── addTable() + ├── addImage() + └── save() ← Записывает ZIP/XML в файл +``` + +--- + +## Формат строк шаблона + +Строки шаблона «скармливаются» программе в начале работы. +Каждая строка задаёт один шаг мастера: + +``` +тип|Подсказка для пользователя|Вариант1;Вариант2;Вариант3 +``` + +### Типы элементов + +| Тип | Результат в документе | Примечание | +|----------|-------------------------------|--------------------------------------| +| heading1 | Заголовок 1 (18pt, жирный) | Times New Roman | +| heading2 | Заголовок 2 (15pt, жирный) | Times New Roman | +| heading3 | Заголовок 3 (13pt, жирный) | Times New Roman | +| text | Обычный абзац (12pt) | Times New Roman | +| table | Таблица с заголовками | UI для ввода строк | +| image | Встроенное изображение | PNG/JPG, размер в см | + +### Пример шаблона + +``` +heading1|Введите название документа|Технический отчёт;Пояснительная записка +heading2|Название раздела «Введение»|1. Введение;1. Общие сведения +text|Напишите вводный абзац|Настоящий документ описывает…;В данной работе… +table|Заполните таблицу характеристик| +image|Вставьте схему архитектуры| +heading2|Заключение|4. Заключение;4. Выводы +text|Напишите заключение|В результате работы…;Таким образом… +``` + +--- + +## Сборка + +```bash +cd docx_module +qmake DocxWizard.pro +make # Linux/macOS +# или +nmake # Windows (MSVC) +``` + +### Альтернатива QZipWriter → QuaZip + +Если Qt5 собрана без приватных заголовков (`-private`): + +```bash +# Ubuntu/Debian +sudo apt install libquazip5-dev + +# или vcpkg +vcpkg install quazip +``` + +Затем в `DocxBuilder.cpp` замените: +```cpp +// Было: +#include + +// Стало: +#include +#include +``` + +И адаптируйте метод `DocxBuilder::save()` — используйте `QuaZip` + `QuaZipFile` +вместо `QZipWriter`. API очень похожи. + +--- + +## Интеграция в существующий Qt5 проект + +1. Скопируйте `DocxBuilder.h/.cpp` и `DocumentWizard.h/.cpp` в свой проект. +2. В `.pro` добавьте: + ``` + QT += core-private # для QZipWriter + SOURCES += DocxBuilder.cpp DocumentWizard.cpp + HEADERS += DocxBuilder.h DocumentWizard.h + ``` +3. Используйте из кода: + ```cpp + QStringList tmpl = loadTemplateFromFile("template.txt"); + DocumentWizard dlg(tmpl, this); + if (dlg.exec() == QDialog::Accepted) + qDebug() << "Сохранено:" << dlg.outputFilePath(); + ``` + +--- + +## Расширение функциональности + +| Задача | Куда добавить | +|--------------------------------|------------------------------------------| +| Нумерованные списки | `DocxBuilder::addList()` | +| Верхний/нижний колонтитул | `DocxBuilder::buildDocumentXml()` — `` | +| Оглавление | XML-элемент `` с `TOC` | +| Сохранение шаблона в файл | `QSettings` или простой `.txt` | +| Предпросмотр документа | LibreOffice → конвертировать в PDF → показать | diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..a67d5f3 --- /dev/null +++ b/main.cpp @@ -0,0 +1,109 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DocumentWizard.h" + +// ============================================================ +// Пример главного окна приложения +// ============================================================ +class MainWindow : public QMainWindow { + Q_OBJECT +public: + MainWindow(QWidget* parent = nullptr) : QMainWindow(parent) { + setWindowTitle("Генератор Word-документов"); + resize(600, 400); + + auto* central = new QWidget(this); + setCentralWidget(central); + auto* layout = new QVBoxLayout(central); + + auto* info = new QLabel( + "Шаблон документа задаётся набором строк.
" + "Каждая строка: тип|подсказка|выбор1;выбор2
" + "Типы: heading1, heading2, heading3, text, table, image", + this); + info->setWordWrap(true); + info->setStyleSheet("background:#fffbe6; padding:10px; border-radius:4px;"); + layout->addWidget(info); + + m_templateEdit = new QTextEdit(this); + m_templateEdit->setPlaceholderText("Введите строки шаблона…"); + m_templateEdit->setPlainText(defaultTemplate()); + m_templateEdit->setFont(QFont("Courier New", 10)); + layout->addWidget(m_templateEdit, 1); + + auto* startBtn = new QPushButton("▶ Запустить мастер создания документа", this); + startBtn->setStyleSheet( + "QPushButton { background:#2c5f8a; color:white; font-weight:bold;" + " padding:10px; border-radius:6px; font-size:13px; }" + "QPushButton:hover { background:#3a7ab5; }"); + layout->addWidget(startBtn); + + connect(startBtn, &QPushButton::clicked, this, &MainWindow::launchWizard); + } + +private slots: + void launchWizard() { + QStringList lines = m_templateEdit->toPlainText().split('\n'); + DocumentWizard wizard(lines, this); + wizard.exec(); + } + +private: + QTextEdit* m_templateEdit; + + static QString defaultTemplate() { + // Это и есть «строки, скармливаемые в начале работы программы» + return + "# Строки шаблона — определяют структуру будущего документа\n" + "# Формат: тип|подсказка пользователю|вариант1;вариант2;вариант3\n" + "\n" + "heading1|Введите название документа (Заголовок 1)|" + "Технический отчёт;Пояснительная записка;Руководство пользователя\n" + "\n" + "heading2|Выберите название раздела «Введение»|" + "1. Введение;1. Общие сведения;1. Назначение системы\n" + "\n" + "text|Напишите вводный абзац|" + "Настоящий документ описывает…;В данной работе рассматривается…;" + "Целью настоящего документа является…\n" + "\n" + "heading2|Раздел «Характеристики»|" + "2. Технические характеристики;2. Параметры системы\n" + "\n" + "table|Заполните таблицу характеристик системы|\n" + "\n" + "heading2|Раздел со схемой|" + "3. Структурная схема;3. Архитектура решения\n" + "\n" + "image|Вставьте изображение схемы или диаграммы|\n" + "\n" + "heading2|Заключительный раздел|" + "4. Заключение;4. Выводы\n" + "\n" + "text|Напишите заключение|" + "В результате работы были получены…;Таким образом, в ходе…;" + "Проведённые исследования показали…\n"; + } +}; + +// ============================================================ +// main +// ============================================================ +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + app.setStyle("Fusion"); + + MainWindow w; + w.show(); + return app.exec(); +} + +#include "main.moc"