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 ADDED
@@ -0,0 +1,8 @@
1
+ """Colloquium — agent-native slide creation tool for research talks."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from colloquium.slide import Slide
6
+ from colloquium.deck import Deck
7
+
8
+ __all__ = ["Slide", "Deck", "__version__"]
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"![{alt}]({src})"]
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)