plotkit 0.5.0

A matplotlib-shaped, publication-quality plotting library for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! Tests for SVG rendering in plotkit.
//!
//! Validates the FigureExt::to_svg_string() pipeline and the SvgRenderer
//! backend directly.

use plotkit::prelude::*;
use plotkit::FigureExt;

// ===========================================================================
// 1. End-to-end: FigureExt::to_svg_string()
// ===========================================================================

#[test]
fn svg_output_is_valid_svg_wrapper() {
    let x: Vec<f64> = (0..50).map(|i| i as f64 * 0.1).collect();
    let y: Vec<f64> = x.iter().map(|&v| v.sin()).collect();

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot(&x, &y).unwrap();

    let svg = fig.to_svg_string().expect("to_svg_string failed");

    // Must start with <svg and end with </svg>
    let trimmed = svg.trim();
    assert!(
        trimmed.starts_with("<svg"),
        "SVG output must start with <svg, got: {}",
        &trimmed[..trimmed.len().min(80)]
    );
    assert!(
        trimmed.ends_with("</svg>"),
        "SVG output must end with </svg>, got: {}",
        &trimmed[trimmed.len().saturating_sub(80)..]
    );
}

#[test]
fn svg_contains_xmlns_and_viewbox() {
    let mut fig = Figure::with_size(640, 480);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]).unwrap();

    let svg = fig.to_svg_string().unwrap();

    assert!(
        svg.contains("xmlns=\"http://www.w3.org/2000/svg\""),
        "SVG must contain xmlns declaration"
    );
    assert!(
        svg.contains("viewBox=\"0 0 640 480\""),
        "SVG must contain correct viewBox"
    );
    assert!(
        svg.contains("width=\"640\""),
        "SVG must contain width attribute"
    );
    assert!(
        svg.contains("height=\"480\""),
        "SVG must contain height attribute"
    );
}

#[test]
fn svg_contains_path_elements_for_plot_data() {
    let x: Vec<f64> = (0..20).map(|i| i as f64).collect();
    let y: Vec<f64> = x.iter().map(|&v| v * v).collect();

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot(&x, &y).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // The rendered SVG should contain <path elements for the plot lines
    assert!(
        svg.contains("<path"),
        "SVG must contain <path elements for the plotted data"
    );

    // Should contain SVG path data commands (M for move, L for line)
    assert!(
        svg.contains(" d=\"M"),
        "SVG path elements must contain 'd' attribute with path data starting with M (moveto)"
    );
}

#[test]
fn svg_contains_text_elements_for_labels() {
    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([1.0, 2.0, 3.0], [1.0, 4.0, 9.0]).unwrap();
    ax.set_title("Test Title");
    ax.set_xlabel("X Axis");
    ax.set_ylabel("Y Axis");

    let svg = fig.to_svg_string().unwrap();

    assert!(
        svg.contains("<text"),
        "SVG must contain <text elements for labels"
    );
    assert!(
        svg.contains("Test Title"),
        "SVG must contain the title text"
    );
    assert!(
        svg.contains("X Axis"),
        "SVG must contain the xlabel text"
    );
    assert!(
        svg.contains("Y Axis"),
        "SVG must contain the ylabel text"
    );
}

#[test]
fn svg_scatter_contains_circles_or_paths() {
    let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
    let y = vec![2.0, 4.0, 1.0, 3.0, 5.0];

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.scatter(&x, &y).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // Scatter points are rendered as circle paths (via Path::circle -> CurveTo elements)
    // so we expect multiple <path elements with curve commands (C)
    let path_count = svg.matches("<path").count();
    assert!(
        path_count >= 5,
        "Scatter plot with 5 points should generate at least 5 path elements (got {})",
        path_count
    );
}

#[test]
fn svg_bar_chart_generates_filled_paths() {
    let cats = vec!["A", "B", "C"];
    let vals = vec![10.0, 20.0, 15.0];

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.bar(cats.as_slice(), &vals).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // Bar charts produce filled rectangles (fill_path calls)
    // Check for filled paths (no stroke, have fill attribute)
    assert!(
        svg.contains("fill=\""),
        "Bar chart SVG must contain filled paths"
    );
}

