decktile 0.2.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.
Files changed (46) hide show
  1. decktile/__init__.py +5 -0
  2. decktile/__main__.py +3 -0
  3. decktile/assets/lucide/VENDORED.md +21 -0
  4. decktile/assets/lucide/lucide.css +2193 -0
  5. decktile/assets/lucide/lucide.ttf +3 -0
  6. decktile/cli.py +269 -0
  7. decktile/parser/__init__.py +0 -0
  8. decktile/parser/ast.py +191 -0
  9. decktile/parser/markdown.py +289 -0
  10. decktile/plugins/cards.py +25 -0
  11. decktile/plugins/directives.py +56 -0
  12. decktile/plugins/fenced.py +228 -0
  13. decktile/plugins/layouts.py +69 -0
  14. decktile/plugins/lucide.py +65 -0
  15. decktile/plugins/lucide_font.py +26 -0
  16. decktile/render/__init__.py +0 -0
  17. decktile/render/html.py +287 -0
  18. decktile/render/ir.py +536 -0
  19. decktile/render/odf/__init__.py +4 -0
  20. decktile/render/odf/geometry.py +510 -0
  21. decktile/render/odf/shapes.py +719 -0
  22. decktile/render/odf/styles.py +828 -0
  23. decktile/render/odp.py +380 -0
  24. decktile/render/pdf.py +17 -0
  25. decktile/render/pdg.py +72 -0
  26. decktile/render/seaborn.py +155 -0
  27. decktile/render/templates/base.css +741 -0
  28. decktile/render/templates/decktile.js +371 -0
  29. decktile/render/templates/shell.html +106 -0
  30. decktile/seaborn_styles/__init__.py +5 -0
  31. decktile/seaborn_styles/ossummit_europe.py +30 -0
  32. decktile/theme/__init__.py +0 -0
  33. decktile/theme/colors.py +53 -0
  34. decktile/theme/loader.py +22 -0
  35. decktile/theme/parser.py +264 -0
  36. decktile/themes/default.css +146 -0
  37. decktile/themes/kcd_vietnam.css +159 -0
  38. decktile/themes/kubecon_china.css +213 -0
  39. decktile/themes/kubecon_japan.css +156 -0
  40. decktile/themes/ossummit_europe.css +196 -0
  41. decktile/themes/ossummit_korea.css +196 -0
  42. decktile-0.2.0.dist-info/METADATA +625 -0
  43. decktile-0.2.0.dist-info/RECORD +46 -0
  44. decktile-0.2.0.dist-info/WHEEL +4 -0
  45. decktile-0.2.0.dist-info/entry_points.txt +6 -0
  46. decktile-0.2.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,3 @@
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0b98954b05aba6a770306609847f2d2194395ab225288346c7841a4a50ce2300
3
+ size 932460
decktile/cli.py ADDED
@@ -0,0 +1,269 @@
1
+ """decktile CLI entry point."""
2
+
3
+ import glob
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from decktile.parser.markdown import parse
11
+ from decktile.render.html import render as render_html, default_theme, load_theme, base_css
12
+ from decktile.render.ir import assemble_css
13
+ from decktile.render.odp import render as render_odp
14
+ from decktile.render.pdf import render as render_pdf
15
+
16
+ app = typer.Typer(help="Markdown to styled HTML + ODP + PDF", no_args_is_help=True)
17
+
18
+
19
+ @app.callback(invoke_without_command=True)
20
+ def main(
21
+ files: Optional[list[Path]] = typer.Argument(None, help="Markdown files to build; globs like '*.md' or 'talks/**/*.md' are expanded"),
22
+ output_dir: Optional[Path] = typer.Option(None, "-o", "--output-dir", help="Output directory (default: <input>/dist/)"),
23
+ pdf: bool = typer.Option(False, "--pdf", help="Generate PDF"),
24
+ odp: bool = typer.Option(False, "--odp", help="Generate ODP (programmatic)"),
25
+ image_odp: bool = typer.Option(False, "--image-odp", help="Generate ODP (PDF screenshots)"),
26
+ dist: bool = typer.Option(False, "--dist", help="Package HTML + referenced assets into a zip"),
27
+ optimize_images: bool = typer.Option(False, "--optimize-images/--no-optimize-images", help="Optimize images in PDF output (jpeg_quality=85, dpi=150)"),
28
+ watch: bool = typer.Option(False, "-w", "--watch", help="Watch file and rebuild on changes"),
29
+ debug: bool = typer.Option(False, "--debug", help="Dump AST + write debug CSS"),
30
+ css: Optional[Path] = typer.Option(None, "--css", help="Custom CSS theme file (overrides default)"),
31
+ ):
32
+ if not files:
33
+ raise typer.Exit()
34
+ if css and not css.is_file():
35
+ _error(css, "CSS file not found")
36
+ raise typer.Exit(1)
37
+
38
+ paths, missing = _expand(files)
39
+ for f in missing:
40
+ _error(f, "no files match" if any(c in str(f) for c in "*?[")
41
+ else "not a file" if f.exists() else "no such file")
42
+
43
+ if watch:
44
+ _watch(paths, output_dir, pdf, odp, image_odp, debug, css)
45
+ return
46
+
47
+ failed = len(missing)
48
+ for f in paths:
49
+ try:
50
+ _build(f, output_dir, pdf, odp, image_odp, dist, optimize_images, debug, css)
51
+ except Exception as e:
52
+ if debug:
53
+ raise
54
+ _error(f, e)
55
+ failed += 1
56
+ if failed:
57
+ raise typer.Exit(1)
58
+
59
+
60
+ def _expand(args: list[Path]) -> tuple[list[Path], list[Path]]:
61
+ """Expand glob patterns the shell left alone (quoted, or no match);
62
+ keep order, drop duplicates. Returns (existing files, unmatched args).
63
+ Duplicates are by resolved path: output dirs hold symlinks back to the
64
+ source dirs, so '**/*.md' reaches each deck again through every dist/.
65
+ The real (non-symlinked) path wins, so output lands next to it."""
66
+ paths: list[Path] = []
67
+ seen: set[Path] = set()
68
+ missing: list[Path] = []
69
+ for a in args:
70
+ matches = ([Path(m) for m in glob.glob(str(a), recursive=True)]
71
+ if any(c in str(a) for c in "*?[") else [a])
72
+ found = sorted((m for m in matches if m.is_file()),
73
+ key=lambda m: (m.resolve() != m.absolute(), str(m)))
74
+ if not found:
75
+ missing.append(a)
76
+ for m in found:
77
+ if m.resolve() not in seen:
78
+ seen.add(m.resolve())
79
+ paths.append(m)
80
+ return paths, missing
81
+
82
+
83
+ def _error(path: Path, msg: object) -> None:
84
+ typer.echo(f"error: {path}: {msg}", err=True)
85
+
86
+
87
+ def _build(file: Path, output_dir: Optional[Path], pdf: bool,
88
+ odp: bool, image_odp: bool, dist: bool, optimize_images: bool,
89
+ debug: bool, css_path: Optional[Path]) -> None:
90
+ content = file.read_text(encoding="utf-8")
91
+ doc = parse(content)
92
+ dims = doc.meta.dimensions()
93
+
94
+ out_dir = output_dir or file.parent / "dist"
95
+ out_dir.mkdir(parents=True, exist_ok=True)
96
+ _symlink_assets(file.parent, out_dir)
97
+ stem = file.stem
98
+
99
+ if doc.meta.theme and doc.meta.theme != "default":
100
+ theme_css = load_theme(doc.meta.theme)
101
+ else:
102
+ theme_css = default_theme()
103
+ if css_path and css_path.is_file():
104
+ theme_css += "\n" + css_path.read_text(encoding="utf-8")
105
+ theme_css += "\n" + (doc.meta.style or "")
106
+
107
+ # Assemble CSS: base.css (with dims) + theme + logo + pygments
108
+ css = assemble_css(base_css(), theme_css, dims, doc.meta.logo,
109
+ doc.meta.watermark, doc.meta.pygments_style or "default",
110
+ doc.meta.logo_dark, doc.meta.background)
111
+
112
+ html = render_html(doc, theme_css, css, dims, doc.meta.logo)
113
+ html_path = out_dir / f"{stem}.html"
114
+ html_path.write_text(html, encoding="utf-8")
115
+ typer.echo(f"Wrote {html_path} ({len(html)} bytes)")
116
+
117
+ if dist:
118
+ _make_dist(file.parent, out_dir, html_path, html, theme_css, stem)
119
+
120
+ if debug:
121
+ css = base_css().replace("SLIDE_W", str(dims[0])).replace("SLIDE_H", str(dims[1]))
122
+ css = css.replace("THEME_CSS", theme_css)
123
+ css = css.replace("LOGO_CSS", "").replace("{theme_css}", "").replace("{logo_css}", "")
124
+ css_path = out_dir / f"{stem}.css"
125
+ css_path.write_text(css, encoding="utf-8")
126
+ typer.echo(f"Wrote {css_path} (debug CSS, {len(css)} bytes)")
127
+
128
+ if odp:
129
+ odp_path = out_dir / f"{stem}.odp"
130
+ render_odp(doc, odp_path, base_css(), theme_css,
131
+ source_dir=file.parent)
132
+ typer.echo(f"Wrote {odp_path} ({odp_path.stat().st_size} bytes)")
133
+
134
+ if image_odp:
135
+ img_path = out_dir / f"{stem}.odp"
136
+ pdf_path = out_dir / f"{stem}.pdf"
137
+ render_pdf(html_path, pdf_path)
138
+ _pdf_to_odp(pdf_path, img_path, dims=dims)
139
+ typer.echo(f"Wrote {img_path} ({img_path.stat().st_size} bytes)")
140
+ return
141
+
142
+ if pdf:
143
+ pdf_path = out_dir / f"{stem}.pdf"
144
+ render_pdf(html_path, pdf_path, optimize_images=optimize_images)
145
+ typer.echo(f"Wrote {pdf_path} ({pdf_path.stat().st_size} bytes)")
146
+ return
147
+
148
+ typer.echo(f"Parsed {len(doc.slides)} slides")
149
+ if debug:
150
+ for i, slide in enumerate(doc.slides):
151
+ typer.echo(f" Slide {i+1}: {slide.layout} ({len(slide.children)} children)")
152
+ for n in slide.children:
153
+ typer.echo(f" {type(n).__name__}: {_node_summary(n)}")
154
+
155
+
156
+ def _watch(files: list[Path], output_dir: Optional[Path], pdf: bool,
157
+ odp: bool, image_odp: bool, debug: bool, css_path: Optional[Path]) -> None:
158
+ if not files:
159
+ raise typer.Exit(1)
160
+ mtimes: dict[Path, float] = {}
161
+ typer.echo(f"Watching {len(files)} file(s) for changes (Ctrl+C to stop)")
162
+
163
+ while True:
164
+ try:
165
+ for f in files:
166
+ try:
167
+ mtime = f.stat().st_mtime
168
+ except FileNotFoundError:
169
+ continue # mid-save rename or deleted: pick it up when it's back
170
+ if mtimes.get(f) != mtime:
171
+ mtimes[f] = mtime
172
+ typer.echo("---")
173
+ try:
174
+ _build(f, output_dir, pdf, odp, image_odp, False, False, debug, css_path)
175
+ except Exception as e:
176
+ _error(f, e)
177
+ time.sleep(1)
178
+ except KeyboardInterrupt:
179
+ typer.echo("\nStopped watching.")
180
+ break
181
+
182
+
183
+ def _pdf_to_odp(pdf_path: Path, output_path: Path, dims: tuple[int, int]) -> None:
184
+ import os, subprocess, tempfile
185
+ from odfdo import Document, DrawPage, Frame
186
+
187
+ sw, sh = dims
188
+ odp = Document("presentation")
189
+ odp.body.clear()
190
+
191
+ with tempfile.TemporaryDirectory() as tmp:
192
+ subprocess.run(
193
+ ["pdftoppm", "-png", "-r", "150", str(pdf_path), f"{tmp}/page"],
194
+ capture_output=True, text=True, timeout=60,
195
+ )
196
+ for png_file in sorted(Path(tmp).glob("page-*.png")):
197
+ i = int(png_file.stem.split("-")[1]) - 1
198
+ page = DrawPage(f"slide{i + 1}", name=f"Slide {i + 1}")
199
+ uri = odp.add_file(str(png_file))
200
+ frame = Frame.image_frame(
201
+ image=uri, text="",
202
+ size=(f"{sw * 2.54 / 96:.2f}cm", f"{sh * 2.54 / 96:.2f}cm"),
203
+ position=("0cm", "0cm"),
204
+ anchor_type="page",
205
+ )
206
+ page.append(frame)
207
+ odp.body.append(page)
208
+
209
+ odp.save(str(output_path), pretty=True)
210
+
211
+
212
+ def _node_summary(node) -> str:
213
+ from decktile.parser.ast import Heading, Table, Grid, AttrNode
214
+
215
+ if isinstance(node, Heading):
216
+ return f"L{node.level}: {node.content[0].content if node.content else ''}"
217
+ elif isinstance(node, Table):
218
+ return f"({len(node.headers)} cols x {len(node.rows)} rows)"
219
+ elif isinstance(node, Grid):
220
+ return f"cols={node.cols} children={len(node.children)}"
221
+ elif isinstance(node, AttrNode):
222
+ return f"{node.type}: {node.value}"
223
+ return ""
224
+
225
+
226
+ def _symlink_assets(source_dir: Path, dist_dir: Path) -> None:
227
+ """Symlink all directories from source to dist. Skips files and dist itself."""
228
+ for item in source_dir.iterdir():
229
+ if not item.is_dir():
230
+ continue
231
+ if item.name == dist_dir.name:
232
+ continue
233
+ dest = dist_dir / item.name
234
+ if not dest.exists():
235
+ dest.symlink_to(item.resolve())
236
+
237
+
238
+ def _make_dist(source_dir: Path, out_dir: Path, html_path: Path,
239
+ html: str, theme_css: str, stem: str) -> None:
240
+ import re, zipfile
241
+
242
+ zip_path = out_dir / f"{stem}.zip"
243
+ seen = set()
244
+
245
+ with zipfile.ZipFile(str(zip_path), "w", zipfile.ZIP_DEFLATED) as zf:
246
+ zf.write(str(html_path), html_path.name)
247
+
248
+ urls = set()
249
+ for pattern in [r'src="([^"]+)"', r'href="([^"]+)"', r'url\(["\']?([^)"\']+)["\']?\)']:
250
+ for m in re.finditer(pattern, html + theme_css):
251
+ urls.add(m.group(1))
252
+
253
+ for url in sorted(urls):
254
+ if url.startswith(("http:", "https:", "data:", "#")):
255
+ continue
256
+ if url.startswith("/"):
257
+ continue
258
+ asset = source_dir / url
259
+ if not asset.is_file():
260
+ continue
261
+ # Use real path for dedup, resolved symlink for actual file access
262
+ real = asset.resolve()
263
+ if str(real) in seen:
264
+ continue
265
+ seen.add(str(real))
266
+ # Preserve original relative path structure in zip
267
+ zf.write(str(real), url)
268
+
269
+ typer.echo(f"Wrote {zip_path} ({zip_path.stat().st_size} bytes, {len(seen)} assets)")
File without changes
decktile/parser/ast.py ADDED
@@ -0,0 +1,191 @@
1
+ """AST node types for decktile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Optional
8
+
9
+
10
+ class LayoutType(Enum):
11
+ TITLE = "title"
12
+ GRID2 = "grid-2"
13
+ GRID3 = "grid-3"
14
+ GRID4 = "grid-4"
15
+ CONTENT = "content"
16
+
17
+
18
+ @dataclass
19
+ class Meta:
20
+ theme: str = ""
21
+ title: str = ""
22
+ footer: str = ""
23
+ paginate: bool = False
24
+ size: str = "16:9"
25
+ style: str = ""
26
+ logo: str = ""
27
+ logo_dark: str = ""
28
+ watermark: str = ""
29
+ background: str = ""
30
+ pygments_style: str = "default"
31
+ seaborn_theme: str = ""
32
+ theme_variant: str = "light"
33
+ transition: str = ""
34
+ autoplay: int = 7
35
+
36
+ def dimensions(self) -> tuple[int, int]:
37
+ raw = str(self.size)
38
+ if "x" in raw:
39
+ w, h = raw.split("x")
40
+ return (min(max(int(w), 320), 7680), min(max(int(h), 320), 7680))
41
+ return {"16:9": (1280, 720), "4:3": (1024, 768), "16:10": (1280, 800)}.get(raw, (1280, 720))
42
+
43
+ def physical_dims(self) -> tuple[float, float]:
44
+ """Slide dimensions in mm for ODP/print."""
45
+ raw = str(self.size)
46
+ return {"16:9": (280.0, 157.5), "4:3": (280.0, 210.0),
47
+ "16:10": (280.0, 175.0)}.get(raw, (280.0, 157.5))
48
+
49
+
50
+ @dataclass
51
+ class Document:
52
+ meta: Meta
53
+ slides: list["Slide"]
54
+
55
+
56
+ @dataclass
57
+ class Slide:
58
+ layout: str = "content"
59
+ children: list["Node"] = field(default_factory=list)
60
+ notes: str = ""
61
+ variant: str = ""
62
+ transition: str = ""
63
+
64
+
65
+ class Node:
66
+ pass
67
+
68
+
69
+ @dataclass
70
+ class Heading(Node):
71
+ level: int = 1
72
+ content: list["Inline"] = field(default_factory=list)
73
+
74
+
75
+ @dataclass
76
+ class Paragraph(Node):
77
+ content: list["Inline"] = field(default_factory=list)
78
+
79
+
80
+ @dataclass
81
+ class Grid(Node):
82
+ cols: int = 2
83
+ class_: str = ""
84
+ children: list[Node] = field(default_factory=list)
85
+
86
+
87
+ @dataclass
88
+ class Card(Node):
89
+ header: str = ""
90
+ body: list[str] = field(default_factory=list)
91
+ children: list[Node] = field(default_factory=list)
92
+ tag: Optional[str] = None
93
+ class_: str = ""
94
+ label: str = "" # ::: card {label="Q1"}: short name rendered on a
95
+ # timeline layout's spine, next to the milestone dot
96
+
97
+
98
+ @dataclass
99
+ class Arrow(Node):
100
+ """Connector between two cards (arrow icon, label, etc.)."""
101
+ content: str = ""
102
+
103
+
104
+ @dataclass
105
+ class Notes(Node):
106
+ """Full-width conclusion card below a comparison layout."""
107
+ content: str = ""
108
+ tag: Optional[str] = None
109
+
110
+
111
+ @dataclass
112
+ class Row(Node):
113
+ """Horizontal row of elements placed side by side."""
114
+ children: list[Node] = field(default_factory=list)
115
+
116
+
117
+ @dataclass
118
+ class Table(Node):
119
+ headers: list[str] = field(default_factory=list)
120
+ rows: list[list[str]] = field(default_factory=list)
121
+
122
+
123
+ @dataclass
124
+ class Quote(Node):
125
+ content: list["Inline"] = field(default_factory=list)
126
+
127
+
128
+ @dataclass
129
+ class ListNode(Node):
130
+ items: list[list[Inline]] = field(default_factory=list)
131
+
132
+
133
+ @dataclass
134
+ class AttrNode(Node):
135
+ type: str = ""
136
+ value: str = ""
137
+ attrs: dict[str, str] = field(default_factory=dict)
138
+
139
+
140
+ @dataclass
141
+ class CodeBlock(Node):
142
+ content: str = ""
143
+ language: str = ""
144
+
145
+
146
+ @dataclass
147
+ class Inline:
148
+ pass
149
+
150
+
151
+ @dataclass
152
+ class Text(Inline):
153
+ content: str = ""
154
+
155
+
156
+ @dataclass
157
+ class Strong(Inline):
158
+ children: list[Inline] = field(default_factory=list)
159
+
160
+
161
+ @dataclass
162
+ class Emphasis(Inline):
163
+ children: list[Inline] = field(default_factory=list)
164
+
165
+
166
+ @dataclass
167
+ class Strikethrough(Inline):
168
+ children: list[Inline] = field(default_factory=list)
169
+
170
+
171
+ @dataclass
172
+ class CodeSpan(Inline):
173
+ content: str = ""
174
+
175
+
176
+ @dataclass
177
+ class Image(Inline):
178
+ src: str = ""
179
+ alt: str = ""
180
+ title: str = ""
181
+
182
+
183
+ @dataclass
184
+ class SoftBreak(Inline):
185
+ pass
186
+
187
+
188
+ @dataclass
189
+ class LucideIcon(Inline):
190
+ name: str = ""
191
+ attrs: dict[str, str] = field(default_factory=dict)