Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ And alongside it:
Built in means no runner download, the selection honoured, geodesic distances and attributes
kept. Before adding a tool, check whether the GeoLibre suite already has it (`gp.tools`); a
built-in version must justify itself on one of those.
- SVG layout export; STL export; copy a layout to the clipboard.
- SVG layout export with map frames and surrounds as vectors (they are pictures in it today); STL
export; copy a layout to the clipboard.
- UTM and MGRS grids and coordinate readouts.
- H3 grids built in, next to the A5 grids (the GeoLibre suite has H3 tools only when its runner
is installed).
Expand Down
26 changes: 24 additions & 2 deletions crates/engine/src/cmd/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ pub fn commands() -> Vec<C> {
.params(r#"{"layout"?}"#),
C::new("layout.export", "Export Layout", "Publish › Output", export)
.view()
.params(r#"{"layout"?, "path": "*.png|*.pdf", "dpi"?: 72..600, "allPages"?: bool (default true: every map series page, up to 500; false: the current page; PDF)}"#),
.params(r#"{"layout"?, "path": "*.png|*.pdf|*.svg" (PNG/SVG: current page; SVG: each element a group of its own; text and shapes as SVG, map frames and surrounds as pictures at dpi), "dpi"?: 72..600, "allPages"?: bool (PDF only; default true: every map series page, up to 500; false: the current page)}"#),
C::new("layout.save", "Save Layout File", "Publish › Save As", |s, p| {
let path = ps(p, "path").ok_or("path is required")?;
let l = active(s, p)?.clone();
Expand Down Expand Up @@ -3008,15 +3008,37 @@ fn export(s: &mut Session, p: &Value) -> CmdResult {
let li = index(s, p)?;
let lower = path.to_ascii_lowercase();
let pdf = lower.ends_with(".pdf");
if lower.ends_with(".svg") {
Comment thread
giswqs marked this conversation as resolved.
let text = layout_svg(s, li, f(p, "dpi").unwrap_or(150.0))?;
rustgis_data::io::write_atomic(Path::new(&path), text.as_bytes()).map_err(|e| format!("{path}: {e}"))?;
s.notify(format!("Exported {path}"), false);
return Ok(json!({"path": path, "bytes": text.len()}));
}
if !pdf && !lower.ends_with(".png") {
return Err("export to a .png or .pdf file".into());
return Err("export to a .png, .pdf or .svg file".into());
}
let bytes = layout_bytes(s, li, pdf, f(p, "dpi").unwrap_or(150.0), b(p, "allPages").unwrap_or(true))?;
rustgis_data::io::write_atomic(Path::new(&path), &bytes).map_err(|e| format!("{path}: {e}"))?;
s.notify(format!("Exported {path}"), false);
Ok(json!({"path": path, "bytes": bytes.len()}))
}

/// The layout `li` as it is now (a map series at its current page) as SVG, pictures at `dpi`.
Comment thread
giswqs marked this conversation as resolved.
fn layout_svg(s: &mut Session, li: usize, dpi: f64) -> Result<String, String> {
let dpi = if dpi.is_finite() { dpi.clamp(36.0, 600.0) } else { 150.0 };
let current = s.project.layouts.get(li).ok_or("no layout")?.series.as_ref().map_or(0, |sr| sr.page);
let series = series_info(s, li, current);
let info = rustgis_render::layout::PageInfo {
page: current + 1,
pages: series.as_ref().map_or(1, |i| i.0),
page_name: series.map(|i| i.1).unwrap_or_default(),
};
// Rendering borrows the current layout; it does not step or modify the series page.
let layout = s.project.layouts.get(li).ok_or("no layout")?;
rustgis_render::svg::layout_svg(layout, &mut s.project.maps, dpi, &info)
.ok_or_else(|| "could not draw the page; check its map sources and export size".into())
}

pub(crate) fn import(s: &mut Session, text: &str) -> CmdResult {
if text.len() as u64 > MAX_LAYOUT {
return Err("the layout file is too large".into());
Expand Down
58 changes: 57 additions & 1 deletion crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3689,7 +3689,8 @@ fn layouts_compose_export_and_round_trip() {
assert!(pdf.windows(count.len()).any(|w| w == count.as_bytes()), "one PDF page per series page");
assert_eq!(s.project.layouts[0].series.as_ref().unwrap().page, 1, "the shown page is restored");
assert_eq!(scale_of(&s), 3_000_000.0, "and so is the frame's zoom");
assert!(s.run("layout.export", &json!({"path": dir.join("x.svg").display().to_string()})).is_err());
// A format the export has no writer for is refused.
assert!(s.run("layout.export", &json!({"path": dir.join("x.tiff").display().to_string()})).is_err());
// Layout files.
let file = dir.join("europe.rgislayout");
s.run("layout.save", &json!({"path": file.display().to_string()})).unwrap();
Expand Down Expand Up @@ -15972,6 +15973,61 @@ fn toggling_out_the_only_picked_layer_leaves_none_active() {
assert!(s.run("layer.remove", &Value::Null).is_err(), "nothing picked, nothing removed");
}

#[test]
fn a_layout_exports_to_svg_with_each_element_its_own_group() {
let mut s = world();
s.run("layout.new", &json!({"page": "a4", "orientation": "landscape"})).unwrap();
let title = s
.run("layout.addElement", &json!({"kind": "text", "text": "Cities & rivers <2026>", "x": 20, "y": 10, "w": 150, "h": 15}))
.unwrap()["element"]
.as_u64()
.unwrap();
s.run("layout.addElement", &json!({"kind": "rectangle", "x": 5, "y": 5, "w": 40, "h": 20})).unwrap();
let out = std::env::temp_dir().join(format!("rustgis-layout-{}.svg", std::process::id()));
let r = s.run("layout.export", &json!({"path": out.to_string_lossy(), "dpi": 72})).unwrap();
let svg = std::fs::read_to_string(&out).unwrap();
let _ = std::fs::remove_file(&out);
assert_eq!(r["bytes"].as_u64(), Some(svg.len() as u64));
// A page in millimetres, as the layout is.
assert!(
svg.starts_with("<?xml") && svg.contains(r#"width="297mm" height="210mm" viewBox="0 0 297 210""#),
Comment thread
giswqs marked this conversation as resolved.
"{}",
svg.chars().take(300).collect::<String>()
);
let layout = &s.project.layouts[0];
for e in layout.elements.iter().filter(|e| e.visible) {
assert!(svg.contains(&format!(r#"<g id="element-{}""#, e.id)), "element {} ({}) has a group", e.id, e.name);
}
// Text stays text, escaped; shapes stay shapes; what has no vector form yet is a picture in its place.
assert!(svg.contains("Cities &amp; rivers &lt;2026&gt;</text>"), "the title is text");
assert!(svg.contains(&format!(r#"<g id="element-{title}""#)));
assert!(svg.contains("<rect"), "the rectangle is a shape");
assert!(svg.contains(r#"<image"#) && svg.contains("data:image/png;base64,"), "the map frame is a picture");
assert!(svg.trim_end().ends_with("</svg>"));
// SVG exports only the current series page, even when allPages is supplied.
s.run("layout.series", &json!({"layer": "Lakes", "nameField": "name"})).unwrap();
s.run("layout.seriesPage", &json!({"page": "next"})).unwrap();
s.run("layout.update", &json!({"element": title, "text": "Page {page} of {pages}: {pageName}"})).unwrap();
let before = s.project.layouts[0].clone();
s.run("layout.export", &json!({"path": out.to_string_lossy(), "dpi": 36, "allPages": true})).unwrap();
let svg = std::fs::read_to_string(&out).unwrap();
assert!(svg.contains("Page 2 of "));
assert_eq!(s.project.layouts[0], before, "export keeps the selected page and frame");
let _ = std::fs::remove_file(&out);
// A required map-source failure is reported without leaving an incomplete file.
let dir = std::env::temp_dir().join(format!("rustgis-svg-failure-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut source = rustgis_map::source::SourceLayer::default();
source.path = Some(dir.join("missing.shp").display().to_string());
s.map_mut().layers[0].source = Some(source);
// Use a world view so the missing layer is visible on the exported page.
s.run("layout.frameZoom", &json!({"to": "full"})).unwrap();
let error = s.run("layout.export", &json!({"path": out.to_string_lossy(), "dpi": 36})).unwrap_err();
assert!(error.contains("map sources"), "{error}");
assert!(!out.exists());
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn manual_breaks_are_the_ones_given() {
use rustgis_map::{ClassMethod, Renderer};
Expand Down
53 changes: 45 additions & 8 deletions crates/render/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,51 @@ pub(crate) fn frame_requires_complete(map: &Map, view: &View) -> bool {

/// Renders a layout at `dpi` (dots per inch). `maps` are the project's maps, found by name.
pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> Option<Pixmap> {
let (w, h, k) = page_geometry(layout, dpi);
let bounds = tiny_skia::IntRect::from_xywh(0, 0, w, h)?;
draw_page(layout, maps, info, k, bounds, None)
}

/// Page pixel dimensions and the shared millimetre scale, capped to the renderer's budget.
pub(crate) fn page_geometry(layout: &Layout, dpi: f64) -> (u32, u32, f64) {
let ppp = (if dpi.is_finite() { dpi } else { 96.0 } / 96.0).clamp(0.25, 8.0);
let k = PT_PER_MM * ppp; // pixels per millimetre
let k = PT_PER_MM * ppp;
let k = k.min((MAX_PIXELS / (layout.width * layout.height).max(1.0)).sqrt());
let (w, h) = ((layout.width * k).clamp(1.0, MAX_SIDE), (layout.height * k).clamp(1.0, MAX_SIDE));
let k = k.min(w / layout.width).min(h / layout.height);
// A page too large for the cap draws at a lower resolution throughout.
(w as u32, h as u32, k.min(w / layout.width).min(h / layout.height))
}

/// One element on transparency, allocating only its on-page pixel bounds.
/// Outer `None` means rendering failed; inner `None` means the element is off-page.
pub(crate) fn render_element(layout: &Layout, id: u32, maps: &mut [Map], dpi: f64, info: &PageInfo) -> Option<Option<(Pixmap, [f64; 4])>> {
let e = layout.element(id)?;
let (w, h, k) = page_geometry(layout, dpi);
let x0 = (e.x * k).floor().clamp(0.0, f64::from(w));
let y0 = (e.y * k).floor().clamp(0.0, f64::from(h));
let x1 = ((e.x + e.w) * k).ceil().clamp(0.0, f64::from(w));
let y1 = ((e.y + e.h) * k).ceil().clamp(0.0, f64::from(h));
let Some(bounds) = tiny_skia::IntRect::from_xywh(x0 as i32, y0 as i32, (x1 - x0) as u32, (y1 - y0) as u32) else {
return Some(None);
};
let pm = draw_page(layout, maps, info, k, bounds, Some(id))?;
Some(Some((pm, [x0 / k, y0 / k, (x1 - x0) / k, (y1 - y0) / k])))
}

/// Draws the selected region with the full layout's frame and dynamic-text context.
fn draw_page(
layout: &Layout,
maps: &mut [Map],
info: &PageInfo,
k: f64,
bounds: tiny_skia::IntRect,
element: Option<u32>,
) -> Option<Pixmap> {
let ppp = k / PT_PER_MM;
let mut pm = Pixmap::new(w as u32, h as u32)?;
pm.fill(tiny_skia::Color::WHITE);
let mut pm = Pixmap::new(bounds.width(), bounds.height())?;
if element.is_none() {
pm.fill(tiny_skia::Color::WHITE);
}
let visible = |e: &&rustgis_map::layout::Element| e.visible && element.is_none_or(|id| e.id == id);
// Frame cameras first: surrounds and extent indicators read them.
let frames: Vec<(u32, usize, rustgis_map::Camera, f64, f64)> = layout
.elements
Expand All @@ -79,7 +115,7 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O
// to that frame's view first, so a streamed layer holds that frame's features whichever order
// the frames and legends come in, and whichever other frame showed the map last.
let mut shown: Vec<(u32, Vec<(u32, Vec<LegendEntry>)>)> = Vec::new();
for e in layout.elements.iter().filter(|e| e.visible) {
for e in layout.elements.iter().filter(visible) {
if let ElementKind::Legend { frame: f, all_classes: false, .. } = &e.kind
&& !shown.iter().any(|(id, _)| id == f)
&& let Some(&(_, mi, cam, fw, fh)) = frame(*f)
Expand All @@ -92,8 +128,9 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O
shown.push((*f, complete_shown_legends(map, scale)?));
}
}
for e in layout.elements.iter().filter(|e| e.visible) {
let (x, y, ew, eh) = ((e.x * k) as f32, (e.y * k) as f32, (e.w * k) as f32, (e.h * k) as f32);
for e in layout.elements.iter().filter(visible) {
let (x, y, ew, eh) =
((e.x * k - f64::from(bounds.x())) as f32, (e.y * k - f64::from(bounds.y())) as f32, (e.w * k) as f32, (e.h * k) as f32);
let Some(r) = Rect::from_xywh(x, y, ew.max(1.0), eh.max(1.0)) else { continue };
match &e.kind {
ElementKind::MapFrame { shape, grid, extent_of, extent_color, extent_width, .. } => {
Expand Down
1 change: 1 addition & 0 deletions crates/render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub mod post;
mod raster;
pub mod report;
mod stroke_paths;
pub mod svg;
pub mod terrain;
mod text;
pub mod tile_mesh;
Expand Down
Loading
Loading