#[test]
fn svg_multi_series_with_legend() {
    let x: Vec<f64> = (0..50).map(|i| i as f64 * 0.1).collect();
    let y1: Vec<f64> = x.iter().map(|&v| v.sin()).collect();
    let y2: Vec<f64> = x.iter().map(|&v| v.cos()).collect();

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot(&x, &y1).unwrap().label("sin(x)");
    ax.plot(&x, &y2).unwrap().label("cos(x)");
    ax.legend();

    let svg = fig.to_svg_string().unwrap();

    // Legend text should appear in the SVG
    assert!(svg.contains("sin(x)"), "SVG must contain legend label sin(x)");
    assert!(svg.contains("cos(x)"), "SVG must contain legend label cos(x)");
}

#[test]
fn svg_suptitle_appears_in_output() {
    let mut fig = Figure::with_size(800, 600);
    fig.suptitle("Overall Title");
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([0.0, 1.0], [0.0, 1.0]).unwrap();

    let svg = fig.to_svg_string().unwrap();

    assert!(
        svg.contains("Overall Title"),
        "SVG must contain the suptitle text"
    );
}

#[test]
fn svg_dark_theme_uses_dark_background() {
    let mut fig = Figure::with_size(800, 600);
    fig.set_theme(Theme::dark());
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([0.0, 1.0], [0.0, 1.0]).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // Dark theme background is Color::rgb(0x1C, 0x1C, 0x1C) = #1c1c1c
    assert!(
        svg.contains("#1c1c1c"),
        "Dark theme SVG should contain the dark background color #1c1c1c"
    );
}

#[test]
fn svg_empty_axes_still_produces_valid_svg() {
    let mut fig = Figure::with_size(400, 300);
    let _ax = fig.add_subplot(1, 1, 1);
    // No data plotted

    let svg = fig.to_svg_string().unwrap();

    let trimmed = svg.trim();
    assert!(trimmed.starts_with("<svg"), "Empty axes SVG must start with <svg");
    assert!(trimmed.ends_with("</svg>"), "Empty axes SVG must end with </svg>");
}

#[test]
fn svg_fill_between_generates_filled_region() {
    let x: Vec<f64> = (0..30).map(|i| i as f64 * 0.1).collect();
    let y1: Vec<f64> = x.iter().map(|&v| v.sin() - 0.2).collect();
    let y2: Vec<f64> = x.iter().map(|&v| v.sin() + 0.2).collect();

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.fill_between(&x, &y1, &y2).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // fill_between should produce a filled path
    assert!(
        svg.contains("<path") && svg.contains("fill=\""),
        "fill_between SVG must contain a filled path element"
    );
}

// ===========================================================================
// 2. SvgRenderer unit-level tests (via the public API)
// ===========================================================================

#[test]
fn svg_renderer_clip_path_elements() {
    // Clipping is used during axes rendering, so any plot should produce clip paths
    let mut fig = Figure::with_size(400, 300);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([0.0, 1.0, 2.0], [0.0, 1.0, 0.5]).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // The renderer uses clipPath + <g clip-path="url(#clipN)"> for clipping
    assert!(
        svg.contains("<clipPath"),
        "SVG should contain <clipPath elements for axes clipping"
    );
    assert!(
        svg.contains("clip-path=\"url(#clip"),
        "SVG should contain clip-path references"
    );
}

#[test]
fn svg_stroke_attributes_present() {
    let mut fig = Figure::with_size(400, 300);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([0.0, 1.0, 2.0, 3.0], [0.0, 1.0, 0.5, 2.0]).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // Line plots produce stroked paths
    assert!(
        svg.contains("stroke=\""),
        "Line plot SVG must contain stroke attributes"
    );
    assert!(
        svg.contains("stroke-width=\""),
        "Line plot SVG must contain stroke-width attributes"
    );
    assert!(
        svg.contains("stroke-linecap=\""),
        "Line plot SVG must contain stroke-linecap attributes"
    );
    assert!(
        svg.contains("stroke-linejoin=\""),
        "Line plot SVG must contain stroke-linejoin attributes"
    );
}

