818a044aa1
Python application / build (push) Has been cancelled
- update документация - промт для ии полу конфигурируемый
263 lines
8.6 KiB
Python
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
|