"""JSON overlay for document styles (page margins + paragraph styles). Layers (later wins, deep-merge of specified keys only): 1) built-in preset from DocProfile.style_preset (mirea | pis_custom) 2) md2gost.styles.json next to the .md (if present) 3) --styles path / GUI path """ from __future__ import annotations import copy import json import logging from dataclasses import dataclass, field, fields from pathlib import Path from typing import Any, Callable _log = logging.getLogger(__name__) STYLES_FILENAME = "md2gost.styles.json" PAGE_KEYS = frozenset({"left_mm", "right_mm", "top_mm", "bottom_mm"}) STYLE_NAMES = frozenset({ "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", }) ALIGNMENT_VALUES = frozenset({"left", "center", "justify"}) LINE_SPACING_VALUES = frozenset({1.0, 1.5}) STYLE_FIELD_KEYS = frozenset({ "font_name", "size_pt", "bold", "italic", "all_caps", "underline", "alignment", "first_line_indent_cm", "left_indent_cm", "right_indent_cm", "space_before_mm", "space_after_mm", "line_spacing", "page_break_before", "keep_with_next", "widow_control", }) TOP_LEVEL_KEYS = frozenset({"page", "styles"}) class StyleConfigError(ValueError): """Invalid styles JSON or unknown key/style name.""" @dataclass class PageSpec: left_mm: float | None = None right_mm: float | None = None top_mm: float | None = None bottom_mm: float | None = None def merge(self, overlay: "PageSpec | dict[str, Any] | None") -> "PageSpec": if overlay is None: return copy.copy(self) if isinstance(overlay, PageSpec): data = { f.name: getattr(overlay, f.name) for f in fields(overlay) if getattr(overlay, f.name) is not None } else: data = dict(overlay) out = copy.copy(self) for key, val in data.items(): if key not in PAGE_KEYS: raise StyleConfigError(f"Unknown page key: {key!r}") setattr(out, key, float(val)) return out def to_dict(self) -> dict[str, float]: out: dict[str, float] = {} for f in fields(self): val = getattr(self, f.name) if val is not None: out[f.name] = val return out def require_complete(self) -> "PageSpec": """Ensure all margins are set (for apply). Missing → GOST defaults.""" return PageSpec( left_mm=30.0 if self.left_mm is None else self.left_mm, right_mm=10.0 if self.right_mm is None else self.right_mm, top_mm=20.0 if self.top_mm is None else self.top_mm, bottom_mm=20.0 if self.bottom_mm is None else self.bottom_mm, ) @dataclass class ParagraphStyleSpec: font_name: str | None = None size_pt: float | None = None bold: bool | None = None italic: bool | None = None all_caps: bool | None = None underline: bool | None = None alignment: str | None = None # left | center | justify first_line_indent_cm: float | None = None left_indent_cm: float | None = None right_indent_cm: float | None = None space_before_mm: float | None = None space_after_mm: float | None = None line_spacing: float | None = None # 1.0 | 1.5 page_break_before: bool | None = None keep_with_next: bool | None = None widow_control: bool | None = None def merge(self, overlay: "ParagraphStyleSpec | dict[str, Any] | None") -> "ParagraphStyleSpec": if overlay is None: return copy.copy(self) if isinstance(overlay, ParagraphStyleSpec): data = { f.name: getattr(overlay, f.name) for f in fields(overlay) if getattr(overlay, f.name) is not None } else: data = dict(overlay) out = copy.copy(self) for key, val in data.items(): if key not in STYLE_FIELD_KEYS: raise StyleConfigError(f"Unknown style field: {key!r}") if val is None: continue if key == "alignment": s = str(val).lower() if s not in ALIGNMENT_VALUES: raise StyleConfigError( f"Invalid alignment {val!r}; expected one of {sorted(ALIGNMENT_VALUES)}" ) setattr(out, key, s) elif key == "line_spacing": fval = float(val) if fval not in LINE_SPACING_VALUES: raise StyleConfigError( f"Invalid line_spacing {val!r}; expected 1.0 or 1.5" ) setattr(out, key, fval) elif key in ("font_name",): setattr(out, key, str(val)) elif key in ("size_pt", "first_line_indent_cm", "left_indent_cm", "right_indent_cm", "space_before_mm", "space_after_mm"): setattr(out, key, float(val)) elif key in ("bold", "italic", "all_caps", "underline", "page_break_before", "keep_with_next", "widow_control"): setattr(out, key, bool(val)) else: setattr(out, key, val) return out def to_dict(self) -> dict[str, Any]: out: dict[str, Any] = {} for f in fields(self): val = getattr(self, f.name) if val is not None: out[f.name] = val return out @dataclass class StyleConfig: page: PageSpec = field(default_factory=PageSpec) styles: dict[str, ParagraphStyleSpec] = field(default_factory=dict) def merge(self, overlay: "StyleConfig | None") -> "StyleConfig": if overlay is None: return copy.deepcopy(self) page = self.page.merge(overlay.page) styles = {name: copy.copy(spec) for name, spec in self.styles.items()} for name, spec in overlay.styles.items(): if name not in STYLE_NAMES: raise StyleConfigError(f"Unknown style name: {name!r}") if name in styles: styles[name] = styles[name].merge(spec) else: styles[name] = copy.copy(spec) return StyleConfig(page=page, styles=styles) def to_dict(self) -> dict[str, Any]: return { "page": self.page.to_dict(), "styles": {name: spec.to_dict() for name, spec in self.styles.items()}, } def _ps(**kwargs) -> ParagraphStyleSpec: return ParagraphStyleSpec(**kwargs) def _common_body_and_misc() -> dict[str, ParagraphStyleSpec]: """Shared Normal + captions/misc (identical for mirea and pis_custom).""" return { "Normal": _ps( font_name="Times New Roman", size_pt=14, bold=False, italic=False, alignment="justify", first_line_indent_cm=1.25, left_indent_cm=0, right_indent_cm=0, space_before_mm=0, space_after_mm=0, line_spacing=1.5, widow_control=True, ), "Caption Figure": _ps( font_name="Times New Roman", size_pt=12, bold=True, italic=False, alignment="center", first_line_indent_cm=0, left_indent_cm=0, space_before_mm=0, space_after_mm=6, line_spacing=1.0, widow_control=True, ), "Caption Table": _ps( font_name="Times New Roman", size_pt=12, bold=False, italic=True, alignment="left", first_line_indent_cm=0, left_indent_cm=0, space_before_mm=6, space_after_mm=0, line_spacing=1.0, keep_with_next=True, widow_control=True, ), "Название таблицы": _ps( font_name="Times New Roman", size_pt=12, bold=False, italic=True, alignment="left", first_line_indent_cm=0, left_indent_cm=0, space_before_mm=6, space_after_mm=0, line_spacing=1.0, keep_with_next=True, widow_control=True, ), "Caption Listing": _ps( font_name="Times New Roman", size_pt=12, bold=False, italic=True, alignment="left", first_line_indent_cm=0, left_indent_cm=0, space_before_mm=6, space_after_mm=0, line_spacing=1.0, keep_with_next=True, widow_control=True, ), "Caption": _ps( font_name="Times New Roman", size_pt=12, bold=True, italic=False, alignment="center", first_line_indent_cm=0, space_before_mm=0, space_after_mm=6, line_spacing=1.0, ), "Code": _ps( font_name="Courier New", size_pt=10, bold=False, italic=False, alignment="left", first_line_indent_cm=0, left_indent_cm=0, space_before_mm=0, space_after_mm=0, line_spacing=1.0, ), "Table Text": _ps( font_name="Times New Roman", size_pt=12, bold=False, italic=False, alignment="left", first_line_indent_cm=0, left_indent_cm=0, space_before_mm=0, space_after_mm=0, line_spacing=1.0, ), "Bibliography": _ps( font_name="Times New Roman", size_pt=14, bold=False, italic=False, alignment="justify", first_line_indent_cm=1.25, space_before_mm=0, space_after_mm=0, line_spacing=1.5, ), "Bibliography Heading": _ps( font_name="Times New Roman", size_pt=14, bold=False, italic=False, all_caps=True, alignment="center", first_line_indent_cm=0, left_indent_cm=1.25, space_before_mm=6, space_after_mm=6, line_spacing=1.5, keep_with_next=True, ), "toc 1": _ps( font_name="Times New Roman", size_pt=14, bold=False, all_caps=True, alignment="left", first_line_indent_cm=0, space_before_mm=0, space_after_mm=0, line_spacing=1.5, ), "toc 2": _ps( font_name="Times New Roman", size_pt=14, bold=False, all_caps=False, alignment="left", first_line_indent_cm=0, space_before_mm=0, space_after_mm=0, line_spacing=1.5, ), "toc 3": _ps( font_name="Times New Roman", size_pt=14, bold=False, all_caps=False, alignment="left", first_line_indent_cm=0, space_before_mm=0, space_after_mm=0, line_spacing=1.5, ), "Footer": _ps( font_name="Times New Roman", size_pt=12, alignment="center", first_line_indent_cm=0, ), "Hyperlink": _ps( font_name="Times New Roman", size_pt=14, underline=False, ), "FollowedHyperlink": _ps( font_name="Times New Roman", size_pt=14, underline=False, ), "Space After Table": _ps( first_line_indent_cm=1.25, space_before_mm=6, space_after_mm=0, line_spacing=1.5, ), } def preset_mirea() -> StyleConfig: styles = _common_body_and_misc() styles["Heading 1"] = _ps( font_name="Times New Roman", size_pt=18, bold=True, all_caps=True, alignment="left", first_line_indent_cm=0, left_indent_cm=1.25, right_indent_cm=0, space_before_mm=0, space_after_mm=10, line_spacing=1.5, page_break_before=True, keep_with_next=True, widow_control=True, ) styles["Heading 2"] = _ps( font_name="Times New Roman", size_pt=16, bold=True, all_caps=False, alignment="left", first_line_indent_cm=0, left_indent_cm=1.25, right_indent_cm=0, space_before_mm=15, space_after_mm=10, line_spacing=1.5, page_break_before=False, keep_with_next=True, widow_control=True, ) styles["Heading 3"] = _ps( font_name="Times New Roman", size_pt=14, bold=True, all_caps=False, alignment="left", first_line_indent_cm=0, left_indent_cm=1.25, right_indent_cm=0, space_before_mm=15, space_after_mm=10, line_spacing=1.5, page_break_before=False, keep_with_next=True, widow_control=True, ) return StyleConfig( page=PageSpec(left_mm=30, right_mm=10, top_mm=20, bottom_mm=20), styles=styles, ) def preset_pis_custom() -> StyleConfig: styles = _common_body_and_misc() styles["Heading 1"] = _ps( font_name="Times New Roman", size_pt=14, bold=True, all_caps=True, alignment="center", first_line_indent_cm=0, left_indent_cm=0, right_indent_cm=0, space_before_mm=0, space_after_mm=10, line_spacing=1.5, page_break_before=True, keep_with_next=True, widow_control=True, ) styles["Heading 2"] = _ps( font_name="Times New Roman", size_pt=14, bold=True, all_caps=False, alignment="justify", first_line_indent_cm=1.25, left_indent_cm=0, right_indent_cm=0, space_before_mm=15, space_after_mm=10, line_spacing=1.5, page_break_before=False, keep_with_next=True, widow_control=True, ) styles["Heading 3"] = _ps( font_name="Times New Roman", size_pt=14, bold=True, all_caps=False, alignment="justify", first_line_indent_cm=1.25, left_indent_cm=0, right_indent_cm=0, space_before_mm=10, space_after_mm=10, line_spacing=1.5, page_break_before=False, keep_with_next=True, widow_control=True, ) return StyleConfig( page=PageSpec(left_mm=30, right_mm=10, top_mm=20, bottom_mm=20), styles=styles, ) PRESETS: dict[str, Callable[[], StyleConfig]] = { "mirea": preset_mirea, "pis_custom": preset_pis_custom, } def get_preset(name: str) -> StyleConfig: factory = PRESETS.get(name, preset_mirea) return factory() def style_config_from_dict(data: dict[str, Any]) -> StyleConfig: if not isinstance(data, dict): raise StyleConfigError("Styles JSON root must be an object") unknown = set(data.keys()) - TOP_LEVEL_KEYS if unknown: raise StyleConfigError(f"Unknown top-level key(s): {sorted(unknown)}") page = PageSpec() if "page" in data: raw_page = data["page"] if not isinstance(raw_page, dict): raise StyleConfigError("'page' must be an object") page = PageSpec().merge(raw_page) styles: dict[str, ParagraphStyleSpec] = {} if "styles" in data: raw_styles = data["styles"] if not isinstance(raw_styles, dict): raise StyleConfigError("'styles' must be an object") for name, raw_spec in raw_styles.items(): if name not in STYLE_NAMES: raise StyleConfigError(f"Unknown style name: {name!r}") if not isinstance(raw_spec, dict): raise StyleConfigError(f"Style {name!r} must be an object") styles[name] = ParagraphStyleSpec().merge(raw_spec) return StyleConfig(page=page, styles=styles) def load_styles_file(path: Path) -> StyleConfig: if not path.is_file(): raise StyleConfigError(f"Styles file not found: {path}") try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise StyleConfigError(f"Cannot read styles {path}: {exc}") from exc if not isinstance(raw, dict): raise StyleConfigError(f"Styles JSON root must be an object: {path}") return style_config_from_dict(raw) def resolve_style_config( style_preset: str, *, md_dir: str | Path | None = None, styles_path: str | Path | None = None, ) -> StyleConfig: """Merge preset ← near-md file ← explicit --styles path.""" config = get_preset(style_preset) if md_dir: near = Path(md_dir) / STYLES_FILENAME if near.is_file(): try: config = config.merge(load_styles_file(near)) _log.info("Applied styles overlay: %s", near) except StyleConfigError: raise if styles_path: path = Path(styles_path) config = config.merge(load_styles_file(path)) _log.info("Applied styles overlay: %s", path) return config