colloquium 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.
- colloquium/__init__.py +8 -0
- colloquium/build.py +174 -0
- colloquium/cli.py +116 -0
- colloquium/deck.py +138 -0
- colloquium/export.py +116 -0
- colloquium/parse.py +131 -0
- colloquium/serve.py +83 -0
- colloquium/slide.py +45 -0
- colloquium/themes/__init__.py +0 -0
- colloquium/themes/default/presentation.js +170 -0
- colloquium/themes/default/theme.css +381 -0
- colloquium-0.1.0.dist-info/METADATA +89 -0
- colloquium-0.1.0.dist-info/RECORD +16 -0
- colloquium-0.1.0.dist-info/WHEEL +4 -0
- colloquium-0.1.0.dist-info/entry_points.txt +2 -0
- colloquium-0.1.0.dist-info/licenses/LICENSE +21 -0
colloquium/__init__.py
ADDED
colloquium/build.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""HTML builder — converts a Deck into a self-contained HTML file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from string import Template
|
|
7
|
+
|
|
8
|
+
from markdown_it import MarkdownIt
|
|
9
|
+
from mdit_py_plugins.dollarmath import dollarmath_plugin
|
|
10
|
+
|
|
11
|
+
from colloquium.deck import Deck
|
|
12
|
+
from colloquium.slide import Slide
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_theme_path(theme: str = "default") -> Path:
|
|
16
|
+
"""Get the path to a theme directory."""
|
|
17
|
+
themes_dir = Path(__file__).parent / "themes" / theme
|
|
18
|
+
if not themes_dir.exists():
|
|
19
|
+
themes_dir = Path(__file__).parent / "themes" / "default"
|
|
20
|
+
return themes_dir
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_theme_css(theme: str = "default") -> str:
|
|
24
|
+
"""Read the theme CSS file."""
|
|
25
|
+
return (_get_theme_path(theme) / "theme.css").read_text(encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read_presentation_js(theme: str = "default") -> str:
|
|
29
|
+
"""Read the presentation JS file."""
|
|
30
|
+
return (_get_theme_path(theme) / "presentation.js").read_text(encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _create_md_renderer() -> MarkdownIt:
|
|
34
|
+
"""Create a markdown-it renderer with math support."""
|
|
35
|
+
md = MarkdownIt("commonmark", {"html": True, "typographer": True})
|
|
36
|
+
dollarmath_plugin(md, double_inline=True)
|
|
37
|
+
return md
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _render_markdown(text: str, md: MarkdownIt) -> str:
|
|
41
|
+
"""Render markdown text to HTML."""
|
|
42
|
+
if not text:
|
|
43
|
+
return ""
|
|
44
|
+
return md.render(text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_slide_html(slide: Slide, index: int, md: MarkdownIt) -> str:
|
|
48
|
+
"""Build the HTML for a single slide."""
|
|
49
|
+
# CSS classes
|
|
50
|
+
classes = ["slide", f"slide--{slide.layout}"]
|
|
51
|
+
if index == 0:
|
|
52
|
+
classes.append("active")
|
|
53
|
+
classes.extend(slide.classes)
|
|
54
|
+
|
|
55
|
+
class_str = " ".join(classes)
|
|
56
|
+
|
|
57
|
+
# Style attribute
|
|
58
|
+
style_attr = f' style="{slide.style}"' if slide.style else ""
|
|
59
|
+
|
|
60
|
+
# Slide content
|
|
61
|
+
parts = []
|
|
62
|
+
if slide.title:
|
|
63
|
+
tag = "h1" if slide.is_title_slide else "h2"
|
|
64
|
+
parts.append(f"<{tag}>{slide.title}</{tag}>")
|
|
65
|
+
|
|
66
|
+
if slide.content:
|
|
67
|
+
rendered = _render_markdown(slide.content, md)
|
|
68
|
+
parts.append(f'<div class="slide-content">{rendered}</div>')
|
|
69
|
+
|
|
70
|
+
inner = "\n".join(parts)
|
|
71
|
+
|
|
72
|
+
return f'<section class="{class_str}"{style_attr} data-index="{index}">\n{inner}\n</section>'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# HTML template — uses $-style substitution
|
|
76
|
+
_HTML_TEMPLATE = Template("""\
|
|
77
|
+
<!DOCTYPE html>
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<meta charset="utf-8">
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
82
|
+
<title>$title</title>
|
|
83
|
+
|
|
84
|
+
<!-- KaTeX for LaTeX math -->
|
|
85
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
|
|
86
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
|
|
87
|
+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>
|
|
88
|
+
|
|
89
|
+
<!-- highlight.js for code syntax highlighting -->
|
|
90
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github.min.css">
|
|
91
|
+
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
|
|
92
|
+
|
|
93
|
+
<style>
|
|
94
|
+
$theme_css
|
|
95
|
+
$custom_css
|
|
96
|
+
</style>
|
|
97
|
+
</head>
|
|
98
|
+
<body>
|
|
99
|
+
<div class="colloquium-deck">
|
|
100
|
+
$slides_html
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="colloquium-nav">
|
|
104
|
+
<span class="colloquium-counter">1 / $total_slides</span>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="colloquium-progress">
|
|
108
|
+
<div class="colloquium-progress-bar" style="width: 0%"></div>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<script>
|
|
112
|
+
$presentation_js
|
|
113
|
+
</script>
|
|
114
|
+
|
|
115
|
+
<script>
|
|
116
|
+
// Initialize KaTeX auto-render after load
|
|
117
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
118
|
+
if (typeof renderMathInElement !== "undefined") {
|
|
119
|
+
renderMathInElement(document.body, {
|
|
120
|
+
delimiters: [
|
|
121
|
+
{left: "$$$$", right: "$$$$", display: true},
|
|
122
|
+
{left: "$$", right: "$$", display: false},
|
|
123
|
+
{left: "\\\\(", right: "\\\\)", display: false},
|
|
124
|
+
{left: "\\\\[", right: "\\\\]", display: true}
|
|
125
|
+
],
|
|
126
|
+
throwOnError: false
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// Initialize highlight.js
|
|
130
|
+
if (typeof hljs !== "undefined") {
|
|
131
|
+
hljs.highlightAll();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
</script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|
|
137
|
+
""")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def build_deck(deck: Deck) -> str:
|
|
141
|
+
"""Build a Deck into a self-contained HTML string."""
|
|
142
|
+
md = _create_md_renderer()
|
|
143
|
+
theme_css = _read_theme_css(deck.theme)
|
|
144
|
+
presentation_js = _read_presentation_js(deck.theme)
|
|
145
|
+
|
|
146
|
+
slides_html_parts = []
|
|
147
|
+
for i, slide in enumerate(deck.slides):
|
|
148
|
+
slides_html_parts.append(_build_slide_html(slide, i, md))
|
|
149
|
+
|
|
150
|
+
slides_html = "\n\n".join(slides_html_parts)
|
|
151
|
+
|
|
152
|
+
return _HTML_TEMPLATE.substitute(
|
|
153
|
+
title=deck.title,
|
|
154
|
+
theme_css=theme_css,
|
|
155
|
+
custom_css=deck.custom_css,
|
|
156
|
+
slides_html=slides_html,
|
|
157
|
+
total_slides=len(deck.slides),
|
|
158
|
+
presentation_js=presentation_js,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_file(input_path: str, output_path: str | None = None) -> str:
|
|
163
|
+
"""Build a markdown file into an HTML file. Returns the output path."""
|
|
164
|
+
from colloquium.parse import parse_file
|
|
165
|
+
|
|
166
|
+
deck = parse_file(input_path)
|
|
167
|
+
html = build_deck(deck)
|
|
168
|
+
|
|
169
|
+
if output_path is None:
|
|
170
|
+
output_path = str(Path(input_path).with_suffix(".html"))
|
|
171
|
+
|
|
172
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
Path(output_path).write_text(html, encoding="utf-8")
|
|
174
|
+
return output_path
|
colloquium/cli.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""CLI entry point for colloquium."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build(args):
|
|
11
|
+
"""Build a markdown file into an HTML presentation."""
|
|
12
|
+
from colloquium.build import build_file
|
|
13
|
+
|
|
14
|
+
input_path = args.file
|
|
15
|
+
if not Path(input_path).exists():
|
|
16
|
+
print(f"Error: {input_path} not found", file=sys.stderr)
|
|
17
|
+
sys.exit(1)
|
|
18
|
+
|
|
19
|
+
if args.output:
|
|
20
|
+
output_dir = args.output
|
|
21
|
+
stem = Path(input_path).stem
|
|
22
|
+
output_path = str(Path(output_dir) / f"{stem}.html")
|
|
23
|
+
else:
|
|
24
|
+
output_path = None # build_file will default to same dir as input
|
|
25
|
+
|
|
26
|
+
result = build_file(input_path, output_path)
|
|
27
|
+
print(f"Built: {result}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _serve(args):
|
|
31
|
+
"""Serve a presentation with live reload."""
|
|
32
|
+
from colloquium.serve import serve
|
|
33
|
+
|
|
34
|
+
input_path = args.file
|
|
35
|
+
if not Path(input_path).exists():
|
|
36
|
+
print(f"Error: {input_path} not found", file=sys.stderr)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
serve(input_path, port=args.port)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _export(args):
|
|
43
|
+
"""Export a presentation to PDF."""
|
|
44
|
+
from colloquium.export import export_pdf
|
|
45
|
+
|
|
46
|
+
input_path = args.file
|
|
47
|
+
if not Path(input_path).exists():
|
|
48
|
+
print(f"Error: {input_path} not found", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
output_path = args.output
|
|
52
|
+
result = export_pdf(input_path, output_path)
|
|
53
|
+
|
|
54
|
+
if result:
|
|
55
|
+
print(f"Exported: {result}")
|
|
56
|
+
else:
|
|
57
|
+
# Build HTML and give instructions
|
|
58
|
+
from colloquium.build import build_file
|
|
59
|
+
|
|
60
|
+
html_path = build_file(input_path)
|
|
61
|
+
print("No compatible browser found for headless PDF export.")
|
|
62
|
+
print(f"HTML built at: {html_path}")
|
|
63
|
+
print(f"Open it in a browser and use Cmd+P / Ctrl+P to print to PDF.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
parser = argparse.ArgumentParser(
|
|
68
|
+
prog="colloquium",
|
|
69
|
+
description="Agent-native slide creation tool for research talks",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--version",
|
|
73
|
+
action="version",
|
|
74
|
+
version=f"%(prog)s {_get_version()}",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
78
|
+
|
|
79
|
+
# build
|
|
80
|
+
build_parser = subparsers.add_parser("build", help="Build markdown to HTML")
|
|
81
|
+
build_parser.add_argument("file", help="Input markdown file")
|
|
82
|
+
build_parser.add_argument("-o", "--output", help="Output directory")
|
|
83
|
+
build_parser.set_defaults(func=_build)
|
|
84
|
+
|
|
85
|
+
# serve
|
|
86
|
+
serve_parser = subparsers.add_parser("serve", help="Dev server with live reload")
|
|
87
|
+
serve_parser.add_argument("file", help="Input markdown file")
|
|
88
|
+
serve_parser.add_argument("-p", "--port", type=int, default=8080, help="Port (default: 8080)")
|
|
89
|
+
serve_parser.set_defaults(func=_serve)
|
|
90
|
+
|
|
91
|
+
# export
|
|
92
|
+
export_parser = subparsers.add_parser("export", help="Export to PDF")
|
|
93
|
+
export_parser.add_argument("file", help="Input markdown file")
|
|
94
|
+
export_parser.add_argument("--pdf", action="store_true", default=True, help="Export as PDF (default)")
|
|
95
|
+
export_parser.add_argument("-o", "--output", help="Output PDF path")
|
|
96
|
+
export_parser.set_defaults(func=_export)
|
|
97
|
+
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
|
|
100
|
+
if not args.command:
|
|
101
|
+
parser.print_help()
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
args.func(args)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _get_version() -> str:
|
|
108
|
+
try:
|
|
109
|
+
from colloquium import __version__
|
|
110
|
+
return __version__
|
|
111
|
+
except ImportError:
|
|
112
|
+
return "unknown"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
colloquium/deck.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Deck class — the agent-facing API for building presentations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from colloquium.slide import Slide
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Deck:
|
|
12
|
+
"""A presentation deck composed of slides."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
title: str = "Untitled",
|
|
17
|
+
author: str = "",
|
|
18
|
+
date: str = "",
|
|
19
|
+
theme: str = "default",
|
|
20
|
+
aspect_ratio: str = "16:9",
|
|
21
|
+
custom_css: str = "",
|
|
22
|
+
):
|
|
23
|
+
self.title = title
|
|
24
|
+
self.author = author
|
|
25
|
+
self.date = date
|
|
26
|
+
self.theme = theme
|
|
27
|
+
self.aspect_ratio = aspect_ratio
|
|
28
|
+
self.custom_css = custom_css
|
|
29
|
+
self.slides: list[Slide] = []
|
|
30
|
+
|
|
31
|
+
def add_slide(
|
|
32
|
+
self,
|
|
33
|
+
title: str = "",
|
|
34
|
+
content: str = "",
|
|
35
|
+
layout: str = "content",
|
|
36
|
+
speaker_notes: str = "",
|
|
37
|
+
classes: list[str] | None = None,
|
|
38
|
+
style: str = "",
|
|
39
|
+
**metadata,
|
|
40
|
+
) -> Slide:
|
|
41
|
+
"""Add a slide to the deck and return it."""
|
|
42
|
+
slide = Slide(
|
|
43
|
+
title=title,
|
|
44
|
+
content=content,
|
|
45
|
+
layout=layout,
|
|
46
|
+
speaker_notes=speaker_notes,
|
|
47
|
+
classes=classes or [],
|
|
48
|
+
style=style,
|
|
49
|
+
metadata=metadata,
|
|
50
|
+
)
|
|
51
|
+
self.slides.append(slide)
|
|
52
|
+
return slide
|
|
53
|
+
|
|
54
|
+
def add_title_slide(
|
|
55
|
+
self,
|
|
56
|
+
title: str = "",
|
|
57
|
+
subtitle: str = "",
|
|
58
|
+
author: str = "",
|
|
59
|
+
date: str = "",
|
|
60
|
+
) -> Slide:
|
|
61
|
+
"""Add a title slide to the deck."""
|
|
62
|
+
parts = []
|
|
63
|
+
if subtitle:
|
|
64
|
+
parts.append(subtitle)
|
|
65
|
+
# Use deck-level author/date as fallback
|
|
66
|
+
slide_author = author or self.author
|
|
67
|
+
slide_date = date or self.date
|
|
68
|
+
if slide_author:
|
|
69
|
+
parts.append(slide_author)
|
|
70
|
+
if slide_date:
|
|
71
|
+
parts.append(slide_date)
|
|
72
|
+
return self.add_slide(
|
|
73
|
+
title=title or self.title,
|
|
74
|
+
content="\n\n".join(parts),
|
|
75
|
+
layout="title",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def insert_figure(
|
|
79
|
+
self,
|
|
80
|
+
src: str,
|
|
81
|
+
alt: str = "",
|
|
82
|
+
caption: str = "",
|
|
83
|
+
slide_title: str = "",
|
|
84
|
+
) -> Slide:
|
|
85
|
+
"""Add a slide with a figure."""
|
|
86
|
+
parts = [f""]
|
|
87
|
+
if caption:
|
|
88
|
+
parts.append(f"*{caption}*")
|
|
89
|
+
return self.add_slide(
|
|
90
|
+
title=slide_title,
|
|
91
|
+
content="\n\n".join(parts),
|
|
92
|
+
layout="content",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def set_theme(self, theme: str) -> None:
|
|
96
|
+
"""Set the deck theme."""
|
|
97
|
+
self.theme = theme
|
|
98
|
+
|
|
99
|
+
def build(self, output_dir: str = ".") -> str:
|
|
100
|
+
"""Build the deck to HTML and return the output path."""
|
|
101
|
+
from colloquium.build import build_deck
|
|
102
|
+
|
|
103
|
+
output_path = Path(output_dir)
|
|
104
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
|
|
106
|
+
# Generate a filename from the deck title
|
|
107
|
+
safe_title = self.title.lower().replace(" ", "-")
|
|
108
|
+
safe_title = "".join(c for c in safe_title if c.isalnum() or c == "-")
|
|
109
|
+
filename = f"{safe_title or 'presentation'}.html"
|
|
110
|
+
filepath = output_path / filename
|
|
111
|
+
|
|
112
|
+
html = build_deck(self)
|
|
113
|
+
filepath.write_text(html, encoding="utf-8")
|
|
114
|
+
return str(filepath)
|
|
115
|
+
|
|
116
|
+
def to_markdown(self) -> str:
|
|
117
|
+
"""Serialize the deck back to markdown format."""
|
|
118
|
+
lines = ["---"]
|
|
119
|
+
lines.append(f"title: {self.title}")
|
|
120
|
+
if self.author:
|
|
121
|
+
lines.append(f"author: {self.author}")
|
|
122
|
+
if self.date:
|
|
123
|
+
lines.append(f"date: {self.date}")
|
|
124
|
+
if self.theme != "default":
|
|
125
|
+
lines.append(f"theme: {self.theme}")
|
|
126
|
+
if self.aspect_ratio != "16:9":
|
|
127
|
+
lines.append(f"aspect_ratio: {self.aspect_ratio}")
|
|
128
|
+
if self.custom_css:
|
|
129
|
+
lines.append(f"custom_css: {self.custom_css}")
|
|
130
|
+
lines.append("---")
|
|
131
|
+
|
|
132
|
+
for slide in self.slides:
|
|
133
|
+
lines.append("")
|
|
134
|
+
lines.append("---")
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append(slide.to_markdown())
|
|
137
|
+
|
|
138
|
+
return "\n".join(lines)
|
colloquium/export.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""PDF export via system browser and print CSS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _find_browser() -> str | None:
|
|
12
|
+
"""Find a headless-capable browser on the system."""
|
|
13
|
+
# macOS application paths
|
|
14
|
+
mac_paths = [
|
|
15
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
16
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
17
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
18
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# Check macOS application paths first
|
|
22
|
+
for path in mac_paths:
|
|
23
|
+
if os.path.isfile(path):
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
# Check PATH for common browser commands
|
|
27
|
+
for cmd in ["google-chrome", "chromium", "chromium-browser", "microsoft-edge", "brave"]:
|
|
28
|
+
found = shutil.which(cmd)
|
|
29
|
+
if found:
|
|
30
|
+
return found
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_chromium_based(browser_path: str) -> bool:
|
|
36
|
+
"""Check if the browser is Chromium-based (supports --print-to-pdf)."""
|
|
37
|
+
name = browser_path.lower()
|
|
38
|
+
return any(x in name for x in ["chrome", "chromium", "edge", "brave"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def export_pdf(input_path: str, output_path: str | None = None) -> str | None:
|
|
42
|
+
"""Export a presentation to PDF using a system browser.
|
|
43
|
+
|
|
44
|
+
Returns the output path on success, or None if no browser was found.
|
|
45
|
+
"""
|
|
46
|
+
from colloquium.build import build_file
|
|
47
|
+
|
|
48
|
+
input_path = str(Path(input_path).resolve())
|
|
49
|
+
|
|
50
|
+
# Build HTML first
|
|
51
|
+
html_path = build_file(input_path)
|
|
52
|
+
|
|
53
|
+
if output_path is None:
|
|
54
|
+
output_path = str(Path(input_path).with_suffix(".pdf"))
|
|
55
|
+
|
|
56
|
+
html_url = f"file://{Path(html_path).resolve()}"
|
|
57
|
+
|
|
58
|
+
browser = _find_browser()
|
|
59
|
+
if browser is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
if not _is_chromium_based(browser):
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
# Use Chromium's headless print-to-pdf
|
|
66
|
+
cmd = [
|
|
67
|
+
browser,
|
|
68
|
+
"--headless",
|
|
69
|
+
"--disable-gpu",
|
|
70
|
+
f"--print-to-pdf={output_path}",
|
|
71
|
+
"--no-pdf-header-footer",
|
|
72
|
+
html_url,
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
subprocess.run(cmd, capture_output=True, timeout=30, check=True)
|
|
77
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
if Path(output_path).exists():
|
|
81
|
+
# Optional ghostscript compression
|
|
82
|
+
_compress_pdf(output_path)
|
|
83
|
+
return output_path
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _compress_pdf(pdf_path: str) -> None:
|
|
89
|
+
"""Compress PDF with ghostscript if available."""
|
|
90
|
+
gs = shutil.which("gs")
|
|
91
|
+
if not gs:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
compressed = pdf_path + ".compressed"
|
|
95
|
+
cmd = [
|
|
96
|
+
gs,
|
|
97
|
+
"-sDEVICE=pdfwrite",
|
|
98
|
+
"-dCompatibilityLevel=1.4",
|
|
99
|
+
"-dPDFSETTINGS=/prepress",
|
|
100
|
+
"-dNOPAUSE",
|
|
101
|
+
"-dQUIET",
|
|
102
|
+
"-dBATCH",
|
|
103
|
+
f"-sOutputFile={compressed}",
|
|
104
|
+
pdf_path,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
subprocess.run(cmd, capture_output=True, timeout=30, check=True)
|
|
109
|
+
# Only replace if compressed is smaller
|
|
110
|
+
if Path(compressed).stat().st_size < Path(pdf_path).stat().st_size:
|
|
111
|
+
os.replace(compressed, pdf_path)
|
|
112
|
+
else:
|
|
113
|
+
os.unlink(compressed)
|
|
114
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
115
|
+
if os.path.exists(compressed):
|
|
116
|
+
os.unlink(compressed)
|
colloquium/parse.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Markdown parser — converts markdown files into Deck objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from colloquium.deck import Deck
|
|
10
|
+
from colloquium.slide import Slide
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Directive patterns: <!-- key: value -->
|
|
14
|
+
_DIRECTIVE_RE = re.compile(
|
|
15
|
+
r"<!--\s*(layout|class|style|notes)\s*:\s*(.*?)\s*-->",
|
|
16
|
+
re.DOTALL,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
21
|
+
"""Extract YAML frontmatter from markdown text.
|
|
22
|
+
|
|
23
|
+
Returns (metadata_dict, remaining_text).
|
|
24
|
+
"""
|
|
25
|
+
text = text.strip()
|
|
26
|
+
if not text.startswith("---"):
|
|
27
|
+
return {}, text
|
|
28
|
+
|
|
29
|
+
# Find closing ---
|
|
30
|
+
end = text.find("---", 3)
|
|
31
|
+
if end == -1:
|
|
32
|
+
return {}, text
|
|
33
|
+
|
|
34
|
+
yaml_str = text[3:end].strip()
|
|
35
|
+
remaining = text[end + 3 :].strip()
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
metadata = yaml.safe_load(yaml_str) or {}
|
|
39
|
+
except yaml.YAMLError:
|
|
40
|
+
metadata = {}
|
|
41
|
+
|
|
42
|
+
if not isinstance(metadata, dict):
|
|
43
|
+
return {}, text
|
|
44
|
+
|
|
45
|
+
return metadata, remaining
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_slide(text: str) -> Slide:
|
|
49
|
+
"""Parse a single slide's markdown text into a Slide object."""
|
|
50
|
+
layout = "content"
|
|
51
|
+
classes = []
|
|
52
|
+
style = ""
|
|
53
|
+
notes = ""
|
|
54
|
+
title = ""
|
|
55
|
+
content_lines = []
|
|
56
|
+
|
|
57
|
+
# Extract directives and notes
|
|
58
|
+
remaining = text
|
|
59
|
+
for match in _DIRECTIVE_RE.finditer(text):
|
|
60
|
+
key = match.group(1)
|
|
61
|
+
value = match.group(2).strip()
|
|
62
|
+
if key == "layout":
|
|
63
|
+
layout = value
|
|
64
|
+
elif key == "class":
|
|
65
|
+
classes.extend(value.split())
|
|
66
|
+
elif key == "style":
|
|
67
|
+
style = value
|
|
68
|
+
elif key == "notes":
|
|
69
|
+
notes = value
|
|
70
|
+
remaining = remaining.replace(match.group(0), "")
|
|
71
|
+
|
|
72
|
+
# Parse remaining content for title
|
|
73
|
+
lines = remaining.strip().splitlines()
|
|
74
|
+
content_parts = []
|
|
75
|
+
|
|
76
|
+
for line in lines:
|
|
77
|
+
stripped = line.strip()
|
|
78
|
+
if not title and stripped.startswith("# "):
|
|
79
|
+
title = stripped[2:].strip()
|
|
80
|
+
if layout == "content":
|
|
81
|
+
layout = "title"
|
|
82
|
+
elif not title and stripped.startswith("## "):
|
|
83
|
+
title = stripped[3:].strip()
|
|
84
|
+
else:
|
|
85
|
+
content_parts.append(line)
|
|
86
|
+
|
|
87
|
+
content = "\n".join(content_parts).strip()
|
|
88
|
+
|
|
89
|
+
return Slide(
|
|
90
|
+
title=title,
|
|
91
|
+
content=content,
|
|
92
|
+
layout=layout,
|
|
93
|
+
speaker_notes=notes,
|
|
94
|
+
classes=classes,
|
|
95
|
+
style=style,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_markdown(text: str) -> Deck:
|
|
100
|
+
"""Parse a full markdown file into a Deck."""
|
|
101
|
+
metadata, body = parse_frontmatter(text)
|
|
102
|
+
|
|
103
|
+
deck = Deck(
|
|
104
|
+
title=metadata.get("title", "Untitled"),
|
|
105
|
+
author=metadata.get("author", ""),
|
|
106
|
+
date=str(metadata.get("date", "")),
|
|
107
|
+
theme=metadata.get("theme", "default"),
|
|
108
|
+
aspect_ratio=str(metadata.get("aspect_ratio", "16:9")),
|
|
109
|
+
custom_css=metadata.get("custom_css", ""),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Split on --- slide separators (horizontal rules)
|
|
113
|
+
# A --- on its own line (possibly with whitespace) separates slides
|
|
114
|
+
slide_texts = re.split(r"\n---\s*\n", body)
|
|
115
|
+
|
|
116
|
+
for slide_text in slide_texts:
|
|
117
|
+
slide_text = slide_text.strip()
|
|
118
|
+
if not slide_text:
|
|
119
|
+
continue
|
|
120
|
+
slide = parse_slide(slide_text)
|
|
121
|
+
deck.slides.append(slide)
|
|
122
|
+
|
|
123
|
+
return deck
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_file(path: str) -> Deck:
|
|
127
|
+
"""Parse a markdown file into a Deck."""
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
|
|
130
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
131
|
+
return parse_markdown(text)
|