Skip to content

Commit d357f96

Browse files
Merge pull request steam-bell-92#1093 from nishtha-agarwal-211/feat-markdown-pdf-converter
feat: Add Markdown to PDF Converter utility with visual themes
2 parents f3151b3 + 385b2b7 commit d357f96

5 files changed

Lines changed: 803 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ matplotlib = "3.8.3"
1212
pillow = "12.2.0"
1313
nltk = "3.9.4"
1414
requests = "2.34.2"
15+
markdown2 = "2.5.5"
16+
reportlab = "4.5.1"
1517

1618
[tool.poetry.group.dev.dependencies]
1719
pytest = ">=8.0.0"

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ pytest>=8.0.0
1010

1111
nltk==3.9.4
1212
requests==2.34.2
13+
14+
# Markdown-to-PDF dependencies
15+
markdown2==2.5.5
16+
reportlab==4.5.1

tests/test_markdown_to_pdf.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import os
2+
import sys
3+
import unittest
4+
import tempfile
5+
import importlib.util
6+
from reportlab.platypus import Paragraph, Preformatted, Table
7+
from reportlab.platypus.flowables import HRFlowable
8+
9+
# Dynamically import the markdown_to_pdf script from the hyphenated directory
10+
curr_dir = os.path.dirname(os.path.abspath(__file__))
11+
script_path = os.path.join(curr_dir, "../utilities/Markdown-PDF-Converter/markdown_to_pdf.py")
12+
13+
spec = importlib.util.spec_from_file_location("markdown_to_pdf", script_path)
14+
markdown_to_pdf = importlib.util.module_from_spec(spec)
15+
sys.modules["markdown_to_pdf"] = markdown_to_pdf
16+
spec.loader.exec_module(markdown_to_pdf)
17+
18+
HTMLToFlowablesParser = markdown_to_pdf.HTMLToFlowablesParser
19+
setup_styles = markdown_to_pdf.setup_styles
20+
THEMES = markdown_to_pdf.THEMES
21+
convert_markdown_to_pdf = markdown_to_pdf.convert_markdown_to_pdf
22+
23+
24+
class TestMarkdownToPDF(unittest.TestCase):
25+
def setUp(self):
26+
self.theme = THEMES["Classic"]
27+
self.styles = setup_styles(self.theme)
28+
self.printable_width = 504 # standard letter width minus margins
29+
30+
def test_themes_exist(self):
31+
"""Verify the three core visual themes are defined."""
32+
self.assertIn("Classic", THEMES)
33+
self.assertIn("Modern", THEMES)
34+
self.assertIn("Sleek Dark", THEMES)
35+
36+
def test_styles_setup(self):
37+
"""Verify styles are properly defined and customized."""
38+
styles = setup_styles(self.theme)
39+
self.assertIn("Normal", styles)
40+
self.assertIn("Heading1", styles)
41+
self.assertIn("CustomListItem", styles)
42+
self.assertIn("CodeStyle", styles)
43+
self.assertIn("QuoteParaStyle", styles)
44+
45+
def test_parse_paragraphs_and_headers(self):
46+
"""Verify headers and paragraph parsing."""
47+
html = "<h1>Header 1</h1><p>This is a <b>bold</b> and <i>italic</i> paragraph.</p>"
48+
parser = HTMLToFlowablesParser(self.styles, self.theme, self.printable_width)
49+
parser.feed(html)
50+
51+
story = parser.story
52+
self.assertEqual(len(story), 2)
53+
54+
# Verify Header Flowable
55+
self.assertIsInstance(story[0], Paragraph)
56+
self.assertEqual(story[0].text, "Header 1")
57+
self.assertEqual(story[0].style.name, "Heading1")
58+
59+
# Verify Paragraph Flowable
60+
self.assertIsInstance(story[1], Paragraph)
61+
self.assertEqual(story[1].text, "This is a <b>bold</b> and <i>italic</i> paragraph.")
62+
self.assertEqual(story[1].style.name, "Normal")
63+
64+
def test_parse_lists(self):
65+
"""Verify ordered and unordered lists parsing."""
66+
html = "<ul><li>Item A</li><li>Item B</li></ul>"
67+
parser = HTMLToFlowablesParser(self.styles, self.theme, self.printable_width)
68+
parser.feed(html)
69+
70+
story = parser.story
71+
# 2 list items + 1 spacer after list
72+
self.assertEqual(len(story), 3)
73+
self.assertIsInstance(story[0], Paragraph)
74+
self.assertIn("Item A", story[0].text)
75+
self.assertIn("&bull;", story[0].text)
76+
77+
# Test ordered list
78+
html_ol = "<ol><li>First</li><li>Second</li></ol>"
79+
parser_ol = HTMLToFlowablesParser(self.styles, self.theme, self.printable_width)
80+
parser_ol.feed(html_ol)
81+
82+
story_ol = parser_ol.story
83+
self.assertEqual(len(story_ol), 3)
84+
self.assertIsInstance(story_ol[0], Paragraph)
85+
self.assertIn("1.&nbsp;&nbsp;First", story_ol[0].text)
86+
self.assertIn("2.&nbsp;&nbsp;Second", story_ol[1].text)
87+
88+
def test_parse_blockquote(self):
89+
"""Verify blockquotes convert into wrapped tables with borders."""
90+
html = "<blockquote><p>To be or not to be.</p></blockquote>"
91+
parser = HTMLToFlowablesParser(self.styles, self.theme, self.printable_width)
92+
parser.feed(html)
93+
94+
story = parser.story
95+
# 1 table representing blockquote + 1 spacer after table
96+
self.assertEqual(len(story), 2)
97+
self.assertIsInstance(story[0], Table)
98+
99+
# Extract paragraph inside table cell
100+
cell_flowables = story[0]._cellvalues[0][0]
101+
self.assertEqual(len(cell_flowables), 1)
102+
self.assertIsInstance(cell_flowables[0], Paragraph)
103+
self.assertEqual(cell_flowables[0].text, "To be or not to be.")
104+
self.assertEqual(cell_flowables[0].style.name, "QuoteParaStyle")
105+
106+
def test_parse_code_blocks(self):
107+
"""Verify preformatted code blocks parse correctly."""
108+
html = "<pre><code>def func():\n return 42\n</code></pre>"
109+
parser = HTMLToFlowablesParser(self.styles, self.theme, self.printable_width)
110+
parser.feed(html)
111+
112+
story = parser.story
113+
# 1 table wrapping code block + 1 spacer
114+
self.assertEqual(len(story), 2)
115+
self.assertIsInstance(story[0], Table)
116+
117+
pre = story[0]._cellvalues[0][0]
118+
self.assertIsInstance(pre, Preformatted)
119+
self.assertEqual(pre.lines, ["def func():", " return 42"])
120+
121+
def test_parse_tables(self):
122+
"""Verify table parsing maps rows and headers correctly."""
123+
html = "<table><tr><th>H1</th><th>H2</th></tr><tr><td>C1</td><td>C2</td></tr></table>"
124+
parser = HTMLToFlowablesParser(self.styles, self.theme, self.printable_width)
125+
parser.feed(html)
126+
127+
story = parser.story
128+
# 1 table + 1 spacer
129+
self.assertEqual(len(story), 2)
130+
self.assertIsInstance(story[0], Table)
131+
132+
# Check table contents
133+
cells = story[0]._cellvalues
134+
self.assertEqual(len(cells), 2) # 2 rows
135+
self.assertEqual(len(cells[0]), 2) # 2 cols
136+
137+
# Header cell Paragraph
138+
self.assertIsInstance(cells[0][0], Paragraph)
139+
self.assertEqual(cells[0][0].text, "H1")
140+
self.assertEqual(cells[0][0].style.name, "TableHeaderStyle")
141+
142+
# Body cell Paragraph
143+
self.assertIsInstance(cells[1][0], Paragraph)
144+
self.assertEqual(cells[1][0].text, "C1")
145+
self.assertEqual(cells[1][0].style.name, "TableCellStyle")
146+
147+
def test_parse_horizontal_rules(self):
148+
"""Verify hr maps to HRFlowable."""
149+
html = "<p>Text</p><hr /><p>More text</p>"
150+
parser = HTMLToFlowablesParser(self.styles, self.theme, self.printable_width)
151+
parser.feed(html)
152+
153+
story = parser.story
154+
self.assertEqual(len(story), 3)
155+
self.assertIsInstance(story[0], Paragraph)
156+
self.assertIsInstance(story[1], HRFlowable)
157+
self.assertIsInstance(story[2], Paragraph)
158+
159+
def test_end_to_end_conversion(self):
160+
"""Verify the end to end conversion writes a PDF file correctly."""
161+
md_text = """# Sample Markdown Document
162+
This is some body text with **bold styling** and `inline code`.
163+
164+
## List Section
165+
- Point 1
166+
- Point 2
167+
168+
## Quote Section
169+
> Quote goes here.
170+
171+
## Table Section
172+
| Parameter | Value |
173+
|-----------|-------|
174+
| Speed | Fast |
175+
"""
176+
with tempfile.TemporaryDirectory() as tmpdir:
177+
input_file = os.path.join(tmpdir, "input.md")
178+
output_file = os.path.join(tmpdir, "output.pdf")
179+
180+
with open(input_file, "w", encoding="utf-8") as f:
181+
f.write(md_text)
182+
183+
# Run conversion
184+
convert_markdown_to_pdf(input_file, output_file, "Modern")
185+
186+
self.assertTrue(os.path.exists(output_file))
187+
self.assertGreater(os.path.getsize(output_file), 0)
188+
189+
190+
if __name__ == "__main__":
191+
unittest.main()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Markdown to PDF Converter
2+
3+
A lightweight Python command-line utility that parses a local Markdown file (`.md`) and exports it into a beautifully styled PDF document (`.pdf`).
4+
5+
## Features
6+
7+
- **Standard Markdown Parsing**: Supports headers, paragraphs, bold/italic formatting, inline/block code, blockquotes, lists, tables, and horizontal rules.
8+
- **Custom Visual Themes**: Select from three beautiful, curated themes:
9+
- `Classic` (Default): Traditional serif/sans-serif combination with clean dark-blue accents.
10+
- `Modern`: Modern and clean sans-serif layout with teal/accent coloring and distinct tables/code styling.
11+
- `Sleek Dark`: Premium dark mode layout with deep dark backgrounds and neon cyan/purple accents.
12+
- **Auto-pagination & Styling**: Automatically wraps text in tables and lists, implements running page headers, running footers, and page numbers.
13+
14+
## Requirements
15+
16+
The utility uses two lightweight Python libraries:
17+
- `markdown2` (version 2.5.5) - for Markdown to HTML compilation.
18+
- `reportlab` (version 4.5.1) - for PDF document rendering.
19+
20+
No system binary installations (like `wkhtmltopdf` or `cairo`) are required.
21+
22+
## Installation
23+
24+
Install the required python packages using `pip`:
25+
```bash
26+
pip install markdown2 reportlab
27+
```
28+
Or if using poetry:
29+
```bash
30+
poetry install
31+
```
32+
33+
## Usage
34+
35+
Run the script from the command-line, specifying the path to your Markdown file:
36+
37+
```bash
38+
python utilities/Markdown-PDF-Converter/markdown_to_pdf.py input.md
39+
```
40+
41+
### CLI Arguments
42+
43+
- `input_file` (Required): Path to the input `.md` file.
44+
- `-o`, `--output` (Optional): Path to the output `.pdf` file. Defaults to `<input_file_basename>.pdf`.
45+
- `-t`, `--theme` (Optional): Visual style theme. Choices are `Classic` (default), `Modern`, and `Sleek Dark`.
46+
47+
### Examples
48+
49+
Generate a PDF using the **Modern** theme:
50+
```bash
51+
python utilities/Markdown-PDF-Converter/markdown_to_pdf.py document.md --theme Modern -o output.pdf
52+
```
53+
54+
Generate a PDF using the **Sleek Dark** theme:
55+
```bash
56+
python utilities/Markdown-PDF-Converter/markdown_to_pdf.py document.md --theme "Sleek Dark"
57+
```

0 commit comments

Comments
 (0)