simpdf 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.
simpdf/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from .fonts import DEJAVU_FONT_FILES, FontFace, download_dejavu_fonts
2
+ from .renderer import MarkdownPdfRenderer, render_markdown_to_pdf_bytes, render_markdown_to_pdf_file
3
+
4
+ __all__ = [
5
+ "DEJAVU_FONT_FILES",
6
+ "FontFace",
7
+ "MarkdownPdfRenderer",
8
+ "download_dejavu_fonts",
9
+ "render_markdown_to_pdf_bytes",
10
+ "render_markdown_to_pdf_file",
11
+ ]
simpdf/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
simpdf/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .fonts import FontFace, download_dejavu_fonts
8
+ from .renderer import MarkdownPdfRenderer
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(prog="simpdf", description="Render Markdown into PDF with configurable TTF fonts.")
13
+ subparsers = parser.add_subparsers(dest="command", required=True)
14
+
15
+ render_parser = subparsers.add_parser("render", help="Render a Markdown file into PDF.")
16
+ render_parser.add_argument("input_markdown", type=Path)
17
+ render_parser.add_argument("output_pdf", type=Path)
18
+ render_parser.add_argument("--fonts-dir", type=Path, required=True)
19
+ render_parser.add_argument("--family-name", default="DejaVuSans")
20
+ render_parser.add_argument("--font-regular", required=True)
21
+ render_parser.add_argument("--font-bold")
22
+ render_parser.add_argument("--font-italic")
23
+ render_parser.add_argument("--font-bold-italic")
24
+ render_parser.add_argument("--options-file", type=Path, help="Path to a JSON file with formatting options.")
25
+
26
+ download_parser = subparsers.add_parser("download-dejavu", help="Download the DejaVu Sans font files.")
27
+ download_parser.add_argument("target_dir", type=Path)
28
+ download_parser.add_argument("--overwrite", action="store_true")
29
+
30
+ return parser
31
+
32
+
33
+ def main(argv: list[str] | None = None) -> int:
34
+ parser = build_parser()
35
+ args = parser.parse_args(argv)
36
+
37
+ if args.command == "download-dejavu":
38
+ download_dejavu_fonts(args.target_dir, overwrite=args.overwrite)
39
+ return 0
40
+
41
+ options = {}
42
+ if args.options_file:
43
+ options = json.loads(args.options_file.read_text(encoding="utf-8"))
44
+
45
+ renderer = MarkdownPdfRenderer(
46
+ font_directory=args.fonts_dir,
47
+ font_face=FontFace(
48
+ family=args.family_name,
49
+ regular=args.font_regular,
50
+ bold=args.font_bold,
51
+ italic=args.font_italic,
52
+ bold_italic=args.font_bold_italic,
53
+ ),
54
+ formatting_options=options,
55
+ )
56
+ markdown_text = args.input_markdown.read_text(encoding="utf-8")
57
+ renderer.render_to_file(markdown_text, args.output_pdf)
58
+ return 0
simpdf/fonts.py ADDED
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Mapping
6
+ from urllib.request import urlopen
7
+
8
+
9
+ DEJAVU_BASE_URL = "https://github.com/shwars/simpdf/raw/refs/heads/main/fonts"
10
+ DEJAVU_FONT_FILES = (
11
+ "DejaVuSans.ttf",
12
+ "DejaVuSans-Bold.ttf",
13
+ "DejaVuSans-Oblique.ttf",
14
+ "DejaVuSans-BoldOblique.ttf",
15
+ "DejaVuSansCondensed.ttf",
16
+ "DejaVuSansCondensed-Bold.ttf",
17
+ "DejaVuSansCondensed-Oblique.ttf",
18
+ "DejaVuSansCondensed-BoldOblique.ttf",
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FontFace:
24
+ family: str
25
+ regular: str
26
+ bold: str | None = None
27
+ italic: str | None = None
28
+ bold_italic: str | None = None
29
+
30
+ @classmethod
31
+ def dejavu_sans(cls) -> "FontFace":
32
+ return cls(
33
+ family="DejaVuSans",
34
+ regular="DejaVuSans.ttf",
35
+ bold="DejaVuSans-Bold.ttf",
36
+ italic="DejaVuSans-Oblique.ttf",
37
+ bold_italic="DejaVuSans-BoldOblique.ttf",
38
+ )
39
+
40
+
41
+ def coerce_font_face(font_face: FontFace | Mapping[str, str]) -> FontFace:
42
+ if isinstance(font_face, FontFace):
43
+ return font_face
44
+
45
+ try:
46
+ family = font_face["family"]
47
+ regular = font_face["regular"]
48
+ except KeyError as exc:
49
+ raise ValueError(f"Missing required font face field: {exc.args[0]}") from exc
50
+
51
+ return FontFace(
52
+ family=family,
53
+ regular=regular,
54
+ bold=font_face.get("bold"),
55
+ italic=font_face.get("italic"),
56
+ bold_italic=font_face.get("bold_italic"),
57
+ )
58
+
59
+
60
+ def resolve_font_paths(font_directory: str | Path, font_face: FontFace | Mapping[str, str]) -> dict[str, Path]:
61
+ directory = Path(font_directory)
62
+ face = coerce_font_face(font_face)
63
+ if not directory.exists():
64
+ raise FileNotFoundError(f"Font directory does not exist: {directory}")
65
+
66
+ regular = _resolve_path(directory, face.regular)
67
+ bold = _resolve_path(directory, face.bold) if face.bold else regular
68
+ italic = _resolve_path(directory, face.italic) if face.italic else regular
69
+ bold_italic = _resolve_path(directory, face.bold_italic) if face.bold_italic else bold
70
+
71
+ missing = [path for path in (regular, bold, italic, bold_italic) if not path.exists()]
72
+ if missing:
73
+ message = ", ".join(str(path) for path in missing)
74
+ raise FileNotFoundError(f"Missing required font files: {message}")
75
+
76
+ return {
77
+ "regular": regular,
78
+ "bold": bold,
79
+ "italic": italic,
80
+ "bold_italic": bold_italic,
81
+ }
82
+
83
+
84
+ def register_font_family(pdf, font_directory: str | Path, font_face: FontFace | Mapping[str, str]) -> str:
85
+ face = coerce_font_face(font_face)
86
+ resolved = resolve_font_paths(font_directory, face)
87
+ pdf.add_font(face.family, style="", fname=str(resolved["regular"]))
88
+ pdf.add_font(face.family, style="B", fname=str(resolved["bold"]))
89
+ pdf.add_font(face.family, style="I", fname=str(resolved["italic"]))
90
+ pdf.add_font(face.family, style="BI", fname=str(resolved["bold_italic"]))
91
+ return face.family
92
+
93
+
94
+ def download_dejavu_fonts(
95
+ target_dir: str | Path,
96
+ *,
97
+ overwrite: bool = False,
98
+ timeout: int = 30,
99
+ ) -> list[Path]:
100
+ target = Path(target_dir)
101
+ target.mkdir(parents=True, exist_ok=True)
102
+ downloaded: list[Path] = []
103
+
104
+ for filename in DEJAVU_FONT_FILES:
105
+ destination = target / filename
106
+ if destination.exists() and not overwrite:
107
+ downloaded.append(destination)
108
+ continue
109
+
110
+ url = f"{DEJAVU_BASE_URL}/{filename}"
111
+ with urlopen(url, timeout=timeout) as response:
112
+ destination.write_bytes(response.read())
113
+ downloaded.append(destination)
114
+
115
+ return downloaded
116
+
117
+
118
+ def _resolve_path(base_dir: Path, value: str) -> Path:
119
+ path = Path(value)
120
+ if path.is_absolute():
121
+ return path
122
+ return base_dir / path
simpdf/markdown.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from markdown_it import MarkdownIt
6
+ from markdown_it.tree import SyntaxTreeNode
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class InlineFragment:
11
+ text: str
12
+ bold: bool = False
13
+ italic: bool = False
14
+ code: bool = False
15
+ link: str | None = None
16
+
17
+
18
+ def create_markdown_parser() -> MarkdownIt:
19
+ return MarkdownIt("commonmark", {"html": False, "linkify": True}).enable("table").enable("strikethrough")
20
+
21
+
22
+ def parse_markdown_tree(text: str, parser: MarkdownIt | None = None) -> SyntaxTreeNode:
23
+ active_parser = parser or create_markdown_parser()
24
+ return SyntaxTreeNode(active_parser.parse(text or ""))
25
+
26
+
27
+ def inline_fragments_from_node(node: SyntaxTreeNode) -> list[InlineFragment]:
28
+ return _collect_inline_fragments(node, bold=False, italic=False, code=False, link=None)
29
+
30
+
31
+ def plain_text_from_node(node: SyntaxTreeNode) -> str:
32
+ return "".join(fragment.text for fragment in inline_fragments_from_node(node))
33
+
34
+
35
+ def table_rows_from_node(table_node: SyntaxTreeNode) -> tuple[list[str], list[list[str]]]:
36
+ header: list[str] = []
37
+ rows: list[list[str]] = []
38
+
39
+ for child in table_node.children or []:
40
+ if child.type == "thead":
41
+ header = [_cell_text(cell) for row in child.children or [] for cell in row.children or []]
42
+ elif child.type == "tbody":
43
+ for row in child.children or []:
44
+ rows.append([_cell_text(cell) for cell in row.children or []])
45
+
46
+ return header, rows
47
+
48
+
49
+ def _cell_text(cell_node: SyntaxTreeNode) -> str:
50
+ if not cell_node.children:
51
+ return ""
52
+ return plain_text_from_node(cell_node.children[0]).strip()
53
+
54
+
55
+ def _collect_inline_fragments(
56
+ node: SyntaxTreeNode,
57
+ *,
58
+ bold: bool,
59
+ italic: bool,
60
+ code: bool,
61
+ link: str | None,
62
+ ) -> list[InlineFragment]:
63
+ node_type = node.type
64
+ if node_type == "text":
65
+ if not node.content:
66
+ return []
67
+ return [InlineFragment(text=node.content, bold=bold, italic=italic, code=code, link=link)]
68
+
69
+ if node_type in {"softbreak", "hardbreak"}:
70
+ return [InlineFragment(text="\n", bold=bold, italic=italic, code=code, link=link)]
71
+
72
+ if node_type == "code_inline":
73
+ return [InlineFragment(text=node.content, bold=bold, italic=italic, code=True, link=link)]
74
+
75
+ next_bold = bold or node_type == "strong"
76
+ next_italic = italic or node_type == "em"
77
+ next_link = node.attrs.get("href", link) if node_type == "link" else link
78
+
79
+ fragments: list[InlineFragment] = []
80
+ for child in node.children or []:
81
+ fragments.extend(
82
+ _collect_inline_fragments(
83
+ child,
84
+ bold=next_bold,
85
+ italic=next_italic,
86
+ code=code,
87
+ link=next_link,
88
+ )
89
+ )
90
+ return fragments
simpdf/options.py ADDED
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+
5
+
6
+ DEFAULT_FORMATTING_OPTIONS = {
7
+ "page": {
8
+ "format": "A4",
9
+ "orientation": "P",
10
+ "margins": {
11
+ "left": 15,
12
+ "top": 15,
13
+ "right": 15,
14
+ "bottom": 15,
15
+ },
16
+ },
17
+ "text": {
18
+ "font_size": 12,
19
+ "line_height": 1.5,
20
+ "color": (0, 0, 0),
21
+ },
22
+ "headings": {
23
+ "sizes": {
24
+ 1: 22,
25
+ 2: 20,
26
+ 3: 18,
27
+ 4: 16,
28
+ 5: 14,
29
+ 6: 12,
30
+ },
31
+ "line_height": 1.3,
32
+ "spacing_before": {
33
+ 1: 2.0,
34
+ 2: 1.8,
35
+ 3: 1.6,
36
+ 4: 1.4,
37
+ 5: 1.2,
38
+ 6: 1.0,
39
+ },
40
+ "spacing_after": {
41
+ 1: 3.5,
42
+ 2: 3.0,
43
+ 3: 2.5,
44
+ 4: 2.0,
45
+ 5: 1.5,
46
+ 6: 1.5,
47
+ },
48
+ },
49
+ "paragraph": {
50
+ "spacing_after": 3.0,
51
+ },
52
+ "lists": {
53
+ "indent": 7.0,
54
+ "bullet": "\u2022",
55
+ "item_spacing_after": 1.5,
56
+ "block_spacing_after": 1.5,
57
+ },
58
+ "blockquote": {
59
+ "indent": 8.0,
60
+ "bar_width": 0.8,
61
+ "bar_gap": 2.0,
62
+ "text_color": (90, 90, 90),
63
+ "spacing_after": 3.0,
64
+ },
65
+ "table": {
66
+ "font_size": 11,
67
+ "heading_font_size": 12,
68
+ "line_height": 6.0,
69
+ "cell_padding": 1.3,
70
+ "min_col_width": 24.0,
71
+ "spacing_after": 2.5,
72
+ },
73
+ "code_block": {
74
+ "font_size": 10,
75
+ "line_height": 5.5,
76
+ "padding": 2.0,
77
+ "background_color": (245, 245, 245),
78
+ "text_color": (40, 40, 40),
79
+ "spacing_after": 3.0,
80
+ },
81
+ "inline_code": {
82
+ "text_color": (120, 50, 20),
83
+ },
84
+ "links": {
85
+ "color": (0, 102, 204),
86
+ "underline": True,
87
+ },
88
+ "thematic_break": {
89
+ "spacing_before": 2.0,
90
+ "spacing_after": 4.0,
91
+ "width": 0.4,
92
+ "color": (160, 160, 160),
93
+ },
94
+ }
95
+
96
+
97
+ def merge_formatting_options(overrides: dict | None = None) -> dict:
98
+ merged = deepcopy(DEFAULT_FORMATTING_OPTIONS)
99
+ if overrides:
100
+ _deep_update(merged, overrides)
101
+ _normalize_heading_keys(merged.get("headings", {}))
102
+ return merged
103
+
104
+
105
+ def _deep_update(target: dict, updates: dict) -> None:
106
+ for key, value in updates.items():
107
+ if isinstance(value, dict) and isinstance(target.get(key), dict):
108
+ _deep_update(target[key], value)
109
+ continue
110
+ target[key] = value
111
+
112
+
113
+ def _normalize_heading_keys(headings: dict) -> None:
114
+ for key in ("sizes", "spacing_before", "spacing_after"):
115
+ values = headings.get(key)
116
+ if not isinstance(values, dict):
117
+ continue
118
+ normalized = {}
119
+ for subkey, value in values.items():
120
+ try:
121
+ normalized[int(subkey)] = value
122
+ except (TypeError, ValueError):
123
+ normalized[subkey] = value
124
+ headings[key] = normalized
simpdf/pdfgen.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .fonts import FontFace
6
+ from .renderer import MarkdownPdfRenderer
7
+
8
+ _legacy_font_directory: Path | None = None
9
+ _legacy_font_face = FontFace.dejavu_sans()
10
+
11
+
12
+ def set_font_files(fonts_dir):
13
+ global _legacy_font_directory
14
+ _legacy_font_directory = Path(fonts_dir)
15
+
16
+
17
+ def get_font_config():
18
+ if _legacy_font_directory is None:
19
+ return {}
20
+ return {
21
+ "font_directory": str(_legacy_font_directory),
22
+ "family": _legacy_font_face.family,
23
+ "regular": _legacy_font_face.regular,
24
+ "bold": _legacy_font_face.bold,
25
+ "italic": _legacy_font_face.italic,
26
+ "bold_italic": _legacy_font_face.bold_italic,
27
+ }
28
+
29
+
30
+ def render_text_to_pdf_bytes(text, formatting_options=None):
31
+ if _legacy_font_directory is None:
32
+ raise RuntimeError("Fonts are not configured. Call set_font_files(...) first.")
33
+
34
+ renderer = MarkdownPdfRenderer(
35
+ font_directory=_legacy_font_directory,
36
+ font_face=_legacy_font_face,
37
+ formatting_options=formatting_options,
38
+ )
39
+ return renderer.render_to_bytes(text)
simpdf/renderer.py ADDED
@@ -0,0 +1,653 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+ import re
6
+ from typing import Mapping
7
+
8
+ from fpdf import FPDF
9
+
10
+ from .fonts import FontFace, coerce_font_face, register_font_family
11
+ from .markdown import InlineFragment, create_markdown_parser, inline_fragments_from_node, parse_markdown_tree, table_rows_from_node
12
+ from .options import merge_formatting_options
13
+
14
+
15
+ class MarkdownPdfRenderer:
16
+ def __init__(
17
+ self,
18
+ font_directory: str | Path,
19
+ font_face: FontFace | Mapping[str, str],
20
+ formatting_options: dict | None = None,
21
+ ) -> None:
22
+ self.font_directory = Path(font_directory)
23
+ self.font_face = coerce_font_face(font_face)
24
+ self.options = merge_formatting_options(formatting_options)
25
+ self.parser = create_markdown_parser()
26
+
27
+ def render_to_bytes(self, markdown_text: str) -> bytes:
28
+ pdf = self._create_pdf()
29
+ font_family = register_font_family(pdf, self.font_directory, self.font_face)
30
+ self._render_tree(pdf, parse_markdown_tree(markdown_text, self.parser), font_family)
31
+ output = pdf.output()
32
+ if isinstance(output, str):
33
+ return output.encode("latin-1")
34
+ if isinstance(output, bytearray):
35
+ return bytes(output)
36
+ return output
37
+
38
+ def render_to_file(self, markdown_text: str, output_path: str | Path) -> Path:
39
+ destination = Path(output_path)
40
+ destination.parent.mkdir(parents=True, exist_ok=True)
41
+ destination.write_bytes(self.render_to_bytes(markdown_text))
42
+ return destination
43
+
44
+ def _create_pdf(self) -> FPDF:
45
+ page_options = self.options["page"]
46
+ margins = page_options["margins"]
47
+ pdf = FPDF(
48
+ orientation=page_options["orientation"],
49
+ format=page_options["format"],
50
+ )
51
+ pdf.set_margins(margins["left"], margins["top"], margins["right"])
52
+ pdf.set_auto_page_break(auto=True, margin=margins["bottom"])
53
+ pdf.add_page()
54
+ return pdf
55
+
56
+ def _render_tree(self, pdf: FPDF, tree, font_family: str) -> None:
57
+ for child in tree.children or []:
58
+ self._render_block(pdf, child, font_family, list_depth=0)
59
+
60
+ def _render_block(self, pdf: FPDF, node, font_family: str, *, list_depth: int) -> None:
61
+ if node.type == "heading":
62
+ self._render_heading(pdf, node, font_family)
63
+ return
64
+ if node.type == "paragraph":
65
+ self._render_paragraph(pdf, node, font_family)
66
+ return
67
+ if node.type in {"bullet_list", "ordered_list"}:
68
+ self._render_list(pdf, node, font_family, list_depth=list_depth)
69
+ return
70
+ if node.type == "blockquote":
71
+ self._render_blockquote(pdf, node, font_family, list_depth=list_depth)
72
+ return
73
+ if node.type == "table":
74
+ self._render_table(pdf, node, font_family)
75
+ return
76
+ if node.type == "fence":
77
+ self._render_code_block(pdf, node, font_family)
78
+ return
79
+ if node.type == "hr":
80
+ self._render_thematic_break(pdf)
81
+ return
82
+ for child in node.children or []:
83
+ self._render_block(pdf, child, font_family, list_depth=list_depth)
84
+
85
+ def _render_heading(self, pdf: FPDF, node, font_family: str) -> None:
86
+ level = min(6, max(1, int(node.tag[1:])))
87
+ heading_options = self.options["headings"]
88
+ if pdf.get_y() > pdf.t_margin:
89
+ pdf.ln(heading_options["spacing_before"].get(level, 1.5))
90
+ self._write_fragments(
91
+ pdf,
92
+ self._inline_fragments_for_first_child(node),
93
+ font_family=font_family,
94
+ font_size=heading_options["sizes"].get(level, 12),
95
+ line_height_multiplier=heading_options["line_height"],
96
+ force_bold=True,
97
+ )
98
+ pdf.ln(heading_options["spacing_after"].get(level, 2.0))
99
+
100
+ def _render_paragraph(self, pdf: FPDF, node, font_family: str) -> None:
101
+ fragments = self._inline_fragments_for_first_child(node)
102
+ if fragments:
103
+ self._write_fragments(
104
+ pdf,
105
+ fragments,
106
+ font_family=font_family,
107
+ font_size=self.options["text"]["font_size"],
108
+ line_height_multiplier=self.options["text"]["line_height"],
109
+ )
110
+ pdf.ln(self.options["paragraph"]["spacing_after"])
111
+
112
+ def _render_list(self, pdf: FPDF, node, font_family: str, *, list_depth: int) -> None:
113
+ list_options = self.options["lists"]
114
+ start = int(node.attrs.get("start", 1))
115
+ for index, item in enumerate(node.children or []):
116
+ prefix = f"{list_options['bullet']} " if node.type == "bullet_list" else f"{start + index}. "
117
+ self._render_list_item(pdf, item, font_family, prefix=prefix, list_depth=list_depth)
118
+ if node.children:
119
+ pdf.ln(list_options["block_spacing_after"])
120
+
121
+ def _render_list_item(self, pdf: FPDF, node, font_family: str, *, prefix: str, list_depth: int) -> None:
122
+ list_options = self.options["lists"]
123
+ base_left = pdf.l_margin
124
+ indent = list_options["indent"] * (list_depth + 1)
125
+ prefix_width = pdf.get_string_width(prefix) + 1.0
126
+ body_left = base_left + indent + prefix_width
127
+ child_blocks = list(node.children or [])
128
+
129
+ rendered_first = False
130
+ first_block = child_blocks[0] if child_blocks else None
131
+ if first_block and first_block.type == "paragraph":
132
+ self._write_fragments(
133
+ pdf,
134
+ [InlineFragment(prefix)] + self._inline_fragments_for_first_child(first_block),
135
+ font_family=font_family,
136
+ font_size=self.options["text"]["font_size"],
137
+ line_height_multiplier=self.options["text"]["line_height"],
138
+ left_margin=base_left + indent,
139
+ hanging_left=body_left,
140
+ )
141
+ pdf.ln(list_options["item_spacing_after"])
142
+ rendered_first = True
143
+
144
+ with self._temporary_margins(pdf, left=body_left, right=pdf.r_margin):
145
+ for child in child_blocks[1 if rendered_first else 0 :]:
146
+ if not rendered_first:
147
+ self._write_fragments(
148
+ pdf,
149
+ [InlineFragment(prefix)],
150
+ font_family=font_family,
151
+ font_size=self.options["text"]["font_size"],
152
+ line_height_multiplier=self.options["text"]["line_height"],
153
+ left_margin=base_left + indent,
154
+ )
155
+ rendered_first = True
156
+ self._render_block(pdf, child, font_family, list_depth=list_depth + 1)
157
+
158
+ def _render_blockquote(self, pdf: FPDF, node, font_family: str, *, list_depth: int) -> None:
159
+ quote_options = self.options["blockquote"]
160
+ start_page = pdf.page_no()
161
+ start_y = pdf.get_y()
162
+ bar_x = pdf.l_margin
163
+ content_left = pdf.l_margin + quote_options["indent"]
164
+ pdf.set_text_color(*quote_options["text_color"])
165
+ with self._temporary_margins(pdf, left=content_left, right=pdf.r_margin):
166
+ for child in node.children or []:
167
+ self._render_block(pdf, child, font_family, list_depth=list_depth)
168
+ pdf.set_text_color(*self.options["text"]["color"])
169
+ end_y = pdf.get_y()
170
+ if pdf.page_no() == start_page and end_y > start_y:
171
+ pdf.set_draw_color(*quote_options["text_color"])
172
+ pdf.set_line_width(quote_options["bar_width"])
173
+ x = bar_x + (quote_options["bar_gap"] / 2.0)
174
+ pdf.line(x, start_y, x, end_y)
175
+ pdf.set_draw_color(0, 0, 0)
176
+ pdf.ln(quote_options["spacing_after"])
177
+
178
+ def _render_code_block(self, pdf: FPDF, node, font_family: str) -> None:
179
+ code_options = self.options["code_block"]
180
+ font_size = code_options["font_size"]
181
+ line_height = code_options["line_height"]
182
+ padding = code_options["padding"]
183
+ text = self._normalize_for_pdf(node.content.rstrip("\n"))
184
+
185
+ usable_width = pdf.w - pdf.l_margin - pdf.r_margin
186
+ lines = text.splitlines() or [""]
187
+ height = (len(lines) * line_height) + (2 * padding)
188
+ if pdf.get_y() + height > pdf.h - pdf.b_margin:
189
+ pdf.add_page()
190
+
191
+ start_x = pdf.l_margin
192
+ start_y = pdf.get_y()
193
+ pdf.set_fill_color(*code_options["background_color"])
194
+ pdf.rect(start_x, start_y, usable_width, height, style="F")
195
+ pdf.set_xy(start_x + padding, start_y + padding)
196
+ pdf.set_font(font_family, "", font_size)
197
+ pdf.set_text_color(*code_options["text_color"])
198
+ pdf.multi_cell(max(1.0, usable_width - (2 * padding)), line_height, text)
199
+ pdf.set_text_color(*self.options["text"]["color"])
200
+ pdf.set_x(pdf.l_margin)
201
+ pdf.ln(code_options["spacing_after"])
202
+
203
+ def _render_thematic_break(self, pdf: FPDF) -> None:
204
+ rule_options = self.options["thematic_break"]
205
+ pdf.ln(rule_options["spacing_before"])
206
+ y = pdf.get_y()
207
+ pdf.set_draw_color(*rule_options["color"])
208
+ pdf.set_line_width(rule_options["width"])
209
+ pdf.line(pdf.l_margin, y, pdf.w - pdf.r_margin, y)
210
+ pdf.set_draw_color(0, 0, 0)
211
+ pdf.ln(rule_options["spacing_after"])
212
+
213
+ def _render_table(self, pdf: FPDF, node, font_family: str) -> None:
214
+ header, body = table_rows_from_node(node)
215
+ if not header and not body:
216
+ return
217
+
218
+ table_options = self.options["table"]
219
+ col_count = max([len(header)] + [len(row) for row in body] + [1])
220
+ normalized_header = header + [""] * (col_count - len(header))
221
+ normalized_body = [row + [""] * (col_count - len(row)) for row in body]
222
+ usable_width = pdf.w - pdf.l_margin - pdf.r_margin
223
+
224
+ pdf.set_font(font_family, "", table_options["font_size"])
225
+ widths = self._compute_table_col_widths(pdf, [normalized_header] + normalized_body, usable_width, table_options["min_col_width"])
226
+ self._draw_table_header(pdf, font_family, normalized_header, widths)
227
+ pdf.set_font(font_family, "", table_options["font_size"])
228
+ for row in normalized_body:
229
+ expected_height = self._estimate_row_height(pdf, row, widths)
230
+ if pdf.get_y() + expected_height > pdf.h - pdf.b_margin:
231
+ pdf.add_page()
232
+ self._draw_table_header(pdf, font_family, normalized_header, widths)
233
+ pdf.set_font(font_family, "", table_options["font_size"])
234
+ self._draw_table_row(pdf, row, widths)
235
+
236
+ pdf.ln(table_options["spacing_after"])
237
+ pdf.set_x(pdf.l_margin)
238
+
239
+ def _draw_table_header(self, pdf: FPDF, font_family: str, header: list[str], widths: list[float]) -> None:
240
+ table_options = self.options["table"]
241
+ pdf.set_font(font_family, "B", table_options["heading_font_size"])
242
+ expected_height = self._estimate_row_height(pdf, header, widths)
243
+ if pdf.get_y() + expected_height > pdf.h - pdf.b_margin:
244
+ pdf.add_page()
245
+ self._draw_table_row(pdf, header, widths)
246
+
247
+ def _draw_table_row(self, pdf: FPDF, row: list[str], widths: list[float]) -> float:
248
+ table_options = self.options["table"]
249
+ line_height = table_options["line_height"]
250
+ cell_padding = table_options["cell_padding"]
251
+ x0 = pdf.l_margin
252
+ y0 = pdf.get_y()
253
+
254
+ wrapped_per_cell = []
255
+ max_lines = 1
256
+ for index, width in enumerate(widths):
257
+ text = row[index] if index < len(row) else ""
258
+ wrapped = self._wrap_cell_text(pdf, text, max(1.0, width - (2 * cell_padding)))
259
+ wrapped_per_cell.append(wrapped)
260
+ max_lines = max(max_lines, len(wrapped))
261
+
262
+ row_height = max_lines * line_height + (2 * cell_padding)
263
+ x = x0
264
+ for index, width in enumerate(widths):
265
+ pdf.rect(x, y0, width, row_height)
266
+ pdf.set_xy(x + cell_padding, y0 + cell_padding)
267
+ pdf.multi_cell(max(1.0, width - (2 * cell_padding)), line_height, "\n".join(wrapped_per_cell[index]))
268
+ x += width
269
+ pdf.set_xy(x, y0)
270
+
271
+ pdf.set_xy(x0, y0 + row_height)
272
+ return row_height
273
+
274
+ def _estimate_row_height(self, pdf: FPDF, row: list[str], widths: list[float]) -> float:
275
+ table_options = self.options["table"]
276
+ cell_padding = table_options["cell_padding"]
277
+ line_height = table_options["line_height"]
278
+ max_lines = 1
279
+ for index, width in enumerate(widths):
280
+ text = row[index] if index < len(row) else ""
281
+ lines = self._wrap_cell_text(pdf, text, max(1.0, width - (2 * cell_padding)))
282
+ max_lines = max(max_lines, len(lines))
283
+ return max_lines * line_height + (2 * cell_padding)
284
+
285
+ def _compute_table_col_widths(self, pdf: FPDF, rows: list[list[str]], usable_width: float, min_col_width: float) -> list[float]:
286
+ col_count = max((len(row) for row in rows), default=1)
287
+ weights = [self._column_score(pdf, rows, index) for index in range(col_count)]
288
+ total_weight = sum(weights) or 1.0
289
+ widths = [(weight / total_weight) * usable_width for weight in weights]
290
+ widths = [max(min_col_width, width) for width in widths]
291
+ width_sum = sum(widths)
292
+
293
+ if width_sum > usable_width:
294
+ overflow = width_sum - usable_width
295
+ shrinkable = [max(0.0, width - min_col_width) for width in widths]
296
+ shrink_total = sum(shrinkable)
297
+ if shrink_total > 0:
298
+ widths = [
299
+ width - overflow * (shrinkable[index] / shrink_total)
300
+ for index, width in enumerate(widths)
301
+ ]
302
+
303
+ final_sum = sum(widths)
304
+ if final_sum:
305
+ scale = usable_width / final_sum
306
+ widths = [width * scale for width in widths]
307
+ return widths
308
+
309
+ def _column_score(self, pdf: FPDF, rows: list[list[str]], col_index: int) -> float:
310
+ max_cell_width = 0.0
311
+ max_token_width = 0.0
312
+ for row in rows:
313
+ value = self._normalize_for_pdf(row[col_index] if col_index < len(row) else "")
314
+ if not value:
315
+ continue
316
+ max_cell_width = max(max_cell_width, pdf.get_string_width(value))
317
+ for token in value.split():
318
+ max_token_width = max(max_token_width, pdf.get_string_width(token))
319
+ return max(1.0, (max_cell_width * 0.25) + (max_token_width * 0.75))
320
+
321
+ def _wrap_cell_text(self, pdf: FPDF, text: str, max_width: float) -> list[str]:
322
+ value = self._normalize_for_pdf(text)
323
+ if not value:
324
+ return [""]
325
+
326
+ lines: list[str] = []
327
+ for paragraph in value.splitlines():
328
+ if not paragraph.strip():
329
+ lines.append("")
330
+ continue
331
+
332
+ words = paragraph.split()
333
+ current = ""
334
+ for word in words:
335
+ trial = word if not current else f"{current} {word}"
336
+ if pdf.get_string_width(trial) <= max_width:
337
+ current = trial
338
+ continue
339
+
340
+ if current:
341
+ lines.append(current)
342
+ current = ""
343
+
344
+ if pdf.get_string_width(word) <= max_width:
345
+ current = word
346
+ continue
347
+
348
+ chunk = ""
349
+ for character in word:
350
+ trial_chunk = chunk + character
351
+ if pdf.get_string_width(trial_chunk) <= max_width:
352
+ chunk = trial_chunk
353
+ else:
354
+ if chunk:
355
+ lines.append(chunk)
356
+ chunk = character
357
+ current = chunk
358
+
359
+ if current:
360
+ lines.append(current)
361
+
362
+ return lines or [""]
363
+
364
+ def _write_fragments(
365
+ self,
366
+ pdf: FPDF,
367
+ fragments: list[InlineFragment],
368
+ *,
369
+ font_family: str,
370
+ font_size: float,
371
+ line_height_multiplier: float,
372
+ left_margin: float | None = None,
373
+ hanging_left: float | None = None,
374
+ force_bold: bool = False,
375
+ ) -> None:
376
+ line_height = round(font_size * line_height_multiplier / 2.5, 2)
377
+ current_left = left_margin if left_margin is not None else pdf.l_margin
378
+ current_hanging = hanging_left if hanging_left is not None else current_left
379
+ lines = self._layout_fragments_to_lines(
380
+ pdf,
381
+ fragments,
382
+ font_family=font_family,
383
+ font_size=font_size,
384
+ first_left=current_left,
385
+ rest_left=current_hanging,
386
+ force_bold=force_bold,
387
+ )
388
+
389
+ for index, line in enumerate(lines):
390
+ line_left = current_left if index == 0 else current_hanging
391
+ self._ensure_space_for_line(pdf, line_height)
392
+ pdf.set_xy(line_left, pdf.get_y())
393
+ current_x = line_left
394
+ for fragment in line:
395
+ text = fragment.text
396
+ if not text:
397
+ continue
398
+ style = self._fragment_style(fragment, force_bold=force_bold)
399
+ text_color = self._fragment_text_color(fragment)
400
+ pdf.set_font(font_family, style, font_size)
401
+ pdf.set_text_color(*text_color)
402
+ width = pdf.get_string_width(text)
403
+ pdf.set_xy(current_x, pdf.get_y())
404
+ pdf.cell(width, line_height, text, link=fragment.link or "")
405
+ current_x += width
406
+ pdf.set_xy(line_left, pdf.get_y() + line_height)
407
+ pdf.set_text_color(*self.options["text"]["color"])
408
+
409
+ def _layout_fragments_to_lines(
410
+ self,
411
+ pdf: FPDF,
412
+ fragments: list[InlineFragment],
413
+ *,
414
+ font_family: str,
415
+ font_size: float,
416
+ first_left: float,
417
+ rest_left: float,
418
+ force_bold: bool,
419
+ ) -> list[list[InlineFragment]]:
420
+ lines: list[list[InlineFragment]] = []
421
+ current_line: list[InlineFragment] = []
422
+ current_width = 0.0
423
+ line_index = 0
424
+
425
+ def line_left() -> float:
426
+ return first_left if line_index == 0 else rest_left
427
+
428
+ def available_width() -> float:
429
+ return max(1.0, pdf.w - pdf.r_margin - line_left())
430
+
431
+ def flush_line() -> None:
432
+ nonlocal current_line, current_width, line_index
433
+ trimmed = self._trim_trailing_whitespace(current_line)
434
+ lines.append(trimmed)
435
+ current_line = []
436
+ current_width = 0.0
437
+ line_index += 1
438
+
439
+ for fragment in fragments:
440
+ normalized_text = self._normalize_for_pdf(fragment.text)
441
+ if not normalized_text:
442
+ continue
443
+
444
+ pieces = normalized_text.split("\n")
445
+ for piece_index, piece in enumerate(pieces):
446
+ if piece:
447
+ for token in self._split_fragment_tokens(fragment, piece):
448
+ token = self._drop_leading_whitespace_if_needed(token, current_width)
449
+ if not token.text:
450
+ continue
451
+
452
+ token_width = self._fragment_width(pdf, token, font_family, font_size, force_bold=force_bold)
453
+ if current_width + token_width <= available_width():
454
+ current_line.append(token)
455
+ current_width += token_width
456
+ continue
457
+
458
+ if current_line:
459
+ flush_line()
460
+ token = self._drop_leading_whitespace_if_needed(token, current_width)
461
+ if not token.text:
462
+ continue
463
+ token_width = self._fragment_width(pdf, token, font_family, font_size, force_bold=force_bold)
464
+
465
+ if token_width <= available_width():
466
+ current_line.append(token)
467
+ current_width += token_width
468
+ continue
469
+
470
+ for chunk in self._split_fragment_to_fit(
471
+ pdf,
472
+ token,
473
+ font_family=font_family,
474
+ font_size=font_size,
475
+ max_width=available_width(),
476
+ force_bold=force_bold,
477
+ ):
478
+ chunk = self._drop_leading_whitespace_if_needed(chunk, current_width)
479
+ if not chunk.text:
480
+ continue
481
+ chunk_width = self._fragment_width(pdf, chunk, font_family, font_size, force_bold=force_bold)
482
+ if current_width and current_width + chunk_width > available_width():
483
+ flush_line()
484
+ chunk = self._drop_leading_whitespace_if_needed(chunk, current_width)
485
+ if not chunk.text:
486
+ continue
487
+ chunk_width = self._fragment_width(pdf, chunk, font_family, font_size, force_bold=force_bold)
488
+ current_line.append(chunk)
489
+ current_width += chunk_width
490
+
491
+ if piece_index < len(pieces) - 1:
492
+ flush_line()
493
+
494
+ if current_line or not lines:
495
+ lines.append(self._trim_trailing_whitespace(current_line))
496
+ return lines
497
+
498
+ def _split_fragment_tokens(self, fragment: InlineFragment, text: str) -> list[InlineFragment]:
499
+ tokens = re.findall(r"\S+\s*|\s+", text)
500
+ return [self._fragment_with_text(fragment, token) for token in tokens]
501
+
502
+ def _split_fragment_to_fit(
503
+ self,
504
+ pdf: FPDF,
505
+ fragment: InlineFragment,
506
+ *,
507
+ font_family: str,
508
+ font_size: float,
509
+ max_width: float,
510
+ force_bold: bool,
511
+ ) -> list[InlineFragment]:
512
+ chunks: list[InlineFragment] = []
513
+ current = ""
514
+ for character in fragment.text:
515
+ trial = current + character
516
+ if current and self._fragment_width(
517
+ pdf,
518
+ self._fragment_with_text(fragment, trial),
519
+ font_family,
520
+ font_size,
521
+ force_bold=force_bold,
522
+ ) > max_width:
523
+ chunks.append(self._fragment_with_text(fragment, current))
524
+ current = character
525
+ else:
526
+ current = trial
527
+ if current:
528
+ chunks.append(self._fragment_with_text(fragment, current))
529
+ return chunks or [fragment]
530
+
531
+ def _fragment_width(
532
+ self,
533
+ pdf: FPDF,
534
+ fragment: InlineFragment,
535
+ font_family: str,
536
+ font_size: float,
537
+ *,
538
+ force_bold: bool,
539
+ ) -> float:
540
+ pdf.set_font(font_family, self._fragment_style(fragment, force_bold=force_bold), font_size)
541
+ return pdf.get_string_width(fragment.text)
542
+
543
+ def _fragment_style(self, fragment: InlineFragment, *, force_bold: bool) -> str:
544
+ style_parts = []
545
+ if force_bold or fragment.bold:
546
+ style_parts.append("B")
547
+ if fragment.italic:
548
+ style_parts.append("I")
549
+ if fragment.link and self.options["links"]["underline"]:
550
+ style_parts.append("U")
551
+ return "".join(style_parts)
552
+
553
+ def _fragment_text_color(self, fragment: InlineFragment) -> tuple[int, int, int]:
554
+ if fragment.link:
555
+ return self.options["links"]["color"]
556
+ if fragment.code:
557
+ return self.options["inline_code"]["text_color"]
558
+ return self.options["text"]["color"]
559
+
560
+ def _trim_trailing_whitespace(self, fragments: list[InlineFragment]) -> list[InlineFragment]:
561
+ if not fragments:
562
+ return []
563
+ trimmed = list(fragments)
564
+ while trimmed and not trimmed[-1].text.strip():
565
+ trimmed.pop()
566
+ if trimmed and trimmed[-1].text != trimmed[-1].text.rstrip():
567
+ trimmed[-1] = self._fragment_with_text(trimmed[-1], trimmed[-1].text.rstrip())
568
+ return trimmed
569
+
570
+ def _drop_leading_whitespace_if_needed(self, fragment: InlineFragment, current_width: float) -> InlineFragment:
571
+ if current_width > 0:
572
+ return fragment
573
+ stripped = fragment.text.lstrip()
574
+ if stripped == fragment.text:
575
+ return fragment
576
+ return self._fragment_with_text(fragment, stripped)
577
+
578
+ def _fragment_with_text(self, fragment: InlineFragment, text: str) -> InlineFragment:
579
+ return InlineFragment(
580
+ text=text,
581
+ bold=fragment.bold,
582
+ italic=fragment.italic,
583
+ code=fragment.code,
584
+ link=fragment.link,
585
+ )
586
+
587
+ def _ensure_space_for_line(self, pdf: FPDF, line_height: float) -> None:
588
+ if pdf.get_y() + line_height <= pdf.h - pdf.b_margin:
589
+ return
590
+ pdf.add_page()
591
+
592
+ def _inline_fragments_for_first_child(self, node) -> list[InlineFragment]:
593
+ if not node.children:
594
+ return []
595
+ return inline_fragments_from_node(node.children[0])
596
+
597
+ @contextmanager
598
+ def _temporary_margins(self, pdf: FPDF, *, left: float, right: float):
599
+ previous_left = pdf.l_margin
600
+ previous_right = pdf.r_margin
601
+ previous_x = pdf.get_x()
602
+ pdf.set_left_margin(left)
603
+ pdf.set_right_margin(right)
604
+ if pdf.get_x() < left:
605
+ pdf.set_x(left)
606
+ try:
607
+ yield
608
+ finally:
609
+ pdf.set_left_margin(previous_left)
610
+ pdf.set_right_margin(previous_right)
611
+ pdf.set_x(max(previous_left, previous_x))
612
+
613
+ @staticmethod
614
+ def _normalize_for_pdf(text: str | None) -> str:
615
+ if text is None:
616
+ return ""
617
+ return (
618
+ text.replace("\u00A0", " ")
619
+ .replace("\u202F", " ")
620
+ .replace("\u2011", "-")
621
+ .replace("\u00AD", "")
622
+ )
623
+
624
+
625
+ def render_markdown_to_pdf_bytes(
626
+ markdown_text: str,
627
+ *,
628
+ font_directory: str | Path,
629
+ font_face: FontFace | Mapping[str, str],
630
+ formatting_options: dict | None = None,
631
+ ) -> bytes:
632
+ renderer = MarkdownPdfRenderer(
633
+ font_directory=font_directory,
634
+ font_face=font_face,
635
+ formatting_options=formatting_options,
636
+ )
637
+ return renderer.render_to_bytes(markdown_text)
638
+
639
+
640
+ def render_markdown_to_pdf_file(
641
+ markdown_text: str,
642
+ output_path: str | Path,
643
+ *,
644
+ font_directory: str | Path,
645
+ font_face: FontFace | Mapping[str, str],
646
+ formatting_options: dict | None = None,
647
+ ) -> Path:
648
+ renderer = MarkdownPdfRenderer(
649
+ font_directory=font_directory,
650
+ font_face=font_face,
651
+ formatting_options=formatting_options,
652
+ )
653
+ return renderer.render_to_file(markdown_text, output_path)
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.4
2
+ Name: simpdf
3
+ Version: 0.1.0
4
+ Summary: Markdown-to-PDF rendering with FPDF2 and configurable TTF fonts.
5
+ Author: Dmitry
6
+ Keywords: markdown,pdf,fpdf,cyrillic
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: fpdf2<3,>=2.8.0
17
+ Requires-Dist: markdown-it-py<4,>=3.0.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest<9,>=8.0.0; extra == "dev"
20
+
21
+ # simpdf
22
+
23
+ `simpdf` is a small Python library for rendering Markdown into PDF with `fpdf2`, while keeping font handling explicit so Cyrillic and other non-Latin text work reliably with external TTF fonts.
24
+
25
+ The library is centered on a `MarkdownPdfRenderer` class. You give it a directory with TTF files, describe the font face to register, and then render Markdown into PDF bytes or directly into a file.
26
+
27
+ ## Features
28
+
29
+ - Uses `fpdf2` as the PDF backend
30
+ - Supports external TTF fonts and Cyrillic-friendly fonts such as DejaVu Sans
31
+ - Provides a helper to download DejaVu Sans fonts into a target directory
32
+ - Class-first API with optional convenience helpers
33
+ - Minimal CLI for rendering Markdown and downloading DejaVu fonts
34
+ - Supports these Markdown elements in v1:
35
+ - headings
36
+ - paragraphs
37
+ - bold and italic text
38
+ - inline code
39
+ - ordered and unordered lists
40
+ - tables
41
+ - blockquotes
42
+ - fenced code blocks
43
+ - thematic breaks
44
+ - clickable links
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install simpdf
50
+ ```
51
+
52
+ For development and tests:
53
+
54
+ ```bash
55
+ pip install -e .[dev]
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```python
61
+ from simpdf import FontFace, MarkdownPdfRenderer
62
+
63
+ renderer = MarkdownPdfRenderer(
64
+ font_directory="fonts",
65
+ font_face=FontFace.dejavu_sans(),
66
+ )
67
+
68
+ markdown_text = """
69
+ # Example
70
+
71
+ Привет, мир.
72
+
73
+ - one
74
+ - two
75
+ """
76
+
77
+ pdf_bytes = renderer.render_to_bytes(markdown_text)
78
+
79
+ with open("output.pdf", "wb") as handle:
80
+ handle.write(pdf_bytes)
81
+ ```
82
+
83
+ ## Downloading DejaVu Fonts
84
+
85
+ The library does not bundle fonts inside the wheel. Use the download helper to populate your own font directory.
86
+
87
+ ```python
88
+ from simpdf import download_dejavu_fonts
89
+
90
+ downloaded = download_dejavu_fonts("fonts")
91
+ print([path.name for path in downloaded])
92
+ ```
93
+
94
+ The helper downloads the DejaVu font files from this public GitHub repository layout:
95
+
96
+ - `https://github.com/shwars/simpdf/raw/refs/heads/main/fonts/DejaVuSans.ttf`
97
+ - other font files use the same URL pattern
98
+
99
+ ## Using Custom Fonts
100
+
101
+ You can use any TTF family as long as you provide at least a regular face. Bold, italic, and bold-italic are optional; if omitted, the regular face is reused.
102
+
103
+ ```python
104
+ from simpdf import FontFace, MarkdownPdfRenderer
105
+
106
+ renderer = MarkdownPdfRenderer(
107
+ font_directory="my-fonts",
108
+ font_face=FontFace(
109
+ family="NotoSansCustom",
110
+ regular="NotoSans-Regular.ttf",
111
+ bold="NotoSans-Bold.ttf",
112
+ italic="NotoSans-Italic.ttf",
113
+ bold_italic="NotoSans-BoldItalic.ttf",
114
+ ),
115
+ )
116
+ ```
117
+
118
+ Font file values may be file names relative to `font_directory` or absolute paths.
119
+
120
+ ## Formatting Options
121
+
122
+ Formatting is configured with a plain nested dictionary. Any omitted value falls back to the library defaults.
123
+
124
+ Example:
125
+
126
+ ```python
127
+ from simpdf import FontFace, MarkdownPdfRenderer
128
+
129
+ renderer = MarkdownPdfRenderer(
130
+ font_directory="fonts",
131
+ font_face=FontFace.dejavu_sans(),
132
+ formatting_options={
133
+ "text": {"font_size": 11},
134
+ "headings": {
135
+ "sizes": {1: 26, 2: 20, 3: 16},
136
+ },
137
+ "lists": {"indent": 9},
138
+ "table": {"heading_font_size": 13},
139
+ },
140
+ )
141
+ ```
142
+
143
+ Supported option groups:
144
+
145
+ - `page`: page size, orientation, and margins
146
+ - `text`: base font size, line height multiplier, text color
147
+ - `headings`: per-level sizes and spacing
148
+ - `paragraph`: paragraph spacing
149
+ - `lists`: indent, bullet symbol, list spacing
150
+ - `blockquote`: indent, bar styling, text color
151
+ - `table`: font sizes, padding, minimum column width, spacing
152
+ - `code_block`: font size, padding, colors, spacing
153
+ - `inline_code`: inline code text color
154
+ - `links`: link color and underline toggle
155
+ - `thematic_break`: rule color, width, spacing
156
+
157
+ ## Convenience Helpers
158
+
159
+ If you prefer a functional call site, `simpdf` also exports:
160
+
161
+ - `render_markdown_to_pdf_bytes(...)`
162
+ - `render_markdown_to_pdf_file(...)`
163
+
164
+ These helpers internally construct `MarkdownPdfRenderer`.
165
+
166
+ ## CLI Usage
167
+
168
+ Render Markdown into PDF:
169
+
170
+ ```bash
171
+ simpdf render input.md output.pdf \
172
+ --fonts-dir ./fonts \
173
+ --family-name DejaVuSans \
174
+ --font-regular DejaVuSans.ttf \
175
+ --font-bold DejaVuSans-Bold.ttf \
176
+ --font-italic DejaVuSans-Oblique.ttf \
177
+ --font-bold-italic DejaVuSans-BoldOblique.ttf
178
+ ```
179
+
180
+ Download DejaVu fonts:
181
+
182
+ ```bash
183
+ simpdf download-dejavu ./fonts
184
+ ```
185
+
186
+ If you want custom formatting from the CLI, pass a JSON file with `--options-file`.
187
+
188
+ ## Examples
189
+
190
+ See [`examples/basic_render.py`](/D:/GIT/simpdf/examples/basic_render.py), [`examples/custom_font_and_style.py`](/D:/GIT/simpdf/examples/custom_font_and_style.py), and [`examples/rich_markdown.py`](/D:/GIT/simpdf/examples/rich_markdown.py).
191
+
192
+ ## Tests
193
+
194
+ The repo now contains a pytest suite that covers:
195
+
196
+ - font config validation
197
+ - DejaVu download helper behavior
198
+ - markdown token flattening and table extraction
199
+ - PDF rendering with Cyrillic content
200
+ - formatting overrides
201
+ - CLI render and download flows
202
+
203
+ Run tests with:
204
+
205
+ ```bash
206
+ pytest
207
+ ```
208
+
209
+ ## Compatibility Note
210
+
211
+ For older code that imported `simpdf.pdfgen`, a small compatibility wrapper is still present. The recommended API is the class-based renderer from `simpdf`.
@@ -0,0 +1,13 @@
1
+ simpdf/__init__.py,sha256=5JvaXzFEiLCR3VBBM7Zuu0N3XJ9nuDpmb9-WgMvzjiI,354
2
+ simpdf/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
3
+ simpdf/cli.py,sha256=nDgAIXdwJMFhpbwhj1WLeMijDt1embsXyrHOzaUBYP4,2211
4
+ simpdf/fonts.py,sha256=2BneyRsrPXhau60AZ9mz1vXbKGcl2SmFKAAqORaBn3M,3832
5
+ simpdf/markdown.py,sha256=fPdHOD0LvVRE4EdjSClyODxHsxnxY1fiZ7E5RzNqbgc,2820
6
+ simpdf/options.py,sha256=M9ayPK-D1qjn01CdpIHQ6roYEL5EUloZy0NOHxBijI0,2975
7
+ simpdf/pdfgen.py,sha256=e0rImxgYsv1gsv_jdA0nbjUjDW_1b_6OHQc_bQkGMJI,1121
8
+ simpdf/renderer.py,sha256=uPx7TI7fojcGFx576WVvRBmZhRGYu_sbPoSIYShk7zg,26954
9
+ simpdf-0.1.0.dist-info/METADATA,sha256=TvBnCRoWmtBSV_7xdysG_ltw6PuiDASFu2CkA9ryB3g,5977
10
+ simpdf-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ simpdf-0.1.0.dist-info/entry_points.txt,sha256=M7yPEHw9qsc0L_i2WZiArLaUgyzzQofNNqFRJ6f_iIc,43
12
+ simpdf-0.1.0.dist-info/top_level.txt,sha256=FNWhCQN_0gcVN0M2P3YDYhbFcVLWznmY5ESEy-c_8P4,7
13
+ simpdf-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ simpdf = simpdf.cli:main
@@ -0,0 +1 @@
1
+ simpdf