• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DomCR / ACadSharp / 16517130645

25 Jul 2025 08:07AM UTC coverage: 75.158% (-0.02%) from 75.178%
16517130645

Pull #721

github

web-flow
Merge 60d3e6093 into aea407c1e
Pull Request #721: SVG paper units

6054 of 8854 branches covered (68.38%)

Branch coverage included in aggregate %.

92 of 108 new or added lines in 6 files covered. (85.19%)

14 existing lines in 2 files now uncovered.

24000 of 31134 relevant lines covered (77.09%)

78692.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.21
/src/ACadSharp/IO/SVG/SvgXmlWriter.cs
1
using ACadSharp.Entities;
2
using ACadSharp.Extensions;
3
using ACadSharp.IO.DXF;
4
using ACadSharp.Objects;
5
using ACadSharp.Tables;
6
using ACadSharp.Types.Units;
7
using CSMath;
8
using CSUtilities.Extensions;
9
using System;
10
using System.Collections.Generic;
11
using System.Globalization;
12
using System.IO;
13
using System.Linq;
14
using System.Text;
15
using System.Xml;
16

17
namespace ACadSharp.IO.SVG
18
{
19
        internal class SvgXmlWriter : XmlTextWriter
20
        {
21
                public event NotificationEventHandler OnNotification;
22

23
                public SvgConfiguration Configuration { get; } = new();
99✔
24

25
                public Layout Layout { get; set; }
140✔
26

27
                public UnitsType Units { get; set; }
1,177✔
28

29
                public SvgXmlWriter(Stream w, Encoding encoding, SvgConfiguration configuration) : base(w, encoding)
6✔
30
                {
6✔
31
                        this.Configuration = configuration;
6✔
32
                }
6✔
33

34
                public void WriteAttributeString(string localName, double value, UnitsType? units = null)
35
                {
357✔
36
                        string unitSufix = string.Empty;
357✔
37
                        if (units.HasValue)
357✔
38
                        {
325✔
39
                                switch (units.Value)
325!
40
                                {
41
                                        case UnitsType.Centimeters:
NEW
42
                                                unitSufix = "cm";
×
NEW
43
                                                break;
×
44
                                        case UnitsType.Millimeters:
45
                                                unitSufix = "mm";
321✔
46
                                                break;
321✔
47
                                        case UnitsType.Inches:
48
                                                unitSufix = "in";
2✔
49
                                                break;
2✔
50
                                }
51
                        }
325✔
52

53
                        this.WriteAttributeString(
357✔
54
                                localName,
357✔
55
                                $"{value.ToString(CultureInfo.InvariantCulture)}{unitSufix}");
357✔
56
                }
357✔
57

58
                public void WriteBlock(BlockRecord record)
59
                {
1✔
60
                        this.Units = record.Units;
1✔
61

62
                        BoundingBox box = record.GetBoundingBox();
1✔
63

64
                        this.startDocument(box);
1✔
65

66
                        foreach (var e in record.Entities)
47✔
67
                        {
22✔
68
                                this.writeEntity(e);
22✔
69
                        }
22✔
70

71
                        this.endDocument();
1✔
72
                }
1✔
73

74
                public void WriteLayout(Layout layout)
75
                {
5✔
76
                        this.Layout = layout;
5✔
77
                        this.Units = layout.PaperUnits.ToUnits();
5✔
78

79
                        double paperWidth = layout.PaperWidth;
5✔
80
                        double paperHeight = layout.PaperHeight;
5✔
81

82
                        switch (layout.PaperRotation)
5✔
83
                        {
84
                                case PlotRotation.Degrees90:
85
                                case PlotRotation.Degrees270:
86
                                        paperWidth = layout.PaperHeight;
3✔
87
                                        paperHeight = layout.PaperWidth;
3✔
88
                                        break;
3✔
89
                        }
90

91
                        XYZ lowerCorner = XYZ.Zero;
5✔
92
                        XYZ upperCorner = new XYZ(paperWidth, paperHeight, 0.0);
5✔
93
                        BoundingBox paper = new BoundingBox(lowerCorner, upperCorner);
5✔
94

95
                        XYZ lowerMargin = this.Layout.UnprintableMargin.BottomLeftCorner.Convert<XYZ>();
5✔
96
                        BoundingBox margins = new BoundingBox(
5✔
97
                                lowerMargin,
5✔
98
                                this.Layout.UnprintableMargin.TopCorner.Convert<XYZ>());
5✔
99

100
                        this.startDocument(paper);
5✔
101

102
                        Transform transform = new Transform(
5✔
103
                                lowerMargin.ToPixelSize(this.Units),
5✔
104
                                new XYZ(layout.PrintScale),
5✔
105
                                XYZ.Zero);
5✔
106

107
                        foreach (var e in layout.AssociatedBlock.Entities)
223✔
108
                        {
104✔
109
                                this.writeEntity(e, transform);
104✔
110
                        }
104✔
111

112
                        this.endDocument();
5✔
113
                }
5✔
114

115
                private string colorSvg(Color color)
116
                {
119✔
117
                        if (this.Layout != null && color.Equals(Color.Default))
119✔
118
                        {
77✔
119
                                color = Color.Black;
77✔
120
                        }
77✔
121

122
                        return $"rgb({color.R},{color.G},{color.B})";
119✔
123
                }
119✔
124

125
                private void endDocument()
126
                {
6✔
127
                        this.WriteEndElement();
6✔
128
                        this.WriteEndDocument();
6✔
129
                        this.Close();
6✔
130
                }
6✔
131

132
                private void notify(string message, NotificationType type, Exception ex = null)
133
                {
8✔
134
                        this.OnNotification?.Invoke(this, new NotificationEventArgs(message, type, ex));
8!
135
                }
8✔
136

137
                private void startDocument(BoundingBox box)
138
                {
6✔
139
                        this.WriteStartDocument();
6✔
140

141
                        this.WriteStartElement("svg");
6✔
142
                        this.WriteAttributeString("xmlns", "http://www.w3.org/2000/svg");
6✔
143

144
                        this.WriteAttributeString("width", box.Max.X - box.Min.X, this.Units);
6✔
145
                        this.WriteAttributeString("height", box.Max.Y - box.Min.Y, this.Units);
6✔
146

147
                        this.WriteStartAttribute("viewBox");
6✔
148
                        this.WriteValue(box.Min.X.ToSvg(this.Units));
6✔
149
                        this.WriteValue(" ");
6✔
150
                        this.WriteValue(box.Min.Y.ToSvg(this.Units));
6✔
151
                        this.WriteValue(" ");
6✔
152
                        this.WriteValue((box.Max.X - box.Min.X).ToSvg(this.Units));
6✔
153
                        this.WriteValue(" ");
6✔
154
                        this.WriteValue((box.Max.Y - box.Min.Y).ToSvg(this.Units));
6✔
155
                        this.WriteEndAttribute();
6✔
156

157
                        this.WriteAttributeString("transform", $"scale(1,-1)");
6✔
158

159
                        if (this.Layout != null)
6✔
160
                        {
5✔
161
                                this.WriteAttributeString("style", "background-color:white");
5✔
162
                        }
5✔
163
                }
6✔
164

165
                private string svgPoints(IEnumerable<IVector> points, Transform transform)
166
                {
11✔
167
                        if (!points.Any())
11!
168
                        {
×
169
                                return string.Empty;
×
170
                        }
171

172
                        StringBuilder sb = new StringBuilder();
11✔
173
                        sb.Append(points.First().ToSvg(this.Units));
11✔
174
                        foreach (IVector point in points.Skip(1))
2,115✔
175
                        {
1,041✔
176
                                sb.Append(' ');
1,041✔
177
                                sb.Append(point.ToSvg(this.Units));
1,041✔
178
                        }
1,041✔
179

180
                        return sb.ToString();
11✔
181
                }
11✔
182

183
                private void writeArc(Arc arc, Transform transform)
184
                {
3✔
185
                        //A rx ry rotation large-arc-flag sweep-flag x y
186

187
                        this.WriteStartElement("polyline");
3✔
188

189
                        this.writeEntityHeader(arc, transform);
3✔
190

191
                        IEnumerable<IVector> vertices = arc.PolygonalVertexes(256).OfType<IVector>();
3✔
192
                        string pts = this.svgPoints(vertices, transform);
3✔
193
                        this.WriteAttributeString("points", pts);
3✔
194
                        this.WriteAttributeString("fill", "none");
3✔
195

196
                        this.WriteEndElement();
3✔
197
                }
3✔
198

199
                private void writeCircle(Circle circle, Transform transform)
200
                {
2✔
201
                        var loc = transform.ApplyTransform(circle.Center);
2✔
202

203
                        this.WriteStartElement("circle");
2✔
204

205
                        this.writeEntityHeader(circle, transform);
2✔
206

207
                        this.WriteAttributeString("r", circle.Radius);
2✔
208
                        this.WriteAttributeString("cx", loc.X);
2✔
209
                        this.WriteAttributeString("cy", loc.Y);
2✔
210

211
                        this.WriteAttributeString("fill", "none");
2✔
212

213
                        this.WriteEndElement();
2✔
214
                }
2✔
215

216
                private void writeEllipse(Ellipse ellipse, Transform transform)
217
                {
1✔
218
                        this.WriteStartElement("polygon");
1✔
219

220
                        this.writeEntityHeader(ellipse, transform);
1✔
221

222
                        IEnumerable<IVector> vertices = ellipse.PolygonalVertexes(256).OfType<IVector>();
1✔
223
                        string pts = this.svgPoints(vertices, transform);
1✔
224
                        this.WriteAttributeString("points", pts);
1✔
225
                        this.WriteAttributeString("fill", "none");
1✔
226

227
                        this.WriteEndElement();
1✔
228
                }
1✔
229

230
                private void writeEntity(Entity entity)
231
                {
23✔
232
                        this.writeEntity(entity, new Transform());
23✔
233
                }
23✔
234

235
                private void writeEntity(Entity entity, Transform transform)
236
                {
127✔
237
                        switch (entity)
127✔
238
                        {
239
                                case Arc arc:
240
                                        this.writeArc(arc, transform);
3✔
241
                                        break;
3✔
242
                                case Line line:
243
                                        this.writeLine(line, transform);
78✔
244
                                        break;
78✔
245
                                case Point point:
246
                                        this.writePoint(point, transform);
1✔
247
                                        break;
1✔
248
                                case Circle circle:
249
                                        this.writeCircle(circle, transform);
2✔
250
                                        break;
2✔
251
                                case Ellipse ellipse:
252
                                        this.writeEllipse(ellipse, transform);
1✔
253
                                        break;
1✔
254
                                //case Hatch hatch:
255
                                //        this.writeHatch(hatch, transform);
256
                                //        break;
257
                                case Insert insert:
258
                                        this.writeInsert(insert, transform);
1✔
259
                                        break;
1✔
260
                                case IPolyline polyline:
261
                                        this.writePolyline(polyline, transform);
7✔
262
                                        break;
7✔
263
                                case IText text:
264
                                        this.writeText(text, transform);
26✔
265
                                        break;
26✔
266
                                default:
267
                                        this.notify($"[{entity.ObjectName}] Entity not implemented.", NotificationType.NotImplemented);
8✔
268
                                        break;
8✔
269
                        }
270
                }
127✔
271

272
                private void writeEntityHeader(IEntity entity, Transform transform)
273
                {
92✔
274
                        Color color = entity.GetActiveColor();
92✔
275

276
                        this.WriteAttributeString("stroke", this.colorSvg(color));
92✔
277

278
                        var lineWeight = entity.LineWeight;
92✔
279
                        switch (lineWeight)
92✔
280
                        {
281
                                case LineweightType.ByLayer:
282
                                        lineWeight = entity.Layer.LineWeight;
77✔
283
                                        break;
77✔
284
                        }
285

286
                        this.WriteAttributeString("stroke-width", $"{this.Configuration.GetLineWeightValue(lineWeight).ToString(CultureInfo.InvariantCulture)}mm");
92✔
287

288
                        this.writeTransform(transform);
92✔
289
                }
92✔
290

291
                private void writeHatch(Hatch hatch, Transform transform)
292
                {
×
293
                        this.WriteStartElement("g");
×
294

295
                        this.writePattern(hatch.Pattern);
×
296

297
                        foreach (Hatch.BoundaryPath path in hatch.Paths)
×
298
                        {
×
299
                                this.WriteStartElement("polyline");
×
300

301
                                this.writeEntityHeader(hatch, transform);
×
302

303
                                foreach (var item in path.Edges)
×
304
                                {
×
305
                                        //TODO: svg edges for hatch drawing
306
                                }
×
307

308
                                //this.WriteAttributeString("points", pts);
309

310
                                this.WriteAttributeString("fill", "none");
×
311

312
                                this.WriteEndElement();
×
313
                        }
×
314

315
                        this.WriteEndElement();
×
316
                }
×
317

318
                private void writeInsert(Insert insert, Transform transform)
319
                {
1✔
320
                        var insertTransform = insert.GetTransform();
1✔
321
                        var merged = new Transform(transform.Matrix * insertTransform.Matrix);
1✔
322

323
                        this.WriteStartElement("g");
1✔
324
                        this.writeTransform(merged);
1✔
325

326
                        foreach (var e in insert.Block.Entities)
5✔
327
                        {
1✔
328
                                this.writeEntity(e);
1✔
329
                        }
1✔
330

331
                        this.WriteEndElement();
1✔
332
                }
1✔
333

334
                private void writeLine(Line line, Transform transform)
335
                {
78✔
336
                        this.WriteStartElement("line");
78✔
337

338
                        this.writeEntityHeader(line, transform);
78✔
339

340
                        this.WriteAttributeString("x1", line.StartPoint.X, UnitsType.Millimeters);
78✔
341
                        this.WriteAttributeString("y1", line.StartPoint.Y, UnitsType.Millimeters);
78✔
342
                        this.WriteAttributeString("x2", line.EndPoint.X, UnitsType.Millimeters);
78✔
343
                        this.WriteAttributeString("y2", line.EndPoint.Y, UnitsType.Millimeters);
78✔
344

345
                        this.WriteEndElement();
78✔
346
                }
78✔
347

348
                private void writePattern(HatchPattern pattern)
349
                {
×
350
                        this.WriteStartElement("pattern");
×
351

352
                        this.WriteEndElement();
×
353
                }
×
354

355
                private void writePoint(Point point, Transform transform)
356
                {
1✔
357
                        this.WriteStartElement("circle");
1✔
358

359
                        this.writeEntityHeader(point, transform);
1✔
360

361
                        this.WriteAttributeString("r", this.Configuration.PointRadius, UnitsType.Millimeters);
1✔
362
                        this.WriteAttributeString("cx", point.Location.X);
1✔
363
                        this.WriteAttributeString("cy", point.Location.Y);
1✔
364

365
                        this.WriteAttributeString("fill", this.colorSvg(point.GetActiveColor()));
1✔
366

367
                        this.WriteEndElement();
1✔
368
                }
1✔
369

370
                private void writePolyline(IPolyline polyline, Transform transform)
371
                {
7✔
372
                        if (polyline.IsClosed)
7✔
373
                        {
6✔
374
                                this.WriteStartElement("polygon");
6✔
375
                        }
6✔
376
                        else
377
                        {
1✔
378
                                this.WriteStartElement("polyline");
1✔
379
                        }
1✔
380

381
                        this.writeEntityHeader(polyline, transform);
7✔
382

383
                        var vertices = polyline.Vertices.Select(v => v.Location).ToList();
35✔
384

385
                        string pts = this.svgPoints(polyline.Vertices.Select(v => v.Location), transform);
49✔
386
                        this.WriteAttributeString("points", pts);
7✔
387
                        this.WriteAttributeString("fill", "none");
7✔
388

389
                        this.WriteEndElement();
7✔
390
                }
7✔
391

392
                private void writeText(IText text, Transform transform)
393
                {
26✔
394
                        XYZ insert;
395

396
                        if (text is TextEntity lineText
26✔
397
                                && (lineText.HorizontalAlignment != TextHorizontalAlignment.Left
26✔
398
                                || lineText.VerticalAlignment != TextVerticalAlignmentType.Baseline)
26✔
399
                                && !(lineText.HorizontalAlignment == TextHorizontalAlignment.Fit
26✔
400
                                || lineText.HorizontalAlignment == TextHorizontalAlignment.Aligned))
26✔
401
                        {
12✔
402
                                insert = lineText.AlignmentPoint;
12✔
403
                        }
12✔
404
                        else
405
                        {
14✔
406
                                insert = text.InsertPoint;
14✔
407
                        }
14✔
408

409
                        this.WriteStartElement("g");
26✔
410
                        this.writeTransform(transform);
26✔
411

412
                        this.WriteStartElement("text");
26✔
413

414
                        this.writeTransform(translation: insert.ToPixelSize(this.Units), scale: new XYZ(1, -1, 0), rotation: text.Rotation != 0 ? text.Rotation : null);
26✔
415

416
                        this.WriteAttributeString("fill", this.colorSvg(text.GetActiveColor()));
26✔
417

418
                        //<text x="20" y="35" class="small">My</text>
419
                        this.WriteStartAttribute("style");
26✔
420
                        this.WriteValue("font:");
26✔
421
                        this.WriteValue(text.Height.ToSvg(this.Units));
26✔
422
                        if (this.Units == UnitsType.Unitless)
26✔
423
                        {
4✔
424
                                this.WriteValue("px");
4✔
425
                        }
4✔
426
                        this.WriteValue(" ");
26✔
427
                        this.WriteValue(Path.GetFileNameWithoutExtension(text.Style.Filename));
26✔
428
                        this.WriteEndAttribute();
26✔
429

430
                        switch (text)
26✔
431
                        {
432
                                case MText mtext:
433
                                        switch (mtext.AttachmentPoint)
5!
434
                                        {
435
                                                case AttachmentPointType.TopLeft:
436
                                                        this.WriteAttributeString("alignment-baseline", "hanging");
4✔
437
                                                        this.WriteAttributeString("text-anchor", "start");
4✔
438
                                                        break;
4✔
439
                                                case AttachmentPointType.TopCenter:
440
                                                        this.WriteAttributeString("alignment-baseline", "hanging");
×
441
                                                        this.WriteAttributeString("text-anchor", "middle");
×
442
                                                        break;
×
443
                                                case AttachmentPointType.TopRight:
444
                                                        this.WriteAttributeString("alignment-baseline", "hanging");
1✔
445
                                                        this.WriteAttributeString("text-anchor", "end");
1✔
446
                                                        break;
1✔
447
                                                case AttachmentPointType.MiddleLeft:
448
                                                        this.WriteAttributeString("alignment-baseline", "middle");
×
449
                                                        this.WriteAttributeString("text-anchor", "start");
×
450
                                                        break;
×
451
                                                case AttachmentPointType.MiddleCenter:
452
                                                        this.WriteAttributeString("alignment-baseline", "middle");
×
453
                                                        this.WriteAttributeString("text-anchor", "middle");
×
454
                                                        break;
×
455
                                                case AttachmentPointType.MiddleRight:
456
                                                        this.WriteAttributeString("alignment-baseline", "middle");
×
457
                                                        this.WriteAttributeString("text-anchor", "end");
×
458
                                                        break;
×
459
                                                case AttachmentPointType.BottomLeft:
460
                                                        this.WriteAttributeString("alignment-baseline", "baseline");
×
461
                                                        this.WriteAttributeString("text-anchor", "start");
×
462
                                                        break;
×
463
                                                case AttachmentPointType.BottomCenter:
464
                                                        this.WriteAttributeString("alignment-baseline", "baseline");
×
465
                                                        this.WriteAttributeString("text-anchor", "middle");
×
466
                                                        break;
×
467
                                                case AttachmentPointType.BottomRight:
468
                                                        this.WriteAttributeString("alignment-baseline", "baseline");
×
469
                                                        this.WriteAttributeString("text-anchor", "end");
×
470
                                                        break;
×
471
                                                default:
472
                                                        break;
×
473
                                        }
474

475
                                        foreach (var item in mtext.GetTextLines())
53✔
476
                                        {
19✔
477
                                                this.WriteStartElement("tspan");
19✔
478
                                                this.WriteAttributeString("x", 0);
19✔
479
                                                this.WriteAttributeString("dy", "1em");
19✔
480
                                                this.WriteString(item);
19✔
481
                                                this.WriteEndElement();
19✔
482
                                        }
19✔
483

484
                                        //Line to avoid the strange offset at the end
485
                                        this.WriteStartElement("tspan");
5✔
486
                                        this.WriteAttributeString("x", 0);
5✔
487
                                        this.WriteAttributeString("dy", "1em");
5✔
488
                                        this.WriteAttributeString("visibility", "hidden");        
5✔
489
                                        this.WriteString(".");
5✔
490
                                        this.WriteEndElement();
5✔
491
                                        break;
5✔
492
                                case TextEntity textEntity:
493

494
                                        switch (textEntity.HorizontalAlignment)
21✔
495
                                        {
496
                                                case TextHorizontalAlignment.Left:
497
                                                        this.WriteAttributeString("text-anchor", "start");
10✔
498
                                                        break;
10✔
499
                                                case TextHorizontalAlignment.Middle:
500
                                                case TextHorizontalAlignment.Center:
501
                                                        this.WriteAttributeString("text-anchor", "middle");
5✔
502
                                                        break;
5✔
503
                                                case TextHorizontalAlignment.Right:
504
                                                        this.WriteAttributeString("text-anchor", "end");
4✔
505
                                                        break;
4✔
506
                                        }
507

508
                                        switch (textEntity.VerticalAlignment)
21✔
509
                                        {
510
                                                case TextVerticalAlignmentType.Baseline:
511
                                                case TextVerticalAlignmentType.Bottom:
512
                                                        this.WriteAttributeString("alignment-baseline", "baseline");
15✔
513
                                                        break;
15✔
514
                                                case TextVerticalAlignmentType.Middle:
515
                                                        this.WriteAttributeString("alignment-baseline", "middle");
3✔
516
                                                        break;
3✔
517
                                                case TextVerticalAlignmentType.Top:
518
                                                        this.WriteAttributeString("alignment-baseline", "hanging");
3✔
519
                                                        break;
3✔
520
                                        }
521

522
                                        this.WriteString(text.Value);
21✔
523
                                        break;
21✔
524
                        }
525

526
                        this.WriteEndElement();
26✔
527
                        this.WriteEndElement();
26✔
528
                }
26✔
529

530
                private void writeTransform(Transform transform)
531
                {
119✔
532
                        XYZ? translation = transform.Translation != XYZ.Zero ? transform.Translation : null;
119✔
533
                        XYZ? scale = transform.Scale != new XYZ(1) ? transform.Scale : null;
119✔
534
                        double? rotation = transform.EulerRotation.Z != 0 ? transform.EulerRotation.Z : null;
119!
535

536
                        this.writeTransform(translation, scale, rotation);
119✔
537
                }
119✔
538

539
                private void writeTransform(XYZ? translation = null, XYZ? scale = null, double? rotation = null)
540
                {
145✔
541
                        StringBuilder sb = new StringBuilder();
145✔
542

543
                        if (translation.HasValue)
145✔
544
                        {
88✔
545
                                var t = translation.Value;
88✔
546

547
                                sb.Append($"translate(");
88✔
548
                                sb.Append($"{t.X.ToString(CultureInfo.InvariantCulture)},");
88✔
549
                                sb.Append($"{t.Y.ToString(CultureInfo.InvariantCulture)})");
88✔
550
                                sb.Append(' ');
88✔
551
                        }
88✔
552

553
                        if (scale.HasValue)
145✔
554
                        {
83✔
555
                                var s = scale.Value;
83✔
556

557
                                sb.Append($"scale(");
83✔
558
                                sb.Append($"{s.X.ToString(CultureInfo.InvariantCulture)},");
83✔
559
                                sb.Append($"{s.Y.ToString(CultureInfo.InvariantCulture)})");
83✔
560
                                sb.Append(' ');
83✔
561
                        }
83✔
562

563
                        if (rotation.HasValue)
145✔
564
                        {
2✔
565
                                var r = -MathHelper.RadToDeg(rotation.Value);
2✔
566

567
                                sb.Append($"rotate(");
2✔
568
                                sb.Append($"{r.ToString(CultureInfo.InvariantCulture)})");
2✔
569
                        }
2✔
570

571
                        if (sb.ToString().IsNullOrEmpty())
145✔
572
                        {
51✔
573
                                return;
51✔
574
                        }
575

576
                        this.WriteAttributeString("transform", sb.ToString());
94✔
577
                }
145✔
578
        }
579
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc