Compare commits

..

34 Commits

Author SHA1 Message Date
Matias Fontanini
af27dc17aa
feat: add support for Jsonnet (#585)
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
Thanks a lot for such a great piece of software!

This PR adds syntax highlighting and snippets execution support for
Google's [Jsonnet language](https://jsonnet.org/).

<details>
<summary>An example of Jsonnet code execution</summary>


![jsonnet-code-execution](https://github.com/user-attachments/assets/e6e24d79-5e36-4bb6-b5c3-02c7e00652fa)
</details>

Regards,
Imo
2025-05-09 06:35:18 -07:00
Matias Fontanini
bb60be3d10
Fix typo in highlighting.md (#586)
Single word change on line 150: `s/shot/show`

Thanks!
2025-05-09 06:31:24 -07:00
Tristram Oaten
f626a5c320
Fix typo in highlighting.md
Single word change on line 150: `s/shot/show`

Thanks!
2025-05-09 07:31:14 +01:00
Imobach González Sosa
ed65e117cb
feat: add support for Jsonnet
* Add syntax highlighting and snippets execution support.
2025-05-09 07:02:23 +01:00
Matias Fontanini
d745c36d53
feat: allow configuring sequential executable snippet processing (#584)
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
This adds a new attribute `export.snippets` in the config file that
configured how executable snippets are handled when exporting
PDFs/HTMLs. By default it uses `parallel` meaning all snippets are ran
at the same time, whereas `sequential` means only one snippet will be
ran at a time. This can help handle cases were snippets have to be ran
sequentially because the output of snippet N depends on the output of
snippet M < N.

Example config:

```yaml
export:
  snippets: sequential
```

Closes #582
2025-05-08 17:01:24 -07:00
Matias Fontanini
ed9fa1bb52 feat: allow configuring sequential executable snippet processing 2025-05-08 16:56:31 -07:00
Matias Fontanini
2443e8a8f7
fix: execute snippets only once during export (#583)
A recent refactoring on snippet execution caused a disconnect between a
snippet being executed and how it reflected its state on the shared
types between the snippet and the component that polls it. This caused
PDF exports that contained more than one executable snippet to only have
its first snippet executed and the rest still shown as "running".

Addresses part of #582
2025-05-08 16:40:50 -07:00
Matias Fontanini
c8df679dcf fix: execute snippets only once during export 2025-05-08 16:24:38 -07:00
Matias Fontanini
c0298506c5
docs: include right heading in link to configuration
Some checks failed
Merge checks / Validate bat assets (push) Has been cancelled
Merge checks / Validate JSON schemas (push) Has been cancelled
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
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
26 changed files with 576 additions and 148 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

@ -141,6 +141,14 @@
"$ref": "#/definitions/PauseExportPolicy" "$ref": "#/definitions/PauseExportPolicy"
} }
] ]
},
"snippets": {
"description": "The policy for executable snippets when exporting.",
"allOf": [
{
"$ref": "#/definitions/SnippetsExportPolicy"
}
]
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -702,6 +710,7 @@
"Java", "Java",
"JavaScript", "JavaScript",
"Json", "Json",
"Jsonnet",
"Julia", "Julia",
"Kotlin", "Kotlin",
"Latex", "Latex",
@ -767,6 +776,25 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"SnippetsExportPolicy": {
"description": "The policy for executable snippets when exporting.",
"oneOf": [
{
"description": "Render all executable snippets in parallel.",
"type": "string",
"enum": [
"parallel"
]
},
{
"description": "Render all executable snippets sequentially.",
"type": "string",
"enum": [
"sequential"
]
}
]
},
"SpeakerNotesConfig": { "SpeakerNotesConfig": {
"type": "object", "type": "object",
"properties": { "properties": {

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

@ -147,7 +147,7 @@ If you'd like to include only a subset of the file, you can use the optional fie
```file +exec +line_numbers ```file +exec +line_numbers
path: snippet.rs path: snippet.rs
language: rust language: rust
# Only shot lines 5-10 # Only show lines 5-10
start_line: 5 start_line: 5
end_line: 10 end_line: 10
``` ```

View File

@ -1,7 +1,7 @@
# Slide transitions # Slide transitions
Slide transitions allow animating your presentation every time you move from a slide to the next/previous one. See the 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: The following animations are supported:

View File

@ -47,6 +47,11 @@ julia:
commands: commands:
- ["julia", "$pwd/snippet.jl"] - ["julia", "$pwd/snippet.jl"]
hidden_line_prefix: "/// " hidden_line_prefix: "/// "
jsonnet:
filename: snippet.jsonnet
commands:
- ["jsonnet", "$pwd/snippet.jsonnet"]
hidden_line_prefix: "## "
kotlin: kotlin:
filename: snippet.kts filename: snippet.kts
commands: commands:

View File

@ -131,6 +131,7 @@ impl SnippetHighlighter {
Java => "java", Java => "java",
JavaScript => "js", JavaScript => "js",
Json => "json", Json => "json",
Jsonnet => "jsonnet",
Julia => "jl", Julia => "jl",
Kotlin => "kt", Kotlin => "kt",
Latex => "tex", Latex => "tex",

View File

@ -469,6 +469,7 @@ pub enum SnippetLanguage {
Java, Java,
JavaScript, JavaScript,
Json, Json,
Jsonnet,
Julia, Julia,
Kotlin, Kotlin,
Latex, Latex,
@ -543,6 +544,7 @@ impl FromStr for SnippetLanguage {
"java" => Java, "java" => Java,
"javascript" | "js" => JavaScript, "javascript" | "js" => JavaScript,
"json" => Json, "json" => Json,
"jsonnet" => Jsonnet,
"julia" => Julia, "julia" => Julia,
"kotlin" => Kotlin, "kotlin" => Kotlin,
"latex" => Latex, "latex" => Latex,

View File

@ -518,6 +518,10 @@ pub struct ExportConfig {
/// Whether pauses should create new slides. /// Whether pauses should create new slides.
#[serde(default)] #[serde(default)]
pub pauses: PauseExportPolicy, pub pauses: PauseExportPolicy,
/// The policy for executable snippets when exporting.
#[serde(default)]
pub snippets: SnippetsExportPolicy,
} }
/// The policy for pauses when exporting. /// The policy for pauses when exporting.
@ -533,6 +537,19 @@ pub enum PauseExportPolicy {
NewSlide, NewSlide,
} }
/// The policy for executable snippets when exporting.
#[derive(Clone, Debug, Default, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub enum SnippetsExportPolicy {
/// Render all executable snippets in parallel.
#[default]
Parallel,
/// Render all executable snippets sequentially.
Sequential,
}
/// The dimensions to use for presentation exports. /// The dimensions to use for presentation exports.
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]

View File

@ -1,8 +1,8 @@
use crate::{ use crate::{
MarkdownParser, Resources, MarkdownParser, Resources,
code::execute::SnippetExecutor, code::execute::SnippetExecutor,
config::{KeyBindingsConfig, PauseExportPolicy}, config::{KeyBindingsConfig, PauseExportPolicy, SnippetsExportPolicy},
export::pdf::PdfRender, export::output::{ExportRenderer, OutputFormat},
markdown::{parse::ParseError, text_style::Color}, markdown::{parse::ParseError, text_style::Color},
presentation::{ presentation::{
Presentation, Presentation,
@ -67,6 +67,7 @@ pub struct Exporter<'a> {
themes: Themes, themes: Themes,
dimensions: WindowSize, dimensions: WindowSize,
options: PresentationBuilderOptions, options: PresentationBuilderOptions,
snippet_policy: SnippetsExportPolicy,
} }
impl<'a> Exporter<'a> { impl<'a> Exporter<'a> {
@ -82,6 +83,7 @@ impl<'a> Exporter<'a> {
mut options: PresentationBuilderOptions, mut options: PresentationBuilderOptions,
mut dimensions: WindowSize, mut dimensions: WindowSize,
pause_policy: PauseExportPolicy, pause_policy: PauseExportPolicy,
snippet_policy: SnippetsExportPolicy,
) -> Self { ) -> Self {
// We don't want dynamically highlighted code blocks. // We don't want dynamically highlighted code blocks.
options.allow_mutations = false; options.allow_mutations = false;
@ -95,27 +97,25 @@ impl<'a> Exporter<'a> {
let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64); let width = (0.5 * dimensions.columns as f64) / (dimensions.rows as f64 / dimensions.height as f64);
dimensions.width = width as u16; dimensions.width = width as u16;
Self { parser, default_theme, resources, third_party, code_executor, themes, options, dimensions } Self {
parser,
default_theme,
resources,
third_party,
code_executor,
themes,
options,
dimensions,
snippet_policy,
}
} }
/// 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,9 +132,12 @@ 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); match self.snippet_policy {
SnippetsExportPolicy::Parallel => Self::wait_async_renders_parallel(&mut presentation),
SnippetsExportPolicy::Sequential => Self::wait_async_renders_sequential(&mut presentation),
};
for (index, slide) in presentation.into_slides().into_iter().enumerate() { for (index, slide) in presentation.into_slides().into_iter().enumerate() {
let index = index + 1; let index = index + 1;
@ -143,10 +146,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,7 +183,37 @@ impl<'a> Exporter<'a> {
Ok(()) Ok(())
} }
fn render_async_images(presentation: &mut Presentation) { /// 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 wait_async_renders_parallel(presentation: &mut Presentation) {
let poller = Poller::launch(); let poller = Poller::launch();
let mut pollables = Vec::new(); let mut pollables = Vec::new();
for (index, slide) in presentation.iter_slides().enumerate() { for (index, slide) in presentation.iter_slides().enumerate() {
@ -189,6 +244,27 @@ impl<'a> Exporter<'a> {
} }
} }
fn wait_async_renders_sequential(presentation: &mut Presentation) {
let poller = Poller::launch();
for (index, slide) in presentation.iter_slides_mut().enumerate() {
for op in slide.iter_operations_mut() {
if let RenderOperation::RenderAsync(inner) = op {
// Send a pollable to the poller
poller.send(PollerCommand::Poll { pollable: inner.pollable(), slide: index });
// Poll until it's done
let mut pollable = inner.pollable();
while let PollableState::Unmodified | PollableState::Modified = pollable.poll() {}
// Replace it with its contents
let window_size = WindowSize { rows: 0, columns: 0, width: 0, height: 0 };
let new_operations = inner.as_render_operations(&window_size);
*op = RenderOperation::RenderDynamic(Rc::new(RenderMany(new_operations)));
}
}
}
}
fn validate_weasyprint_exists() -> Result<(), ExportError> { fn validate_weasyprint_exists() -> Result<(), ExportError> {
let result = ThirdPartyTools::weasyprint(&["--version"]).run_and_capture_stdout(); let result = ThirdPartyTools::weasyprint(&["--version"]).run_and_capture_stdout();
match result { match result {

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,
@ -421,12 +425,17 @@ fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
builder_options, builder_options,
dimensions, dimensions,
config.export.pauses, config.export.pauses,
config.export.snippets,
); );
let output_directory = match cli.export_temporary_path { let output_directory = match cli.export_temporary_path {
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

@ -959,7 +959,10 @@ impl<'a> PresentationBuilder<'a> {
let mutators = mem::take(&mut self.chunk_mutators); let mutators = mem::take(&mut self.chunk_mutators);
if !self.slide_state.skip_slide { 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 chunks = mem::take(&mut self.slide_chunks);
let builder = SlideBuilder::default().chunks(chunks); let builder = SlideBuilder::default().chunks(chunks);
@ -977,6 +980,18 @@ impl<'a> PresentationBuilder<'a> {
self.slide_state.last_element = LastElement::None; 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> { fn generate_footer(&self) -> Result<Vec<RenderOperation>, BuildError> {
let generator = FooterGenerator::new(self.theme.footer.clone(), &self.footer_vars, &self.theme.palette)?; let generator = FooterGenerator::new(self.theme.footer.clone(), &self.footer_vars, &self.theme.palette)?;
Ok(vec![ Ok(vec![
@ -1721,6 +1736,7 @@ theme:
ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered }, ListItem { depth: 1, contents: "two".into(), item_type: ListItemType::Unordered },
ListItem { depth: 0, contents: "three".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(); let slides = build_presentation_with_options(elements, options).into_slides();
assert_eq!(slides[0].iter_chunks().count(), expected_chunks); assert_eq!(slides[0].iter_chunks().count(), expected_chunks);
@ -1756,6 +1772,20 @@ theme:
assert_eq!(slides.len(), 2); 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] #[test]
fn skip_slide() { fn skip_slide() {
let elements = vec![ let elements = vec![

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

@ -194,7 +194,7 @@ pub(crate) trait Pollable: Send + 'static {
} }
/// The state of a [Pollable]. /// The state of a [Pollable].
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq)]
pub(crate) enum PollableState { pub(crate) enum PollableState {
Unmodified, Unmodified,
Modified, Modified,

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

@ -27,12 +27,20 @@ use std::{
const MINIMUM_SEPARATOR_WIDTH: u16 = 32; const MINIMUM_SEPARATOR_WIDTH: u16 = 32;
#[derive(Debug)] #[derive(Default, Debug)]
enum State {
#[default]
Initial,
Running(ExecutionHandle),
Done,
}
#[derive(Debug, Default)]
struct Inner { struct Inner {
output_lines: Vec<WeightedLine>, output_lines: Vec<WeightedLine>,
max_line_length: u16, max_line_length: u16,
process_status: Option<ProcessStatus>, process_status: Option<ProcessStatus>,
started: bool, state: State,
} }
#[derive(Debug)] #[derive(Debug)]
@ -66,7 +74,7 @@ impl RunSnippetOperation {
let block_colors = style.style.colors; let block_colors = style.style.colors;
let status_colors = style.status.clone(); let status_colors = style.status.clone();
let block_length = alignment.adjust_size(block_length); let block_length = alignment.adjust_size(block_length);
let inner = Inner { output_lines: Vec::new(), max_line_length: 0, process_status: None, started: false }; let inner = Inner::default();
Self { Self {
code, code,
executor, executor,
@ -116,7 +124,7 @@ impl AsRenderOperations for RunSnippetOperation {
} }
DisplaySeparator::Off => vec![], DisplaySeparator::Off => vec![],
}; };
if !inner.started { if let State::Initial = inner.state {
return operations; return operations;
} }
operations.push(RenderOperation::RenderLineBreak); operations.push(RenderOperation::RenderLineBreak);
@ -155,9 +163,8 @@ impl RenderAsync for RunSnippetOperation {
inner: self.inner.clone(), inner: self.inner.clone(),
executor: self.executor.clone(), executor: self.executor.clone(),
code: self.code.clone(), code: self.code.clone(),
handle: None,
last_length: 0, last_length: 0,
starting_style: TextStyle::default().size(self.font_size), style: TextStyle::default().size(self.font_size),
}) })
} }
@ -170,24 +177,21 @@ struct OperationPollable {
inner: Arc<Mutex<Inner>>, inner: Arc<Mutex<Inner>>,
executor: Arc<SnippetExecutor>, executor: Arc<SnippetExecutor>,
code: Snippet, code: Snippet,
handle: Option<ExecutionHandle>,
last_length: usize, last_length: usize,
starting_style: TextStyle, style: TextStyle,
} }
impl OperationPollable { impl OperationPollable {
fn try_start(&mut self) { fn try_start(&self, inner: &mut Inner) {
let mut inner = self.inner.lock().unwrap(); // Don't run twice.
if inner.started { if !matches!(inner.state, State::Initial) {
return; return;
} }
inner.started = true; inner.state = match self.executor.execute_async(&self.code) {
match self.executor.execute_async(&self.code) { Ok(handle) => State::Running(handle),
Ok(handle) => {
self.handle = Some(handle);
}
Err(e) => { Err(e) => {
inner.output_lines = vec![WeightedLine::from(e.to_string())]; inner.output_lines = vec![WeightedLine::from(e.to_string())];
State::Done
} }
} }
} }
@ -195,10 +199,13 @@ impl OperationPollable {
impl Pollable for OperationPollable { impl Pollable for OperationPollable {
fn poll(&mut self) -> PollableState { fn poll(&mut self) -> PollableState {
self.try_start(); let mut inner = self.inner.lock().unwrap();
self.try_start(&mut inner);
// At this point if we don't have a handle it's because we're done. // At this point if we don't have a handle it's because we're done.
let Some(handle) = self.handle.as_mut() else { return PollableState::Done }; let State::Running(handle) = &mut inner.state else {
return PollableState::Done;
};
// Pull data out of the process' output and drop the handle state. // Pull data out of the process' output and drop the handle state.
let mut state = handle.state.lock().unwrap(); let mut state = handle.state.lock().unwrap();
@ -217,23 +224,20 @@ impl Pollable for OperationPollable {
drop(state); drop(state);
let mut max_line_length = 0; let mut max_line_length = 0;
let (lines, style) = AnsiSplitter::new(self.starting_style).split_lines(&lines); let (lines, _) = AnsiSplitter::new(self.style).split_lines(&lines);
for line in &lines { for line in &lines {
let width = u16::try_from(line.width()).unwrap_or(u16::MAX); let width = u16::try_from(line.width()).unwrap_or(u16::MAX);
max_line_length = max_line_length.max(width); max_line_length = max_line_length.max(width);
} }
let mut inner = self.inner.lock().unwrap();
let is_finished = status.is_finished(); let is_finished = status.is_finished();
inner.process_status = Some(status); inner.process_status = Some(status);
inner.output_lines = lines; inner.output_lines = lines;
inner.max_line_length = inner.max_line_length.max(max_line_length); inner.max_line_length = inner.max_line_length.max(max_line_length);
if is_finished { if is_finished {
self.handle.take(); inner.state = State::Done;
PollableState::Done PollableState::Done
} else { } else {
// Save the style so we continue with it next time
self.starting_style = style;
match modified { match modified {
true => PollableState::Modified, true => PollableState::Modified,
false => PollableState::Unmodified, false => PollableState::Unmodified,
@ -241,3 +245,68 @@ impl Pollable for OperationPollable {
} }
} }
} }
#[cfg(all(target_os = "linux", test))]
mod tests {
use super::*;
use crate::{
code::snippet::{SnippetAttributes, SnippetExec, SnippetLanguage},
markdown::text_style::Color,
};
fn make_run_shell(code: &str) -> RunSnippetOperation {
let snippet = Snippet {
contents: code.into(),
language: SnippetLanguage::Bash,
attributes: SnippetAttributes { execution: SnippetExec::Exec, ..Default::default() },
};
let executor = Arc::new(SnippetExecutor::new(Default::default(), ".".into()).unwrap());
let default_colors = Default::default();
let style = ExecutionOutputBlockStyle::default();
let block_length = 0;
let separator = DisplaySeparator::On;
let alignment = Default::default();
let font_size = 1;
let policy = RenderAsyncStartPolicy::OnDemand;
RunSnippetOperation::new(
snippet,
executor,
default_colors,
style,
block_length,
separator,
alignment,
font_size,
policy,
)
}
#[test]
fn run_command() {
let operation = make_run_shell("echo -e '\\033[1;31mhi mom'");
let mut pollable = operation.pollable();
// Run until done
while let PollableState::Modified | PollableState::Unmodified = pollable.poll() {}
// Expect to see the output lines
let inner = operation.inner.lock().unwrap();
let line = Line::from(Text::new("hi mom", TextStyle::default().fg_color(Color::Red).bold()));
assert_eq!(inner.output_lines, vec![line.into()]);
}
#[test]
fn multiple_pollables() {
let operation = make_run_shell("echo -e '\\033[1;31mhi mom'");
let mut main_pollable = operation.pollable();
let mut pollable2 = operation.pollable();
// Run until done
while let PollableState::Modified | PollableState::Unmodified = main_pollable.poll() {}
// Polling a pollable created early should return `Done` immediately
assert_eq!(pollable2.poll(), PollableState::Done);
// A new pollable should claim `Done` immediately
let mut pollable3 = operation.pollable();
assert_eq!(pollable3.poll(), PollableState::Done);
}
}

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);
}
} }