"""Apply MIREA TZ (GOST 7.32 / методичка 2022) paragraph styles to a Document.""" from __future__ import annotations from docx.document import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING, WD_TAB_ALIGNMENT, WD_TAB_LEADER from docx.oxml.ns import qn from docx.shared import Cm, Mm, Pt from docx.styles.style import _ParagraphStyle as ParagraphStyle from .style_config import ( ParagraphStyleSpec, StyleConfig, get_preset, resolve_style_config, ) _ALIGN = { "left": WD_ALIGN_PARAGRAPH.LEFT, "center": WD_ALIGN_PARAGRAPH.CENTER, "justify": WD_ALIGN_PARAGRAPH.JUSTIFY, } # Styles that may be missing from Template.docx and need ensure + base _ENSURE_BASE = { "Caption Figure": "Caption", "Caption Table": "Caption", "Название таблицы": "Caption Table", "Caption Listing": "Caption", "Code": "Normal", "Table Text": "Normal", "Bibliography": "Normal", "Bibliography Heading": "Normal", "Space After Table": "Normal", } def _set_run_font(style: ParagraphStyle, name: str, size_pt: float, bold: bool = False, italic: bool = False): """Lock typeface/size/color so Word theme (Calibri + accent blue) cannot leak.""" from docx.oxml import OxmlElement from docx.shared import RGBColor font = style.font font.name = name font.size = Pt(size_pt) font.bold = bold font.italic = italic font.color.rgb = RGBColor(0, 0, 0) rPr = style.element.get_or_add_rPr() for child in list(rPr): tag = child.tag if tag.endswith("}rFonts") or tag.endswith("}color") or tag.endswith("}sz") or tag.endswith("}szCs"): rPr.remove(child) rFonts = OxmlElement("w:rFonts") rFonts.set(qn("w:ascii"), name) rFonts.set(qn("w:hAnsi"), name) rFonts.set(qn("w:cs"), name) rFonts.set(qn("w:eastAsia"), name) rPr.insert(0, rFonts) half_points = str(int(size_pt * 2)) sz = OxmlElement("w:sz") sz.set(qn("w:val"), half_points) rPr.append(sz) sz_cs = OxmlElement("w:szCs") sz_cs.set(qn("w:val"), half_points) rPr.append(sz_cs) color = OxmlElement("w:color") color.set(qn("w:val"), "000000") rPr.append(color) def _ensure_style(document: Document, name: str, base: str = "Normal") -> ParagraphStyle: try: return document.styles[name] except KeyError: style = document.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH) style.base_style = document.styles[base] return style def _get_or_ensure(document: Document, name: str) -> ParagraphStyle | None: base = _ENSURE_BASE.get(name) if base is not None: return _ensure_style(document, name, base) try: return document.styles[name] except KeyError: if name.startswith("toc "): return _ensure_style(document, name, "Normal") return None def _clear_tab_stops(style: ParagraphStyle) -> None: pPr = style.element.get_or_add_pPr() tabs = pPr.find(qn("w:tabs")) if tabs is not None: pPr.remove(tabs) def _fix_toc_tab_stops(document: Document) -> None: """Align TOC page-number tabs with the text area (Template.docx used 175 mm for L=25 mm).""" section = document.sections[0] right_tab = section.page_width - section.left_margin - section.right_margin left_tabs = { "toc 1": Mm(5), "toc 2": Mm(12.5), "toc 3": Mm(20), } for style in document.styles: if style.type != WD_STYLE_TYPE.PARAGRAPH: continue name = style.name.lower() if not name.startswith("toc ") or name == "toc heading": continue _clear_tab_stops(style) pf = style.paragraph_format left = left_tabs.get(name) if left is not None: pf.tab_stops.add_tab_stop(left, WD_TAB_ALIGNMENT.LEFT) pf.tab_stops.add_tab_stop( right_tab, WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS ) def _apply_paragraph_spec(style, spec: ParagraphStyleSpec) -> None: need_font = ( spec.font_name is not None or spec.size_pt is not None or spec.bold is not None or spec.italic is not None ) if need_font: name = spec.font_name if spec.font_name is not None else (style.font.name or "Times New Roman") size = spec.size_pt if spec.size_pt is not None else ( style.font.size.pt if style.font.size else 14 ) bold = bool(spec.bold) if spec.bold is not None else bool(style.font.bold) italic = bool(spec.italic) if spec.italic is not None else bool(style.font.italic) _set_run_font(style, name, size, bold=bold, italic=italic) if spec.all_caps is not None: style.font.all_caps = spec.all_caps if spec.underline is not None: style.font.underline = spec.underline # Character styles (Hyperlink, …) have no paragraph_format if style.type != WD_STYLE_TYPE.PARAGRAPH: return pf = style.paragraph_format if spec.alignment is not None: pf.alignment = _ALIGN[spec.alignment] if spec.first_line_indent_cm is not None: pf.first_line_indent = Cm(spec.first_line_indent_cm) if spec.left_indent_cm is not None: pf.left_indent = Cm(spec.left_indent_cm) if spec.right_indent_cm is not None: pf.right_indent = Cm(spec.right_indent_cm) if spec.space_before_mm is not None: pf.space_before = Mm(spec.space_before_mm) if spec.space_after_mm is not None: pf.space_after = Mm(spec.space_after_mm) if spec.line_spacing is not None: if spec.line_spacing == 1.5: pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE else: pf.line_spacing_rule = WD_LINE_SPACING.SINGLE if spec.page_break_before is not None: pf.page_break_before = spec.page_break_before if spec.keep_with_next is not None: pf.keep_with_next = spec.keep_with_next if spec.widow_control is not None: pf.widow_control = spec.widow_control def _apply_page_margins(config: StyleConfig) -> None: from . import page_geometry as pg page = config.page.require_complete() pg.MARGIN_LEFT = Mm(page.left_mm) pg.MARGIN_RIGHT = Mm(page.right_mm) pg.MARGIN_TOP = Mm(page.top_mm) pg.MARGIN_BOTTOM = Mm(page.bottom_mm) def apply_style_config(document: Document, config: StyleConfig) -> None: """Apply full StyleConfig (page margins + paragraph styles) to document.""" from .page_geometry import apply_section_geometry, is_landscape_section _apply_page_margins(config) for section in document.sections: apply_section_geometry(section, landscape=is_landscape_section(section)) _fix_toc_tab_stops(document) # Apply in a stable order: Normal first, then headings, then the rest order = [ "Normal", "Heading 1", "Heading 2", "Heading 3", "Caption Figure", "Caption Table", "Название таблицы", "Caption Listing", "Caption", "Code", "Table Text", "Bibliography", "Bibliography Heading", "toc 1", "toc 2", "toc 3", "Footer", "Hyperlink", "FollowedHyperlink", "Space After Table", ] applied = set() for name in order: spec = config.styles.get(name) if spec is None: continue style = _get_or_ensure(document, name) if style is None: continue _apply_paragraph_spec(style, spec) applied.add(name) for name, spec in config.styles.items(): if name in applied: continue style = _get_or_ensure(document, name) if style is None: continue _apply_paragraph_spec(style, spec) def apply_mirea_styles(document: Document) -> None: """Mutate section margins and key paragraph styles to match the MIREA method guide.""" apply_style_config(document, get_preset("mirea")) def apply_pis_custom_styles(document: Document) -> None: """Styles for PIS_custom: итоговый отчёт по практическим работам.""" apply_style_config(document, get_preset("pis_custom")) def apply_document_styles( document: Document, style_preset: str = "mirea", *, overlay: StyleConfig | None = None, md_dir: str | None = None, styles_path: str | None = None, ) -> StyleConfig: """Apply preset (+ optional JSON overlays). Returns the resolved StyleConfig.""" if overlay is not None: config = get_preset(style_preset).merge(overlay) else: config = resolve_style_config( style_preset, md_dir=md_dir, styles_path=styles_path, ) apply_style_config(document, config) return config