dd-format 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 digital-duck
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,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: dd-format
3
+ Version: 0.1.0
4
+ Summary: Abstraction layer for formatting documents from markdown to HTML, PDF, and DOCX.
5
+ Author-email: Digital Duck & Dog Team <info@digitalduck.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/digital-duck/dd-format
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: fpdf2>=2.7
14
+ Requires-Dist: click>=8.0
15
+ Provides-Extra: docx
16
+ Requires-Dist: python-docx>=1.0; extra == "docx"
17
+ Provides-Extra: all
18
+ Requires-Dist: python-docx>=1.0; extra == "all"
19
+ Dynamic: license-file
20
+
21
+ # dd-format
22
+ Abstraction layer for formatting document from markdown to others such as html, pdf
@@ -0,0 +1,2 @@
1
+ # dd-format
2
+ Abstraction layer for formatting document from markdown to others such as html, pdf
@@ -0,0 +1,13 @@
1
+ """dd-format: convert markdown to PDF, DOCX, and HTML."""
2
+
3
+ from dd_format.pdf_writer import markdown_to_pdf
4
+ from dd_format.html_writer import markdown_to_html
5
+
6
+ __all__ = ["markdown_to_pdf", "markdown_to_html", "markdown_to_docx"]
7
+ __version__ = "0.1.0"
8
+
9
+
10
+ def markdown_to_docx(md_text: str, output_path: str, title: str = ""):
11
+ """Lazy import — requires ``pip install dd-format[docx]``."""
12
+ from dd_format.docx_writer import markdown_to_docx as _impl
13
+ return _impl(md_text, output_path, title=title)
@@ -0,0 +1,68 @@
1
+ """dd-format CLI: convert markdown files to PDF, DOCX, or HTML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+
11
+ @click.command()
12
+ @click.argument("source", type=click.Path(exists=True))
13
+ @click.option(
14
+ "--format",
15
+ "out_fmt",
16
+ type=click.Choice(["pdf", "html", "docx"]),
17
+ default="pdf",
18
+ show_default=True,
19
+ help="Output format.",
20
+ )
21
+ @click.option(
22
+ "--out",
23
+ default="",
24
+ help="Output file path (default: <source>.<format>).",
25
+ )
26
+ @click.option(
27
+ "--title",
28
+ default="",
29
+ help="Document title.",
30
+ )
31
+ def main(source: str, out_fmt: str, out: str, title: str) -> None:
32
+ """Convert a markdown file to PDF, DOCX, or HTML.
33
+
34
+ \b
35
+ Examples:
36
+ dd-format notes.md
37
+ dd-format notes.md --format html --out notes.html
38
+ dd-format notes.md --format docx --title "My Notes"
39
+ """
40
+ src = Path(source)
41
+ md_text = src.read_text(encoding="utf-8")
42
+
43
+ if not out:
44
+ out = str(src.with_suffix(f".{out_fmt}"))
45
+
46
+ if out_fmt == "pdf":
47
+ from dd_format.pdf_writer import markdown_to_pdf
48
+ markdown_to_pdf(md_text, out, title=title)
49
+ elif out_fmt == "html":
50
+ from dd_format.html_writer import markdown_to_html
51
+ markdown_to_html(md_text, out, title=title)
52
+ elif out_fmt == "docx":
53
+ try:
54
+ from dd_format.docx_writer import markdown_to_docx
55
+ except ImportError:
56
+ click.echo(
57
+ "Error: python-docx is required for DOCX output.\n"
58
+ "Install it with: pip install dd-format[docx]",
59
+ err=True,
60
+ )
61
+ sys.exit(1)
62
+ markdown_to_docx(md_text, out, title=title)
63
+
64
+ click.echo(f"Written: {out}")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
@@ -0,0 +1,75 @@
1
+ """Convert markdown text to a .docx file using python-docx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+
9
+ def markdown_to_docx(md_text: str, output_path: str, title: str = "") -> Path:
10
+ """Write *md_text* (simple markdown) to a Word document at *output_path*.
11
+
12
+ Supports: headings (# / ## / ###), bullet lists (- / *),
13
+ **bold**, and ``code`` spans.
14
+
15
+ Requires ``pip install dd-format[docx]``.
16
+
17
+ Parameters
18
+ ----------
19
+ md_text : str
20
+ Markdown-formatted text.
21
+ output_path : str
22
+ Destination file path.
23
+ title : str, optional
24
+ Document title rendered as Heading 0.
25
+
26
+ Returns
27
+ -------
28
+ Path
29
+ The output file path.
30
+ """
31
+ from docx import Document # type: ignore
32
+
33
+ doc = Document()
34
+ if title:
35
+ doc.add_heading(title, level=0)
36
+
37
+ for line in md_text.split("\n"):
38
+ stripped = line.strip()
39
+ if not stripped:
40
+ doc.add_paragraph("")
41
+ continue
42
+
43
+ # Headings
44
+ if stripped.startswith("### "):
45
+ doc.add_heading(stripped[4:], level=3)
46
+ elif stripped.startswith("## "):
47
+ doc.add_heading(stripped[3:], level=2)
48
+ elif stripped.startswith("# "):
49
+ doc.add_heading(stripped[2:], level=1)
50
+ elif stripped.startswith("- ") or stripped.startswith("* "):
51
+ p = doc.add_paragraph(style="List Bullet")
52
+ _add_runs(p, stripped[2:])
53
+ else:
54
+ p = doc.add_paragraph()
55
+ _add_runs(p, stripped)
56
+
57
+ out = Path(output_path)
58
+ doc.save(str(out))
59
+ return out
60
+
61
+
62
+ def _add_runs(paragraph, text: str) -> None:
63
+ """Add runs to a paragraph, handling **bold** and `code` spans."""
64
+ pos = 0
65
+ for m in re.finditer(r"\*\*(.+?)\*\*|`([^`]+)`", text):
66
+ if m.start() > pos:
67
+ paragraph.add_run(text[pos : m.start()])
68
+ if m.group(1) is not None:
69
+ paragraph.add_run(m.group(1)).bold = True
70
+ else:
71
+ run = paragraph.add_run(m.group(2))
72
+ run.font.name = "Courier New"
73
+ pos = m.end()
74
+ if pos < len(text):
75
+ paragraph.add_run(text[pos:])
@@ -0,0 +1,163 @@
1
+ """Convert markdown text to a self-contained HTML file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ from pathlib import Path
7
+
8
+ _TEMPLATE = """\
9
+ <!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
+ <title>{title}</title>
15
+ <style>
16
+ :root {{
17
+ --bg: #0f1117; --card: #1a1d27; --accent: #4f8ef7;
18
+ --text: #e0e0e0; --sub: #9ca3af; --border: #2d3148;
19
+ }}
20
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
21
+ body {{
22
+ background: var(--bg); color: var(--text);
23
+ font-family: 'Segoe UI', system-ui, sans-serif;
24
+ padding: 2rem; max-width: 800px; margin: 0 auto;
25
+ line-height: 1.7;
26
+ }}
27
+ h1 {{ font-size: 1.8rem; color: var(--accent); margin: 1.5rem 0 0.5rem; }}
28
+ h2 {{ font-size: 1.4rem; color: var(--accent); margin: 1.2rem 0 0.4rem; }}
29
+ h3 {{ font-size: 1.1rem; color: var(--accent); margin: 1rem 0 0.3rem; }}
30
+ p {{ margin: 0.5rem 0; }}
31
+ ul {{ padding-left: 1.5rem; margin: 0.5rem 0; }}
32
+ li {{ margin: 0.2rem 0; }}
33
+ pre {{
34
+ background: var(--card); border: 1px solid var(--border);
35
+ border-radius: 8px; padding: 1rem; overflow-x: auto;
36
+ font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 0.88rem;
37
+ margin: 0.8rem 0;
38
+ }}
39
+ code {{
40
+ background: var(--card); padding: 0.15rem 0.4rem; border-radius: 4px;
41
+ font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 0.88rem;
42
+ }}
43
+ strong {{ color: #fff; }}
44
+ hr {{ border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }}
45
+ </style>
46
+ </head>
47
+ <body>
48
+ {body}
49
+ </body>
50
+ </html>"""
51
+
52
+
53
+ def markdown_to_html(md_text: str, output_path: str, title: str = "") -> Path:
54
+ """Convert simple markdown to a dark-mode, self-contained HTML file.
55
+
56
+ Supports: headings, bold, code spans, fenced code blocks,
57
+ bullet lists, horizontal rules, and paragraphs.
58
+
59
+ Parameters
60
+ ----------
61
+ md_text : str
62
+ Markdown-formatted text.
63
+ output_path : str
64
+ Destination file path.
65
+ title : str, optional
66
+ HTML ``<title>`` value (defaults to "Document").
67
+
68
+ Returns
69
+ -------
70
+ Path
71
+ The output file path.
72
+ """
73
+ lines = md_text.split("\n")
74
+ out: list[str] = []
75
+ in_code = False
76
+ in_list = False
77
+
78
+ for line in lines:
79
+ stripped = line.strip()
80
+
81
+ # Fenced code blocks
82
+ if stripped.startswith("```"):
83
+ if in_code:
84
+ out.append("</pre>")
85
+ else:
86
+ if in_list:
87
+ out.append("</ul>")
88
+ in_list = False
89
+ out.append("<pre>")
90
+ in_code = not in_code
91
+ continue
92
+ if in_code:
93
+ out.append(html.escape(line))
94
+ continue
95
+
96
+ if not stripped:
97
+ if in_list:
98
+ out.append("</ul>")
99
+ in_list = False
100
+ continue
101
+
102
+ # Horizontal rule
103
+ if stripped in ("---", "***", "___"):
104
+ if in_list:
105
+ out.append("</ul>")
106
+ in_list = False
107
+ out.append("<hr>")
108
+ continue
109
+
110
+ # Headings
111
+ if stripped.startswith("### "):
112
+ if in_list:
113
+ out.append("</ul>")
114
+ in_list = False
115
+ out.append(f"<h3>{_inline(stripped[4:])}</h3>")
116
+ elif stripped.startswith("## "):
117
+ if in_list:
118
+ out.append("</ul>")
119
+ in_list = False
120
+ out.append(f"<h2>{_inline(stripped[3:])}</h2>")
121
+ elif stripped.startswith("# "):
122
+ if in_list:
123
+ out.append("</ul>")
124
+ in_list = False
125
+ out.append(f"<h1>{_inline(stripped[2:])}</h1>")
126
+ # Bullet lists
127
+ elif stripped.startswith("- ") or stripped.startswith("* "):
128
+ if not in_list:
129
+ out.append("<ul>")
130
+ in_list = True
131
+ out.append(f"<li>{_inline(stripped[2:])}</li>")
132
+ # Paragraphs
133
+ else:
134
+ if in_list:
135
+ out.append("</ul>")
136
+ in_list = False
137
+ out.append(f"<p>{_inline(stripped)}</p>")
138
+
139
+ if in_list:
140
+ out.append("</ul>")
141
+ if in_code:
142
+ out.append("</pre>")
143
+
144
+ body = "\n".join(out)
145
+ doc_title = html.escape(title or "Document")
146
+ result = _TEMPLATE.format(title=doc_title, body=body)
147
+
148
+ p = Path(output_path)
149
+ p.write_text(result, encoding="utf-8")
150
+ return p
151
+
152
+
153
+ def _inline(text: str) -> str:
154
+ """Process inline markdown: **bold**, `code`, and escape HTML."""
155
+ import re
156
+
157
+ # Escape HTML first, then apply markdown formatting
158
+ text = html.escape(text)
159
+ # Bold
160
+ text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
161
+ # Code spans
162
+ text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
163
+ return text
@@ -0,0 +1,85 @@
1
+ """Convert markdown text to a PDF file using fpdf2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def markdown_to_pdf(md_text: str, output_path: str, title: str = "") -> Path:
9
+ """Write *md_text* (simple markdown) to a PDF at *output_path*.
10
+
11
+ Supports: headings (# / ## / ###), bullet lists (- / *),
12
+ fenced code blocks, and plain paragraphs.
13
+
14
+ Parameters
15
+ ----------
16
+ md_text : str
17
+ Markdown-formatted text.
18
+ output_path : str
19
+ Destination file path.
20
+ title : str, optional
21
+ Document title rendered at the top.
22
+
23
+ Returns
24
+ -------
25
+ Path
26
+ The output file path.
27
+ """
28
+ from fpdf import FPDF # type: ignore
29
+
30
+ pdf = FPDF()
31
+ pdf.set_auto_page_break(auto=True, margin=15)
32
+ pdf.add_page()
33
+ pdf.set_font("Helvetica", size=11)
34
+
35
+ if title:
36
+ pdf.set_font("Helvetica", "B", 18)
37
+ pdf.cell(0, 12, title, new_x="LMARGIN", new_y="NEXT")
38
+ pdf.ln(4)
39
+
40
+ in_code_block = False
41
+
42
+ def _write_block(text: str, h: float = 6) -> None:
43
+ """Write a multi_cell block, resetting x to left margin afterward."""
44
+ pdf.set_x(pdf.l_margin)
45
+ w = pdf.w - pdf.l_margin - pdf.r_margin
46
+ pdf.multi_cell(w, h, text)
47
+
48
+ for line in md_text.split("\n"):
49
+ stripped = line.strip()
50
+
51
+ # Fenced code blocks
52
+ if stripped.startswith("```"):
53
+ in_code_block = not in_code_block
54
+ continue
55
+ if in_code_block:
56
+ pdf.set_font("Courier", size=9)
57
+ _write_block(line, 5)
58
+ pdf.set_font("Helvetica", size=11)
59
+ continue
60
+
61
+ if not stripped:
62
+ pdf.ln(4)
63
+ continue
64
+
65
+ # Headings
66
+ if stripped.startswith("### "):
67
+ pdf.set_font("Helvetica", "B", 13)
68
+ pdf.cell(0, 8, stripped[4:], new_x="LMARGIN", new_y="NEXT")
69
+ pdf.set_font("Helvetica", size=11)
70
+ elif stripped.startswith("## "):
71
+ pdf.set_font("Helvetica", "B", 15)
72
+ pdf.cell(0, 10, stripped[3:], new_x="LMARGIN", new_y="NEXT")
73
+ pdf.set_font("Helvetica", size=11)
74
+ elif stripped.startswith("# "):
75
+ pdf.set_font("Helvetica", "B", 17)
76
+ pdf.cell(0, 12, stripped[2:], new_x="LMARGIN", new_y="NEXT")
77
+ pdf.set_font("Helvetica", size=11)
78
+ elif stripped.startswith("- ") or stripped.startswith("* "):
79
+ _write_block(f" - {stripped[2:]}")
80
+ else:
81
+ _write_block(stripped)
82
+
83
+ out = Path(output_path)
84
+ pdf.output(str(out))
85
+ return out
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: dd-format
3
+ Version: 0.1.0
4
+ Summary: Abstraction layer for formatting documents from markdown to HTML, PDF, and DOCX.
5
+ Author-email: Digital Duck & Dog Team <info@digitalduck.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/digital-duck/dd-format
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: fpdf2>=2.7
14
+ Requires-Dist: click>=8.0
15
+ Provides-Extra: docx
16
+ Requires-Dist: python-docx>=1.0; extra == "docx"
17
+ Provides-Extra: all
18
+ Requires-Dist: python-docx>=1.0; extra == "all"
19
+ Dynamic: license-file
20
+
21
+ # dd-format
22
+ Abstraction layer for formatting document from markdown to others such as html, pdf
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ dd_format/__init__.py
5
+ dd_format/cli.py
6
+ dd_format/docx_writer.py
7
+ dd_format/html_writer.py
8
+ dd_format/pdf_writer.py
9
+ dd_format.egg-info/PKG-INFO
10
+ dd_format.egg-info/SOURCES.txt
11
+ dd_format.egg-info/dependency_links.txt
12
+ dd_format.egg-info/entry_points.txt
13
+ dd_format.egg-info/requires.txt
14
+ dd_format.egg-info/top_level.txt
15
+ tests/test_format.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dd-format = dd_format.cli:main
@@ -0,0 +1,8 @@
1
+ fpdf2>=2.7
2
+ click>=8.0
3
+
4
+ [all]
5
+ python-docx>=1.0
6
+
7
+ [docx]
8
+ python-docx>=1.0
@@ -0,0 +1 @@
1
+ dd_format
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dd-format"
7
+ version = "0.1.0"
8
+ authors = [
9
+ { name="Digital Duck & Dog Team", email="info@digitalduck.org" },
10
+ ]
11
+ description = "Abstraction layer for formatting documents from markdown to HTML, PDF, and DOCX."
12
+ readme = "README.md"
13
+ requires-python = ">=3.9"
14
+ license = "MIT"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ ]
19
+ dependencies = [
20
+ "fpdf2>=2.7",
21
+ "click>=8.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ docx = ["python-docx>=1.0"]
26
+ all = ["python-docx>=1.0"]
27
+
28
+ [project.urls]
29
+ "Homepage" = "https://github.com/digital-duck/dd-format"
30
+
31
+ [project.scripts]
32
+ dd-format = "dd_format.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["."]
36
+ include = ["dd_format*"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,67 @@
1
+ """Basic tests for dd-format writers."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from dd_format.pdf_writer import markdown_to_pdf
8
+ from dd_format.html_writer import markdown_to_html
9
+
10
+ SAMPLE_MD = """\
11
+ # Test Document
12
+
13
+ ## Section One
14
+
15
+ This is a **bold** paragraph with `inline code`.
16
+
17
+ - Item one
18
+ - Item two
19
+
20
+ ### Subsection
21
+
22
+ ```
23
+ code block here
24
+ ```
25
+
26
+ ---
27
+
28
+ Regular paragraph.
29
+ """
30
+
31
+
32
+ def test_markdown_to_pdf():
33
+ fd, path = tempfile.mkstemp(suffix=".pdf")
34
+ os.close(fd)
35
+ try:
36
+ out = markdown_to_pdf(SAMPLE_MD, path, title="Test")
37
+ assert out.exists()
38
+ assert out.stat().st_size > 0
39
+ finally:
40
+ Path(path).unlink(missing_ok=True)
41
+
42
+
43
+ def test_markdown_to_html():
44
+ fd, path = tempfile.mkstemp(suffix=".html")
45
+ os.close(fd)
46
+ try:
47
+ out = markdown_to_html(SAMPLE_MD, path, title="Test")
48
+ assert out.exists()
49
+ content = out.read_text()
50
+ assert "<h1>" in content
51
+ assert "<strong>bold</strong>" in content
52
+ assert "<code>inline code</code>" in content
53
+ assert "<li>" in content
54
+ assert "<pre>" in content
55
+ finally:
56
+ Path(path).unlink(missing_ok=True)
57
+
58
+
59
+ def test_html_no_title():
60
+ fd, path = tempfile.mkstemp(suffix=".html")
61
+ os.close(fd)
62
+ try:
63
+ out = markdown_to_html("# Hello", path)
64
+ content = out.read_text()
65
+ assert "<title>Document</title>" in content
66
+ finally:
67
+ Path(path).unlink(missing_ok=True)