#!/usr/bin/env python3 """Сборка отчёта: Markdown -> DOCX -> PDF. Скрипт рассчитан на запуск из каталога lab1: python scripts/build_report.py Требуются pandoc, python-docx и libreoffice. PDF собирается через LibreOffice, чтобы не зависеть от LaTeX-шаблона и корректно передать кириллицу, таблицы и рисунки. """ from __future__ import annotations import argparse import shutil import subprocess import sys from pathlib import Path import docx from docx.enum.section import WD_SECTION from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Pt, RGBColor ROOT = Path(__file__).resolve().parents[1] REPORT_MD = ROOT / "report" / "REPORT.md" BUILD = ROOT / "report" / "build" DOCX_OUT = ROOT / "report" / "REPORT.docx" PDF_OUT = ROOT / "report" / "REPORT.pdf" BODY_FONT = "Times New Roman" HEAD_FONT = "Times New Roman" MONO_FONT = "Liberation Mono" INK = RGBColor(0x1A, 0x1A, 0x1A) MUTED = RGBColor(0x55, 0x55, 0x55) ACCENT = RGBColor(0x5B, 0x3A, 0x1E) TABLE_WIDTHS = [ (2.8, 13.8), (11.6, 5.0), (4.6, 12.0), (3.4, 13.2), (5.6, 11.0), (7.6, 3.4, 3.4, 2.2), (9.6, 7.0), (10.0, 6.6), (11.6, 5.0), ] TABLE_CAPTIONS = [ "Гипотезы исследования и способ их проверки", "Характеристики корпуса", "Группы ручных стилометрических признаков", "Варианты латентно-семантического анализа", "Метрики качества и их назначение", "Сравнение конфигураций классификаторов", "Вклад групп признаков", "Контроль содержания: исходный и маскированный текст", "Качество классификации по длине реплики", ] def run(cmd: list[str], cwd: Path | None = None) -> None: print("+", " ".join(cmd)) subprocess.run(cmd, cwd=cwd, check=True) def set_run_font(run, name: str, size: float | None = None, bold: bool | None = None, italic: bool | None = None, color: RGBColor | None = None) -> None: run.font.name = name run._element.rPr.rFonts.set(qn("w:eastAsia"), name) if size is not None: run.font.size = Pt(size) if bold is not None: run.bold = bold if italic is not None: run.italic = italic if color is not None: run.font.color.rgb = color def set_style_font(style, name: str, size: float, bold: bool = False, color: RGBColor = INK, italic: bool | None = None) -> None: style.font.name = name style.font.size = Pt(size) style.font.bold = bold style.font.color.rgb = color if italic is not None: style.font.italic = italic rpr = style.element.get_or_add_rPr() rfonts = rpr.find(qn("w:rFonts")) if rfonts is None: rfonts = OxmlElement("w:rFonts") rpr.append(rfonts) for attr in ("w:ascii", "w:hAnsi", "w:eastAsia", "w:cs"): rfonts.set(qn(attr), name) def set_paragraph_spacing(style, before: float = 0, after: float = 0, line: float = 1.5) -> None: pf = style.paragraph_format pf.space_before = Pt(before) pf.space_after = Pt(after) pf.line_spacing = line def shade(cell, fill: str) -> None: tc_pr = cell._tc.get_or_add_tcPr() shd = OxmlElement("w:shd") shd.set(qn("w:val"), "clear") shd.set(qn("w:color"), "auto") shd.set(qn("w:fill"), fill) tc_pr.append(shd) def set_cell_margins(table, top=60, bottom=60, left=90, right=90) -> None: tbl_pr = table._tbl.tblPr mar = OxmlElement("w:tblCellMar") for edge, value in (("top", top), ("left", left), ("bottom", bottom), ("right", right)): el = OxmlElement(f"w:{edge}") el.set(qn("w:w"), str(value)) el.set(qn("w:type"), "dxa") mar.append(el) tbl_pr.append(mar) def set_repeat_header(row) -> None: tr_pr = row._tr.get_or_add_trPr() el = OxmlElement("w:tblHeader") el.set(qn("w:val"), "true") tr_pr.append(el) cant_split = OxmlElement("w:cantSplit") cant_split.set(qn("w:val"), "true") tr_pr.append(cant_split) def set_table_borders(table, color: str = "B8A88F", size: int = 4) -> None: tbl_pr = table._tbl.tblPr borders = OxmlElement("w:tblBorders") for edge in ("top", "left", "bottom", "right", "insideH", "insideV"): el = OxmlElement(f"w:{edge}") el.set(qn("w:val"), "single") el.set(qn("w:sz"), str(size)) el.set(qn("w:space"), "0") el.set(qn("w:color"), color) borders.append(el) tbl_pr.append(borders) def add_page_field(paragraph, total: bool = False) -> None: run = paragraph.add_run() fld_char_begin = OxmlElement("w:fldChar") fld_char_begin.set(qn("w:fldCharType"), "begin") instr_text = OxmlElement("w:instrText") instr_text.set(qn("xml:space"), "preserve") instr_text.text = "NUMPAGES" if total else "PAGE" fld_char_end = OxmlElement("w:fldChar") fld_char_end.set(qn("w:fldCharType"), "end") run._r.append(fld_char_begin) run._r.append(instr_text) run._r.append(fld_char_end) def add_bottom_border(paragraph, color: str = "5B3A1E", size: int = 6) -> None: p_pr = paragraph._p.get_or_add_pPr() borders = OxmlElement("w:pBdr") bottom = OxmlElement("w:bottom") bottom.set(qn("w:val"), "single") bottom.set(qn("w:sz"), str(size)) bottom.set(qn("w:space"), "4") bottom.set(qn("w:color"), color) borders.append(bottom) p_pr.append(borders) def add_left_border(paragraph, color: str = "B8A88F", size: int = 18) -> None: p_pr = paragraph._p.get_or_add_pPr() borders = OxmlElement("w:pBdr") left = OxmlElement("w:left") left.set(qn("w:val"), "single") left.set(qn("w:sz"), str(size)) left.set(qn("w:space"), "8") left.set(qn("w:color"), color) borders.append(left) p_pr.append(borders) def add_page_break(paragraph) -> None: run = paragraph.add_run() run.add_break(WD_BREAK.PAGE) def style_document(doc) -> None: section = doc.sections[0] section.page_width = Cm(21.0) section.page_height = Cm(29.7) section.left_margin = Cm(2.6) section.right_margin = Cm(1.8) section.top_margin = Cm(2.0) section.bottom_margin = Cm(2.0) section.header_distance = Cm(1.0) section.footer_distance = Cm(1.0) styles = doc.styles normal = styles["Normal"] set_style_font(normal, BODY_FONT, 12) normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY normal.paragraph_format.first_line_indent = Cm(1.25) normal.paragraph_format.line_spacing = 1.5 normal.paragraph_format.space_before = Pt(0) normal.paragraph_format.space_after = Pt(0) for name in ("Body Text", "First Paragraph"): st = styles[name] set_style_font(st, BODY_FONT, 12) st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY st.paragraph_format.first_line_indent = Cm(1.25) st.paragraph_format.line_spacing = 1.5 st.paragraph_format.space_before = Pt(0) st.paragraph_format.space_after = Pt(6) styles["First Paragraph"].paragraph_format.space_before = Pt(0) title = styles["Title"] set_style_font(title, HEAD_FONT, 24, bold=True, color=ACCENT) title.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER title.paragraph_format.space_before = Pt(150) title.paragraph_format.space_after = Pt(12) title.paragraph_format.line_spacing = 1.1 subtitle = styles["Subtitle"] set_style_font(subtitle, HEAD_FONT, 15, bold=False, color=INK) subtitle.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER subtitle.paragraph_format.space_before = Pt(0) subtitle.paragraph_format.space_after = Pt(28) subtitle.paragraph_format.line_spacing = 1.2 author = styles["Author"] set_style_font(author, BODY_FONT, 13, color=INK) author.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER author.paragraph_format.space_before = Pt(0) author.paragraph_format.space_after = Pt(6) date = styles["Date"] set_style_font(date, BODY_FONT, 13, color=MUTED) date.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER date.paragraph_format.space_before = Pt(0) date.paragraph_format.space_after = Pt(0) for name, size, before, after in ( ("Heading 1", 17, 0, 10), ("Heading 2", 14, 14, 6), ("Heading 3", 12.5, 12, 4), ): st = styles[name] set_style_font(st, HEAD_FONT, size, bold=True, color=INK) st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT st.paragraph_format.first_line_indent = Cm(0) st.paragraph_format.space_before = Pt(before) st.paragraph_format.space_after = Pt(after) st.paragraph_format.line_spacing = 1.15 st.paragraph_format.keep_with_next = True styles["Heading 1"].paragraph_format.page_break_before = False styles["Heading 1"].paragraph_format.space_before = Pt(20) block = styles["Block Text"] set_style_font(block, BODY_FONT, 11.5, italic=False) block.paragraph_format.left_indent = Cm(1.0) block.paragraph_format.right_indent = Cm(0.5) block.paragraph_format.first_line_indent = Cm(0) block.paragraph_format.line_spacing = 1.3 block.paragraph_format.space_before = Pt(6) block.paragraph_format.space_after = Pt(8) block.paragraph_format.keep_together = True source = styles["Source Code"] set_style_font(source, MONO_FONT, 9) source.paragraph_format.left_indent = Cm(0.6) source.paragraph_format.first_line_indent = Cm(0) source.paragraph_format.line_spacing = 1.0 source.paragraph_format.space_before = Pt(4) source.paragraph_format.space_after = Pt(4) source.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT source.paragraph_format.keep_together = True try: verbatim = styles["Verbatim Char"] set_style_font(verbatim, MONO_FONT, 9.5) except KeyError: pass for name in ("Image Caption", "Caption"): st = styles[name] set_style_font(st, BODY_FONT, 10, italic=True, color=MUTED) st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER st.paragraph_format.first_line_indent = Cm(0) st.paragraph_format.line_spacing = 1.15 st.paragraph_format.space_before = Pt(4) st.paragraph_format.space_after = Pt(12) for name in ("Table Caption",): st = styles[name] set_style_font(st, BODY_FONT, 10, italic=False, color=INK) st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER st.paragraph_format.first_line_indent = Cm(0) st.paragraph_format.line_spacing = 1.15 st.paragraph_format.space_before = Pt(4) st.paragraph_format.space_after = Pt(12) st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT st.paragraph_format.first_line_indent = Cm(0) st.paragraph_format.line_spacing = 1.15 st.paragraph_format.space_before = Pt(10) st.paragraph_format.space_after = Pt(4) st.paragraph_format.keep_with_next = True def add_header_footer(doc) -> None: section = doc.sections[0] header = section.header header.is_linked_to_previous = False hp = header.paragraphs[0] hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT hp.paragraph_format.space_after = Pt(0) run = hp.add_run("Стилометрия реплик Stardew Valley · отчёт по лабораторной работе №1") set_run_font(run, BODY_FONT, 8.5, color=MUTED) add_bottom_border(hp, color="D8CBB8", size=4) footer = section.footer footer.is_linked_to_previous = False fp = footer.paragraphs[0] fp.alignment = WD_ALIGN_PARAGRAPH.CENTER fp.paragraph_format.space_before = Pt(0) fp.paragraph_format.space_after = Pt(0) run = fp.add_run("Стр. ") set_run_font(run, BODY_FONT, 9, color=MUTED) add_page_field(fp) for r in fp.runs: set_run_font(r, BODY_FONT, 9, color=MUTED) run = fp.add_run(" из ") set_run_font(run, BODY_FONT, 9, color=MUTED) add_page_field(fp, total=True) for r in fp.runs: set_run_font(r, BODY_FONT, 9, color=MUTED) def add_title_page_notes(doc) -> None: """Добавляет подпись направления под титульным блоком и разрыв страницы.""" date_para = None for p in doc.paragraphs: if p.style.name == "Date": date_para = p break if date_para is None: return anchor = date_para note = anchor.insert_paragraph_before() note.alignment = WD_ALIGN_PARAGRAPH.CENTER note.paragraph_format.first_line_indent = Cm(0) note.paragraph_format.space_before = Pt(18) run = note.add_run("Направление: автоматический анализ текстов\n") set_run_font(run, BODY_FONT, 11, color=MUTED) run = note.add_run("Корпус: Stardew Valley · 2005 реплик · 34 персонажа") set_run_font(run, BODY_FONT, 11, color=MUTED) spacer = doc.add_paragraph() spacer.paragraph_format.space_after = Pt(0) add_page_break(spacer) date_para._p.addnext(spacer._p) def number_figures(doc) -> None: n = 0 for p in doc.paragraphs: if p.style.name == "Image Caption" and p.text.strip(): n += 1 p.text = f"Рисунок {n}. {p.text.strip()}" for run in p.runs: set_run_font(run, BODY_FONT, 10, italic=True, color=MUTED) def add_table_captions(doc) -> None: for idx, table in enumerate(doc.tables): if idx >= len(TABLE_CAPTIONS): break para = doc.add_paragraph(style="Table Caption") table._tbl.addprevious(para._p) run = para.add_run(f"Таблица {idx + 1}. {TABLE_CAPTIONS[idx]}") set_run_font(run, BODY_FONT, 10, bold=True, color=INK) para.paragraph_format.first_line_indent = Cm(0) para.paragraph_format.space_before = Pt(10) para.paragraph_format.space_after = Pt(4) para.paragraph_format.keep_with_next = True def set_column_widths(table, widths_cm: tuple[float, ...]) -> None: table.autofit = False tbl_pr = table._tbl.tblPr layout = OxmlElement("w:tblLayout") layout.set(qn("w:type"), "fixed") tbl_pr.append(layout) grid = table._tbl.tblGrid for idx, width in enumerate(widths_cm): if idx < len(grid.gridCol_lst): grid.gridCol_lst[idx].set(qn("w:w"), str(int(Cm(width).twips))) for row in table.rows: for idx, width in enumerate(widths_cm): if idx < len(row.cells): row.cells[idx].width = Cm(width) def style_tables(doc) -> None: for t_idx, table in enumerate(doc.tables): table.alignment = WD_TABLE_ALIGNMENT.CENTER table.autofit = True set_table_borders(table) set_cell_margins(table) for r_idx, row in enumerate(table.rows): if r_idx == 0: set_repeat_header(row) else: tr_pr = row._tr.get_or_add_trPr() cant_split = OxmlElement("w:cantSplit") cant_split.set(qn("w:val"), "true") tr_pr.append(cant_split) for cell in row.cells: for p in cell.paragraphs: p.paragraph_format.first_line_indent = Cm(0) p.paragraph_format.space_before = Pt(1) p.paragraph_format.space_after = Pt(1) p.paragraph_format.line_spacing = 1.0 if r_idx < len(table.rows) - 1: p.paragraph_format.keep_with_next = True for run in p.runs: set_run_font( run, BODY_FONT, 9.5 if len(table.columns) >= 5 else 10, bold=(r_idx == 0), ) if r_idx == 0: shade(cell, "F1E8D8") # Ширина таблицы по ширине полей. tbl_pr = table._tbl.tblPr tbl_w = OxmlElement("w:tblW") tbl_w.set(qn("w:type"), "pct") tbl_w.set(qn("w:w"), "100%") tbl_pr.append(tbl_w) if t_idx < len(TABLE_WIDTHS) and len(TABLE_WIDTHS[t_idx]) == len(table.columns): set_column_widths(table, TABLE_WIDTHS[t_idx]) # Небольшой отступ между таблицей и следующим абзацем. spacer = doc.add_paragraph() spacer.paragraph_format.space_before = Pt(0) spacer.paragraph_format.space_after = Pt(0) spacer.paragraph_format.line_spacing = 1.0 sr = spacer.add_run("") sr.font.size = Pt(4) table._tbl.addnext(spacer._p) def style_blockquotes(doc) -> None: for p in doc.paragraphs: if p.style.name == "Block Text": add_left_border(p) def add_math_font_note(doc) -> None: """OMML-формулы наследуют Cambria Math; явно задаём её для совместимости.""" for r in doc.element.body.iter(qn("m:r")): rpr = r.find(qn("w:rPr")) if rpr is None: rpr = OxmlElement("w:rPr") r.insert(0, rpr) rfonts = rpr.find(qn("w:rFonts")) if rfonts is None: rfonts = OxmlElement("w:rFonts") rpr.append(rfonts) rfonts.set(qn("w:ascii"), "Cambria Math") rfonts.set(qn("w:hAnsi"), "Cambria Math") def build_docx() -> None: BUILD.mkdir(parents=True, exist_ok=True) raw = BUILD / "REPORT_raw.docx" run([ "pandoc", str(REPORT_MD), "-f", "markdown+tex_math_dollars+tex_math_single_backslash+raw_tex", "-t", "docx", "-o", str(raw), f"--resource-path={REPORT_MD.parent}", ]) doc = docx.Document(raw) style_document(doc) section = doc.sections[0] section.different_first_page_header_footer = True add_header_footer(doc) # Титульный лист без колонтитулов. for part in (section.first_page_header, section.first_page_footer): part.is_linked_to_previous = False for paragraph in part.paragraphs: paragraph.text = "" add_title_page_notes(doc) number_figures(doc) add_table_captions(doc) style_tables(doc) style_blockquotes(doc) add_math_font_note(doc) doc.core_properties.title = "Отчёт по лабораторной работе №1" doc.core_properties.subject = "Классификация текстов на основе стилометрических характеристик" doc.core_properties.author = "А. А. Аладьина, В. Н. Копнин, М. В. Савостиков" doc.core_properties.language = "ru-RU" doc.save(DOCX_OUT) print(f"DOCX: {DOCX_OUT}") def build_pdf() -> None: if shutil.which("libreoffice") is None: raise SystemExit("libreoffice не найден: PDF не собран") run([ "libreoffice", "--headless", "--convert-to", "pdf", "--outdir", str(PDF_OUT.parent), str(DOCX_OUT), ]) produced = PDF_OUT.parent / (DOCX_OUT.stem + ".pdf") if produced != PDF_OUT and produced.exists(): produced.replace(PDF_OUT) print(f"PDF: {PDF_OUT}") def check_pdf() -> None: try: import fitz # PyMuPDF except ImportError: print("PyMuPDF не установлен: пропускаю проверку PDF") return doc = fitz.open(PDF_OUT) print(f"PDF страниц: {doc.page_count}") text = "\n".join(page.get_text() for page in doc) required = [ "Аннотация", "Постановка задачи", "Корпус", "Стилометрические признаки", "Классификаторы", "Схема верификации", "Результаты", "Обсуждение", "Ограничения", "Выводы", "Воспроизводимость", ] missing = [item for item in required if item.lower() not in text.lower()] if missing: print("Не найдены разделы:", ", ".join(missing)) raise SystemExit(1) print("Все основные разделы найдены в PDF") def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--skip-pdf", action="store_true", help="только DOCX") args = parser.parse_args() if not REPORT_MD.exists(): raise SystemExit(f"нет файла {REPORT_MD}") build_docx() if not args.skip_pdf: build_pdf() check_pdf() return 0 if __name__ == "__main__": sys.exit(main())