Files
NLP/lab1/scripts/build_report.py

571 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())