Headless SVG Export Macroquad Panic Clarification

Clarified and improved error reporting when drawing routines invoke
Macroquad window or rendering functions (such as `screen_width()` or
`screen_height()`) during headless SVG export (`--export-svg`).

[export.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/export.rs)
- Added `ExportError::Execution(String)` variant to represent drawing
  execution failures and panics.
- Implemented `std::fmt::Display` and `std::error::Error` for
  `ExportError`.
- Created `PanicHookGuard` RAII struct ensuring any installed panic
  hooks are automatically restored after execution.
- In `run_headless_svg_export`:
  - Chains onto the existing panic hook to preserve standard panic
    backtrace and line number information.
  - Detects if the panic originated from uninitialized Macroquad context
    (`THREAD_ID.is_some()`).
  - Emits a clear, prominent diagnostic banner explaining that
    window/GUI functions are unavailable in headless mode.
  - Catches the panic via `std::panic::catch_unwind` and returns
    `Err(ExportError::Execution(...))`.
  - In `handle_svg_export`, uses `{e}` (Display) rather than `{e:?}`
    (Debug) for cleaner error reporting.

[lib.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib-macros/src/lib.rs)
- Updated the expanded `main` function generated by `#[turtle_main]` to
  format errors with `{}` (Display) instead of `{:?}` (Debug).

[sierpinski_triangle.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/examples/sierpinski_triangle.rs)
- Added documentation notes to the module header and
  `sierpinski_triangle_auto` noting that the example requires an active
  graphics window and cannot be exported to SVG headlessly because it
  queries window dimensions.

---

Executed:
```bash
RUST_BACKTRACE=1 cargo run --example sierpinski_triangle --features svg
-- --export-svg sier.svg
```

Output:
```
thread 'main' (49702) panicked at
/home/dietrich/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/macroquad-0.4.16/src/lib.rs:172:13:
assertion failed: THREAD_ID.is_some()
stack backtrace:
0: __rustc::rust_begin_unwind
...
5: macroquad::window::screen_width
6: sierpinski_triangle::sierpinski_triangle_auto
at ./turtle-lib/examples/sierpinski_triangle.rs:100:20
7: sierpinski_triangle::draw_sierpinski
at ./turtle-lib/examples/sierpinski_triangle.rs:39:5
...
================================================================================
Headless SVG Export Note:
A Macroquad window/rendering function (e.g. `screen_width()`,
`screen_height()`,
or input check) was called while running in headless export mode.
Headless export does not initialize a graphics window. To resolve this:
  - Use relative turtle commands or fixed coordinates instead of window
    queries, or
  - Run the program in windowed GUI mode without the `--export-svg`
    flag.
    ================================================================================

Error exporting SVG: execution error: Drawing function called Macroquad
window/GUI functions (e.g. `screen_width()`, `screen_height()`) which
are unavailable in headless SVG export mode.
```
- Process exited cleanly with exit code 1.

Executed:
```bash
cargo run --example koch --features svg -- --export-svg /tmp/koch.svg
```
Output:
```
SVG exported successfully to: /tmp/koch.svg
```
Exit code 0.

