Compare commits

...

23 Commits

Author SHA1 Message Date
Matias Fontanini
2a4ea80a46
fix: allow interleaved spans and variables in footer (#577)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This allows including spans that contain variables in footers. This does
mean that if you pull in a variable like `{title}` that contains `<` or
`>` it will be included as-is and would allow creating valid HTML tags,
but this is harmless so I don't see a problem with it.

Fixes #574
2025-05-03 13:25:21 -07:00
Matias Fontanini
b25fa12b82 fix: allow interleaved spans and variables in footer 2025-05-03 13:20:49 -07:00
Matias Fontanini
725312e71c
feat: make HTML export self contained (#575)
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
#566 did the heavy work to allow HTML exports but the one thing missing
is that images were still referenced on their external paths, which
caused 2 issues:

* Files were not redistributable unless you included images and fixed
their paths.
* Generated images (e.g. mermaid diagrams) were likely pointing to a
temporary directory so they'd be lost.

This changes that so HTML exports are now fully self contained, using
`data:...` notation to inline images within the HTML.
2025-05-02 17:44:53 -07:00
Matias Fontanini
5565d420f5 feat: make HTML export self contained 2025-05-02 17:39:09 -07:00
Matias Fontanini
afb0f0797f
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
2025-05-02 17:38:24 -07:00
KyleUltimate
68a210da5a feat: make container "invisible" if outputting to pdf 2025-05-01 20:47:37 +08:00
Matias Fontanini
60f6208594
fix: truly center +exec_replace snippet output (#572)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
This causes `+exec_replace` blocks to have no margin when using center
alignment. This is a regression introduced somewhere in 0.12.0.

Fixes #571
2025-04-30 17:02:17 -07:00
Matias Fontanini
e2dab4d7ef fix: truly center +exec_replace +no_background snippet output 2025-04-30 16:58:11 -07:00
KyleUltimate
14d2edfeb5 fix: width should not be linked to width 2025-04-29 20:56:20 +08:00
KyleUltimate
8f40a8295b fix: make css into another file 2025-04-28 19:17:48 +08:00
Matias Fontanini
8d54fe225a
fix: Rename parameter name to the correct one in docs (#570)
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
Rename `--config-path` in configuration/introduction section to
`--config-file`
2025-04-27 09:00:04 -07:00
dzu_we
257fa137c5 fix: rename parameter name to the correct one 2025-04-27 18:36:24 +03:00
KyleUltimate
262b2af3e7 fix: also calculate scaled amount regarding height 2025-04-27 08:07:03 +08:00
KyleUltimate
7b2ba0eb8c chore: allow compliation with rust version below 1.79.0 2025-04-25 22:01:37 +08:00
Matias Fontanini
cae76380fa chore: fix changelog attribution
Some checks failed
Deploy docs / build-and-deploy (push) Has been cancelled
Merge checks / Checks (push) Has been cancelled
Merge checks / Validate nix flake (push) Has been cancelled
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
2025-04-25 06:55:52 -07:00
Matias Fontanini
78a3df199e chore: fix typo in changelog link 2025-04-25 06:16:31 -07:00
KyleUltimate
fe818344fe fix: differentiate widths based on output format 2025-04-25 21:10:43 +08:00
KyleUltimate
6ff8e87924 fix: remove hard-fixed presentation width, resolve reviews 2025-04-25 17:47:55 +08:00
KyleUltimate
76561b1281 chore: rename module pdf to output 2025-04-24 22:40:00 +08:00
KyleUltimate
519aad16e8 chore: don't include script when outputting to pdf 2025-04-24 22:30:42 +08:00
KyleUltimate
0d4ffceede feat: allow the ability to create html outputs 2025-04-24 22:26:47 +08:00
KyleUltimate
c3fb212f90 feat: automatically zoom to the browser width 2025-04-24 22:02:56 +08:00
KyleUltimate
d0ea46ce85 feat: make the temporary html generated usable 2025-04-24 21:14:45 +08:00
16 changed files with 356 additions and 117 deletions

View File

@ -45,7 +45,7 @@ jobs:
source ./.venv/bin/activate source ./.venv/bin/activate
uv pip install weasyprint uv pip install weasyprint
- name: Export demo presentation as PDF - name: Export demo presentation as PDF and HTML
run: | run: |
cat >/tmp/config.yaml <<EOL cat >/tmp/config.yaml <<EOL
export: export:
@ -54,7 +54,8 @@ jobs:
columns: 135 columns: 135
EOL EOL
source ./.venv/bin/activate source ./.venv/bin/activate
cargo run -- -e -c /tmp/config.yaml examples/demo.md cargo run -- --export-pdf -c /tmp/config.yaml examples/demo.md
cargo run -- --export-html -c /tmp/config.yaml examples/demo.md
nix-flake: nix-flake:
name: Validate nix flake name: Validate nix flake

View File

@ -6,12 +6,12 @@
## New features ## New features
* Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitons.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)): * Support for [slide transitions](https://mfontanini.github.io/presenterm/features/slide-transitions.html) is now available ([#530](https://github.com/mfontanini/presenterm/issues/530)):
* Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)). * Add fade slide transition ([#534](https://github.com/mfontanini/presenterm/issues/534)).
* Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)). * Add slide horizontally slide transition animation ([#528](https://github.com/mfontanini/presenterm/issues/528)).
* Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)). * Add `collapse_horizontal` slide transition ([#560](https://github.com/mfontanini/presenterm/issues/560)).
* Add `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)). * Add `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)) - thanks @marianozunino.
* Allow specifying [start/end lines](https://mfontanini.github.io/presenterm/features/code/highlighting.html#including-external-code-snippets) in file snippet type ([#565](https://github.com/mfontanini/presenterm/issues/565)) - thanks @marianozunino. * Allow specifying [start/end lines](https://mfontanini.github.io/presenterm/features/code/highlighting.html#including-external-code-snippets) in file snippet type ([#565](https://github.com/mfontanini/presenterm/issues/565)).
* Allow letting [pauses become new slides](https://mfontanini.github.io/presenterm/configuration/settings.html#pause-behavior) when exporting ([#557](https://github.com/mfontanini/presenterm/issues/557)). * Allow letting [pauses become new slides](https://mfontanini.github.io/presenterm/configuration/settings.html#pause-behavior) when exporting ([#557](https://github.com/mfontanini/presenterm/issues/557)).
* Allow [using images on right in footer](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) ([#554](https://github.com/mfontanini/presenterm/issues/554)). * Allow [using images on right in footer](https://mfontanini.github.io/presenterm/features/themes/definition.html#footer-images) ([#554](https://github.com/mfontanini/presenterm/issues/554)).
* Add [`max_rows` configuration](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-height) to cap vertical size ([#531](https://github.com/mfontanini/presenterm/issues/531)). * Add [`max_rows` configuration](https://mfontanini.github.io/presenterm/configuration/settings.html#maximum-presentation-height) to cap vertical size ([#531](https://github.com/mfontanini/presenterm/issues/531)).

View File

@ -10,7 +10,7 @@ custom themes, in the following directories:
The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on The configuration file will be looked up automatically in the directories above under the name `config.yaml`. e.g. on
Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file Linux you should create it under `~/.config/presenterm/config.yaml`. You can also specify a custom path to this file
when running _presenterm_ via the `--config-path` parameter. when running _presenterm_ via the `--config-file` parameter.
A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in A [sample configuration file](https://github.com/mfontanini/presenterm/blob/master/config.sample.yaml) is provided in
the repository that you can use as a base. the repository that you can use as a base.

View File

@ -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();

View File

@ -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;

View File

@ -8,15 +8,11 @@ use crate::{
presentation::Slide, presentation::Slide,
render::{engine::RenderEngine, properties::WindowSize}, render::{engine::RenderEngine, properties::WindowSize},
terminal::{ terminal::{
image::{ image::printer::TerminalImage,
Image, ImageSource,
printer::{ImageProperties, TerminalImage},
},
virt::{TerminalGrid, VirtualTerminal}, virt::{TerminalGrid, VirtualTerminal},
}, },
tools::ThirdPartyTools, tools::ThirdPartyTools,
}; };
use image::{ImageEncoder, codecs::png::PngEncoder};
use std::{ use std::{
fs, io, fs, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -37,8 +33,9 @@ struct HtmlSlide {
} }
impl HtmlSlide { impl HtmlSlide {
fn new(grid: TerminalGrid, content_manager: &mut ContentManager) -> Result<Self, ExportError> { fn new(grid: TerminalGrid) -> 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();
@ -57,11 +54,11 @@ impl HtmlSlide {
other => current_string.push(other), other => current_string.push(other),
} }
if let Some(image) = grid.images.get(&(y as u16, x as u16)) { if let Some(image) = grid.images.get(&(y as u16, x as u16)) {
let image_path = content_manager.persist_image(&image.image)?; let TerminalImage::Raw(raw_image) = image.image.image() else { panic!("not in raw image mode") };
let image_path_str = image_path.display(); let image_contents = raw_image.to_inline_html();
let width_pixels = (image.width_columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil(); let width_pixels = (image.width_columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
let image_tag = format!( let image_tag = format!(
"<img width=\"{width_pixels}\" src=\"file://{image_path_str}\" style=\"position: absolute\" />" "<img width=\"{width_pixels}\" src=\"{image_contents}\" style=\"position: absolute\" />"
); );
current_string.push_str(&image_tag); current_string.push_str(&image_tag);
} }
@ -73,6 +70,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) })
} }
@ -84,35 +82,11 @@ impl HtmlSlide {
pub(crate) struct ContentManager { pub(crate) struct ContentManager {
output_directory: OutputDirectory, output_directory: OutputDirectory,
image_count: usize,
} }
impl ContentManager { impl ContentManager {
pub(crate) fn new(output_directory: OutputDirectory) -> Self { pub(crate) fn new(output_directory: OutputDirectory) -> Self {
Self { output_directory, image_count: 0 } Self { output_directory }
}
fn persist_image(&mut self, image: &Image) -> Result<PathBuf, ExportError> {
match image.source.clone() {
ImageSource::Filesystem(path) => Ok(path),
ImageSource::Generated => {
let mut buffer = Vec::new();
let dimensions = image.image().dimensions();
let TerminalImage::Ascii(image) = image.image() else { panic!("not in ascii mode") };
let image = image.image();
PngEncoder::new(&mut buffer).write_image(
image.as_bytes(),
dimensions.0,
dimensions.1,
image.color().into(),
)?;
let name = format!("img-{}.png", self.image_count);
let path = self.output_directory.path().join(name);
fs::write(&path, buffer)?;
self.image_count += 1;
Ok(path)
}
}
} }
fn persist_file(&self, name: &str, data: &[u8]) -> io::Result<PathBuf> { fn persist_file(&self, name: &str, data: &[u8]) -> io::Result<PathBuf> {
@ -122,17 +96,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> {
@ -141,7 +127,7 @@ impl PdfRender {
engine.render(slide.iter_operations())?; engine.render(slide.iter_operations())?;
let grid = terminal.into_contents(); let grid = terminal.into_contents();
let slide = HtmlSlide::new(grid, &mut self.content_manager)?; let slide = HtmlSlide::new(grid)?;
if self.background_color.is_none() { if self.background_color.is_none() {
self.background_color.clone_from(&slide.background_color); self.background_color.clone_from(&slide.background_color);
} }
@ -152,19 +138,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 +171,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 +188,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
View 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();
});

View File

@ -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.
@ -289,8 +293,8 @@ impl CoreComponents {
} }
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode { fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
if cli.export_pdf { if cli.export_pdf | cli.export_html {
GraphicsMode::AsciiBlocks GraphicsMode::Raw
} else { } else {
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol); let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_protocol);
match GraphicsMode::try_from(protocol) { match GraphicsMode::try_from(protocol) {
@ -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)?;

View File

@ -1,5 +1,3 @@
use itertools::Itertools;
use super::{BuildError, BuildResult, ExecutionMode, PresentationBuilderOptions}; use super::{BuildError, BuildResult, ExecutionMode, PresentationBuilderOptions};
use crate::{ use crate::{
ImageRegistry, ImageRegistry,
@ -18,13 +16,14 @@ use crate::{
properties::WindowSize, properties::WindowSize,
}, },
resource::Resources, resource::Resources,
theme::{CodeBlockStyle, PresentationTheme}, theme::{Alignment, CodeBlockStyle, PresentationTheme},
third_party::{ThirdPartyRender, ThirdPartyRenderRequest}, third_party::{ThirdPartyRender, ThirdPartyRenderRequest},
ui::execution::{ ui::execution::{
RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation, RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation,
disabled::ExecutionType, snippet::DisplaySeparator, disabled::ExecutionType, snippet::DisplaySeparator,
}, },
}; };
use itertools::Itertools;
use std::{cell::RefCell, rc::Rc, sync::Arc}; use std::{cell::RefCell, rc::Rc, sync::Arc};
pub(crate) struct SnippetProcessorState<'a> { pub(crate) struct SnippetProcessorState<'a> {
@ -308,7 +307,15 @@ impl<'a> SnippetProcessor<'a> {
ExecutionMode::AlongSnippet => DisplaySeparator::On, ExecutionMode::AlongSnippet => DisplaySeparator::On,
ExecutionMode::ReplaceSnippet => DisplaySeparator::Off, ExecutionMode::ReplaceSnippet => DisplaySeparator::Off,
}; };
let alignment = self.code_style(&snippet).alignment; let default_alignment = self.code_style(&snippet).alignment;
// If we're replacing the snippet output and we have center alignment, use center alignment but
// without any margins and minimum sizes so we truly center the output.
let alignment = match (&mode, default_alignment) {
(ExecutionMode::ReplaceSnippet, Alignment::Center { .. }) => {
Alignment::Center { minimum_margin: Default::default(), minimum_size: 0 }
}
(_, alignment) => alignment,
};
let default_colors = self.theme.default_style.style.colors; let default_colors = self.theme.default_style.style.colors;
let mut execution_output_style = self.theme.execution_output.clone(); let mut execution_output_style = self.theme.execution_output.clone();
if snippet.attributes.no_background { if snippet.attributes.no_background {

View File

@ -1,7 +1,6 @@
use self::printer::{ImageProperties, TerminalImage};
use image::DynamicImage; use image::DynamicImage;
use protocols::ascii::AsciiImage; use protocols::ascii::AsciiImage;
use self::printer::{ImageProperties, TerminalImage};
use std::{ use std::{
fmt::Debug, fmt::Debug,
ops::Deref, ops::Deref,
@ -43,6 +42,7 @@ impl Image {
TerminalImage::Ascii(image) => image.clone(), TerminalImage::Ascii(image) => image.clone(),
TerminalImage::Kitty(image) => DynamicImage::from(image.as_rgba8()).into(), TerminalImage::Kitty(image) => DynamicImage::from(image.as_rgba8()).into(),
TerminalImage::Iterm(image) => DynamicImage::from(image.as_rgba8()).into(), TerminalImage::Iterm(image) => DynamicImage::from(image.as_rgba8()).into(),
TerminalImage::Raw(_) => unreachable!("raw is only used for exports"),
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(), TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(),
}; };

View File

@ -4,6 +4,7 @@ use super::{
ascii::{AsciiImage, AsciiPrinter}, ascii::{AsciiImage, AsciiPrinter},
iterm::{ItermImage, ItermPrinter}, iterm::{ItermImage, ItermPrinter},
kitty::{KittyImage, KittyMode, KittyPrinter}, kitty::{KittyImage, KittyMode, KittyPrinter},
raw::{RawImage, RawPrinter},
}, },
}; };
use crate::{ use crate::{
@ -54,6 +55,7 @@ pub(crate) enum TerminalImage {
Kitty(KittyImage), Kitty(KittyImage),
Iterm(ItermImage), Iterm(ItermImage),
Ascii(AsciiImage), Ascii(AsciiImage),
Raw(RawImage),
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
Sixel(super::protocols::sixel::SixelImage), Sixel(super::protocols::sixel::SixelImage),
} }
@ -64,6 +66,7 @@ impl ImageProperties for TerminalImage {
Self::Kitty(image) => image.dimensions(), Self::Kitty(image) => image.dimensions(),
Self::Iterm(image) => image.dimensions(), Self::Iterm(image) => image.dimensions(),
Self::Ascii(image) => image.dimensions(), Self::Ascii(image) => image.dimensions(),
Self::Raw(image) => image.dimensions(),
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
Self::Sixel(image) => image.dimensions(), Self::Sixel(image) => image.dimensions(),
} }
@ -74,6 +77,7 @@ pub enum ImagePrinter {
Kitty(KittyPrinter), Kitty(KittyPrinter),
Iterm(ItermPrinter), Iterm(ItermPrinter),
Ascii(AsciiPrinter), Ascii(AsciiPrinter),
Raw(RawPrinter),
Null, Null,
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
Sixel(super::protocols::sixel::SixelPrinter), Sixel(super::protocols::sixel::SixelPrinter),
@ -91,6 +95,7 @@ impl ImagePrinter {
GraphicsMode::Kitty { mode, inside_tmux } => Self::new_kitty(mode, inside_tmux)?, GraphicsMode::Kitty { mode, inside_tmux } => Self::new_kitty(mode, inside_tmux)?,
GraphicsMode::Iterm2 => Self::new_iterm(), GraphicsMode::Iterm2 => Self::new_iterm(),
GraphicsMode::AsciiBlocks => Self::new_ascii(), GraphicsMode::AsciiBlocks => Self::new_ascii(),
GraphicsMode::Raw => Self::new_raw(),
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
GraphicsMode::Sixel => Self::new_sixel()?, GraphicsMode::Sixel => Self::new_sixel()?,
}; };
@ -109,6 +114,10 @@ impl ImagePrinter {
Self::Ascii(AsciiPrinter) Self::Ascii(AsciiPrinter)
} }
fn new_raw() -> Self {
Self::Raw(RawPrinter)
}
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
fn new_sixel() -> Result<Self, CreatePrinterError> { fn new_sixel() -> Result<Self, CreatePrinterError> {
Ok(Self::Sixel(super::protocols::sixel::SixelPrinter::new()?)) Ok(Self::Sixel(super::protocols::sixel::SixelPrinter::new()?))
@ -124,6 +133,7 @@ impl PrintImage for ImagePrinter {
Self::Iterm(printer) => TerminalImage::Iterm(printer.register(spec)?), Self::Iterm(printer) => TerminalImage::Iterm(printer.register(spec)?),
Self::Ascii(printer) => TerminalImage::Ascii(printer.register(spec)?), Self::Ascii(printer) => TerminalImage::Ascii(printer.register(spec)?),
Self::Null => return Err(RegisterImageError::Unsupported), Self::Null => return Err(RegisterImageError::Unsupported),
Self::Raw(printer) => TerminalImage::Raw(printer.register(spec)?),
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
Self::Sixel(printer) => TerminalImage::Sixel(printer.register(spec)?), Self::Sixel(printer) => TerminalImage::Sixel(printer.register(spec)?),
}; };
@ -139,6 +149,7 @@ impl PrintImage for ImagePrinter {
(Self::Iterm(printer), TerminalImage::Iterm(image)) => printer.print(image, options, terminal), (Self::Iterm(printer), TerminalImage::Iterm(image)) => printer.print(image, options, terminal),
(Self::Ascii(printer), TerminalImage::Ascii(image)) => printer.print(image, options, terminal), (Self::Ascii(printer), TerminalImage::Ascii(image)) => printer.print(image, options, terminal),
(Self::Null, _) => Ok(()), (Self::Null, _) => Ok(()),
(Self::Raw(printer), TerminalImage::Raw(image)) => printer.print(image, options, terminal),
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
(Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, terminal), (Self::Sixel(printer), TerminalImage::Sixel(image)) => printer.print(image, options, terminal),
_ => Err(PrintImageError::Unsupported), _ => Err(PrintImageError::Unsupported),
@ -165,6 +176,7 @@ impl fmt::Debug for ImageRegistry {
ImagePrinter::Iterm(_) => "Iterm", ImagePrinter::Iterm(_) => "Iterm",
ImagePrinter::Ascii(_) => "Ascii", ImagePrinter::Ascii(_) => "Ascii",
ImagePrinter::Null => "Null", ImagePrinter::Null => "Null",
ImagePrinter::Raw(_) => "Raw",
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
ImagePrinter::Sixel(_) => "Sixel", ImagePrinter::Sixel(_) => "Sixel",
}; };

View File

@ -36,10 +36,6 @@ impl AsciiImage {
cached_sizes.insert(cache_key, image.into_rgba8()); cached_sizes.insert(cache_key, image.into_rgba8());
} }
} }
pub(crate) fn image(&self) -> &DynamicImage {
&self.inner.image
}
} }
impl ImageProperties for AsciiImage { impl ImageProperties for AsciiImage {

View File

@ -1,5 +1,6 @@
pub(crate) mod ascii; pub(crate) mod ascii;
pub(crate) mod iterm; pub(crate) mod iterm;
pub(crate) mod kitty; pub(crate) mod kitty;
pub(crate) mod raw;
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
pub(crate) mod sixel; pub(crate) mod sixel;

View File

@ -0,0 +1,61 @@
use crate::terminal::{
image::printer::{ImageProperties, ImageSpec, PrintImage, PrintImageError, PrintOptions, RegisterImageError},
printer::TerminalIo,
};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{GenericImageView, ImageEncoder, ImageFormat, codecs::png::PngEncoder};
use std::fs;
pub(crate) struct RawImage {
contents: Vec<u8>,
format: ImageFormat,
width: u32,
height: u32,
}
impl RawImage {
pub(crate) fn to_inline_html(&self) -> String {
let mime_type = self.format.to_mime_type();
let data = STANDARD.encode(&self.contents);
format!("data:{mime_type};base64,{data}")
}
}
impl ImageProperties for RawImage {
fn dimensions(&self) -> (u32, u32) {
(self.width, self.height)
}
}
pub(crate) struct RawPrinter;
impl PrintImage for RawPrinter {
type Image = RawImage;
fn register(&self, spec: ImageSpec) -> Result<Self::Image, RegisterImageError> {
let image = match spec {
ImageSpec::Generated(image) => {
let mut contents = Vec::new();
let encoder = PngEncoder::new(&mut contents);
let (width, height) = image.dimensions();
encoder.write_image(image.as_bytes(), width, height, image.color().into())?;
RawImage { contents, format: ImageFormat::Png, width, height }
}
ImageSpec::Filesystem(path) => {
let contents = fs::read(path)?;
let format = image::guess_format(&contents)?;
let image = image::load_from_memory_with_format(&contents, format)?;
let (width, height) = image.dimensions();
RawImage { contents, format, width, height }
}
};
Ok(image)
}
fn print<T>(&self, _image: &Self::Image, _options: &PrintOptions, _terminal: &mut T) -> Result<(), PrintImageError>
where
T: TerminalIo,
{
Err(PrintImageError::Other("raw images can't be printed".into()))
}
}

View File

@ -15,6 +15,7 @@ pub enum GraphicsMode {
inside_tmux: bool, inside_tmux: bool,
}, },
AsciiBlocks, AsciiBlocks,
Raw,
#[cfg(feature = "sixel")] #[cfg(feature = "sixel")]
Sixel, Sixel,
} }

View File

@ -178,10 +178,9 @@ impl FooterLine {
palette: &ColorPalette, palette: &ColorPalette,
) -> Result<Self, InvalidFooterTemplateError> { ) -> Result<Self, InvalidFooterTemplateError> {
use FooterTemplateChunk::*; use FooterTemplateChunk::*;
let mut line = Line::default();
let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars; let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars;
let arena = Arena::default(); let arena = Arena::default();
let parser = MarkdownParser::new(&arena); let mut reassembled = String::new();
for chunk in template.0 { for chunk in template.0 {
let raw_text = match chunk { let raw_text = match chunk {
CurrentSlide => Cow::Owned(current_slide.to_string()), CurrentSlide => Cow::Owned(current_slide.to_string()),
@ -199,20 +198,22 @@ impl FooterLine {
if raw_text.lines().count() != 1 { if raw_text.lines().count() != 1 {
return Err(InvalidFooterTemplateError::NoNewlines); return Err(InvalidFooterTemplateError::NoNewlines);
} }
let starting_length = raw_text.len(); reassembled.push_str(&raw_text);
let raw_text = raw_text.trim_start(); }
let left_whitespace = starting_length - raw_text.len(); // Inline parsing loses leading/trailing whitespaces so re-add them ourselves
let raw_text = raw_text.trim_end(); let starting_length = reassembled.len();
let right_whitespace = starting_length - raw_text.len() - left_whitespace; let raw_text = reassembled.trim_start();
let inlines = parser.parse_inlines(raw_text)?; let left_whitespace = starting_length - raw_text.len();
let mut contents = inlines.resolve(palette)?; let raw_text = raw_text.trim_end();
if left_whitespace != 0 { let right_whitespace = starting_length - raw_text.len() - left_whitespace;
contents.0.insert(0, " ".repeat(left_whitespace).into()); let parser = MarkdownParser::new(&arena);
} let inlines = parser.parse_inlines(&reassembled)?;
if right_whitespace != 0 { let mut line = inlines.resolve(palette)?;
contents.0.push(" ".repeat(right_whitespace).into()); if left_whitespace != 0 {
} line.0.insert(0, " ".repeat(left_whitespace).into());
line.0.extend(contents.0); }
if right_whitespace != 0 {
line.0.push(" ".repeat(right_whitespace).into());
} }
line.apply_style(style); line.apply_style(style);
Ok(Self(line)) Ok(Self(line))
@ -320,4 +321,25 @@ mod tests {
let template = FooterTemplate(vec![chunk]); let template = FooterTemplate(vec![chunk]);
FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect_err("render succeeded"); FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect_err("render succeeded");
} }
#[test]
fn interleaved_spans() {
let chunks = vec![
FooterTemplateChunk::Literal("<span style=\"color: palette:red\">".into()),
FooterTemplateChunk::CurrentSlide,
FooterTemplateChunk::Literal(" / ".into()),
FooterTemplateChunk::TotalSlides,
FooterTemplateChunk::Literal("</span>".into()),
FooterTemplateChunk::Literal("<span style=\"color: green\">".into()),
FooterTemplateChunk::Title,
FooterTemplateChunk::Literal("</span>".into()),
];
let template = FooterTemplate(chunks);
let line = FooterLine::new(template, &Default::default(), &VARIABLES, &PALETTE).expect("render failed");
let expected = &[
Text::new("1 / 5", TextStyle::default().fg_color(Color::new(255, 0, 0))),
Text::new("hi", TextStyle::default().fg_color(Color::Green)),
];
assert_eq!(line.0.0, expected);
}
} }