#[test]
fn svg_text_html_escaping() {
    // Test that special characters in titles/labels are properly escaped
    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([0.0, 1.0], [0.0, 1.0]).unwrap();
    ax.set_title("x < y & z > w");
    ax.set_xlabel("\"quotes\" & 'apostrophes'");

    let svg = fig.to_svg_string().unwrap();

    // The html_escape function should convert these:
    // & -> &amp;   < -> &lt;   > -> &gt;   " -> &quot;   ' -> &#x27;
    assert!(
        svg.contains("&lt;"),
        "SVG must escape < to &lt;"
    );
    assert!(
        svg.contains("&amp;"),
        "SVG must escape & to &amp;"
    );
    assert!(
        svg.contains("&gt;"),
        "SVG must escape > to &gt;"
    );
    assert!(
        svg.contains("&quot;"),
        "SVG must escape \" to &quot;"
    );
    assert!(
        svg.contains("&#x27;"),
        "SVG must escape ' to &#x27;"
    );

    // Make sure the raw unescaped characters do NOT appear in text content
    // (they will appear in SVG attribute syntax though, so we check specifically
    // within text content)
    assert!(
        !svg.contains(">x < y"),
        "Raw < in text content should be escaped"
    );
}

#[test]
fn svg_font_family_in_text_style() {
    // When a theme specifies a font family, it should appear in text elements
    let mut fig = Figure::with_size(800, 600);
    let theme = Theme { font_family: Some("Arial".to_string()), ..Theme::default() };
    fig.set_theme(theme);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot([0.0, 1.0], [0.0, 1.0]).unwrap();
    ax.set_title("Font Test");

    let svg = fig.to_svg_string().unwrap();

    assert!(
        svg.contains("font-family=\"Arial\""),
        "SVG text elements should include the font-family attribute when set"
    );
}

#[test]
fn svg_output_is_valid_utf8() {
    let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
    let y: Vec<f64> = x.iter().map(|&v| v.sin()).collect();

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot(&x, &y).unwrap();
    ax.set_title("Unicode test: degree sign");

    // to_svg_string() internally does String::from_utf8 -- if it succeeds, the
    // output is valid UTF-8
    let result = fig.to_svg_string();
    assert!(result.is_ok(), "SVG output must be valid UTF-8");
}

#[test]
fn svg_histogram_renders() {
    let data: Vec<f64> = (0..100).map(|i| (i as f64 * 0.31).sin() * 2.0 + 3.0).collect();

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.hist(&data, 10).unwrap();
    ax.set_title("Histogram SVG");

    let svg = fig.to_svg_string().unwrap();

    // Histogram bins are rendered as filled rectangles
    let trimmed = svg.trim();
    assert!(trimmed.starts_with("<svg"));
    assert!(trimmed.ends_with("</svg>"));
    // Should have multiple filled paths for the bins
    let fill_count = svg.matches("fill=\"#").count();
    assert!(
        fill_count >= 10,
        "Histogram with 10 bins should produce at least 10 filled elements (got {})",
        fill_count
    );
}

#[test]
fn svg_multiple_subplots() {
    let mut fig = Figure::with_size(800, 600);

    let ax1 = fig.add_subplot(1, 2, 1);
    ax1.plot([0.0, 1.0], [0.0, 1.0]).unwrap();
    ax1.set_title("Left");

    let ax2 = fig.add_subplot(1, 2, 2);
    ax2.plot([0.0, 1.0], [1.0, 0.0]).unwrap();
    ax2.set_title("Right");

    let svg = fig.to_svg_string().unwrap();

    assert!(svg.contains("Left"), "SVG must contain first subplot title");
    assert!(svg.contains("Right"), "SVG must contain second subplot title");

    // Two subplots should produce at least 2 clip regions
    let clip_count = svg.matches("<clipPath").count();
    assert!(
        clip_count >= 2,
        "Two subplots should produce at least 2 clipPath elements (got {})",
        clip_count
    );
}

#[test]
fn svg_output_is_reasonably_sized() {
    let x: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
    let y: Vec<f64> = x.iter().map(|&v| v.sin()).collect();

    let mut fig = Figure::with_size(800, 600);
    let ax = fig.add_subplot(1, 1, 1);
    ax.plot(&x, &y).unwrap();

    let svg = fig.to_svg_string().unwrap();

    // SVG should be non-trivial in size (more than just the wrapper)
    assert!(
        svg.len() > 500,
        "SVG output should be substantial (got {} bytes)",
        svg.len()
    );
    // But also shouldn't be absurdly large for a simple plot
    assert!(
        svg.len() < 1_000_000,
        "SVG output should not be excessively large (got {} bytes)",
        svg.len()
    );
}