- update документация - промт для ии полу конфигурируемый
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user