markdown-gost 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. markdown_gost-0.2.0/LICENSE +21 -0
  2. markdown_gost-0.2.0/PKG-INFO +104 -0
  3. markdown_gost-0.2.0/README.md +78 -0
  4. markdown_gost-0.2.0/pyproject.toml +59 -0
  5. markdown_gost-0.2.0/src/markdown_gost/__init__.py +5 -0
  6. markdown_gost-0.2.0/src/markdown_gost/cli/__init__.py +0 -0
  7. markdown_gost-0.2.0/src/markdown_gost/cli/__main__.py +191 -0
  8. markdown_gost-0.2.0/src/markdown_gost/cli/commands/__init__.py +0 -0
  9. markdown_gost-0.2.0/src/markdown_gost/cli/commands/import_cmd.py +134 -0
  10. markdown_gost-0.2.0/src/markdown_gost/config/__init__.py +0 -0
  11. markdown_gost-0.2.0/src/markdown_gost/config/errors.py +8 -0
  12. markdown_gost-0.2.0/src/markdown_gost/config/loader.py +59 -0
  13. markdown_gost-0.2.0/src/markdown_gost/config/presets/__init__.py +0 -0
  14. markdown_gost-0.2.0/src/markdown_gost/config/presets/default.yaml +94 -0
  15. markdown_gost-0.2.0/src/markdown_gost/config/presets/gost-7-32-2017.yaml +124 -0
  16. markdown_gost-0.2.0/src/markdown_gost/config/presets/mirea-practice.yaml +124 -0
  17. markdown_gost-0.2.0/src/markdown_gost/config/schema.py +688 -0
  18. markdown_gost-0.2.0/src/markdown_gost/config/units.py +61 -0
  19. markdown_gost-0.2.0/src/markdown_gost/convert.py +84 -0
  20. markdown_gost-0.2.0/src/markdown_gost/core/__init__.py +0 -0
  21. markdown_gost-0.2.0/src/markdown_gost/core/ast/__init__.py +77 -0
  22. markdown_gost-0.2.0/src/markdown_gost/core/ast/nodes.py +253 -0
  23. markdown_gost-0.2.0/src/markdown_gost/core/parser/__init__.py +358 -0
  24. markdown_gost-0.2.0/src/markdown_gost/core/parser/_marko_ext.py +155 -0
  25. markdown_gost-0.2.0/src/markdown_gost/core/parser/attrs.py +191 -0
  26. markdown_gost-0.2.0/src/markdown_gost/image_placeholder.py +78 -0
  27. markdown_gost-0.2.0/src/markdown_gost/import_/__init__.py +21 -0
  28. markdown_gost-0.2.0/src/markdown_gost/import_/image_handler.py +82 -0
  29. markdown_gost-0.2.0/src/markdown_gost/import_/listing_detector.py +92 -0
  30. markdown_gost-0.2.0/src/markdown_gost/import_/metrics.py +36 -0
  31. markdown_gost-0.2.0/src/markdown_gost/import_/pandoc_runner.py +97 -0
  32. markdown_gost-0.2.0/src/markdown_gost/import_/pdf_prepass.py +164 -0
  33. markdown_gost-0.2.0/src/markdown_gost/import_/pipeline.py +345 -0
  34. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/README.md +56 -0
  35. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/__init__.py +55 -0
  36. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/_common.py +18 -0
  37. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/caption_folder.py +212 -0
  38. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/heading_normalizer.py +39 -0
  39. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/html_table_converter.py +348 -0
  40. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/inline_normalizer.py +50 -0
  41. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/listing_wrapper.py +130 -0
  42. markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/unnumbered_heading_detector.py +57 -0
  43. markdown_gost-0.2.0/src/markdown_gost/import_/result.py +61 -0
  44. markdown_gost-0.2.0/src/markdown_gost/import_/semantic_docx.py +72 -0
  45. markdown_gost-0.2.0/src/markdown_gost/metrics_compat.py +38 -0
  46. markdown_gost-0.2.0/src/markdown_gost/output/__init__.py +0 -0
  47. markdown_gost-0.2.0/src/markdown_gost/output/pdf_writer.py +183 -0
  48. markdown_gost-0.2.0/src/markdown_gost/preview/__init__.py +34 -0
  49. markdown_gost-0.2.0/src/markdown_gost/preview/builder.py +2316 -0
  50. markdown_gost-0.2.0/src/markdown_gost/preview/html.py +1350 -0
  51. markdown_gost-0.2.0/src/markdown_gost/preview/model.py +298 -0
  52. markdown_gost-0.2.0/src/markdown_gost/preview_json.py +59 -0
  53. markdown_gost-0.2.0/src/markdown_gost/render/__init__.py +14 -0
  54. markdown_gost-0.2.0/src/markdown_gost/render/_assets/__init__.py +0 -0
  55. markdown_gost-0.2.0/src/markdown_gost/render/_assets/mml2omml.xsl +3822 -0
  56. markdown_gost-0.2.0/src/markdown_gost/render/bibliography.py +202 -0
  57. markdown_gost-0.2.0/src/markdown_gost/render/document_factory.py +140 -0
  58. markdown_gost-0.2.0/src/markdown_gost/render/latex_math.py +235 -0
  59. markdown_gost-0.2.0/src/markdown_gost/render/layout_tracker.py +65 -0
  60. markdown_gost-0.2.0/src/markdown_gost/render/numberer.py +102 -0
  61. markdown_gost-0.2.0/src/markdown_gost/render/paragraph_sizer.py +395 -0
  62. markdown_gost-0.2.0/src/markdown_gost/render/references.py +91 -0
  63. markdown_gost-0.2.0/src/markdown_gost/render/render_index.py +126 -0
  64. markdown_gost-0.2.0/src/markdown_gost/render/renderer.py +358 -0
  65. markdown_gost-0.2.0/src/markdown_gost/renderable/__init__.py +25 -0
  66. markdown_gost-0.2.0/src/markdown_gost/renderable/_oxml.py +40 -0
  67. markdown_gost-0.2.0/src/markdown_gost/renderable/appendix.py +109 -0
  68. markdown_gost-0.2.0/src/markdown_gost/renderable/base.py +84 -0
  69. markdown_gost-0.2.0/src/markdown_gost/renderable/bibliography.py +58 -0
  70. markdown_gost-0.2.0/src/markdown_gost/renderable/caption.py +150 -0
  71. markdown_gost-0.2.0/src/markdown_gost/renderable/equation.py +358 -0
  72. markdown_gost-0.2.0/src/markdown_gost/renderable/factory.py +238 -0
  73. markdown_gost-0.2.0/src/markdown_gost/renderable/heading.py +247 -0
  74. markdown_gost-0.2.0/src/markdown_gost/renderable/image.py +241 -0
  75. markdown_gost-0.2.0/src/markdown_gost/renderable/list.py +306 -0
  76. markdown_gost-0.2.0/src/markdown_gost/renderable/listing.py +514 -0
  77. markdown_gost-0.2.0/src/markdown_gost/renderable/page_break.py +47 -0
  78. markdown_gost-0.2.0/src/markdown_gost/renderable/paragraph.py +543 -0
  79. markdown_gost-0.2.0/src/markdown_gost/renderable/table.py +797 -0
  80. markdown_gost-0.2.0/src/markdown_gost/renderable/thematic_break.py +18 -0
  81. markdown_gost-0.2.0/src/markdown_gost/storage/__init__.py +46 -0
  82. markdown_gost-0.2.0/src/markdown_gost/storage/base.py +68 -0
  83. markdown_gost-0.2.0/src/markdown_gost/storage/fs.py +96 -0
  84. markdown_gost-0.2.0/src/markdown_gost/templates/__init__.py +202 -0
  85. markdown_gost-0.2.0/src/markdown_gost/templates/_placeholder.py +41 -0
  86. markdown_gost-0.2.0/src/markdown_gost/templates/_preview.py +93 -0
  87. markdown_gost-0.2.0/src/markdown_gost/templates/_registry.py +128 -0
  88. markdown_gost-0.2.0/src/markdown_gost/templates/_schema.py +184 -0
  89. markdown_gost-0.2.0/src/markdown_gost/templates/base.docx +0 -0
  90. markdown_gost-0.2.0/src/markdown_gost/templates/content/__init__.py +19 -0
  91. markdown_gost-0.2.0/src/markdown_gost/templates/content/preview.py +77 -0
  92. markdown_gost-0.2.0/src/markdown_gost/templates/content/render.py +241 -0
  93. markdown_gost-0.2.0/src/markdown_gost/templates/content/schema.yaml +30 -0
  94. markdown_gost-0.2.0/src/markdown_gost/templates/titlepage_university/__init__.py +28 -0
  95. markdown_gost-0.2.0/src/markdown_gost/templates/titlepage_university/preview.py +108 -0
  96. markdown_gost-0.2.0/src/markdown_gost/templates/titlepage_university/render.py +407 -0
  97. markdown_gost-0.2.0/src/markdown_gost/templates/titlepage_university/schema.yaml +131 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arseniy Mikheev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: markdown-gost
