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 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
+ )