From d4326d4dc623c8b071e745ef93c3381e44accf74 Mon Sep 17 00:00:00 2001 From: alteralto <182909819+alteralto@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:48:32 +0300 Subject: [PATCH 1/4] test(layout): export to SVG, each element its own group --- crates/engine/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 12795133f..651b7bb38 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -15400,3 +15400,36 @@ fn toggling_out_the_only_picked_layer_leaves_none_active() { assert!(s.selected_layers().is_empty()); 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(""), "the title is text"); + assert!(svg.contains(&format!(r#"")); +} From 2c55e43c401a87968cda068200a7c92da72d6166 Mon Sep 17 00:00:00 2001 From: alteralto <182909819+alteralto@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:01:43 +0300 Subject: [PATCH 2/4] feat(layout): export a layout to SVG, each element a named group --- ROADMAP.md | 3 +- crates/engine/src/cmd/layout.rs | 24 +++- crates/render/src/lib.rs | 1 + crates/render/src/svg.rs | 237 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 7 +- docs/user-guide/layouts.md | 5 +- 6 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 crates/render/src/svg.rs diff --git a/ROADMAP.md b/ROADMAP.md index 483933bd6..39aab8d13 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,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..8d8bb273e 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" (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 (default true: every map series page, up to 500; false: the current page; PDF)}"#), 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,20 @@ 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(), + }; + let layout = s.project.layouts.get(li).ok_or("no layout")?.clone(); + rustgis_render::svg::layout_svg(&layout, &mut s.project.maps, dpi, &info).ok_or_else(|| "the page is too large to draw".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/render/src/lib.rs b/crates/render/src/lib.rs index adb204a84..5bd9689e9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,6 +19,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..e01843251 --- /dev/null +++ b/crates/render/src/svg.rs @@ -0,0 +1,237 @@ +//! 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, render}; + +/// 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 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), + _ => { + if let Some(png) = alone(layout, e.id, maps, dpi, info) { + let _ = writeln!( + s, + r#""#, + num(x), + num(y), + num(w), + num(h), + base64::engine::general_purpose::STANDARD.encode(png) + ); + } + } + } + s.push_str("\n"); + } + s.push_str("\n"); + Some(s) +} + +/// The element `id` drawn alone on the page at `dpi`, cut to its frame, as PNG. +fn alone(layout: &Layout, id: u32, maps: &mut [Map], dpi: f64, info: &PageInfo) -> Option> { + let mut solo = layout.clone(); + for e in &mut solo.elements { + e.visible = e.id == id; + } + let e = layout.element(id)?; + let page = render(&solo, maps, dpi, info)?; + // Pixels per millimetre, as the page came out (a page too large draws at less). + let k = f64::from(page.width()) / layout.width.max(1e-9); + let x0 = (e.x * k).floor().clamp(0.0, f64::from(page.width())); + let y0 = (e.y * k).floor().clamp(0.0, f64::from(page.height())); + let x1 = ((e.x + e.w) * k).ceil().clamp(0.0, f64::from(page.width())); + let y1 = ((e.y + e.h) * k).ceil().clamp(0.0, f64::from(page.height())); + let rect = tiny_skia::IntRect::from_xywh(x0 as i32, y0 as i32, (x1 - x0) as u32, (y1 - y0) as u32)?; + page.clone_rect(rect)?.encode_png().ok() +} + +/// 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) { + let w = (width / PT_PER_MM).max(0.5 / 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, width.max(0.5))); + 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("'"), + // Control characters other than tab aren't allowed in XML. + c if c.is_control() && c != '\t' => {} + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_and_text_are_written_plainly() { + assert_eq!((num(297.0), num(0.1234), num(-0.0001), num(f64::NAN)), ("297".into(), "0.123".into(), "0".into(), "0".into())); + assert_eq!(esc("a & \"c\" 'd'\u{1}"), "a & <b> "c" 'd'"); + 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 ef36a824e..79b5b581a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2365,7 +2365,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(); @@ -2384,8 +2384,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..83a2f8595 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -200,7 +200,10 @@ 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; `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`). From 0aa2c710cb966f78f4c927bbc4de4b16d8673a91 Mon Sep 17 00:00:00 2001 From: alteralto <182909819+alteralto@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:01:18 +0300 Subject: [PATCH 3/4] test(layout): SVG is a format the export writes now --- crates/engine/src/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 651b7bb38..fbbb1aecb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3528,7 +3528,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(); From 9312dbdd8e697d2412d408d279b4f92a9e1ac652 Mon Sep 17 00:00:00 2001 From: giswqs Date: Sat, 24 Oct 2026 16:28:54 -0400 Subject: [PATCH 4/4] Address SVG export review feedback - Filter XML-invalid characters and report map rendering failures clearly. - Borrow the layout and clarify current-page export and image clipping. - Match the page renderer's device-pixel minimum for thin vector lines. - Allow antialias rounding when testing transparent image composition. --- crates/engine/src/cmd/layout.rs | 8 ++++--- crates/engine/src/tests.rs | 3 ++- crates/render/src/layout.rs | 2 +- crates/render/src/svg.rs | 37 ++++++++++++++++++++++++++------- docs/user-guide/layouts.md | 2 +- 5 files changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 8d8bb273e..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|*.svg" (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 (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(); @@ -3033,8 +3033,10 @@ fn layout_svg(s: &mut Session, li: usize, dpi: f64) -> Result { pages: series.as_ref().map_or(1, |i| i.0), page_name: series.map(|i| i.1).unwrap_or_default(), }; - let layout = s.project.layouts.get(li).ok_or("no layout")?.clone(); - rustgis_render::svg::layout_svg(&layout, &mut s.project.maps, dpi, &info).ok_or_else(|| "the page is too large to draw".into()) + // 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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5047cc690..ed265cd10 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -16022,7 +16022,8 @@ fn a_layout_exports_to_svg_with_each_element_its_own_group() { 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(); - assert!(s.run("layout.export", &json!({"path": out.to_string_lossy(), "dpi": 36})).is_err()); + 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); } diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 1aa64875e..076505710 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -58,7 +58,7 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O } /// Page pixel dimensions and the shared millimetre scale, capped to the renderer's budget. -fn page_geometry(layout: &Layout, dpi: f64) -> (u32, u32, f64) { +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; let k = k.min((MAX_PIXELS / (layout.width * layout.height).max(1.0)).sqrt()); diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index f9622661a..1e97a3a25 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -9,11 +9,12 @@ 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, render_element}; +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, @@ -106,7 +107,7 @@ pub fn layout_svg(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) stroke(*outline, *width) ); } - ElementKind::Line { color, width, dir, head } => line(&mut s, dir.ends((x, y, w, h)), *color, *width, *head), + 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()?; @@ -129,8 +130,8 @@ pub fn layout_svg(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) } /// 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) { - let w = (width / PT_PER_MM).max(0.5 / PT_PER_MM); +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)); @@ -139,7 +140,7 @@ fn line(s: &mut String, [(x0, y0), (x1, y1)]: [(f64, f64); 2], color: rustgis_ma 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, width.max(0.5))); + 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)) @@ -198,8 +199,8 @@ fn esc(t: &str) -> String { '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), - // Control characters other than tab aren't allowed in XML. - c if c.is_control() && c != '\t' => {} + // 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), } } @@ -252,7 +253,9 @@ mod tests { ); base.elements.push(layout.element(legend).unwrap().clone()); let actual = crate::layout::render(&base, &mut maps, 72.0, &info).unwrap(); - assert!(actual.data() == expected.data(), "composited SVG pictures match the page"); + // 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] @@ -302,10 +305,28 @@ mod tests { assert!(!svg.contains(" \"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/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index 481d9bd78..7c94530bc 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -205,7 +205,7 @@ Layouts open as tabs next to the maps; while one is open, its groups lead the Pu 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; + 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`).