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.
- markdown_gost-0.2.0/LICENSE +21 -0
- markdown_gost-0.2.0/PKG-INFO +104 -0
- markdown_gost-0.2.0/README.md +78 -0
- markdown_gost-0.2.0/pyproject.toml +59 -0
- markdown_gost-0.2.0/src/markdown_gost/__init__.py +5 -0
- markdown_gost-0.2.0/src/markdown_gost/cli/__init__.py +0 -0
- markdown_gost-0.2.0/src/markdown_gost/cli/__main__.py +191 -0
- markdown_gost-0.2.0/src/markdown_gost/cli/commands/__init__.py +0 -0
- markdown_gost-0.2.0/src/markdown_gost/cli/commands/import_cmd.py +134 -0
- markdown_gost-0.2.0/src/markdown_gost/config/__init__.py +0 -0
- markdown_gost-0.2.0/src/markdown_gost/config/errors.py +8 -0
- markdown_gost-0.2.0/src/markdown_gost/config/loader.py +59 -0
- markdown_gost-0.2.0/src/markdown_gost/config/presets/__init__.py +0 -0
- markdown_gost-0.2.0/src/markdown_gost/config/presets/default.yaml +94 -0
- markdown_gost-0.2.0/src/markdown_gost/config/presets/gost-7-32-2017.yaml +124 -0
- markdown_gost-0.2.0/src/markdown_gost/config/presets/mirea-practice.yaml +124 -0
- markdown_gost-0.2.0/src/markdown_gost/config/schema.py +688 -0
- markdown_gost-0.2.0/src/markdown_gost/config/units.py +61 -0
- markdown_gost-0.2.0/src/markdown_gost/convert.py +84 -0
- markdown_gost-0.2.0/src/markdown_gost/core/__init__.py +0 -0
- markdown_gost-0.2.0/src/markdown_gost/core/ast/__init__.py +77 -0
- markdown_gost-0.2.0/src/markdown_gost/core/ast/nodes.py +253 -0
- markdown_gost-0.2.0/src/markdown_gost/core/parser/__init__.py +358 -0
- markdown_gost-0.2.0/src/markdown_gost/core/parser/_marko_ext.py +155 -0
- markdown_gost-0.2.0/src/markdown_gost/core/parser/attrs.py +191 -0
- markdown_gost-0.2.0/src/markdown_gost/image_placeholder.py +78 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/__init__.py +21 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/image_handler.py +82 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/listing_detector.py +92 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/metrics.py +36 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/pandoc_runner.py +97 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/pdf_prepass.py +164 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/pipeline.py +345 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/README.md +56 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/__init__.py +55 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/_common.py +18 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/caption_folder.py +212 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/heading_normalizer.py +39 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/html_table_converter.py +348 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/inline_normalizer.py +50 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/listing_wrapper.py +130 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/postprocessor/unnumbered_heading_detector.py +57 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/result.py +61 -0
- markdown_gost-0.2.0/src/markdown_gost/import_/semantic_docx.py +72 -0
- markdown_gost-0.2.0/src/markdown_gost/metrics_compat.py +38 -0
- markdown_gost-0.2.0/src/markdown_gost/output/__init__.py +0 -0
- markdown_gost-0.2.0/src/markdown_gost/output/pdf_writer.py +183 -0
- markdown_gost-0.2.0/src/markdown_gost/preview/__init__.py +34 -0
- markdown_gost-0.2.0/src/markdown_gost/preview/builder.py +2316 -0
- markdown_gost-0.2.0/src/markdown_gost/preview/html.py +1350 -0
- markdown_gost-0.2.0/src/markdown_gost/preview/model.py +298 -0
- markdown_gost-0.2.0/src/markdown_gost/preview_json.py +59 -0
- markdown_gost-0.2.0/src/markdown_gost/render/__init__.py +14 -0
- markdown_gost-0.2.0/src/markdown_gost/render/_assets/__init__.py +0 -0
- markdown_gost-0.2.0/src/markdown_gost/render/_assets/mml2omml.xsl +3822 -0
- markdown_gost-0.2.0/src/markdown_gost/render/bibliography.py +202 -0
- markdown_gost-0.2.0/src/markdown_gost/render/document_factory.py +140 -0
- markdown_gost-0.2.0/src/markdown_gost/render/latex_math.py +235 -0
- markdown_gost-0.2.0/src/markdown_gost/render/layout_tracker.py +65 -0
- markdown_gost-0.2.0/src/markdown_gost/render/numberer.py +102 -0
- markdown_gost-0.2.0/src/markdown_gost/render/paragraph_sizer.py +395 -0
- markdown_gost-0.2.0/src/markdown_gost/render/references.py +91 -0
- markdown_gost-0.2.0/src/markdown_gost/render/render_index.py +126 -0
- markdown_gost-0.2.0/src/markdown_gost/render/renderer.py +358 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/__init__.py +25 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/_oxml.py +40 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/appendix.py +109 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/base.py +84 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/bibliography.py +58 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/caption.py +150 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/equation.py +358 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/factory.py +238 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/heading.py +247 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/image.py +241 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/list.py +306 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/listing.py +514 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/page_break.py +47 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/paragraph.py +543 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/table.py +797 -0
- markdown_gost-0.2.0/src/markdown_gost/renderable/thematic_break.py +18 -0
- markdown_gost-0.2.0/src/markdown_gost/storage/__init__.py +46 -0
- markdown_gost-0.2.0/src/markdown_gost/storage/base.py +68 -0
- markdown_gost-0.2.0/src/markdown_gost/storage/fs.py +96 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/__init__.py +202 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/_placeholder.py +41 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/_preview.py +93 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/_registry.py +128 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/_schema.py +184 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/base.docx +0 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/content/__init__.py +19 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/content/preview.py +77 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/content/render.py +241 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/content/schema.yaml +30 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/titlepage_university/__init__.py +28 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/titlepage_university/preview.py +108 -0
- markdown_gost-0.2.0/src/markdown_gost/templates/titlepage_university/render.py +407 -0
- 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"]
|
|
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()
|
|
File without changes
|
|
@@ -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"]
|
|
File without changes
|
|
@@ -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"))
|
|
File without changes
|