markdocx 0.1.0__py3-none-any.whl
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.
- markdocx/__init__.py +11 -0
- markdocx/cli.py +116 -0
- markdocx/code_renderer.py +164 -0
- markdocx/core.py +130 -0
- markdocx/docx_builder.py +743 -0
- markdocx/math_renderer.py +476 -0
- markdocx/md_parser.py +61 -0
- markdocx/styles.py +248 -0
- markdocx-0.1.0.dist-info/METADATA +182 -0
- markdocx-0.1.0.dist-info/RECORD +13 -0
- markdocx-0.1.0.dist-info/WHEEL +4 -0
- markdocx-0.1.0.dist-info/entry_points.txt +2 -0
- markdocx-0.1.0.dist-info/licenses/LICENSE +21 -0
markdocx/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
markdocx
|
|
3
|
+
========
|
|
4
|
+
Convert AI-generated Markdown textbooks to polished DOCX
|
|
5
|
+
with native math equations and syntax-highlighted code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from markdocx.core import convert_file, convert_directory
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["convert_file", "convert_directory"]
|
markdocx/cli.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
markdocx — CLI Entry Point
|
|
4
|
+
===========================
|
|
5
|
+
Convert Markdown textbooks to polished DOCX with native
|
|
6
|
+
math equations and syntax-highlighted code.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
markdocx input.md # Convert single file
|
|
10
|
+
markdocx input.md -o output.docx # Specify output path
|
|
11
|
+
markdocx ./docs/ # Convert all .md in directory
|
|
12
|
+
markdocx ./docs/ -r -o ./output/ # Recursive + output dir
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="markdocx",
|
|
23
|
+
description="Convert Markdown textbooks to DOCX "
|
|
24
|
+
"(math equations, code syntax highlighting, tables, images...)",
|
|
25
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
26
|
+
epilog="""
|
|
27
|
+
Examples:
|
|
28
|
+
markdocx chapter1.md
|
|
29
|
+
markdocx chapter1.md -o bai1.docx
|
|
30
|
+
markdocx ./chapters/ -o ./output/ -r
|
|
31
|
+
markdocx ./chapters/ -r -v
|
|
32
|
+
""",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"input",
|
|
37
|
+
help="Markdown file or directory containing .md files to convert",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"-o", "--output",
|
|
41
|
+
help="Output .docx file path (or directory if input is a directory)",
|
|
42
|
+
default=None,
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-r", "--recursive",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Recursively find .md files in subdirectories",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"-v", "--verbose",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Show detailed processing logs",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
# Lazy import to speed up --help
|
|
58
|
+
from markdocx.core import convert_file, convert_directory
|
|
59
|
+
|
|
60
|
+
input_path = os.path.abspath(args.input)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
if os.path.isfile(input_path):
|
|
64
|
+
# ── Convert single file ──
|
|
65
|
+
if not input_path.lower().endswith(".md"):
|
|
66
|
+
print(f"Error: Expected a .md file, got: {input_path}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
output = convert_file(input_path, args.output, verbose=args.verbose)
|
|
70
|
+
print(f"\nDone: {output}")
|
|
71
|
+
|
|
72
|
+
elif os.path.isdir(input_path):
|
|
73
|
+
# ── Convert directory ──
|
|
74
|
+
results = convert_directory(
|
|
75
|
+
input_path,
|
|
76
|
+
output_dir=args.output,
|
|
77
|
+
recursive=args.recursive,
|
|
78
|
+
verbose=args.verbose,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not results:
|
|
82
|
+
print("No .md files found.", file=sys.stderr)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
print("\n" + "=" * 60)
|
|
86
|
+
print("CONVERSION RESULTS")
|
|
87
|
+
print("=" * 60)
|
|
88
|
+
for md_path, docx_path, success, error in results:
|
|
89
|
+
status = "OK" if success else "FAIL"
|
|
90
|
+
name = os.path.basename(md_path)
|
|
91
|
+
if success:
|
|
92
|
+
print(f" [{status}] {name} -> {os.path.basename(docx_path)}")
|
|
93
|
+
else:
|
|
94
|
+
print(f" [{status}] {name} — Error: {error}")
|
|
95
|
+
|
|
96
|
+
success_count = sum(1 for *_, ok, _ in results if ok)
|
|
97
|
+
fail_count = len(results) - success_count
|
|
98
|
+
print(f"\nTotal: {success_count} succeeded, {fail_count} failed")
|
|
99
|
+
|
|
100
|
+
else:
|
|
101
|
+
print(f"Error: Invalid path: {input_path}", file=sys.stderr)
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
except KeyboardInterrupt:
|
|
105
|
+
print("\nCancelled by user.", file=sys.stderr)
|
|
106
|
+
sys.exit(130)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f"\nError: {e}", file=sys.stderr)
|
|
109
|
+
if args.verbose:
|
|
110
|
+
import traceback
|
|
111
|
+
traceback.print_exc()
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code syntax highlighting renderer.
|
|
3
|
+
Sử dụng Pygments để tạo syntax highlighting cho code blocks trong DOCX.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from pygments import lex
|
|
9
|
+
from pygments.lexers import get_lexer_by_name, TextLexer, guess_lexer
|
|
10
|
+
from pygments.token import Token
|
|
11
|
+
|
|
12
|
+
from docx.shared import Pt, RGBColor
|
|
13
|
+
from docx.oxml import OxmlElement
|
|
14
|
+
from docx.oxml.ns import qn
|
|
15
|
+
|
|
16
|
+
from markdocx.styles import (
|
|
17
|
+
FONT_CODE,
|
|
18
|
+
FONT_SIZE_CODE_BLOCK,
|
|
19
|
+
COLOR_CODE_BLOCK_BG,
|
|
20
|
+
COLOR_CODE_BLOCK_BORDER,
|
|
21
|
+
get_syntax_color,
|
|
22
|
+
set_paragraph_shading,
|
|
23
|
+
set_paragraph_borders,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# Language aliases for common names
|
|
29
|
+
LANGUAGE_ALIASES = {
|
|
30
|
+
"py": "python",
|
|
31
|
+
"js": "javascript",
|
|
32
|
+
"ts": "typescript",
|
|
33
|
+
"rb": "ruby",
|
|
34
|
+
"cs": "csharp",
|
|
35
|
+
"c++": "cpp",
|
|
36
|
+
"c#": "csharp",
|
|
37
|
+
"sh": "bash",
|
|
38
|
+
"shell": "bash",
|
|
39
|
+
"yml": "yaml",
|
|
40
|
+
"md": "markdown",
|
|
41
|
+
"tex": "latex",
|
|
42
|
+
"rs": "rust",
|
|
43
|
+
"kt": "kotlin",
|
|
44
|
+
"m": "objectivec",
|
|
45
|
+
"dockerfile": "docker",
|
|
46
|
+
"plaintext": "text",
|
|
47
|
+
"plain": "text",
|
|
48
|
+
"txt": "text",
|
|
49
|
+
"": "text",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_lexer(language):
|
|
54
|
+
"""Get a Pygments lexer for the given language."""
|
|
55
|
+
lang = language.strip().lower() if language else ""
|
|
56
|
+
lang = LANGUAGE_ALIASES.get(lang, lang)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
return get_lexer_by_name(lang, stripall=False)
|
|
60
|
+
except Exception:
|
|
61
|
+
try:
|
|
62
|
+
return get_lexer_by_name("text", stripall=False)
|
|
63
|
+
except Exception:
|
|
64
|
+
return TextLexer()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def tokenize_code(code, language):
|
|
68
|
+
"""
|
|
69
|
+
Tokenize source code using Pygments.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
code: Source code string
|
|
73
|
+
language: Programming language name
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of (token_type, token_value) tuples
|
|
77
|
+
"""
|
|
78
|
+
lexer = get_lexer(language)
|
|
79
|
+
tokens = list(lex(code, lexer))
|
|
80
|
+
return tokens
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def add_code_block_to_doc(doc, code, language=""):
|
|
84
|
+
"""
|
|
85
|
+
Add a syntax-highlighted code block to the document.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
doc: python-docx Document
|
|
89
|
+
code: Source code string
|
|
90
|
+
language: Programming language name
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The created paragraph
|
|
94
|
+
"""
|
|
95
|
+
# Remove trailing newline if present
|
|
96
|
+
code = code.rstrip("\n")
|
|
97
|
+
if not code:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
tokens = tokenize_code(code, language)
|
|
101
|
+
|
|
102
|
+
# Add language label if specified
|
|
103
|
+
lang_display = language.strip() if language else ""
|
|
104
|
+
if lang_display:
|
|
105
|
+
label_para = doc.add_paragraph()
|
|
106
|
+
label_para.paragraph_format.space_after = Pt(0)
|
|
107
|
+
label_para.paragraph_format.space_before = Pt(8)
|
|
108
|
+
label_run = label_para.add_run(f" {lang_display}")
|
|
109
|
+
label_run.font.name = FONT_CODE
|
|
110
|
+
label_run.font.size = Pt(8)
|
|
111
|
+
label_run.font.color.rgb = RGBColor(0x88, 0x88, 0x88)
|
|
112
|
+
label_run.font.italic = True
|
|
113
|
+
# Set East Asian font for the run
|
|
114
|
+
rPr = label_run._element.get_or_add_rPr()
|
|
115
|
+
rFonts = rPr.find(qn("w:rFonts"))
|
|
116
|
+
if rFonts is None:
|
|
117
|
+
rFonts = OxmlElement("w:rFonts")
|
|
118
|
+
rPr.insert(0, rFonts)
|
|
119
|
+
rFonts.set(qn("w:eastAsia"), FONT_CODE)
|
|
120
|
+
|
|
121
|
+
# Create the code paragraph
|
|
122
|
+
para = doc.add_paragraph()
|
|
123
|
+
para.paragraph_format.space_before = Pt(2) if lang_display else Pt(8)
|
|
124
|
+
para.paragraph_format.space_after = Pt(8)
|
|
125
|
+
para.paragraph_format.line_spacing = 1.0
|
|
126
|
+
|
|
127
|
+
# Style: gray background + borders
|
|
128
|
+
set_paragraph_shading(para, COLOR_CODE_BLOCK_BG)
|
|
129
|
+
set_paragraph_borders(para, color=COLOR_CODE_BLOCK_BORDER, size="4", space="6")
|
|
130
|
+
|
|
131
|
+
# Add padding via indentation
|
|
132
|
+
para.paragraph_format.left_indent = Pt(10)
|
|
133
|
+
para.paragraph_format.right_indent = Pt(10)
|
|
134
|
+
|
|
135
|
+
# Render each token with syntax coloring
|
|
136
|
+
for token_type, token_value in tokens:
|
|
137
|
+
if not token_value:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
color = get_syntax_color(token_type)
|
|
141
|
+
|
|
142
|
+
# Split token value by newlines
|
|
143
|
+
parts = token_value.split("\n")
|
|
144
|
+
for idx, part in enumerate(parts):
|
|
145
|
+
if part:
|
|
146
|
+
run = para.add_run(part)
|
|
147
|
+
run.font.name = FONT_CODE
|
|
148
|
+
run.font.size = FONT_SIZE_CODE_BLOCK
|
|
149
|
+
if color:
|
|
150
|
+
run.font.color.rgb = color
|
|
151
|
+
# East Asian font
|
|
152
|
+
rPr = run._element.get_or_add_rPr()
|
|
153
|
+
rFonts = rPr.find(qn("w:rFonts"))
|
|
154
|
+
if rFonts is None:
|
|
155
|
+
rFonts = OxmlElement("w:rFonts")
|
|
156
|
+
rPr.insert(0, rFonts)
|
|
157
|
+
rFonts.set(qn("w:eastAsia"), FONT_CODE)
|
|
158
|
+
|
|
159
|
+
# Add line break between lines (not after last line)
|
|
160
|
+
if idx < len(parts) - 1:
|
|
161
|
+
br_run = para.add_run()
|
|
162
|
+
br_run.add_break()
|
|
163
|
+
|
|
164
|
+
return para
|
markdocx/core.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core conversion orchestrator.
|
|
3
|
+
Điều phối quá trình chuyển đổi từ Markdown sang DOCX.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import glob
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from markdocx.md_parser import parse_markdown
|
|
12
|
+
from markdocx.docx_builder import DocxBuilder
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def convert_file(input_path, output_path=None, verbose=False):
|
|
18
|
+
"""
|
|
19
|
+
Convert a single Markdown file to DOCX.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
input_path: Path to the .md file
|
|
23
|
+
output_path: Path for the output .docx file (auto-generated if None)
|
|
24
|
+
verbose: Enable verbose logging
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Path to the output .docx file
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
FileNotFoundError: If input file doesn't exist
|
|
31
|
+
ValueError: If input is not a .md file
|
|
32
|
+
"""
|
|
33
|
+
_setup_logging(verbose)
|
|
34
|
+
|
|
35
|
+
input_path = os.path.abspath(input_path)
|
|
36
|
+
|
|
37
|
+
if not os.path.exists(input_path):
|
|
38
|
+
raise FileNotFoundError(f"File not found: {input_path}")
|
|
39
|
+
|
|
40
|
+
if not input_path.lower().endswith(".md"):
|
|
41
|
+
raise ValueError(f"Expected a .md file, got: {input_path}")
|
|
42
|
+
|
|
43
|
+
if output_path is None:
|
|
44
|
+
output_path = os.path.splitext(input_path)[0] + ".docx"
|
|
45
|
+
output_path = os.path.abspath(output_path)
|
|
46
|
+
|
|
47
|
+
logger.info(f"Converting: {input_path}")
|
|
48
|
+
start = time.time()
|
|
49
|
+
|
|
50
|
+
# Read markdown
|
|
51
|
+
with open(input_path, "r", encoding="utf-8") as f:
|
|
52
|
+
md_text = f.read()
|
|
53
|
+
|
|
54
|
+
# Parse
|
|
55
|
+
tokens = parse_markdown(md_text)
|
|
56
|
+
logger.info(f" Parsed {len(tokens)} tokens")
|
|
57
|
+
|
|
58
|
+
# Build DOCX
|
|
59
|
+
base_dir = os.path.dirname(input_path)
|
|
60
|
+
builder = DocxBuilder(base_dir=base_dir)
|
|
61
|
+
builder.build(tokens, output_path)
|
|
62
|
+
|
|
63
|
+
elapsed = time.time() - start
|
|
64
|
+
logger.info(f" Done in {elapsed:.2f}s → {output_path}")
|
|
65
|
+
|
|
66
|
+
return output_path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def convert_directory(input_dir, output_dir=None, recursive=False, verbose=False):
|
|
70
|
+
"""
|
|
71
|
+
Convert all Markdown files in a directory to DOCX.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
input_dir: Path to the directory containing .md files
|
|
75
|
+
output_dir: Path for output .docx files (uses input_dir if None)
|
|
76
|
+
recursive: Search for .md files recursively
|
|
77
|
+
verbose: Enable verbose logging
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of (input_path, output_path, success, error) tuples
|
|
81
|
+
"""
|
|
82
|
+
_setup_logging(verbose)
|
|
83
|
+
|
|
84
|
+
input_dir = os.path.abspath(input_dir)
|
|
85
|
+
if not os.path.isdir(input_dir):
|
|
86
|
+
raise NotADirectoryError(f"Not a directory: {input_dir}")
|
|
87
|
+
|
|
88
|
+
if output_dir is None:
|
|
89
|
+
output_dir = input_dir
|
|
90
|
+
output_dir = os.path.abspath(output_dir)
|
|
91
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
# Find markdown files
|
|
94
|
+
pattern = os.path.join(input_dir, "**", "*.md") if recursive else os.path.join(input_dir, "*.md")
|
|
95
|
+
md_files = sorted(glob.glob(pattern, recursive=recursive))
|
|
96
|
+
|
|
97
|
+
if not md_files:
|
|
98
|
+
logger.warning(f"No .md files found in: {input_dir}")
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
logger.info(f"Found {len(md_files)} markdown file(s)")
|
|
102
|
+
|
|
103
|
+
results = []
|
|
104
|
+
for md_file in md_files:
|
|
105
|
+
# Compute output path preserving relative structure
|
|
106
|
+
rel_path = os.path.relpath(md_file, input_dir)
|
|
107
|
+
out_path = os.path.join(output_dir, os.path.splitext(rel_path)[0] + ".docx")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
convert_file(md_file, out_path, verbose=verbose)
|
|
111
|
+
results.append((md_file, out_path, True, None))
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"Failed to convert {md_file}: {e}")
|
|
114
|
+
results.append((md_file, out_path, False, str(e)))
|
|
115
|
+
|
|
116
|
+
# Summary
|
|
117
|
+
success = sum(1 for *_, ok, _ in results if ok)
|
|
118
|
+
failed = len(results) - success
|
|
119
|
+
logger.info(f"\nConversion complete: {success} succeeded, {failed} failed")
|
|
120
|
+
|
|
121
|
+
return results
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _setup_logging(verbose=False):
|
|
125
|
+
"""Configure logging."""
|
|
126
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
127
|
+
logging.basicConfig(
|
|
128
|
+
level=level,
|
|
129
|
+
format="%(levelname)s: %(message)s",
|
|
130
|
+
)
|