```bash
cargo test --package turtle-lib
cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic
-Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
-Aclippy::cast_possible_truncation
```
- 17 unit tests + 34 doctests passed (1 ignored doctest).
- Clippy completed with zero warnings.
This commit is contained in:
2026-09-20 11:11:59 +02:00
parent 05c2654005
commit d855750970
3 changed files with 114 additions and 7 deletions
@@ -17,6 +17,10 @@
//! That is relatively easy to implement, as long as you follow these steps and let recursion do
//! the rest. Another little bonus this example provides is the ability to customize the drawing
//! size: the triangle will stay correctly sized and positioned automatically.
//!
//! Note: This example queries `screen_width()` and `screen_height()` to calculate positioning
//! relative to the window. Consequently, it requires an active graphics window and does not
//! support headless SVG export (`--export-svg`).
use macroquad::window::{screen_height, screen_width};
use turtle_lib::*;
@@ -86,6 +90,9 @@ fn sierpinski_triangle(turtle: &mut TurtlePlan, level: u8, size: f32) {
/// `level` is still required, it can't be computed automatically. However, given the used
/// canvas size, it will compute the appropriate size and start point so the triangle gets
/// centered and occupies as much drawing space as possible while staying in bounds.
///
/// Note: Because this function queries window dimensions, it requires an active graphics
/// window and cannot be run in headless SVG export mode.
fn sierpinski_triangle_auto(turtle: &mut TurtlePlan, level: u8) {
let size = TRIANGLE_SIZE;
+106 -6
View File
@@ -7,9 +7,29 @@ use crate::TurtlePlan;
pub enum ExportError {
Io(std::io::Error),
Format(String),
Execution(String),
// Additional formats can be added here.
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(err) => write!(f, "I/O error: {err}"),
Self::Format(msg) => write!(f, "format error: {msg}"),
Self::Execution(msg) => write!(f, "execution error: {msg}"),
}
}
}
impl std::error::Error for ExportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Format(_) | Self::Execution(_) => None,
}
}
}
#[derive(Clone, Copy, Debug)]
pub enum DrawingFormat {
#[cfg(feature = "svg")]
@@ -41,25 +61,105 @@ pub fn parse_svg_export_arg() -> Option<String> {
None
}
#[cfg(feature = "svg")]
type PanicHookFn = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send + 'static>;
#[cfg(feature = "svg")]
struct PanicHookGuard {
prev_hook: Option<std::sync::Arc<PanicHookFn>>,
}
#[cfg(feature = "svg")]
impl Drop for PanicHookGuard {
fn drop(&mut self) {
if let Some(prev) = self.prev_hook.take() {
std::panic::set_hook(Box::new(move |info| prev(info)));
}
}
}
/// Headless SVG export that executes drawing commands and writes an SVG file
/// without opening a graphics window and without calling `std::process::exit`.
///
/// # Errors
///
/// Returns `ExportError` if file I/O fails or if the `svg` feature is not enabled.
/// Returns `ExportError` if file I/O fails, drawing execution panics, or if the `svg` feature is not enabled.
pub fn run_headless_svg_export<F>(mut build_commands: F, filename: &str) -> Result<(), ExportError>
where
F: FnMut(&mut TurtlePlan),
{
#[cfg(feature = "svg")]
{
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let mut turtle = crate::create_turtle_plan();
build_commands(&mut turtle);
let mut app = crate::TurtleApp::new();
app.execute_immediate(0, turtle);
let is_mq_panic = Arc::new(AtomicBool::new(false));
let is_mq_clone = Arc::clone(&is_mq_panic);
app.export_drawing(filename, crate::export::DrawingFormat::Svg)
let prev_hook = Arc::new(std::panic::take_hook());
let prev_hook_for_closure = Arc::clone(&prev_hook);
let guard = PanicHookGuard {
prev_hook: Some(prev_hook),
};
std::panic::set_hook(Box::new(move |info| {
prev_hook_for_closure(info);
let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
*s
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.as_str()
} else {
""
};
let loc_file = info.location().map_or("", std::panic::Location::file);
let is_context_panic = msg.contains("THREAD_ID.is_some()")
|| (loc_file.contains("macroquad") && msg.contains("assertion failed"));
if is_context_panic {
is_mq_clone.store(true, Ordering::SeqCst);
eprintln!("\n================================================================================");
eprintln!("Headless SVG Export Note:");
eprintln!("A Macroquad window/rendering function (e.g. `screen_width()`, `screen_height()`,");
eprintln!("or input check) was called while running in headless export mode.");
eprintln!("Headless export does not initialize a graphics window. To resolve this:");
eprintln!(" - Use relative turtle commands or fixed coordinates instead of window queries, or");
eprintln!(" - Run the program in windowed GUI mode without the `--export-svg` flag.");
eprintln!("================================================================================\n");
}
}));
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
build_commands(&mut turtle);
}));
drop(guard);
match result {
Ok(()) => {
let mut app = crate::TurtleApp::new();
app.execute_immediate(0, turtle);
app.export_drawing(filename, crate::export::DrawingFormat::Svg)
}
Err(payload) => {
let err_msg = if is_mq_panic.load(Ordering::SeqCst) {
"Drawing function called Macroquad window/GUI functions (e.g. `screen_width()`, `screen_height()`) which are unavailable in headless SVG export mode.".to_string()
} else if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Drawing function panicked during execution".to_string()
};
Err(ExportError::Execution(err_msg))
}
}
}
#[cfg(not(feature = "svg"))]
@@ -86,7 +186,7 @@ where
std::process::exit(0);
}
Err(e) => {
eprintln!("Error exporting SVG: {e:?}");
eprintln!("Error exporting SVG: {e}");
std::process::exit(1);
}
}