Compare commits

..

26 Commits

Author SHA1 Message Date
Matias Fontanini
c0298506c5
docs: include right heading in link to configuration
Some checks are pending
Deploy docs / build-and-deploy (push) Waiting to run
Merge checks / Validate bat assets (push) Waiting to run
Merge checks / Validate JSON schemas (push) Waiting to run
Merge checks / Checks (push) Waiting to run
Merge checks / Validate nix flake (push) Waiting to run
2025-05-07 06:22:16 -07:00
Matias Fontanini
55b5474d9d
fix: don't add an extra pause after lists if there's nothing left (#580)
When the last piece of content in a slide is a list with
`incremental_lists: true`, there was an extra pause at the very end that
was being added and added nothing. This change removes that, and also
removes any pause at the end of the slide since it adds nothing (e.g. a
`pause` followed right after by `end_slide`).
2025-05-07 06:20:28 -07:00
Matias Fontanini
14722e548f fix: don't add an extra pause after lists if there's nothing left 2025-05-07 06:15:10 -07:00
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
18 changed files with 388 additions and 119 deletions

View File

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

View File

@ -6,12 +6,12 @@
## 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 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 `--output` option to specify the path where the output file is written to during an export ([#526](https://github.com/mfontanini/presenterm/issues/526)).
* 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.
* 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)).
* 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)).
* 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
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
the repository that you can use as a base.

View File

