diff --git a/crates/dxf/src/lib.rs b/crates/dxf/src/lib.rs
--- a/crates/dxf/src/lib.rs
+++ b/crates/dxf/src/lib.rs
@@ -264,7 +264,7 @@ pub fn parse_ascii(text: &str) -> Result<Vec<Tag>> {
 #[allow(clippy::while_let_loop)]
 fn parse_ascii_with_limit(text: &str, max_tags: usize) -> Result<Vec<Tag>> {
     let mut tags = Vec::new();
-    let mut lines = text.lines().enumerate();
+    let mut lines = text.strip_prefix('\u{feff}').unwrap_or(text).lines().enumerate();
     loop {
         let Some((ln, code_line)) = lines.next() else { break };
         let ct = code_line.trim();
diff --git a/crates/dxf/tests/ascii_bom.rs b/crates/dxf/tests/ascii_bom.rs
new file mode 100644
--- /dev/null
+++ b/crates/dxf/tests/ascii_bom.rs
@@ -0,0 +1,19 @@
+//! A UTF-8 byte order mark at the start of an ASCII DXF is skipped.
+
+const BODY: &str = "  0\r\nSECTION\r\n  2\r\nENTITIES\r\n  0\r\nENDSEC\r\n  0\r\nEOF\r\n";
+
+#[test]
+fn utf8_bom_bytes_are_skipped() {
+    let plain = cadcraft_dxf::parse(BODY.as_bytes()).expect("plain parses");
+    let mut bytes = vec![0xEF, 0xBB, 0xBF];
+    bytes.extend_from_slice(BODY.as_bytes());
+    let with_bom = cadcraft_dxf::parse(&bytes);
+    assert!(with_bom.is_ok(), "{with_bom:?}");
+    assert_eq!(with_bom.map(|t| t.len()).unwrap_or(0), plain.len());
+}
+
+#[test]
+fn bom_text_is_skipped() {
+    let text = format!("\u{feff}{BODY}");
+    assert!(cadcraft_dxf::parse_ascii(&text).is_ok());
+}
