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