@ -1,7 +1,7 @@
# Slide transitions
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. See the
[configuration page](../configuration/settings.md) to learn how to configure transitions.
[configuration page](../configuration/settings.md#slide-transitions) to learn how to configure transitions.
The following animations are supported:

View File

@ -2,7 +2,7 @@ use crate::{
MarkdownParser, Resources,
code::execute::SnippetExecutor,
config::{KeyBindingsConfig, PauseExportPolicy},
export::pdf::PdfRender,
export::output::{ExportRenderer, OutputFormat},
markdown::{parse::ParseError, text_style::Color},
presentation::{
Presentation,
@ -98,24 +98,12 @@ impl<'a> Exporter<'a> {
Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions }
}
/// Export the given presentation into PDF.
///
/// This uses a separate `presenterm-export` tool.
pub fn export_pdf(
mut self,
fn build_renderer(
&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")?;
renderer: OutputFormat,
) -> Result<ExportRenderer, ExportError> {
let content = fs::read_to_string(presentation_path).map_err(ExportError::ReadPresentation)?;
let elements = self.parser.parse(&content)?;
@ -132,7 +120,7 @@ impl<'a> Exporter<'a> {
.build(elements)?;
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::render_async_images(&mut presentation);
@ -143,10 +131,32 @@ impl<'a> Exporter<'a> {
}
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 {
Some(path) => path.to_path_buf(),
None => presentation_path.with_extension("pdf"),
};
render.generate(&pdf_path)?;
execute!(
@ -158,6 +168,36 @@ impl<'a> Exporter<'a> {
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) {
let poller = Poller::launch();
let mut pollables = Vec::new();

View File

@ -1,3 +1,3 @@
pub mod exporter;
pub(crate) mod html;
pub(crate) mod pdf;
pub(crate) mod output;

View File

@ -8,15 +8,11 @@ use crate::{
presentation::Slide,
render::{engine::RenderEngine, properties::WindowSize},
terminal::{
image::{
Image, ImageSource,
printer::{ImageProperties, TerminalImage},
},
image::printer::TerminalImage,
virt::{TerminalGrid, VirtualTerminal},
},
tools::ThirdPartyTools,
};
use image::{ImageEncoder, codecs::png::PngEncoder};
use std::{
fs, io,
path::{Path, PathBuf},
@ -37,8 +33,9 @@ struct 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();
rows.push(String::from("<div class=\"container\">"));
for (y, row) in grid.rows.into_iter().enumerate() {
let mut finalized_row = "<div class=\"content-line\"><pre>".to_string();
let mut current_style = row.first().map(|c| c.style).unwrap_or_default();
@ -57,11 +54,11 @@ impl HtmlSlide {
other => current_string.push(other),
}
if let Some(image) = grid.images.get(&(y as u16, x as u16)) {
let image_path = content_manager.persist_image(&image.image)?;
let image_path_str = image_path.display();
let TerminalImage::Raw(raw_image) = image.image.image() else { panic!("not in raw image mode") };
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 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);
}
@ -73,6 +70,7 @@ impl HtmlSlide {
finalized_row.push_str("</pre></div>");
rows.push(finalized_row);
}
rows.push(String::from("</div>"));
Ok(HtmlSlide { rows, background_color: grid.background_color.as_ref().map(color_to_html) })
}
@ -84,35 +82,11 @@ impl HtmlSlide {
pub(crate) struct ContentManager {
output_directory: OutputDirectory,
image_count: usize,
}
impl ContentManager {
pub(crate) fn new(output_directory: OutputDirectory) -> Self {
Self { output_directory, image_count: 0 }
}
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)
}
}
Self { output_directory }
}
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,
output_format: OutputFormat,
dimensions: WindowSize,
html_body: String,
background_color: Option<String>,
}
impl PdfRender {
pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory) -> Self {
impl ExportRenderer {
pub(crate) fn new(dimensions: WindowSize, output_directory: OutputDirectory, output_type: OutputFormat) -> Self {
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> {
@ -141,7 +127,7 @@ impl PdfRender {
engine.render(slide.iter_operations())?;
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() {
self.background_color.clone_from(&slide.background_color);
}
@ -152,19 +138,24 @@ impl PdfRender {
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 = format!(
r#"<html>
<head>
</head>
<body>
{html_body}</body>
</html>"#
);
let script = include_str!("script.js");
let width = (self.dimensions.columns as f64 * FONT_SIZE as f64 * FONT_SIZE_WIDTH).ceil();
let height = self.dimensions.rows * LINE_HEIGHT;
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!(
r"
pre {{
@ -180,8 +171,14 @@ impl PdfRender {
margin: 0;
font-size: {FONT_SIZE}px;
line-height: {LINE_HEIGHT}px;
background-color: {background_color};
width: {width}px;
height: {height}px;
transform-origin: top left;
background-color: {background_color};
}}
.container {{
{container}
}}
.content-line {{
@ -191,25 +188,73 @@ impl PdfRender {
width: {width}px;
}}
.hidden {{
display: none;
}}
@page {{
margin: 0;
height: {height}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 css_path = self.content_manager.persist_file("styles.css", css.as_bytes())?;
ThirdPartyTools::weasyprint(&[
"-s",
css_path.to_string_lossy().as_ref(),
"--presentational-hints",
"-e",
"utf8",
html_path.to_string_lossy().as_ref(),
pdf_path.to_string_lossy().as_ref(),
])
.run()?;
match self.output_format {
OutputFormat::Pdf => {
ThirdPartyTools::weasyprint(&[
"-s",
css_path.to_string_lossy().as_ref(),
"--presentational-hints",
"-e",
"utf8",
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(())
}
}

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>,
/// Export the presentation as a PDF rather than displaying it.
#[clap(short, long)]
#[clap(short, long, group = "export")]
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.
#[clap(long, requires = "export_pdf")]
#[clap(long, requires = "export")]
export_temporary_path: Option<PathBuf>,
/// 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>,
/// Generate a JSON schema for the configuration file.
@ -289,8 +293,8 @@ impl CoreComponents {
}
fn select_graphics_mode(cli: &Cli, config: &Config) -> GraphicsMode {
if cli.export_pdf {
GraphicsMode::AsciiBlocks
if cli.export_pdf | cli.export_html {
GraphicsMode::Raw
} else {
let protocol = cli.image_protocol.as_ref().unwrap_or(&config.defaults.image_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 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 {
Some(dimensions) => WindowSize {
rows: dimensions.rows,
@ -426,7 +430,11 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
Some(path) => OutputDirectory::external(path),
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 {
let SpeakerNotesComponents { events_listener, events_publisher } =
SpeakerNotesComponents::new(&cli, &config, &path)?;

View File

@ -959,7 +959,10 @@ impl<'a> PresentationBuilder<'a> {
let mutators = mem::take(&mut self.chunk_mutators);
if !self.slide_state.skip_slide {
self.slide_chunks.push(SlideChunk::new(operations, mutators));
// Don't allow a last empty pause in slide since it adds nothing
if self.slide_chunks.is_empty() || !Self::is_chunk_empty(&operations) {
self.slide_chunks.push(SlideChunk::new(operations, mutators));
}
let chunks = mem::take(&mut self.slide_chunks);
let builder = SlideBuilder::default().chunks(chunks);
@ -977,6 +980,18 @@ impl<'a> PresentationBuilder<'a> {
self.slide_state.last_element = LastElement::None;
}
fn is_chunk_empty(operations: &[RenderOperation]) -> bool {
if operations.is_empty() {
return true;
}
for operation in operations {
if !matches!(operation, RenderOperation::RenderLineBreak) {
return false;
}
}
true
}
fn generate_footer(&self) -> Result<Vec<RenderOperation>, BuildError> {
let generator = FooterGenerator::new(self.theme.footer.clone(), &self.footer_vars, &self.theme.palette)?;
Ok(vec![
@ -1721,6 +1736,7 @@ theme:
ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered },
ListItem { depth: 0, contents: "three".into(), item_type: ListItemType::Unordered },
]),
MarkdownElement::Paragraph(vec!["hi".into()]),
];
let slides = build_presentation_with_options(elements, options).into_slides();
assert_eq!(slides[0].iter_chunks().count(), expected_chunks);
@ -1756,6 +1772,20 @@ theme:
assert_eq!(slides.len(), 2);
}
#[test]
fn incremental_lists_end_of_slide() {
let elements = vec![
MarkdownElement::Comment { comment: "incremental_lists: true".into(), source_position: Default::default() },
MarkdownElement::List(vec![
ListItem { depth: 0, contents: "one".into(), item_type: ListItemType::Unordered },
ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered },
]),
];
let slides = build_presentation(elements).into_slides();
// There shouldn't be an extra one at the end
assert_eq!(slides[0].iter_chunks().count(), 3);
}
#[test]
fn skip_slide() {
let elements = vec![

View File

@ -1,5 +1,3 @@
use itertools::Itertools;
use super::{BuildError, BuildResult, ExecutionMode, PresentationBuilderOptions};
use crate::{
ImageRegistry,
@ -18,13 +16,14 @@ use crate::{
properties::WindowSize,
},
resource::Resources,
theme::{CodeBlockStyle, PresentationTheme},
theme::{Alignment, CodeBlockStyle, PresentationTheme},
third_party::{ThirdPartyRender, ThirdPartyRenderRequest},
ui::execution::{
RunAcquireTerminalSnippet, RunImageSnippet, RunSnippetOperation, SnippetExecutionDisabledOperation,
disabled::ExecutionType, snippet::DisplaySeparator,
},
};
use itertools::Itertools;
use std::{cell::RefCell, rc::Rc, sync::Arc};
pub(crate) struct SnippetProcessorState<'a> {
@ -308,7 +307,15 @@ impl<'a> SnippetProcessor<'a> {
ExecutionMode::AlongSnippet => DisplaySeparator::On,
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 mut execution_output_style = self.theme.execution_output.clone();
if snippet.attributes.no_background {

View File

@ -1,7 +1,6 @@
use self::printer::{ImageProperties, TerminalImage};
use image::DynamicImage;
use protocols::ascii::AsciiImage;
use self::printer::{ImageProperties, TerminalImage};
use std::{
fmt::Debug,
ops::Deref,
@ -43,6 +42,7 @@ impl Image {
TerminalImage::Ascii(image) => image.clone(),
TerminalImage::Kitty(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")]
TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(),
};

View File

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

View File

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

View File

@ -1,5 +1,6 @@
pub(crate) mod ascii;
pub(crate) mod iterm;
pub(crate) mod kitty;
pub(crate) mod raw;
#[cfg(feature = "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,
},
AsciiBlocks,
Raw,
#[cfg(feature = "sixel")]
Sixel,
}

View File

@ -178,10 +178,9 @@ impl FooterLine {
palette: &ColorPalette,
) -> Result<Self, InvalidFooterTemplateError> {
use FooterTemplateChunk::*;
let mut line = Line::default();
let FooterVariables { current_slide, total_slides, author, title, sub_title, event, location, date } = vars;
let arena = Arena::default();
let parser = MarkdownParser::new(&arena);
let mut reassembled = String::new();
for chunk in template.0 {
let raw_text = match chunk {
CurrentSlide => Cow::Owned(current_slide.to_string()),
@ -199,20 +198,22 @@ impl FooterLine {
if raw_text.lines().count() != 1 {
return Err(InvalidFooterTemplateError::NoNewlines);
}
let starting_length = raw_text.len();
let raw_text = raw_text.trim_start();
let left_whitespace = starting_length - raw_text.len();
let raw_text = raw_text.trim_end();
let right_whitespace = starting_length - raw_text.len() - left_whitespace;
let inlines = parser.parse_inlines(raw_text)?;
let mut contents = inlines.resolve(palette)?;
if left_whitespace != 0 {
contents.0.insert(0, " ".repeat(left_whitespace).into());
}
if right_whitespace != 0 {
contents.0.push(" ".repeat(right_whitespace).into());
}
line.0.extend(contents.0);
reassembled.push_str(&raw_text);
}
// Inline parsing loses leading/trailing whitespaces so re-add them ourselves
let starting_length = reassembled.len();
let raw_text = reassembled.trim_start();
let left_whitespace = starting_length - raw_text.len();
let raw_text = raw_text.trim_end();
let right_whitespace = starting_length - raw_text.len() - left_whitespace;
let parser = MarkdownParser::new(&arena);
let inlines = parser.parse_inlines(&reassembled)?;
let mut line = inlines.resolve(palette)?;
if left_whitespace != 0 {
line.0.insert(0, " ".repeat(left_whitespace).into());
}
if right_whitespace != 0 {
line.0.push(" ".repeat(right_whitespace).into());
}
line.apply_style(style);
Ok(Self(line))
@ -320,4 +321,25 @@ mod tests {
let template = FooterTemplate(vec![chunk]);
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);
}
}