diff --git a/ROADMAP.md b/ROADMAP.md index a9a18b442..06820544a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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). diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index e5bc00b8a..44256f29a 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -117,7 +117,7 @@ pub fn commands() -> Vec { .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(); @@ -3008,8 +3008,14 @@ 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") { + 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}"))?; @@ -3017,6 +3023,22 @@ fn export(s: &mut Session, p: &Value) -> CmdResult { 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`. +fn layout_svg(s: &mut Session, li: usize, dpi: f64) -> Result { + 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()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d98e0146a..ed265cd10 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(); @@ -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("() + ); + let layout = &s.project.layouts[0]; + for e in layout.elements.iter().filter(|e| e.visible) { + assert!(svg.contains(&format!(r#""), "the title is text"); + assert!(svg.contains(&format!(r#"")); + // 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}; diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index db47502d3..076505710 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -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 { + 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> { + 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, +) -> Option { 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 @@ -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)>)> = 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) @@ -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, .. } => { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 30efde401..ee4d5115d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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; diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs new file mode 100644 index 000000000..1e97a3a25 --- /dev/null +++ b/crates/render/src/svg.rs @@ -0,0 +1,333 @@ +//! A layout as SVG: the page in millimetres, each element in a group of its own, named after it. +//! Text, rectangles, ellipses and lines are SVG text and shapes, placed and styled as +//! [`crate::layout::render`] draws them; the elements with no vector form yet (map frames and the +//! surrounds) are each a picture of that element alone, in its place. + +use std::fmt::Write as _; + +use base64::Engine; +use rustgis_map::Map; +use rustgis_map::layout::{ElementKind, FontFamily, Layout, LineHead, PT_PER_MM, TextAlign, frame_camera}; + +use crate::layout::{PageInfo, expand, page_geometry, render_element}; + +/// Writes `layout` as an SVG document, pictures drawn at `dpi`; `None` when the page can't be drawn. +pub fn layout_svg(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> Option { + let (pw, ph) = (layout.width, layout.height); + let ppp = page_geometry(layout, dpi).2 / PT_PER_MM; + let mut s = String::new(); + let _ = write!( + s, + concat!( + r#""#, + "\n", + r#""#, + "\n", + r##""##, + "\n" + ), + w = num(pw), + h = num(ph) + ); + for e in layout.elements.iter().filter(|e| e.visible) { + let _ = writeln!(s, r#""#, e.id, esc(&e.name)); + let (x, y, w, h) = (e.x, e.y, e.w, e.h); + match &e.kind { + ElementKind::Text { text: t, size, color, bold, italic, align, letter_spacing, font } => { + let first = layout.first_frame().and_then(|id| layout.element(id)).and_then(|f| match &f.kind { + ElementKind::MapFrame { map, center, scale, bearing, pitch, .. } => { + let m = maps.iter().find(|m| m.name.eq_ignore_ascii_case(map))?; + Some((m, frame_camera(m, *center, *scale, *bearing, *pitch))) + } + _ => None, + }); + let text = expand(t, layout, info, first); + // As the page draws it: the size in points, lines 1.25 apart, the first baseline + // 0.6 of the size below the top, lines past the frame left out. + let em = size.clamp(2.0, 400.0) * 1.33 / PT_PER_MM; + let (anchor, tx) = match align { + TextAlign::Left => ("start", x), + TextAlign::Center => ("middle", x + w / 2.0), + TextAlign::Right => ("end", x + w), + }; + let lines: Vec<(f64, &str)> = text + .lines() + .take(200) + .enumerate() + .map(|(i, l)| (y + em * (0.6 + 1.25 * i as f64), l)) + .take_while(|(ly, _)| *ly <= y + h + em) + .collect(); + let _ = write!( + s, + r#""#, + num(tx), + num(lines.first().map_or(y + em * 0.6, |l| l.0)), + if *font == FontFamily::Serif { "serif" } else { "sans-serif" }, + num(em), + if *bold { "bold" } else { "normal" }, + if *italic { "italic" } else { "normal" }, + num(*letter_spacing * 1.33 / PT_PER_MM), + paint("fill", *color), + ); + match lines.as_slice() { + [(_, only)] => s.push_str(&esc(only)), + many => { + for (ly, l) in many { + let _ = write!(s, r#"{}"#, num(tx), num(*ly), esc(l)); + } + } + } + s.push_str("\n"); + } + ElementKind::Rectangle { fill, outline, width, radius } => { + let rx = radius.clamp(0.0, w.min(h) / 2.0); + let _ = writeln!( + s, + r#""#, + num(x), + num(y), + num(w), + num(h), + num(rx), + paint("fill", *fill), + stroke(*outline, *width) + ); + } + ElementKind::Ellipse { fill, outline, width } => { + let _ = writeln!( + s, + r#""#, + num(x + w / 2.0), + num(y + h / 2.0), + num(w / 2.0), + num(h / 2.0), + paint("fill", *fill), + stroke(*outline, *width) + ); + } + ElementKind::Line { color, width, dir, head } => line(&mut s, dir.ends((x, y, w, h)), *color, *width, *head, ppp), + _ => { + if let Some((image, [ix, iy, iw, ih])) = render_element(layout, e.id, maps, dpi, info)? { + let png = image.encode_png().ok()?; + let _ = writeln!( + s, + r#""#, + num(ix), + num(iy), + num(iw), + num(ih), + base64::engine::general_purpose::STANDARD.encode(png) + ); + } + } + } + s.push_str("\n"); + } + s.push_str("\n"); + Some(s) +} + +/// A line element as the page draws it: a shaft and a filled triangle for each head, in millimetres. +fn line(s: &mut String, [(x0, y0), (x1, y1)]: [(f64, f64); 2], color: rustgis_map::Rgba, width: f64, head: LineHead, ppp: f64) { + let w = (width / PT_PER_MM).max(0.5 / (ppp * PT_PER_MM)); + let (at_start, at_end) = (matches!(head, LineHead::Start | LineHead::Both), matches!(head, LineHead::End | LineHead::Both)); + let len = (x1 - x0).hypot(y1 - y0); + let heads = f64::from(u8::from(at_start) + u8::from(at_end)); + let head_len = if heads == 0.0 || len <= 0.0 { 0.0 } else { ((6.0 + 2.5 * width) / PT_PER_MM).min(len * 0.4 / heads) }; + let (ux, uy) = if len > 0.0 { ((x1 - x0) / len, (y1 - y0) / len) } else { (1.0, 0.0) }; + let inset = head_len * 0.8; + let (sx, sy) = if at_start { (x0 + ux * inset, y0 + uy * inset) } else { (x0, y0) }; + let (ex, ey) = if at_end { (x1 - ux * inset, y1 - uy * inset) } else { (x1, y1) }; + let _ = writeln!(s, r#""#, num(sx), num(sy), num(ex), num(ey), stroke(color, w * PT_PER_MM)); + let half = (head_len * 0.45).max(w); + for ((tx, ty), (dx, dy)) in + [(at_start, (x0, y0), (-ux, -uy)), (at_end, (x1, y1), (ux, uy))].into_iter().filter(|h| h.0).map(|h| (h.1, h.2)) + { + let (bx, by) = (tx - dx * head_len, ty - dy * head_len); + let _ = writeln!( + s, + r#""#, + num(tx), + num(ty), + num(bx - dy * half), + num(by + dx * half), + num(bx + dy * half), + num(by - dx * half), + paint("fill", color) + ); + } +} + +/// ` stroke="…" stroke-width="…"` for an outline `width` points wide, or `stroke="none"`. +fn stroke(color: rustgis_map::Rgba, width: f64) -> String { + if width > 0.0 { + format!(r#"{} stroke-width="{}""#, paint("stroke", color), num(width / PT_PER_MM)) + } else { + r#" stroke="none""#.into() + } +} + +/// ` fill="#rrggbb"` (or stroke), with its opacity when it isn't opaque. +fn paint(attr: &str, c: rustgis_map::Rgba) -> String { + let [r, g, b, a] = c.0; + let mut out = format!(r##" {attr}="#{r:02x}{g:02x}{b:02x}""##); + if a < 255 { + let _ = write!(out, r#" {attr}-opacity="{}""#, num(f64::from(a) / 255.0)); + } + out +} + +/// A number with at most three decimals and no trailing zeros. +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".into(); + } + let t = format!("{v:.3}"); + let t = t.trim_end_matches('0').trim_end_matches('.'); + if t == "-0" { "0".into() } else { t.into() } +} + +/// Text escaped for SVG content and attributes. +fn esc(t: &str) -> String { + let mut out = String::with_capacity(t.len()); + for ch in t.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + // Keep text and attributes valid in XML 1.0, including hostile layout names. + c if (c.is_control() && c != '\t') || matches!(c, '\u{fffe}' | '\u{ffff}') => {} + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A small page with a frame whose context is also used by surrounds. + fn page() -> (Layout, Vec, u32) { + let mut layout = Layout::new("SVG", 100.0, 80.0); + let kind = serde_json::from_value(serde_json::json!({"MapFrame": { + "map": "Map", "center": {"x": 0, "y": 0}, "scale": 100000000 + }})) + .unwrap(); + let frame = layout.add("Map", (0.0, 0.0, 100.0, 80.0), kind); + (layout, vec![Map::new("Map")], frame) + } + + #[test] + fn embedded_surrounds_preserve_transparency_and_page_context() { + let (mut layout, mut maps, frame) = page(); + let legend = layout.add( + "Legend", + (10.0, 10.0, 40.0, 30.0), + ElementKind::Legend { frame, title: "Map legend".into(), all_classes: true, hide_other: false, exclude: vec![] }, + ); + let info = PageInfo::default(); + let svg = layout_svg(&layout, &mut maps, 72.0, &info).unwrap(); + let group = svg.split(&format!(" 0), "the title still uses the frame context"); + let (mut base, mut maps, _) = page(); + let mut expected = crate::layout::render(&base, &mut maps, 72.0, &info).unwrap(); + assert!(pm.width() < expected.width() / 2 && pm.height() < expected.height() / 2, "only the surround's bounds are allocated"); + let (image, [x, y, ..]) = render_element(&layout, legend, &mut maps, 72.0, &info).unwrap().unwrap(); + let k = PT_PER_MM * 72.0 / 96.0; + expected.draw_pixmap( + (x * k).round() as i32, + (y * k).round() as i32, + image.as_ref(), + &tiny_skia::PixmapPaint::default(), + tiny_skia::Transform::identity(), + None, + ); + base.elements.push(layout.element(legend).unwrap().clone()); + let actual = crate::layout::render(&base, &mut maps, 72.0, &info).unwrap(); + // Drawing onto transparency and compositing rounds the antialias coverage twice. + let difference = actual.data().iter().zip(expected.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(difference <= 2, "composited SVG pictures match the page within antialias rounding: {difference}"); + } + + #[test] + fn cropped_images_use_their_actual_pixel_bounds() { + let (mut layout, mut maps, _) = page(); + let id = layout.add("Picture", (-5.3, 70.2, 20.7, 20.0), ElementKind::Picture { image: String::new() }); + let info = PageInfo::default(); + let (pm, bounds) = render_element(&layout, id, &mut maps, 36.0, &info).unwrap().unwrap(); + let [x, y, w, h] = bounds; + let k = PT_PER_MM * 36.0 / 96.0; + assert_eq!(x, 0.0); + assert!((y * k - (70.2 * k).floor()).abs() < 1e-9); + assert!((w * k - f64::from(pm.width())).abs() < 1e-9); + assert!((h * k - f64::from(pm.height())).abs() < 1e-9); + let svg = layout_svg(&layout, &mut maps, 36.0, &info).unwrap(); + assert!(svg.contains(&format!(r#" \"c\" 'd'\u{1}"), "a & <b> "c" 'd'"); + assert_eq!(esc("valid\u{fffe}\u{ffff}\t🙂"), "valid\t🙂"); + assert_eq!(paint("fill", rustgis_map::Rgba::new(255, 0, 16, 128)), r##" fill="#ff0010" fill-opacity="0.502""##); + assert_eq!(stroke(rustgis_map::Rgba::rgb(0, 0, 0), 0.0), r#" stroke="none""#); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 81f2a69e2..503bc3c19 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2422,7 +2422,7 @@ impl RustGisApp { return self.run("project.saveAs", json!({"path": path})); } } - // Layouts: export (PNG/PDF), print, save and import layout files, draw frames. + // Layouts: export (PDF/PNG/SVG), print, save and import layout files, draw frames. "ui.exportLayout" | "ui.printLayout" => { let li = self.session.active_layout.ok_or("open a layout first")?; let name = self.session.project.layouts.get(li).map(|l| l.name.clone()).unwrap_or_default(); @@ -2441,8 +2441,9 @@ impl RustGisApp { open_with_system(&path)?; self.session.notify(i18n::fmt("Opened {name} for printing", &[("name", &path.display().to_string())]), false); } else { - let exts: &[&str] = if pdf_only { &["pdf"] } else { &["pdf", "png"] }; - if let Some(path) = dialogs::pick_save(&format!("{name}.pdf"), exts, tl!("PDF or PNG")) { + let (exts, kinds): (&[&str], _) = + if pdf_only { (&["pdf"], tl!("PDF")) } else { (&["pdf", "png", "svg"], tl!("PDF, PNG or SVG")) }; + if let Some(path) = dialogs::pick_save(&format!("{name}.pdf"), exts, kinds) { return self.run("layout.export", json!({"layout": li, "path": path, "dpi": 200})); } } diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index 469c108b5..7c94530bc 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -200,7 +200,12 @@ Layouts open as tabs next to the maps; while one is open, its groups lead the Pu - **Snap** starts on: a dragged or resized element snaps to the guides, the page's edges and centre, and the other elements' edges and centres, and the line it snapped to is highlighted. Hold Alt to place an element freely, or turn **Snap** off in the Page group. -- **Export** writes PDF (every map series page by default, up to 500) or PNG of the current page; +- **Export** writes PDF (every map series page by default, up to 500), or PNG or SVG of the current page. + In SVG each element is a group (an Inkscape layer) named after it: text stays text and rectangles, + ellipses and lines stay shapes, while map frames and the other surrounds are pictures at the export + resolution, so the page can be finished in a vector editor. SVG text uses the viewer's + serif or sans-serif font, so its alignment and line widths can differ from PNG/PDF and can + change when edited. Embedded pictures are clipped to their element boxes; `layout.export` accepts `dpi` and `"allPages": false` for just the current PDF page. **Print** opens the PDF in the system viewer on desktop; **Save Layout File** and **Import Layout** move layouts between projects (`.rgislayout`).