mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-05 23:42:59 +00:00
feat: allow exporting to html (#566)
the main modifications are - makes every page exist in a separate container - allows switching pages via left and right arrows - merges the css from an outside file to directly within the html - zooms the content to fit the browser width
This commit is contained in:
commit
afb0f0797f
@ -2,7 +2,7 @@ use crate::{
|
|||||||
MarkdownParser, Resources,
|
MarkdownParser, Resources,
|
||||||
code::execute::SnippetExecutor,
|
code::execute::SnippetExecutor,
|
||||||
config::{KeyBindingsConfig, PauseExportPolicy},
|
config::{KeyBindingsConfig, PauseExportPolicy},
|
||||||
export::pdf::PdfRender,
|
export::output::{ExportRenderer, OutputFormat},
|
||||||
markdown::{parse::ParseError, text_style::Color},
|
markdown::{parse::ParseError, text_style::Color},
|
||||||
presentation::{
|
presentation::{
|
||||||
Presentation,
|
Presentation,
|
||||||
@ -98,24 +98,12 @@ impl<'a> Exporter<'a> {
|
|||||||
Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions }
|
Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export the given presentation into PDF.
|
fn build_renderer(
|
||||||
///
|
&mut self,
|
||||||
/// This uses a separate `presenterm-export` tool.
|
|
||||||
pub fn export_pdf(
|
|
||||||
mut self,
|
|
||||||
presentation_path: &Path,
|
presentation_path: &Path,
|
||||||
output_directory: OutputDirectory,
|
output_directory: OutputDirectory,
|
||||||
output_path: Option<&Path>,
|
renderer: OutputFormat,
|
||||||
) -> Result<(), ExportError> {
|
) -> Result<ExportRenderer, ExportError> {
|
||||||
println!(
|
|
||||||
"exporting using rows={}, columns={}, width={}, height={}",
|
|
||||||
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
|
|
||||||
);
|
|
||||||
|
|
||||||
println!("checking for weasyprint...");
|
|
||||||
Self::validate_weasyprint_exists()?;
|
|
||||||
Self::log("weasyprint installation found")?;
|
|
||||||
|
|
||||||
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
|
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
|
||||||
let elements = self.parser.parse(&content)?;
|
let elements = self.parser.parse(&content)?;
|
||||||
|
|
||||||
@ -132,7 +120,7 @@ impl<'a> Exporter<'a> {
|
|||||||
.build(elements)?;
|
.build(elements)?;
|
||||||
Self::validate_theme_colors(&presentation)?;
|
Self::validate_theme_colors(&presentation)?;
|
||||||
|
|
||||||
let mut render = PdfRender::new(self.dimensions, output_directory);
|
let mut render = ExportRenderer::new(self.dimensions.clone(), output_directory, renderer);
|
||||||
Self::log("waiting for images to be generated and code to be executed, if any...")?;
|
Self::log("waiting for images to be generated and code to be executed, if any...")?;
|
||||||
Self::render_async_images(&mut presentation);
|
Self::render_async_images(&mut presentation);
|
||||||
|
|
||||||
@ -143,10 +131,32 @@ impl<'a> Exporter<'a> {
|
|||||||
}
|
}
|
||||||
Self::log("invoking weasyprint...")?;
|
Self::log("invoking weasyprint...")?;
|
||||||
|
|
||||||
|
Ok(render)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the given presentation into PDF.
|
||||||
|
pub fn export_pdf(
|
||||||
|
mut self,
|
||||||
|
presentation_path: &Path,
|
||||||
|
output_directory: OutputDirectory,
|
||||||
|
output_path: Option<&Path>,
|
||||||
|
) -> Result<(), ExportError> {
|
||||||
|
println!(
|
||||||
|
"exporting using rows={}, columns={}, width={}, height={}",
|
||||||
|
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("checking for weasyprint...");
|
||||||
|
Self::validate_weasyprint_exists()?;
|
||||||
|
Self::log("weasyprint installation found")?;
|
||||||
|
|
||||||
|
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Pdf)?;
|
||||||
|
|
||||||
let pdf_path = match output_path {
|
let pdf_path = match output_path {
|
||||||
Some(path) => path.to_path_buf(),
|
Some(path) => path.to_path_buf(),
|
||||||
None => presentation_path.with_extension("pdf"),
|
None => presentation_path.with_extension("pdf"),
|
||||||
};
|
};
|
||||||
|
|
||||||
render.generate(&pdf_path)?;
|
render.generate(&pdf_path)?;
|
||||||
|
|
||||||
execute!(
|
execute!(
|
||||||
@ -158,6 +168,36 @@ impl<'a> Exporter<'a> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Export the given presentation into HTML.
|
||||||
|
pub fn export_html(
|
||||||
|
mut self,
|
||||||
|
presentation_path: &Path,
|
||||||
|
output_directory: OutputDirectory,
|
||||||
|
output_path: Option<&Path>,
|
||||||
|
) -> Result<(), ExportError> {
|
||||||
|
println!(
|
||||||
|
"exporting using rows={}, columns={}, width={}, height={}",
|
||||||
|
self.dimensions.rows, self.dimensions.columns, self.dimensions.width, self.dimensions.height
|
||||||
|
);
|
||||||
|
|
||||||
|
let render = self.build_renderer(presentation_path, output_directory, OutputFormat::Html)?;
|
||||||
|
|
||||||
|
let output_path = match output_path {
|
||||||
|
Some(path) => path.to_path_buf(),
|
||||||
|
None => presentation_path.with_extension("html"),
|
||||||
|
};
|
||||||
|
|
||||||
|
render.generate(&output_path)?;
|
||||||
|
|
||||||
|
execute!(
|
||||||
|
io::stdout(),
|
||||||
|
PrintStyledContent(
|
||||||
|
format!("output file is at {}\n", output_path.display()).stylize().with(Color::Green.into())
|
||||||
|
)
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn render_async_images(presentation: &mut Presentation) {
|
fn render_async_images(presentation: &mut Presentation) {
|
||||||
let poller = Poller::launch();
|
let poller = Poller::launch();
|
||||||
let mut pollables = Vec::new();
|
let mut pollables = Vec::new();
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
pub mod exporter;
|
pub mod exporter;
|
||||||
pub(crate) mod html;
|
pub(crate) mod html;
|
||||||
pub(crate) mod pdf;
|
pub(crate) mod output;
|
||||||
|
@ -39,6 +39,7 @@ struct HtmlSlide {
|
|||||||
impl HtmlSlide {
|
impl HtmlSlide {
|
||||||
fn new(grid: TerminalGrid, content_manager: &mut ContentManager) -> Result<Self, ExportError> {
|
fn new(grid: TerminalGrid, content_manager: &mut ContentManager) -> Result<Self, ExportError> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
|
rows.push(String::from("<div class=\"container\">"));
|
||||||
for (y, row) in grid.rows.into_iter().enumerate() {
|
for (y, row) in grid.rows.into_iter().enumerate() {
|
||||||
let mut finalized_row = "<div class=\"content-line\"><pre>".to_string();
|
let mut finalized_row = "<div class=\"content-line\"><pre>".to_string();
|
||||||
let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
|
let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
|
||||||
@ -73,6 +74,7 @@ impl HtmlSlide {
|
|||||||
finalized_row.push_str("</pre></div>");
|
finalized_row.push_str("</pre></div>");
|
||||||
rows.push(finalized_row);
|
rows.push(finalized_row);
|
||||||
}
|
}
|
||||||
|
rows.push(String::from("</div>"));
|
||||||
|
|
||||||
Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) })
|
Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) })
|
||||||
}
|
}
|
||||||
@ -122,17 +124,29 @@ impl ContentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct PdfRender {
|
pub(crate) enum OutputFormat {
|
||||||
|
Pdf,
|
||||||
|
Html,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ExportRenderer {
|
||||||
content_manager: ContentManager,
|
content_manager: ContentManager,
|
||||||
|
output_format: OutputFormat,
|
||||||
dimensions: WindowSize,
|
dimensions: WindowSize,
|
||||||
html_body: String,
|
html_body: String,
|
||||||
background_color: Option<String>,
|
background_color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PdfRender {
|
impl ExportRenderer {
|
||||||
pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory) -> Self {
|
pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory, output_type: OutputFormat) -> Self {
|
||||||
let image_manager = ContentManager::new(output_directory);
|
let image_manager = ContentManager::new(output_directory);
|
||||||
Self { content_manager: image_manager, dimensions, html_body: "".to_string(), background_color: None }
|
Self {
|
||||||
|
content_manager: image_manager,
|
||||||
|
dimensions,
|
||||||
|
html_body: "".to_string(),
|
||||||
|
background_color: None,
|
||||||
|
output_format: output_type,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> {
|
pub(crate) fn process_slide(&mut self, slide: Slide) -> Result<(), ExportError> {
|
||||||
@ -152,19 +166,24 @@ impl PdfRender {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn generate(self, pdf_path: &Path) -> Result<(), ExportError> {
|
pub(crate) fn generate(self, output_path: &Path) -> Result<(), ExportError> {
|
||||||
let html_body = &self.html_body;
|
let html_body = &self.html_body;
|
||||||
let html = format!(
|
let script = include_str!("script.js");
|
||||||
r#"<html>
|
|
||||||
<head>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{html_body}</body>
|
|
||||||
</html>"#
|
|
||||||
);
|
|
||||||
let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
|
let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
|
||||||
let height = self.dimensions.rows * LINE_HEIGHT;
|
let height = self.dimensions.rows * LINE_HEIGHT;
|
||||||
let background_color = self.background_color.unwrap_or_else(|| "black".into());
|
let background_color = self.background_color.unwrap_or_else(|| "black".into());
|
||||||
|
let container = match self.output_format {
|
||||||
|
OutputFormat::Pdf => String::from("display: contents;"),
|
||||||
|
OutputFormat::Html => String::from(
|
||||||
|
"
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
",
|
||||||
|
),
|
||||||
|
};
|
||||||
let css = format!(
|
let css = format!(
|
||||||
r"
|
r"
|
||||||
pre {{
|
pre {{
|
||||||
@ -180,8 +199,14 @@ impl PdfRender {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: {FONT_SIZE}px;
|
font-size: {FONT_SIZE}px;
|
||||||
line-height: {LINE_HEIGHT}px;
|
line-height: {LINE_HEIGHT}px;
|
||||||
background-color: {background_color};
|
|
||||||
width: {width}px;
|
width: {width}px;
|
||||||
|
height: {height}px;
|
||||||
|
transform-origin: top left;
|
||||||
|
background-color: {background_color};
|
||||||
|
}}
|
||||||
|
|
||||||
|
.container {{
|
||||||
|
{container}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
.content-line {{
|
.content-line {{
|
||||||
@ -191,25 +216,73 @@ impl PdfRender {
|
|||||||
width: {width}px;
|
width: {width}px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
.hidden {{
|
||||||
|
display: none;
|
||||||
|
}}
|
||||||
|
|
||||||
@page {{
|
@page {{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: {height}px;
|
height: {height}px;
|
||||||
width: {width}px;
|
width: {width}px;
|
||||||
}}"
|
}}"
|
||||||
);
|
);
|
||||||
|
let html_script = match self.output_format {
|
||||||
|
OutputFormat::Pdf => String::new(),
|
||||||
|
OutputFormat::Html => {
|
||||||
|
format!(
|
||||||
|
"
|
||||||
|
<script>
|
||||||
|
let originalWidth = {width};
|
||||||
|
let originalHeight = {height};
|
||||||
|
{script}
|
||||||
|
</script>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let style = match self.output_format {
|
||||||
|
OutputFormat::Pdf => String::new(),
|
||||||
|
OutputFormat::Html => format!(
|
||||||
|
"
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
{css}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let html = format!(
|
||||||
|
r"
|
||||||
|
<html>
|
||||||
|
{style}
|
||||||
|
<body>
|
||||||
|
{html_body}
|
||||||
|
{html_script}
|
||||||
|
</body>
|
||||||
|
</html>"
|
||||||
|
);
|
||||||
|
|
||||||
let html_path = self.content_manager.persist_file("index.html", html.as_bytes())?;
|
let html_path = self.content_manager.persist_file("index.html", html.as_bytes())?;
|
||||||
let css_path = self.content_manager.persist_file("styles.css", css.as_bytes())?;
|
let css_path = self.content_manager.persist_file("styles.css", css.as_bytes())?;
|
||||||
ThirdPartyTools::weasyprint(&[
|
|
||||||
"-s",
|
match self.output_format {
|
||||||
css_path.to_string_lossy().as_ref(),
|
OutputFormat::Pdf => {
|
||||||
"--presentational-hints",
|
ThirdPartyTools::weasyprint(&[
|
||||||
"-e",
|
"-s",
|
||||||
"utf8",
|
css_path.to_string_lossy().as_ref(),
|
||||||
html_path.to_string_lossy().as_ref(),
|
"--presentational-hints",
|
||||||
pdf_path.to_string_lossy().as_ref(),
|
"-e",
|
||||||
])
|
"utf8",
|
||||||
.run()?;
|
html_path.to_string_lossy().as_ref(),
|
||||||
|
output_path.to_string_lossy().as_ref(),
|
||||||
|
])
|
||||||
|
.run()?;
|
||||||
|
}
|
||||||
|
OutputFormat::Html => {
|
||||||
|
fs::write(output_path, html.as_bytes())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
45
src/export/script.js
Normal file
45
src/export/script.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const allLines = document.querySelectorAll('body > div');
|
||||||
|
const pageBreakMarkers = document.querySelectorAll('.container');
|
||||||
|
let currentPageIndex = 0;
|
||||||
|
|
||||||
|
|
||||||
|
function showCurrentPage() {
|
||||||
|
allLines.forEach((line) => {
|
||||||
|
line.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
allLines[currentPageIndex].classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function scaler() {
|
||||||
|
var w = document.documentElement.clientWidth;
|
||||||
|
var h = document.documentElement.clientHeight;
|
||||||
|
let widthScaledAmount= w/originalWidth;
|
||||||
|
let heightScaledAmount= h/originalHeight;
|
||||||
|
let scaledAmount = Math.min(widthScaledAmount, heightScaledAmount);
|
||||||
|
document.querySelector("body").style.transform = `scale(${scaledAmount})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyPress(event) {
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
if (currentPageIndex > 0) {
|
||||||
|
currentPageIndex--;
|
||||||
|
showCurrentPage();
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
if (currentPageIndex < pageBreakMarkers.length - 1) {
|
||||||
|
currentPageIndex++;
|
||||||
|
showCurrentPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyPress);
|
||||||
|
window.addEventListener("resize", scaler);
|
||||||
|
|
||||||
|
scaler();
|
||||||
|
showCurrentPage();
|
||||||
|
});
|
||||||
|
|
18
src/main.rs
18
src/main.rs
@ -68,15 +68,19 @@ struct Cli {
|
|||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
|
|
||||||
/// Export the presentation as a PDF rather than displaying it.
|
/// Export the presentation as a PDF rather than displaying it.
|
||||||
#[clap(short, long)]
|
#[clap(short, long, group = "export")]
|
||||||
export_pdf: bool,
|
export_pdf: bool,
|
||||||
|
|
||||||
|
/// Export the presentation as a HTML rather than displaying it.
|
||||||
|
#[clap(long, group = "export")]
|
||||||
|
export_html: bool,
|
||||||
|
|
||||||
/// The path in which to store temporary files used when exporting.
|
/// The path in which to store temporary files used when exporting.
|
||||||
#[clap(long, requires = "export_pdf")]
|
#[clap(long, requires = "export")]
|
||||||
export_temporary_path: Option<PathBuf>,
|
export_temporary_path: Option<PathBuf>,
|
||||||
|
|
||||||
/// The output path for the exported PDF.
|
/// The output path for the exported PDF.
|
||||||
#[clap(short = 'o', long = "output", requires = "export_pdf")]
|
#[clap(short = 'o', long = "output", requires = "export")]
|
||||||
export_output: Option<PathBuf>,
|
export_output: Option<PathBuf>,
|
||||||
|
|
||||||
/// Generate a JSON schema for the configuration file.
|
/// Generate a JSON schema for the configuration file.
|
||||||
@ -401,7 +405,7 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let parser = MarkdownParser::new(&arena);
|
let parser = MarkdownParser::new(&arena);
|
||||||
let validate_overflows =
|
let validate_overflows =
|
||||||
overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows;
|
overflow_validation_enabled(&present_mode, &config.defaults.validate_overflows) || cli.validate_overflows;
|
||||||
if cli.export_pdf {
|
if cli.export_pdf || cli.export_html {
|
||||||
let dimensions = match config.export.dimensions {
|
let dimensions = match config.export.dimensions {
|
||||||
Some(dimensions) => WindowSize {
|
Some(dimensions) => WindowSize {
|
||||||
rows: dimensions.rows,
|
rows: dimensions.rows,
|
||||||
@ -426,7 +430,11 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Some(path) => OutputDirectory::external(path),
|
Some(path) => OutputDirectory::external(path),
|
||||||
None => OutputDirectory::temporary(),
|
None => OutputDirectory::temporary(),
|
||||||
}?;
|
}?;
|
||||||
exporter.export_pdf(&path, output_directory, cli.export_output.as_deref())?;
|
if cli.export_pdf {
|
||||||
|
exporter.export_pdf(&path, output_directory, cli.export_output.as_deref())?;
|
||||||
|
} else {
|
||||||
|
exporter.export_html(&path, output_directory, cli.export_output.as_deref())?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let SpeakerNotesComponents { events_listener, events_publisher } =
|
let SpeakerNotesComponents { events_listener, events_publisher } =
|
||||||
SpeakerNotesComponents::new(&cli, &config, &path)?;
|
SpeakerNotesComponents::new(&cli, &config, &path)?;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user