Skip to main content

ogeom_io/
dxf.rs

1//! DXF: 2D drawings, the interchange the field expects.
2//!
3//! Reading takes a drawing's curves as written ([`read_dxf_entities`]: lines,
4//! arcs, circles, ellipses, splines, bulged polylines and text, with the
5//! units and which layers are dashed), or as polylines ([`read_dxf`]).
6//!
7//! Writing takes the same entities ([`write_dxf_entities`]: R2000, with the
8//! unit and named layers), or bare polylines ([`write_dxf`]). The polyline
9//! writer is R12 ASCII, the most widely readable dialect: a TABLES section
10//! declaring the two linetypes and two layers a technical drawing needs,
11//! then one `POLYLINE` per curve. Visible curves go on the `VISIBLE` layer
12//! with continuous lines; hidden curves on `HIDDEN`, dashed. It takes bare
13//! polylines rather than a drawing type, so anything that produces 2D
14//! curves (the hidden-line projector, a section outline, a sketch) writes
15//! without this crate knowing where they came from.
16
17mod entities_out;
18
19pub use entities_out::{DxfItem, DxfLayer, DxfOut, DxfShape, DxfUnit, write_dxf_entities};
20
21use ogeom_math::{Point2, Vector2};
22use std::fmt::Write as _;
23
24/// Write polylines as an R12 DXF document.
25///
26/// `visible` draws continuous on layer `VISIBLE`; `hidden` draws dashed on
27/// layer `HIDDEN`. Polylines with fewer than two points are skipped; a
28/// point is not a line in a drawing, here as everywhere.
29#[must_use]
30pub fn write_dxf(visible: &[Vec<Point2>], hidden: &[Vec<Point2>]) -> String {
31    let mut out = String::new();
32    // Header: R12 says almost nothing and needs almost nothing.
33    push(
34        &mut out,
35        &[("0", "SECTION"), ("2", "HEADER"), ("0", "ENDSEC")],
36    );
37
38    // Tables: the linetypes first, because the layers name them.
39    push(&mut out, &[("0", "SECTION"), ("2", "TABLES")]);
40    push(&mut out, &[("0", "TABLE"), ("2", "LTYPE"), ("70", "2")]);
41    push(
42        &mut out,
43        &[
44            ("0", "LTYPE"),
45            ("2", "CONTINUOUS"),
46            ("70", "0"),
47            ("3", "Solid line"),
48            ("72", "65"),
49            ("73", "0"),
50            ("40", "0.0"),
51        ],
52    );
53    push(
54        &mut out,
55        &[
56            ("0", "LTYPE"),
57            ("2", "DASHED"),
58            ("70", "0"),
59            ("3", "Dashed line"),
60            ("72", "65"),
61            ("73", "2"),
62            ("40", "0.75"),
63            ("49", "0.5"),
64            ("49", "-0.25"),
65        ],
66    );
67    push(&mut out, &[("0", "ENDTAB")]);
68    push(&mut out, &[("0", "TABLE"), ("2", "LAYER"), ("70", "2")]);
69    push(
70        &mut out,
71        &[
72            ("0", "LAYER"),
73            ("2", "VISIBLE"),
74            ("70", "0"),
75            ("62", "7"),
76            ("6", "CONTINUOUS"),
77        ],
78    );
79    push(
80        &mut out,
81        &[
82            ("0", "LAYER"),
83            ("2", "HIDDEN"),
84            ("70", "0"),
85            ("62", "8"),
86            ("6", "DASHED"),
87        ],
88    );
89    push(&mut out, &[("0", "ENDTAB"), ("0", "ENDSEC")]);
90
91    push(&mut out, &[("0", "SECTION"), ("2", "ENTITIES")]);
92    for (layer, curves) in [("VISIBLE", visible), ("HIDDEN", hidden)] {
93        for curve in curves {
94            if curve.len() < 2 {
95                continue;
96            }
97            push(
98                &mut out,
99                &[("0", "POLYLINE"), ("8", layer), ("66", "1"), ("70", "0")],
100            );
101            for p in curve {
102                push(&mut out, &[("0", "VERTEX"), ("8", layer)]);
103                let _ = writeln!(out, "10\n{}\n20\n{}\n30\n0.0", real(p.x), real(p.y));
104            }
105            push(&mut out, &[("0", "SEQEND")]);
106        }
107    }
108    push(&mut out, &[("0", "ENDSEC"), ("0", "EOF")]);
109    out
110}
111
112/// A DXF real: shortest exact form, decimal point guaranteed.
113fn real(v: f64) -> String {
114    let s = format!("{v:?}");
115    if s.contains('.') || s.contains('e') {
116        s
117    } else {
118        format!("{s}.0")
119    }
120}
121
122/// Append group-code/value pairs, one per line each.
123fn push(out: &mut String, pairs: &[(&str, &str)]) {
124    for (code, value) in pairs {
125        let _ = writeln!(out, "{code}\n{value}");
126    }
127}
128
129#[cfg(test)]
130#[allow(clippy::unwrap_used)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn a_drawing_writes_layers_polylines_and_exact_coordinates() {
136        let visible = vec![vec![
137            Point2::new(0.0, 0.0),
138            Point2::new(10.0, 0.0),
139            Point2::new(10.0, 5.0),
140        ]];
141        let hidden = vec![vec![Point2::new(1.5, 2.25), Point2::new(3.0, 2.25)]];
142        let text = write_dxf(&visible, &hidden);
143
144        assert!(text.starts_with("0\nSECTION"));
145        assert!(text.trim_end().ends_with("EOF"));
146        assert_eq!(text.matches("POLYLINE").count(), 2);
147        assert_eq!(text.matches("VERTEX").count(), 5);
148        assert_eq!(text.matches("SEQEND").count(), 2);
149        // Both layers declared and used, hidden dashed.
150        assert!(text.contains("VISIBLE"));
151        assert!(text.contains("HIDDEN"));
152        assert!(text.contains("DASHED"));
153        // Coordinates exact and point-carrying.
154        assert!(text.contains("10\n10.0\n20\n5.0"));
155        assert!(text.contains("10\n1.5\n20\n2.25"));
156    }
157
158    #[test]
159    fn degenerate_polylines_are_dropped() {
160        let text = write_dxf(&[vec![Point2::new(1.0, 1.0)]], &[vec![]]);
161        assert_eq!(text.matches("POLYLINE").count(), 0);
162    }
163}
164
165/// The polylines a DXF carries, by the layer they were drawn on.
166#[derive(Debug, Clone, Default, PartialEq)]
167pub struct DxfDrawing {
168    /// Curves on the `VISIBLE` layer, or on no named layer at all.
169    pub visible: Vec<Vec<Point2>>,
170    /// Curves on the `HIDDEN` layer.
171    pub hidden: Vec<Vec<Point2>>,
172}
173
174/// Read the polylines out of an ASCII DXF.
175///
176/// `POLYLINE` and `LWPOLYLINE` become polylines, a closed one ending on
177/// its first point again; `LINE` becomes a polyline of two points. A
178/// polyline's bulges are read as straight chords here: the typed reader,
179/// [`read_dxf_entities`], keeps them and every curve besides. Hidden
180/// curves are those on the `HIDDEN` layer or drawn in a dashed or hidden
181/// linetype.
182///
183/// # Errors
184///
185/// As [`read_dxf_entities`].
186pub fn read_dxf(text: &str) -> ogeom_core::OgeomResult<DxfDrawing> {
187    let mut out = DxfDrawing::default();
188    for entity in read_dxf_entities(text)?.entities {
189        let points = match entity.curve {
190            DxfCurve::Line { start, end } => vec![start, end],
191            DxfCurve::Polyline { vertices, closed } => {
192                let mut points: Vec<Point2> = vertices.iter().map(|v| v.0).collect();
193                if closed
194                    && points.len() > 2
195                    && let (Some(first), Some(last)) = (points.first(), points.last())
196                    && first.distance(*last) > 0.0
197                {
198                    points.push(*first);
199                }
200                points
201            }
202            _ => continue,
203        };
204        if points.len() < 2 {
205            continue;
206        }
207        if entity.hidden {
208            out.hidden.push(points);
209        } else {
210            out.visible.push(points);
211        }
212    }
213    Ok(out)
214}
215
216/// A DXF's drawing entities, typed, with the drawing's units.
217#[derive(Debug, Clone, Default, PartialEq)]
218pub struct DxfEntities {
219    /// `$INSUNITS` from the HEADER, when present (0 is unitless).
220    pub insunits: Option<i32>,
221    /// Millimetres per drawing unit, when `insunits` names a length unit.
222    pub unit_mm: Option<f64>,
223    /// The ENTITIES section's curves, in file order.
224    pub entities: Vec<DxfEntity>,
225    /// The ENTITIES section's `TEXT`s, in file order.
226    pub texts: Vec<DxfLabel>,
227}
228
229/// A line of text: where it sits, how tall, at what angle, and what it says.
230#[derive(Debug, Clone, PartialEq)]
231pub struct DxfText {
232    /// The start of its baseline (the first alignment point).
233    pub at: Point2,
234    /// The height of its capitals.
235    pub height: f64,
236    /// The baseline's direction, radians counter-clockwise from x.
237    pub angle: f64,
238    /// The string, with `\U+XXXX` escapes read as the characters they name.
239    pub text: String,
240}
241
242/// A text, and the layer it was drawn on.
243#[derive(Debug, Clone, PartialEq)]
244pub struct DxfLabel {
245    /// Group 8, as written (`"0"` when absent).
246    pub layer: String,
247    /// The text.
248    pub text: DxfText,
249}
250
251/// One entity: the curve, and where it was drawn.
252#[derive(Debug, Clone, PartialEq)]
253pub struct DxfEntity {
254    /// Group 8, as written (`"0"` when absent).
255    pub layer: String,
256    /// Whether the layer is `HIDDEN`, or the entity's or its layer's
257    /// linetype is dashed or hidden.
258    pub hidden: bool,
259    /// The curve.
260    pub curve: DxfCurve,
261}
262
263/// A DXF curve in the drawing's plane.
264#[derive(Debug, Clone, PartialEq)]
265pub enum DxfCurve {
266    /// A segment.
267    Line {
268        /// Where it starts.
269        start: Point2,
270        /// Where it ends.
271        end: Point2,
272    },
273    /// Counter-clockwise from `start_angle` to `end_angle`, in radians.
274    Arc {
275        /// The centre.
276        centre: Point2,
277        /// The radius.
278        radius: f64,
279        /// Where it starts, radians.
280        start_angle: f64,
281        /// Where it ends, radians.
282        end_angle: f64,
283    },
284    /// A full circle.
285    Circle {
286        /// The centre.
287        centre: Point2,
288        /// The radius.
289        radius: f64,
290    },
291    /// An ellipse or elliptic arc, parameters as DXF gives them: a full
292    /// ellipse when they span two pi.
293    Ellipse {
294        /// The centre.
295        centre: Point2,
296        /// The major axis's end, relative to the centre.
297        major: Vector2,
298        /// Minor over major.
299        ratio: f64,
300        /// Start parameter.
301        start_param: f64,
302        /// End parameter.
303        end_param: f64,
304    },
305    /// A B-spline as written: degree, knots, control points and weights.
306    /// A spline given only by fit points carries them as its control
307    /// points and no knots.
308    Spline {
309        /// The degree.
310        degree: usize,
311        /// The knot vector.
312        knots: Vec<f64>,
313        /// The control points, or the fit points where there are none.
314        control_points: Vec<Point2>,
315        /// The weights, for a rational spline.
316        weights: Option<Vec<f64>>,
317        /// Whether the spline is closed.
318        closed: bool,
319    },
320    /// A `POLYLINE` or `LWPOLYLINE`: each vertex with the bulge of the
321    /// segment that starts at it (the tangent of a quarter of the included
322    /// angle, positive counter-clockwise).
323    Polyline {
324        /// The vertices and their bulges.
325        vertices: Vec<(Point2, f64)>,
326        /// Whether the last vertex joins the first.
327        closed: bool,
328    },
329}
330
331/// Millimetres per unit for a `$INSUNITS` code that names a length.
332fn unit_mm(code: i32) -> Option<f64> {
333    Some(match code {
334        1 => 25.4,
335        2 => 304.8,
336        3 => 1_609_344.0,
337        4 => 1.0,
338        5 => 10.0,
339        6 => 1000.0,
340        7 => 1.0e6,
341        8 => 2.54e-5,
342        9 => 0.0254,
343        10 => 914.4,
344        11 => 1.0e-7,
345        12 => 1.0e-6,
346        13 => 1.0e-3,
347        14 => 100.0,
348        15 => 1.0e4,
349        16 => 1.0e5,
350        17 => 1.0e12,
351        18 => 1.495_978_707e14,
352        19 => 9.460_730_472_580_8e18,
353        20 => 3.085_677_581_491_367e19,
354        _ => return None,
355    })
356}
357
358/// One group-coded record: the entity or table entry a `0` code opens, and
359/// the pairs up to the next.
360struct Record<'a> {
361    kind: &'a str,
362    pairs: Vec<(i32, &'a str)>,
363}
364
365impl Record<'_> {
366    fn text(&self, code: i32) -> Option<&str> {
367        self.pairs.iter().find(|p| p.0 == code).map(|p| p.1)
368    }
369
370    fn real(&self, code: i32) -> Option<f64> {
371        self.text(code).and_then(|v| v.parse().ok())
372    }
373
374    fn int(&self, code: i32) -> Option<i64> {
375        self.text(code).and_then(|v| v.parse().ok())
376    }
377
378    fn point(&self, x: i32, y: i32) -> Option<Point2> {
379        Some(Point2::new(self.real(x)?, self.real(y)?))
380    }
381
382    fn reals(&self, code: i32) -> Vec<f64> {
383        self.pairs
384            .iter()
385            .filter(|p| p.0 == code)
386            .filter_map(|p| p.1.parse().ok())
387            .collect()
388    }
389
390    /// Points given as repeated `x`/`y` pairs, in order.
391    fn points(&self, x: i32, y: i32) -> Vec<Point2> {
392        let mut out: Vec<Point2> = Vec::new();
393        for (code, value) in &self.pairs {
394            let Ok(v) = value.parse::<f64>() else {
395                continue;
396            };
397            if *code == x {
398                out.push(Point2::new(v, 0.0));
399            } else if *code == y
400                && let Some(last) = out.last_mut()
401            {
402                last.y = v;
403            }
404        }
405        out
406    }
407}
408
409/// Whether a linetype name draws hidden lines.
410fn dashed(linetype: &str) -> bool {
411    let name = linetype.to_ascii_uppercase();
412    name.contains("HIDDEN") || name.contains("DASH")
413}
414
415/// Read the typed entities, the units and the layers' linetypes out of an
416/// ASCII DXF.
417///
418/// Lines, arcs, circles, ellipses, splines, `POLYLINE` and `LWPOLYLINE`
419/// are read from the ENTITIES section, with a polyline's closed flag and
420/// bulges; a `POLYLINE` header's own point is its elevation, not a
421/// vertex. An entity whose extrusion is `(0, 0, -1)` is seen from below,
422/// and its planar coordinates are mirrored in x to the drawing's own view.
423/// `TEXT` is read into [`DxfEntities::texts`]; seen from below, its point
424/// and baseline are mirrored like a curve's. Blocks, inserts, multiline
425/// text, dimensions and hatches are skipped.
426///
427/// # Errors
428///
429/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
430/// file is not group-coded in pairs, or an entity's extrusion is neither
431/// up nor down, or an ellipse or spline is seen from below.
432pub fn read_dxf_entities(text: &str) -> ogeom_core::OgeomResult<DxfEntities> {
433    // A DXF is a stream of (code, value) pairs, one per line each.
434    let lines: Vec<&str> = text.lines().map(str::trim).collect();
435    if !lines.len().is_multiple_of(2) && !lines.last().is_some_and(|l| l.is_empty()) {
436        ogeom_core::ogeom_bail!(
437            Construction,
438            "a DXF is group codes and values in pairs; this has an odd number of lines"
439        );
440    }
441    let mut pairs: Vec<(i32, &str)> = Vec::with_capacity(lines.len() / 2);
442    for [code, value] in lines.as_chunks::<2>().0 {
443        let Ok(code) = code.parse::<i32>() else {
444            ogeom_core::ogeom_bail!(
445                Construction,
446                "a DXF group code is an integer; found {code:?}"
447            );
448        };
449        pairs.push((code, *value));
450    }
451
452    // Records within each section, by name.
453    let mut sections: Vec<(&str, Vec<Record<'_>>)> = Vec::new();
454    let mut k = 0;
455    while k < pairs.len() {
456        if pairs[k] == (0, "SECTION") && k + 1 < pairs.len() && pairs[k + 1].0 == 2 {
457            let name = pairs[k + 1].1;
458            k += 2;
459            let mut records: Vec<Record<'_>> = Vec::new();
460            // The pairs before the section's first 0 code (the HEADER's
461            // variables) make a record of their own.
462            let mut current = Record {
463                kind: "",
464                pairs: Vec::new(),
465            };
466            while k < pairs.len() && pairs[k] != (0, "ENDSEC") {
467                if pairs[k].0 == 0 {
468                    records.push(core::mem::replace(
469                        &mut current,
470                        Record {
471                            kind: pairs[k].1,
472                            pairs: Vec::new(),
473                        },
474                    ));
475                } else {
476                    current.pairs.push(pairs[k]);
477                }
478                k += 1;
479            }
480            records.push(current);
481            sections.push((name, records));
482        }
483        k += 1;
484    }
485
486    let mut out = DxfEntities::default();
487    let mut layer_linetype: ogeom_core::FastMap<String, String> = ogeom_core::FastMap::default();
488    for (name, records) in &sections {
489        match *name {
490            "HEADER" => {
491                for record in records {
492                    let mut it = record.pairs.iter();
493                    while let Some((code, value)) = it.next() {
494                        if *code == 9
495                            && *value == "$INSUNITS"
496                            && let Some((70, units)) = it.next()
497                            && let Ok(units) = units.parse::<i32>()
498                        {
499                            out.insunits = Some(units);
500                            out.unit_mm = unit_mm(units);
501                        }
502                    }
503                }
504            }
505            "TABLES" => {
506                for record in records.iter().filter(|r| r.kind == "LAYER") {
507                    if let Some(layer) = record.text(2) {
508                        layer_linetype.insert(
509                            layer.to_ascii_uppercase(),
510                            record.text(6).unwrap_or("").to_string(),
511                        );
512                    }
513                }
514            }
515            _ => {}
516        }
517    }
518
519    let Some((_, records)) = sections.iter().find(|(name, _)| *name == "ENTITIES") else {
520        return Ok(out);
521    };
522    let mut k = 0;
523    while k < records.len() {
524        let record = &records[k];
525        k += 1;
526        let layer = record.text(8).unwrap_or("0").to_string();
527        let hidden = layer.eq_ignore_ascii_case("HIDDEN")
528            || record.text(6).is_some_and(dashed)
529            || layer_linetype
530                .get(&layer.to_ascii_uppercase())
531                .is_some_and(|l| dashed(l));
532        // The arbitrary axis: straight up reads as written, straight down
533        // mirrors x; anything tilted is not a drawing's plane.
534        let from_below = match record.real(230) {
535            None => false,
536            Some(z)
537                if (record.real(210).unwrap_or(0.0).abs()
538                    + record.real(220).unwrap_or(0.0).abs())
539                    <= 1e-12 =>
540            {
541                z < 0.0
542            }
543            Some(_) => ogeom_core::ogeom_bail!(
544                Construction,
545                "a {} entity is extruded along a tilted axis; only drawings in the XY plane \
546                 are read",
547                record.kind
548            ),
549        };
550        let flip = |p: Point2| {
551            if from_below {
552                Point2::new(-p.x, p.y)
553            } else {
554                p
555            }
556        };
557        let curve = match record.kind {
558            "LINE" => {
559                let (Some(a), Some(b)) = (record.point(10, 20), record.point(11, 21)) else {
560                    continue;
561                };
562                DxfCurve::Line {
563                    start: flip(a),
564                    end: flip(b),
565                }
566            }
567            "CIRCLE" => {
568                let (Some(centre), Some(radius)) = (record.point(10, 20), record.real(40)) else {
569                    continue;
570                };
571                DxfCurve::Circle {
572                    centre: flip(centre),
573                    radius,
574                }
575            }
576            "ARC" => {
577                let (Some(centre), Some(radius)) = (record.point(10, 20), record.real(40)) else {
578                    continue;
579                };
580                let start = record.real(50).unwrap_or(0.0).to_radians();
581                let end = record.real(51).unwrap_or(360.0).to_radians();
582                // Mirrored, the counter-clockwise run from start to end
583                // becomes the one from the mirror of end to that of start.
584                let (start_angle, end_angle) = if from_below {
585                    (core::f64::consts::PI - end, core::f64::consts::PI - start)
586                } else {
587                    (start, end)
588                };
589                DxfCurve::Arc {
590                    centre: flip(centre),
591                    radius,
592                    start_angle,
593                    end_angle,
594                }
595            }
596            "TEXT" => {
597                let (Some(at), Some(height)) = (record.point(10, 20), record.real(40)) else {
598                    continue;
599                };
600                let angle = record.real(50).unwrap_or(0.0).to_radians();
601                out.texts.push(DxfLabel {
602                    layer,
603                    text: DxfText {
604                        at: flip(at),
605                        height,
606                        // The mirror of direction (cos a, sin a) is (-cos a, sin a).
607                        angle: if from_below {
608                            core::f64::consts::PI - angle
609                        } else {
610                            angle
611                        },
612                        text: unescape(record.text(1).unwrap_or("")),
613                    },
614                });
615                continue;
616            }
617            "ELLIPSE" | "SPLINE" if from_below => ogeom_core::ogeom_bail!(
618                Construction,
619                "a {} seen from below (extrusion 0, 0, -1) is not read yet",
620                record.kind
621            ),
622            "ELLIPSE" => {
623                let (Some(centre), Some(major)) = (record.point(10, 20), record.point(11, 21))
624                else {
625                    continue;
626                };
627                DxfCurve::Ellipse {
628                    centre,
629                    major: Vector2::new(major.x, major.y),
630                    ratio: record.real(40).unwrap_or(1.0),
631                    start_param: record.real(41).unwrap_or(0.0),
632                    end_param: record.real(42).unwrap_or(core::f64::consts::TAU),
633                }
634            }
635            "SPLINE" => {
636                let flags = record.int(70).unwrap_or(0);
637                let control = record.points(10, 20);
638                let weights = record.reals(41);
639                let (control_points, knots) = if control.is_empty() {
640                    (record.points(11, 21), Vec::new())
641                } else {
642                    (control, record.reals(40))
643                };
644                DxfCurve::Spline {
645                    degree: usize::try_from(record.int(71).unwrap_or(3)).unwrap_or(3),
646                    knots,
647                    weights: (!weights.is_empty() && weights.len() == control_points.len())
648                        .then_some(weights),
649                    control_points,
650                    closed: flags & 1 != 0,
651                }
652            }
653            "LWPOLYLINE" => {
654                let mut vertices: Vec<(Point2, f64)> = Vec::new();
655                for (code, value) in &record.pairs {
656                    let Ok(v) = value.parse::<f64>() else {
657                        continue;
658                    };
659                    match code {
660                        10 => vertices.push((Point2::new(v, 0.0), 0.0)),
661                        20 => {
662                            if let Some(last) = vertices.last_mut() {
663                                last.0.y = v;
664                            }
665                        }
666                        42 => {
667                            if let Some(last) = vertices.last_mut() {
668                                last.1 = v;
669                            }
670                        }
671                        _ => {}
672                    }
673                }
674                polyline(vertices, record.int(70).unwrap_or(0), from_below)
675            }
676            "POLYLINE" => {
677                // The header's own 10/20 is the elevation; the vertices
678                // follow as records of their own up to SEQEND.
679                let mut vertices: Vec<(Point2, f64)> = Vec::new();
680                while k < records.len() && records[k].kind == "VERTEX" {
681                    let v = &records[k];
682                    k += 1;
683                    // A spline frame's control point is not on the curve.
684                    if v.int(70).unwrap_or(0) & 16 != 0 {
685                        continue;
686                    }
687                    if let Some(p) = v.point(10, 20) {
688                        vertices.push((p, v.real(42).unwrap_or(0.0)));
689                    }
690                }
691                if k < records.len() && records[k].kind == "SEQEND" {
692                    k += 1;
693                }
694                polyline(vertices, record.int(70).unwrap_or(0), from_below)
695            }
696            _ => continue,
697        };
698        out.entities.push(DxfEntity {
699            layer,
700            hidden,
701            curve,
702        });
703    }
704    Ok(out)
705}
706
707/// A DXF string with each `\U+XXXX` read as the character it names; any
708/// other backslash stays as written.
709fn unescape(text: &str) -> String {
710    let mut out = String::with_capacity(text.len());
711    let mut rest = text;
712    while let Some(at) = rest.find("\\U+") {
713        out.push_str(&rest[..at]);
714        let code = rest
715            .get(at + 3..at + 7)
716            .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
717            .and_then(|h| u32::from_str_radix(h, 16).ok())
718            .and_then(char::from_u32);
719        if let Some(c) = code {
720            out.push(c);
721            rest = &rest[at + 7..];
722        } else {
723            out.push('\\');
724            rest = &rest[at + 1..];
725        }
726    }
727    out.push_str(rest);
728    out
729}
730
731/// A polyline from its vertices and flags, mirrored in x when seen from
732/// below (a mirror turns every bulge the other way).
733fn polyline(vertices: Vec<(Point2, f64)>, flags: i64, from_below: bool) -> DxfCurve {
734    let vertices = if from_below {
735        vertices
736            .into_iter()
737            .map(|(p, b)| (Point2::new(-p.x, p.y), -b))
738            .collect()
739    } else {
740        vertices
741    };
742    DxfCurve::Polyline {
743        vertices,
744        closed: flags & 1 != 0,
745    }
746}