Files
md_to_gost/md2gost/styles.py
T
Igor20264 818a044aa1
Python application / build (push) Has been cancelled
update 0.4.4
- update документация
- промт для ии полу конфигурируемый
2026-09-06 11:04:01 +03:00

263 lines
8.6 KiB
Python

"""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