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 +11 -0
- simpdf/__main__.py +5 -0
- simpdf/cli.py +58 -0
- simpdf/fonts.py +122 -0
- simpdf/markdown.py +90 -0
- simpdf/options.py +124 -0
- simpdf/pdfgen.py +39 -0
- simpdf/renderer.py +653 -0
- simpdf-0.1.0.dist-info/METADATA +211 -0
- simpdf-0.1.0.dist-info/RECORD +13 -0
- simpdf-0.1.0.dist-info/WHEEL +5 -0
- simpdf-0.1.0.dist-info/entry_points.txt +2 -0
- simpdf-0.1.0.dist-info/top_level.txt +1 -0
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
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 @@
|
|
|
1
|
+
simpdf
|