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

598 lines
18 KiB
Python

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