3
+ Version: 0.2.0
4
+ Summary: Markdown to GOST 7.32 DOCX/PDF converter with an HTML preview engine
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Arseniy Mikheev
8
+ Requires-Python: >=3.13,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Provides-Extra: pdf
13
+ Requires-Dist: click (>=8.1.0,<9.0.0)
14
+ Requires-Dist: freetype-py (>=2.5.0,<3.0.0)
15
+ Requires-Dist: latex2mathml (>=3.76.0,<4.0.0)
16
+ Requires-Dist: lxml (>=5.0.0,<6.0.0)
17
+ Requires-Dist: marko (>=2.1.0,<3.0.0)
18
+ Requires-Dist: pillow (>=11.0.0,<12.0.0)
19
+ Requires-Dist: pydantic (>=2.10.0,<3.0.0)
20
+ Requires-Dist: pygments (>=2.18.0,<3.0.0)
21
+ Requires-Dist: python-docx (>=1.2.0,<2.0.0)
22
+ Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
23
+ Requires-Dist: unoserver (>=3.0,<4.0) ; extra == "pdf"
24
+ Description-Content-Type: text/markdown
25
+
26
+ # markdown-gost
27
+
28
+ Markdown to GOST 7.32 DOCX/PDF converter with a paginated HTML preview.
29
+
30
+ - [Syntax reference](skills/markdown-gost/syntax.md) — extended Markdown: tables, listings, equations, appendices, bibliography, templates
31
+ - [Architecture](docs/architecture.md) — pipeline, config system, storage protocol, testing tiers
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pipx install markdown-gost # DOCX + preview
37
+ pipx install "markdown-gost[pdf]" # + unoserver tooling
38
+ ```
39
+
40
+ PDF output requires a running [unoserver](https://github.com/unoconv/unoserver)
41
+ (`pipx install unoserver && unoserver &`), reachable via `UNOSERVER_HOST` /
42
+ `UNOSERVER_PORT` (default `127.0.0.1:2003`).
43
+ Import requires pandoc >= 2.19 in PATH (`PANDOC_BINARY` to override).
44
+ Fonts for accurate pagination: `apt install fontconfig fonts-liberation
45
+ fonts-dejavu` (macOS: `brew install fontconfig font-liberation`).
46
+
47
+ Docker image with LibreOffice, unoserver, pandoc and fonts included:
48
+
49
+ ```bash
50
+ docker run --rm -v "$PWD":/work -w /work ghcr.io/grenition/markdown-gost:0.2.0 \
51
+ convert paper.md -o paper.docx
52
+ ```
53
+
54
+ ## Agent skill
55
+
56
+ ```bash
57
+ npx skills add grenition/markdown-gost # skills.sh registry: Claude Code, Cursor, Codex, opencode, ...
58
+ cp -r skills/markdown-gost ~/.claude/skills/ # or copy into your harness skills directory
59
+ ```
60
+
61
+ ## Commands
62
+
63
+ ```bash
64
+ markdown-gost convert input.md -o out.docx
65
+ markdown-gost convert input.md -o out.pdf # needs unoserver
66
+ markdown-gost convert input.md -o out.docx --config my.yaml
67
+ markdown-gost validate input.md
68
+ markdown-gost import legacy.docx -o imported.md # needs pandoc
69
+ markdown-gost import legacy.pdf -o imported.md # needs pandoc + unoserver
70
+ ```
71
+
72
+ Config: YAML with presets (`default`, `gost-7-32-2017`, `mirea-practice`),
73
+ selected inside the file (`preset: gost-7-32-2017`), any field overridable.
74
+ Full schema: `markdown_gost.config.schema.Config.model_json_schema()`.
75
+
76
+ Preview API:
77
+
78
+ ```python
79
+ from markdown_gost.preview import build_preview_model, render_preview_html
80
+
81
+ document = build_preview_model(markdown_text, config)
82
+ html = render_preview_html(document)
83
+ ```
84
+
85
+ Object stores are not bundled: implement the `Storage` protocol and pass your
86
+ implementation to the conversion APIs.
87
+
88
+ ## Development
89
+
90
+ Python 3.13, Poetry 2.x.
91
+
92
+ ```bash
93
+ poetry install
94
+ make test-unit
95
+ make test-integration
96
+ make test-screenshot # pixel-diff merge gate
97
+ make lint typecheck
98
+ make test-in-docker # full suite in the test image
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT — see [LICENSE](LICENSE).
104
+
@@ -0,0 +1,78 @@
1
+ # markdown-gost
2
+
3
+ Markdown to GOST 7.32 DOCX/PDF converter with a paginated HTML preview.
4
+
5
+ - [Syntax reference](skills/markdown-gost/syntax.md) — extended Markdown: tables, listings, equations, appendices, bibliography, templates
6
+ - [Architecture](docs/architecture.md) — pipeline, config system, storage protocol, testing tiers
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pipx install markdown-gost # DOCX + preview
12
+ pipx install "markdown-gost[pdf]" # + unoserver tooling
13
+ ```
14
+
15
+ PDF output requires a running [unoserver](https://github.com/unoconv/unoserver)
16
+ (`pipx install unoserver && unoserver &`), reachable via `UNOSERVER_HOST` /
17
+ `UNOSERVER_PORT` (default `127.0.0.1:2003`).
18
+ Import requires pandoc >= 2.19 in PATH (`PANDOC_BINARY` to override).
19
+ Fonts for accurate pagination: `apt install fontconfig fonts-liberation
20
+ fonts-dejavu` (macOS: `brew install fontconfig font-liberation`).
21
+
22
+ Docker image with LibreOffice, unoserver, pandoc and fonts included:
23
+
24
+ ```bash
25
+ docker run --rm -v "$PWD":/work -w /work ghcr.io/grenition/markdown-gost:0.2.0 \
26
+ convert paper.md -o paper.docx
27
+ ```
28
+
29
+ ## Agent skill
30
+
31
+ ```bash
32
+ npx skills add grenition/markdown-gost # skills.sh registry: Claude Code, Cursor, Codex, opencode, ...
33
+ cp -r skills/markdown-gost ~/.claude/skills/ # or copy into your harness skills directory
34
+ ```
35
+
36
+ ## Commands
37
+
38
+ ```bash
39
+ markdown-gost convert input.md -o out.docx
40
+ markdown-gost convert input.md -o out.pdf # needs unoserver
41
+ markdown-gost convert input.md -o out.docx --config my.yaml
42
+ markdown-gost validate input.md
43
+ markdown-gost import legacy.docx -o imported.md # needs pandoc
44
+ markdown-gost import legacy.pdf -o imported.md # needs pandoc + unoserver
45
+ ```
46
+
47
+ Config: YAML with presets (`default`, `gost-7-32-2017`, `mirea-practice`),
48
+ selected inside the file (`preset: gost-7-32-2017`), any field overridable.
49
+ Full schema: `markdown_gost.config.schema.Config.model_json_schema()`.
50
+
51
+ Preview API:
52
+
53
+ ```python
54
+ from markdown_gost.preview import build_preview_model, render_preview_html
55
+
56
+ document = build_preview_model(markdown_text, config)
57
+ html = render_preview_html(document)
58
+ ```
59
+
60
+ Object stores are not bundled: implement the `Storage` protocol and pass your
61
+ implementation to the conversion APIs.
62
+
63
+ ## Development
64
+
65
+ Python 3.13, Poetry 2.x.
66
+
67
+ ```bash
68
+ poetry install
69
+ make test-unit
70
+ make test-integration
71
+ make test-screenshot # pixel-diff merge gate
72
+ make lint typecheck
73
+ make test-in-docker # full suite in the test image
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "markdown-gost"
3
+ version = "0.2.0"
4
+ description = "Markdown to GOST 7.32 DOCX/PDF converter with an HTML preview engine"
5
+ authors = [{ name = "Arseniy Mikheev" }]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ requires-python = ">=3.13,<4.0"
9
+ dependencies = [
10
+ "marko (>=2.1.0,<3.0.0)",
11
+ "python-docx (>=1.2.0,<2.0.0)",
12
+ "pyyaml (>=6.0.2,<7.0.0)",
13
+ "pydantic (>=2.10.0,<3.0.0)",
14
+ "click (>=8.1.0,<9.0.0)",
15
+ "pygments (>=2.18.0,<3.0.0)",
16
+ "pillow (>=11.0.0,<12.0.0)",
17
+ "freetype-py (>=2.5.0,<3.0.0)",
18
+ "latex2mathml (>=3.76.0,<4.0.0)",
19
+ "lxml (>=5.0.0,<6.0.0)",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ pdf = ["unoserver (>=3.0,<4.0)"]
24
+
25
+ [project.scripts]
26
+ markdown-gost = "markdown_gost.cli.__main__:cli"
27
+
28
+ [tool.poetry]
29
+ packages = [{ include = "markdown_gost", from = "src" }]
30
+ include = [{ path = "src/markdown_gost/render/_assets/*.xsl", format = ["sdist", "wheel"] }]
31
+
32
+ [tool.poetry.group.dev.dependencies]
33
+ pytest = "^8.3.0"
34
+ pytest-cov = "^6.0.0"
35
+ ruff = "^0.9.0"
36
+ mypy = "^1.13.0"
37
+ pdf2image = "^1.17.0"
38
+ unoserver = "^3.0.0"
39
+ types-pyyaml = "^6.0.12"
40
+ playwright = "^1.61.0"
41
+ # The metrics stack stays optional for end users, but the test suite
42
+ # asserts its real behaviour.
43
+ prometheus-client = ">=0.21.0,<1.0.0"
44
+
45
+ [build-system]
46
+ requires = ["poetry-core>=2.0.0"]
47
+ build-backend = "poetry.core.masonry.api"
48
+
49
+ [tool.semantic_release]
50
+ version_toml = ["pyproject.toml:project.version"]
51
+ version_variables = ["src/markdown_gost/__init__.py:__version__"]
52
+ tag_format = "v{version}"
53
+ commit_message = "chore(release): v{version} [skip ci]"
54
+ major_on_zero = true
55
+ build_command = "python -m pip install --quiet build && python -m build --outdir dist"
56
+
57
+ [tool.semantic_release.commit_parser_options]
58
+ minor_tags = ["feat"]
59
+ patch_tags = ["fix", "perf"]
@@ -0,0 +1,5 @@
1
+ from markdown_gost.convert import convert
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = ["__version__", "convert"]
File without changes
@@ -0,0 +1,191 @@
1
+ """markdown-gost CLI (T020).
2
+
3
+ Commands: ``convert``, ``validate``. Exit codes: ``0`` ok, ``1`` user error
4
+ (config/parse), ``2`` system error / usage error.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import sys
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import Any, NoReturn, cast
14
+
15
+ import click
16
+ import yaml
17
+
18
+ from markdown_gost import __version__
19
+ from markdown_gost.cli.commands.import_cmd import import_command
20
+ from markdown_gost.config.errors import ConfigError
21
+ from markdown_gost.config.loader import load_config_from_path, load_config_from_string
22
+ from markdown_gost.config.schema import Config
23
+ from markdown_gost.convert import Format
24
+ from markdown_gost.convert import convert as convert_pipeline
25
+ from markdown_gost.core.parser import parse as parse_markdown
26
+ from markdown_gost.storage import get_storage
27
+
28
+ _DEFAULT_CONFIG = "preset: default\n"
29
+ _VALID_FORMATS: tuple[str, ...] = ("docx", "pdf")
30
+ _LOGGER = logging.getLogger("markdown_gost.cli")
31
+
32
+
33
+ def _resolve_format(explicit: str | None, output: Path | None) -> Format:
34
+ if explicit is not None:
35
+ return cast(Format, explicit.lower())
36
+ if output is not None:
37
+ suffix = output.suffix.lstrip(".").lower()
38
+ if suffix in _VALID_FORMATS:
39
+ return cast(Format, suffix)
40
+ return "docx"
41
+
42
+
43
+ def _load_config(config_path: Path | None) -> Config:
44
+ if config_path is None:
45
+ return load_config_from_string(_DEFAULT_CONFIG)
46
+ return load_config_from_path(config_path)
47
+
48
+
49
+ def _user_error(message: str) -> NoReturn:
50
+ raise click.ClickException(message)
51
+
52
+
53
+ def _system_error(message: str) -> NoReturn:
54
+ click.echo(f"Error: {message}", err=True)
55
+ sys.exit(2)
56
+
57
+
58
+ def _execute(action: Callable[[], Any]) -> Any:
59
+ """Run *action* and translate exceptions to documented exit codes.
60
+
61
+ ``ConfigError`` / ``ValueError`` (incl. pydantic ``ValidationError``) /
62
+ ``yaml.YAMLError`` → exit 1. Anything else → exit 2.
63
+ """
64
+
65
+ try:
66
+ return action()
67
+ except click.ClickException:
68
+ raise
69
+ except (ConfigError, ValueError, yaml.YAMLError) as exc:
70
+ _user_error(str(exc))
71
+ except Exception as exc:
72
+ _LOGGER.debug("system error", exc_info=True)
73
+ _system_error(str(exc))
74
+
75
+
76
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
77
+ @click.version_option(__version__, prog_name="markdown-gost")
78
+ @click.option(
79
+ "--verbose",
80
+ "-v",
81
+ is_flag=True,
82
+ help="Enable DEBUG-level logging.",
83
+ )
84
+ def cli(verbose: bool) -> None:
85
+ """markdown-gost — Markdown → DOCX/PDF по ГОСТ.
86
+
87
+ Пресет ГОСТа задаётся в YAML-конфиге, не флагом CLI (см. ADR-0004).
88
+ """
89
+
90
+ level = logging.DEBUG if verbose else logging.INFO
91
+ logging.basicConfig(
92
+ level=level,
93
+ format="%(levelname)s %(name)s: %(message)s",
94
+ force=True,
95
+ )
96
+
97
+
98
+ @cli.command("convert")
99
+ @click.argument(
100
+ "input_path",
101
+ metavar="INPUT",
102
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
103
+ )
104
+ @click.option(
105
+ "-o",
106
+ "--output",
107
+ type=click.Path(dir_okay=False, path_type=Path),
108
+ default=None,
109
+ help="Output file. Defaults to <input>.<format>.",
110
+ )
111
+ @click.option(
112
+ "--config",
113
+ "config_path",
114
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
115
+ default=None,
116
+ help="Path to YAML config (preset + overrides). Defaults to built-in default preset.",
117
+ )
118
+ @click.option(
119
+ "--format",
120
+ "fmt",
121
+ type=click.Choice(list(_VALID_FORMATS), case_sensitive=False),
122
+ default=None,
123
+ help="Output format. Inferred from --output extension if omitted.",
124
+ )
125
+ def convert_command(
126
+ input_path: Path,
127
+ output: Path | None,
128
+ config_path: Path | None,
129
+ fmt: str | None,
130
+ ) -> None:
131
+ """Convert INPUT markdown into the chosen format."""
132
+
133
+ resolved_fmt = _resolve_format(fmt, output)
134
+ if output is None:
135
+ output = input_path.with_suffix(f".{resolved_fmt}")
136
+
137
+ def _do() -> None:
138
+ config = _load_config(config_path)
139
+ markdown = input_path.read_text(encoding="utf-8")
140
+ storage = get_storage(default_base_dir=input_path.parent)
141
+ _LOGGER.debug(
142
+ "convert input=%s output=%s format=%s preset=%s",
143
+ input_path,
144
+ output,
145
+ resolved_fmt,
146
+ config.preset,
147
+ )
148
+ data = convert_pipeline(
149
+ markdown, config, format=resolved_fmt, storage=storage
150
+ )
151
+ assert output is not None
152
+ output.write_bytes(data)
153
+ click.echo(f"Wrote {output}")
154
+
155
+ _execute(_do)
156
+
157
+
158
+ @cli.command("validate")
159
+ @click.argument(
160
+ "input_path",
161
+ metavar="INPUT",
162
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
163
+ )
164
+ @click.option(
165
+ "--config",
166
+ "config_path",
167
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
168
+ default=None,
169
+ help="Path to YAML config (preset + overrides). Defaults to built-in default preset.",
170
+ )
171
+ def validate_command(input_path: Path, config_path: Path | None) -> None:
172
+ """Validate INPUT markdown + config without producing output."""
173
+
174
+ def _do() -> None:
175
+ config = _load_config(config_path)
176
+ markdown = input_path.read_text(encoding="utf-8")
177
+ from markdown_gost.render.bibliography import BibliographyIndex
178
+ from markdown_gost.render.references import prepare_document
179
+
180
+ document = prepare_document(parse_markdown(markdown), config)
181
+ BibliographyIndex.from_document(document, config)
182
+ click.echo(f"OK (preset={config.preset})")
183
+
184
+ _execute(_do)
185
+
186
+
187
+ cli.add_command(import_command)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ cli()
@@ -0,0 +1,134 @@
1
+ """``markdown-gost import`` — конвертация DOCX/PDF → расширенный markdown.
2
+
3
+ T036 ввёл docx-импорт; T041 добавил PDF через unoserver-prepass.
4
+
5
+ Exit codes:
6
+
7
+ * ``0`` — успех (даже если были fallbacks).
8
+ * ``1`` — невалидный вход: файл не найден или расширение не ``.docx`` / ``.pdf``.
9
+ * ``2`` — pandoc/unoserver упал на верхнем уровне.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import click
20
+
21
+ from markdown_gost.import_ import ImportContext, ImportResult, import_docx, import_pdf
22
+ from markdown_gost.import_.metrics import IMPORT_TOTAL
23
+ from markdown_gost.import_.pandoc_runner import PandocError
24
+ from markdown_gost.output.pdf_writer import UnoserverError
25
+ from markdown_gost.storage import get_storage
26
+
27
+ _LOGGER = logging.getLogger("markdown_gost.cli.import")
28
+
29
+ _HEADING_RE = re.compile(r"^#{1,6}\s")
30
+ _TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$")
31
+
32
+
33
+ def _count_headings(markdown: str) -> int:
34
+ return sum(1 for line in markdown.splitlines() if _HEADING_RE.match(line))
35
+
36
+
37
+ def _count_tables(markdown: str) -> int:
38
+ return sum(1 for line in markdown.splitlines() if _TABLE_SEP_RE.match(line))
39
+
40
+
41
+ def _total_fallbacks(result: ImportResult) -> int:
42
+ return sum(result.fallbacks.values())
43
+
44
+
45
+ @click.command("import")
46
+ @click.argument("input_path", metavar="INPUT", type=click.Path(path_type=Path))
47
+ @click.option(
48
+ "-o",
49
+ "--output",
50
+ type=click.Path(dir_okay=False, path_type=Path),
51
+ default=None,
52
+ help="Output markdown path. Defaults to <input-stem>.md рядом с input.",
53
+ )
54
+ @click.option(
55
+ "--images-dir",
56
+ "images_dir",
57
+ type=click.Path(file_okay=False, path_type=Path),
58
+ default=None,
59
+ help="Куда класть извлечённые картинки. По умолчанию <output-stem>_files/.",
60
+ )
61
+ def import_command(
62
+ input_path: Path,
63
+ output: Path | None,
64
+ images_dir: Path | None,
65
+ ) -> None:
66
+ """Импорт DOCX/PDF в расширенный markdown markdown_gost.
67
+
68
+ PDF-input идёт через unoserver-prepass (PDF → DOCX → md). Сканированные
69
+ PDF без текстового слоя возвращаются как пустой md без падения.
70
+ """
71
+ if not input_path.exists():
72
+ click.echo(f"Error: input not found: {input_path}", err=True)
73
+ sys.exit(1)
74
+
75
+ suffix = input_path.suffix.lower()
76
+ if suffix not in (".docx", ".pdf"):
77
+ click.echo(
78
+ f"Error: unsupported input extension {suffix!r}; expected .docx or .pdf",
79
+ err=True,
80
+ )
81
+ sys.exit(1)
82
+
83
+ if output is None:
84
+ output = input_path.with_suffix(".md")
85
+ if images_dir is None:
86
+ images_dir = output.with_name(f"{output.stem}_files")
87
+
88
+ storage = get_storage(default_base_dir=output.parent)
89
+ ctx = ImportContext(
90
+ storage=storage,
91
+ images_prefix=None,
92
+ images_dir=images_dir,
93
+ )
94
+ fmt_label = "pdf" if suffix == ".pdf" else "docx"
95
+
96
+ _LOGGER.info(
97
+ "import input=%s output=%s images_dir=%s format=%s",
98
+ input_path,
99
+ output,
100
+ images_dir,
101
+ fmt_label,
102
+ )
103
+
104
+ importer = import_pdf if suffix == ".pdf" else import_docx
105
+ try:
106
+ result = importer(input_path, ctx)
107
+ except (PandocError, UnoserverError) as exc:
108
+ IMPORT_TOTAL.labels(format=fmt_label, result="error").inc()
109
+ click.echo(f"Error: {exc}", err=True)
110
+ sys.exit(2)
111
+
112
+ output.parent.mkdir(parents=True, exist_ok=True)
113
+ output.write_text(result.markdown, encoding="utf-8")
114
+
115
+ if logging.getLogger().level <= logging.DEBUG and result.warnings:
116
+ for warning in result.warnings:
117
+ click.echo(f"warning: {warning}", err=True)
118
+
119
+ fallback_total = _total_fallbacks(result)
120
+ IMPORT_TOTAL.labels(format=fmt_label, result="success").inc()
121
+ if fallback_total > 0:
122
+ IMPORT_TOTAL.labels(format=fmt_label, result="fallback").inc()
123
+
124
+ click.echo(
125
+ "Imported: "
126
+ f"{_count_headings(result.markdown)} headings, "
127
+ f"{_count_tables(result.markdown)} tables, "
128
+ f"{len(result.images)} images, "
129
+ f"{fallback_total} fallbacks",
130
+ err=True,
131
+ )
132
+
133
+
134
+ __all__ = ["import_command"]
@@ -0,0 +1,8 @@
1
+ class ConfigError(Exception):
2
+ """Base class for configuration loading errors."""
3
+
4
+
5
+ class UnknownPresetError(ConfigError):
6
+ def __init__(self, name: str) -> None:
7
+ super().__init__(f"Unknown preset: {name!r}")
8
+ self.name = name
@@ -0,0 +1,59 @@
1
+ from importlib import resources
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import yaml
6
+
7
+ from markdown_gost.config.errors import UnknownPresetError
8
+ from markdown_gost.config.schema import Config
9
+
10
+
11
+ def _deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
12
+ result: dict[str, Any] = dict(base)
13
+ for key, value in overrides.items():
14
+ existing = result.get(key)
15
+ if isinstance(existing, dict) and isinstance(value, dict):
16
+ result[key] = _deep_merge(existing, value)
17
+ else:
18
+ result[key] = value
19
+ return result
20
+
21
+
22
+ def _load_preset(name: str) -> dict[str, Any]:
23
+ try:
24
+ text = (
25
+ resources.files("markdown_gost.config.presets")
26
+ .joinpath(f"{name}.yaml")
27
+ .read_text(encoding="utf-8")
28
+ )
29
+ except (FileNotFoundError, IsADirectoryError, ModuleNotFoundError) as e:
30
+ raise UnknownPresetError(name) from e
31
+ parsed = yaml.safe_load(text)
32
+ if parsed is None:
33
+ return {}
34
+ if not isinstance(parsed, dict):
35
+ raise UnknownPresetError(name)
36
+ return parsed
37
+
38
+
39
+ def load_config_from_string(content: str) -> Config:
40
+ user = yaml.safe_load(content)
41
+ if not isinstance(user, dict):
42
+ return Config.model_validate(user if user is not None else {})
43
+
44
+ preset_name = user.get("preset")
45
+ if not isinstance(preset_name, str) or not preset_name:
46
+ return Config.model_validate(user)
47
+
48
+ preset_data = _load_preset(preset_name)
49
+ overrides = user.get("overrides") or {}
50
+ if not isinstance(overrides, dict):
51
+ return Config.model_validate({"preset": preset_name, "overrides": overrides})
52
+
53
+ merged = _deep_merge(preset_data, overrides)
54
+ merged["preset"] = preset_name
55
+ return Config.model_validate(merged)
56
+
57
+
58
+ def load_config_from_path(path: Path) -> Config:
59
+ return load_config_from_string(path.read_text(encoding="utf-8"))