Добавлены итоговый отчёт и архив лабораторной работы
This commit is contained in:
570
lab1/scripts/build_report.py
Normal file
570
lab1/scripts/build_report.py
Normal file
@@ -0,0 +1,570 @@
|
||||
#!/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())
|
||||
208
lab1/scripts/package_submission.py
Normal file
208
lab1/scripts/package_submission.py
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Собирает итоговый ZIP-архив лабораторной работы.
|
||||
|
||||
Запуск из каталога lab1:
|
||||
|
||||
python scripts/package_submission.py
|
||||
|
||||
Скрипт копирует только нужные файлы во временный каталог, формирует
|
||||
MANIFEST.txt и CHECKSUMS.sha256, затем упаковывает всё в
|
||||
dist/nlp_lab1_submission_YYYYMMDD.zip. Рядом кладётся .sha256 самого архива.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DIST = ROOT / "dist"
|
||||
DEFAULT_NAME = f"nlp_lab1_submission_{date.today():%Y%m%d}"
|
||||
|
||||
EXCLUDE_PREFIXES = (
|
||||
"data/processed/",
|
||||
"features/",
|
||||
"results/",
|
||||
"nltk_data/",
|
||||
".venv/",
|
||||
".git/",
|
||||
"presentation/preview/",
|
||||
"report/build/",
|
||||
".pytest_cache/",
|
||||
"dist/",
|
||||
"exports/nlp_lab1_results_",
|
||||
"exports/nlp_lab1_final_results_20261026_120000.tar.gz.sha256",
|
||||
)
|
||||
|
||||
EXCLUDE_NAMES = {
|
||||
"stardew_stylometry.bak",
|
||||
"START_HERE.md",
|
||||
"ARCHIVE_README.md",
|
||||
}
|
||||
|
||||
LATEX_AUX_SUFFIXES = {".aux", ".log", ".nav", ".out", ".snm", ".toc"}
|
||||
|
||||
KEY_FILES = [
|
||||
"lab1/report/REPORT.pdf",
|
||||
"lab1/report/REPORT.docx",
|
||||
"lab1/report/REPORT.md",
|
||||
"lab1/presentation/stardew_stylometry.pdf",
|
||||
"lab1/presentation/stardew_stylometry.pptx",
|
||||
"lab1/presentation/latex/stardew_stylometry.pdf",
|
||||
"lab1/presentation/latex/stardew_stylometry.tex",
|
||||
"lab1/FINAL_RESULTS.csv",
|
||||
"lab1/data/dialogues.csv",
|
||||
"lab1/exports/nlp_lab1_final_results_20261026_120000.tar.gz",
|
||||
]
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def should_copy(rel: Path) -> bool:
|
||||
posix = rel.as_posix()
|
||||
if rel.name in EXCLUDE_NAMES:
|
||||
return False
|
||||
if "__pycache__" in rel.parts or rel.suffix == ".pyc":
|
||||
return False
|
||||
if rel.suffix in LATEX_AUX_SUFFIXES:
|
||||
return False
|
||||
if any(posix == prefix.rstrip("/") or posix.startswith(prefix)
|
||||
for prefix in EXCLUDE_PREFIXES):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def copy_tree(stage: Path) -> int:
|
||||
target_root = stage / "lab1"
|
||||
count = 0
|
||||
for path in sorted(ROOT.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT)
|
||||
if not should_copy(rel):
|
||||
continue
|
||||
dst = target_root / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(path, dst)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def write_manifest(stage: Path) -> int:
|
||||
files = sorted(p for p in stage.rglob("*") if p.is_file())
|
||||
lines = [
|
||||
"Архив: " + stage.name,
|
||||
f"Файлов в манифесте: {len(files)} (сам MANIFEST.txt не включён)",
|
||||
"",
|
||||
f"{'SHA-256':64} {'Размер':>12} Путь",
|
||||
"-" * 100,
|
||||
]
|
||||
total = 0
|
||||
for path in files:
|
||||
size = path.stat().st_size
|
||||
total += size
|
||||
rel = path.relative_to(stage).as_posix()
|
||||
lines.append(f"{sha256(path)} {size:>12} {rel}")
|
||||
lines.append("-" * 100)
|
||||
lines.append(f"Итого байт: {total}")
|
||||
(stage / "MANIFEST.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return len(files)
|
||||
|
||||
|
||||
def write_checksums(stage: Path) -> None:
|
||||
lines = []
|
||||
for rel_name in KEY_FILES:
|
||||
path = stage / rel_name
|
||||
if path.exists():
|
||||
lines.append(f"{sha256(path)} {rel_name}")
|
||||
else:
|
||||
print(f"ВНИМАНИЕ: нет ключевого файла {rel_name}")
|
||||
(stage / "CHECKSUMS.sha256").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def zip_stage(stage: Path, archive: Path) -> None:
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6) as zf:
|
||||
for path in sorted(stage.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(stage).as_posix()
|
||||
arcname = f"{stage.name}/{rel}"
|
||||
info = zipfile.ZipInfo.from_file(path, arcname)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
with path.open("rb") as src, zf.open(info, "w") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
def human(n: int) -> str:
|
||||
for unit in ("Б", "КБ", "МБ", "ГБ"):
|
||||
if n < 1024 or unit == "ГБ":
|
||||
return f"{n:.1f} {unit}" if unit != "Б" else f"{n} {unit}"
|
||||
n /= 1024
|
||||
return f"{n} Б"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--name", default=DEFAULT_NAME, help="имя каталога и ZIP")
|
||||
parser.add_argument("--keep-stage", action="store_true",
|
||||
help="не удалять промежуточный каталог dist/stage")
|
||||
args = parser.parse_args()
|
||||
|
||||
name = args.name
|
||||
stage = DIST / "stage" / name
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
stage.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"1/4. Копирование файлов в {stage} ...")
|
||||
copied = copy_tree(stage)
|
||||
shutil.copy2(ROOT / "START_HERE.md", stage / "START_HERE.md")
|
||||
shutil.copy2(ROOT / "ARCHIVE_README.md", stage / "ARCHIVE_README.md")
|
||||
|
||||
print("2/4. Формирование CHECKSUMS.sha256 ...")
|
||||
write_checksums(stage)
|
||||
|
||||
print("3/4. Формирование MANIFEST.txt ...")
|
||||
total = write_manifest(stage)
|
||||
|
||||
archive = DIST / f"{name}.zip"
|
||||
print(f"4/4. Упаковка {archive} ...")
|
||||
zip_stage(stage, archive)
|
||||
|
||||
digest = sha256(archive)
|
||||
(DIST / f"{name}.zip.sha256").write_text(
|
||||
f"{digest} {archive.name}\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print()
|
||||
print(f"Файлов в lab1: {copied}")
|
||||
print(f"Всего файлов в архиве: {total + 1}")
|
||||
print(f"Размер ZIP: {human(archive.stat().st_size)}")
|
||||
print(f"SHA-256 ZIP: {digest}")
|
||||
print(f"Архив: {archive}")
|
||||
print(f"Контрольная сумма: {archive}.sha256")
|
||||
|
||||
if not args.keep_stage:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
try:
|
||||
(DIST / "stage").rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user