From f4160c7c6ca1b4cdd9c5273a3916b4fd087b5e34 Mon Sep 17 00:00:00 2001 From: Nico Lube Date: Thu, 21 May 2026 10:06:42 +0200 Subject: [PATCH 1/3] fix: tokenize Gerber commands on end-of-block (*), not newlines Commands are delimited by `*`, not newlines (spec 4.1). The old line-based tokenizer merged single-line files into one block, causing a spurious CoordinateDataWithoutOperationCode error. Now splits on `*`, treats `%...%` spans (incl. macros) as one block, and ends a block at a newline outside such spans. --- src/parser.rs | 113 +++++++++++++++++++++++++++++++++------ tests/component_tests.rs | 53 +++++++++++++++++- 2 files changed, 149 insertions(+), 17 deletions(-) diff --git a/src/parser.rs b/src/parser.rs index 5df5332..b3c6c41 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -22,7 +22,7 @@ use gerber_types::{ use lazy_regex::*; use regex::Regex; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Lines, Read}; +use std::io::{BufReader, Bytes, Read}; use std::iter::FromIterator; use std::str::Chars; use std::sync::LazyLock; @@ -79,36 +79,116 @@ enum ModalOperationMode { } struct ParserContext { + // Physical source line on which the most recently returned block started (1-based). + // Used only for error context; Gerber itself treats newlines as insignificant. line_number: usize, - lines: Lines>, + bytes: Bytes>, + // Running count of newlines consumed so far. + current_line: usize, aperture_attributes: HashMap, object_attributes: HashMap, modal_operation: ModalOperationMode, } impl ParserContext { - pub fn new(lines: Lines>) -> ParserContext { + pub fn new(reader: BufReader) -> ParserContext { ParserContext { line_number: 0, - lines, + bytes: reader.bytes(), + current_line: 1, aperture_attributes: HashMap::new(), object_attributes: HashMap::new(), modal_operation: ModalOperationMode::Undefined, } } + // Yield the next Gerber command block. Commands are delimited by the end-of-block + // char `*` (gerber spec 4.1), so a single physical line may hold many commands. + // Extended commands `%...%` may contain several `*`-terminated words and span many + // lines (e.g. an aperture macro), so the whole `%...%` span is one block. Outside + // such a span a newline also ends the block, isolating each physical line so stray + // junk errors on its own rather than merging into the next command. Whitespace + // inside a block is kept (attribute/comment text may contain spaces) but trimmed. pub fn next(&mut self) -> Option> { - let line = self.lines.next(); - if line.is_some() { - self.line_number += 1; + let mut buf: Vec = Vec::new(); + let mut in_extended = false; + let mut block_line: Option = None; + // Skip whitespace that leads a block (or follows a newline within one). + let mut skip_ws = true; + + loop { + let byte = match self.bytes.next() { + None => break, // EOF + Some(Ok(byte)) => byte, + Some(Err(e)) => { + return Some(Err(ContentError::IoError(format!( + "IO error on line: {}, error: {}", + self.current_line, e + )))); + } + }; + + if byte == b'\r' { + continue; // carriage returns are never significant + } + + if byte == b'\n' { + self.current_line += 1; + // Trim trailing whitespace and skip whatever leads the next line. + while buf.last().is_some_and(u8::is_ascii_whitespace) { + buf.pop(); + } + skip_ws = true; + // Inside a `%...%` span newlines are insignificant; elsewhere a newline + // ends the block, isolating each physical line (junk included). + if !in_extended && !buf.is_empty() { + break; + } + continue; + } + + if skip_ws && byte.is_ascii_whitespace() { + continue; + } + skip_ws = false; + + if block_line.is_none() { + block_line = Some(self.current_line); + } + + match byte { + b'%' if in_extended => { + buf.push(byte); + break; // end of extended `%...%` block + } + b'%' if buf.is_empty() => { + in_extended = true; + buf.push(byte); // start of extended `%...%` block + } + b'*' if !in_extended => { + buf.push(byte); + break; // end-of-block for a normal command + } + _ => buf.push(byte), + } } - line.map(|result| { - result.map_err(|e| { - ContentError::IoError( - format!("IO error on line: {}, error: {}", self.line_number, e).to_string(), - ) - }) - }) + + // Leading whitespace was already skipped; only a block ended by EOF (rather than a + // delimiter) can still carry trailing whitespace, so trim that here. + while buf.last().is_some_and(u8::is_ascii_whitespace) { + buf.pop(); + } + if buf.is_empty() { + return None; // nothing left but trailing whitespace / EOF + } + + self.line_number = block_line.unwrap_or(self.current_line); + Some(String::from_utf8(buf).map_err(|e| { + ContentError::IoError(format!( + "Invalid UTF-8 on line: {}, error: {}", + self.line_number, e + )) + })) } // Update the modal operation mode after a command was parsed (gerber spec 8.3). @@ -141,7 +221,7 @@ impl ParserContext { pub fn parse(reader: BufReader) -> Result { let mut gerber_doc = GerberDoc::default(); - let mut parser_context = ParserContext::new(reader.lines()); + let mut parser_context = ParserContext::new(reader); let mut parse_error: Option = None; @@ -912,6 +992,9 @@ fn parse_aperture_macro_definition( log::trace!("macro chunks: {:?}", chunks); for chunk in chunks { + // A block may span several physical lines; the tokenizer drops newlines but + // keeps the surrounding indentation, so trim each primitive before matching. + let chunk = chunk.trim(); if let Some(stripped) = chunk.strip_prefix("0 ") { // Handle the special-case comment primitive diff --git a/tests/component_tests.rs b/tests/component_tests.rs index 919ceea..87e03ab 100644 --- a/tests/component_tests.rs +++ b/tests/component_tests.rs @@ -568,6 +568,55 @@ fn deprecated_modal_d01_invalid_without_preceding_d01() { assert_eq!(filtered_commands.len(), 3) } +/// Commands are delimited by the end-of-block char `*`, not by newlines (gerber spec 4.1): +/// a file may pack the whole stream onto a single physical line. Each `*` must still be +/// parsed as its own command, including modal D01 coordinate-only blocks. +#[test] +fn commands_separated_by_block_terminator_on_one_line() { + // given + logging_init(); + + // Header, aperture, and a modal-D01 run all on one line, separated only by `*`. + let reader = gerber_to_reader( + "%FSLAX23Y23*%%MOMM*%%ADD10C, 0.01*%D10*X700Y1000D01*X1200Y1000*X1200Y1300*M02*", + ); + + let fs = CoordinateFormat::new(ZeroOmission::Leading, CoordinateMode::Absolute, 2, 3); + + // when + parse_and_filter!(reader, commands, filtered_commands, |cmd| matches!( + cmd, + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate(_, _)) + ))) + )); + + // then + assert_eq_commands!( + filtered_commands, + vec![ + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate( + coordinates_from_gerber(700, 1000, fs).unwrap(), + None, + )) + ))), + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate( + coordinates_from_gerber(1200, 1000, fs).unwrap(), + None, + )) + ))), + Ok(Command::FunctionCode(FunctionCode::DCode( + DCode::Operation(Operation::Interpolate( + coordinates_from_gerber(1200, 1300, fs).unwrap(), + None, + )) + ))), + ] + ) +} + /// Test the D01* statements (circular) #[test] #[allow(non_snake_case)] @@ -2280,7 +2329,7 @@ fn missing_eof() { let reader = gerber_to_reader( " %FSLAX23Y23*% - %MOMM*%- + %MOMM*% G04 We should have a MO2 at the end, but what if we forget it?* ", @@ -3874,7 +3923,7 @@ fn malformed_aperture_definition() { let reader = gerber_to_reader( " %FSLAX23Y23*% - %MOMM*%- + %MOMM*% G04 Too many parameters * From a665f019eae0c17d182d532b8d13ae91762a2179 Mon Sep 17 00:00:00 2001 From: Nico Lube Date: Thu, 21 May 2026 11:20:54 +0200 Subject: [PATCH 2/3] feat: add line, offset, and failing token to parse error context Parse errors carried only a (line, content) tuple. Replace it with an ErrorContext { line, offset, token } so callers can pinpoint which command failed, which matters when several are packed onto one line. Also funnel outer parse_line errors through the context path instead of dropping them. --- src/error.rs | 36 ++++++++++---- src/parser.rs | 100 +++++++++++++++++++++++---------------- tests/component_tests.rs | 84 ++++++++++++++++++++++++++------ 3 files changed, 156 insertions(+), 64 deletions(-) diff --git a/src/error.rs b/src/error.rs index 4706d7b..7caed91 100644 --- a/src/error.rs +++ b/src/error.rs @@ -137,9 +137,11 @@ pub enum ContentError { } impl ContentError { - /// line number is 1-based, for humans. - pub fn to_with_context(self, line: Option<(usize, String)>) -> GerberParserErrorWithContext { - GerberParserErrorWithContext { error: self, line } + pub fn to_with_context(self, context: Option) -> GerberParserErrorWithContext { + GerberParserErrorWithContext { + error: self, + context, + } } } @@ -150,18 +152,36 @@ impl PartialEq for ContentError { } } +/// Where in the source a parse error occurred. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct ErrorContext { + /// 1-based line number on which the failing token starts. + pub line: usize, + /// 1-based column offset of the failing token within that line. + pub offset: usize, + /// The token (command block) that failed to parse. + pub token: String, +} + #[derive(Error, Debug, PartialEq)] pub struct GerberParserErrorWithContext { pub error: ContentError, - /// line number is 1-based, for humans. - pub line: Option<(usize, String)>, + pub context: Option, } impl std::fmt::Display for GerberParserErrorWithContext { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match &self.line { - Some((number, content)) => { - write!(f, "Error: {}\nLine {}: '{}'", self.error, number, content) + match &self.context { + Some(ErrorContext { + line, + offset, + token, + }) => { + write!( + f, + "Error: {}\nLine {}:{}: '{}'", + self.error, line, offset, token + ) } _ => { write!(f, "Error at unspecified line: {}", self.error) diff --git a/src/parser.rs b/src/parser.rs index b3c6c41..81a6ed7 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1,5 +1,5 @@ use crate::document::GerberDoc; -use crate::error::ContentError; +use crate::error::{ContentError, ErrorContext}; use crate::gerber_types::{ Aperture, ApertureAttribute, ApertureFunction, ApertureMacro, CenterLinePrimitive, Circle, CirclePrimitive, Command, CoordinateFormat, DCode, ExtendedCode, FiducialScope, FileAttribute, @@ -78,13 +78,18 @@ enum ModalOperationMode { Interpolate, } +// 1-based source line and column. Gerber treats newlines as insignificant, so this is +// only carried for error reporting. +#[derive(Copy, Clone)] +struct SourcePos { + line: usize, + offset: usize, +} + struct ParserContext { - // Physical source line on which the most recently returned block started (1-based). - // Used only for error context; Gerber itself treats newlines as insignificant. - line_number: usize, bytes: Bytes>, - // Running count of newlines consumed so far. - current_line: usize, + // Position of the byte stream consumed so far. + current: SourcePos, aperture_attributes: HashMap, object_attributes: HashMap, modal_operation: ModalOperationMode, @@ -93,26 +98,26 @@ struct ParserContext { impl ParserContext { pub fn new(reader: BufReader) -> ParserContext { ParserContext { - line_number: 0, bytes: reader.bytes(), - current_line: 1, + current: SourcePos { line: 1, offset: 0 }, aperture_attributes: HashMap::new(), object_attributes: HashMap::new(), modal_operation: ModalOperationMode::Undefined, } } - // Yield the next Gerber command block. Commands are delimited by the end-of-block - // char `*` (gerber spec 4.1), so a single physical line may hold many commands. - // Extended commands `%...%` may contain several `*`-terminated words and span many - // lines (e.g. an aperture macro), so the whole `%...%` span is one block. Outside - // such a span a newline also ends the block, isolating each physical line so stray - // junk errors on its own rather than merging into the next command. Whitespace - // inside a block is kept (attribute/comment text may contain spaces) but trimmed. - pub fn next(&mut self) -> Option> { + // Yield the next Gerber command block together with the position where it starts. + // Commands are delimited by the end-of-block char `*` (gerber spec 4.1), so a single + // physical line may hold many commands. Extended commands `%...%` may contain several + // `*`-terminated words and span many lines (e.g. an aperture macro), so the whole + // `%...%` span is one block. Outside such a span a newline also ends the block, + // isolating each physical line so stray junk errors on its own rather than merging + // into the next command. Whitespace inside a block is kept (attribute/comment text may + // contain spaces) but trimmed. + pub fn next(&mut self) -> Option> { let mut buf: Vec = Vec::new(); let mut in_extended = false; - let mut block_line: Option = None; + let mut block_start: Option = None; // Skip whitespace that leads a block (or follows a newline within one). let mut skip_ws = true; @@ -123,18 +128,20 @@ impl ParserContext { Some(Err(e)) => { return Some(Err(ContentError::IoError(format!( "IO error on line: {}, error: {}", - self.current_line, e + self.current.line, e )))); } }; + self.current.offset += 1; if byte == b'\r' { continue; // carriage returns are never significant } if byte == b'\n' { - self.current_line += 1; - // Trim trailing whitespace and skip whatever leads the next line. + self.current.line += 1; + self.current.offset = 0; // next byte is column 1 + // Trim trailing whitespace and skip whatever leads the next line. while buf.last().is_some_and(u8::is_ascii_whitespace) { buf.pop(); } @@ -152,8 +159,8 @@ impl ParserContext { } skip_ws = false; - if block_line.is_none() { - block_line = Some(self.current_line); + if block_start.is_none() { + block_start = Some(self.current); } match byte { @@ -182,13 +189,17 @@ impl ParserContext { return None; // nothing left but trailing whitespace / EOF } - self.line_number = block_line.unwrap_or(self.current_line); - Some(String::from_utf8(buf).map_err(|e| { - ContentError::IoError(format!( - "Invalid UTF-8 on line: {}, error: {}", - self.line_number, e - )) - })) + let position = block_start.unwrap_or(self.current); + Some( + String::from_utf8(buf) + .map(|text| (text, position)) + .map_err(|e| { + ContentError::IoError(format!( + "Invalid UTF-8 on line: {}, error: {}", + position.line, e + )) + }), + ) } // Update the modal operation mode after a command was parsed (gerber spec 8.3). @@ -230,10 +241,8 @@ pub fn parse(reader: BufReader) -> Result line, + let (raw_line, position) = match line_result { + Ok(block) => block, Err(ContentError::IoError(error)) => { parse_error = Some(ParseError::IoError(error)); break; @@ -242,12 +251,17 @@ pub fn parse(reader: BufReader) -> Result results, + Err(error) => vec![Err(error)], + }; + for result in line_results { let final_result = match result { Ok(command) => { log::trace!("Parsed command: {:?}", command); @@ -260,8 +274,12 @@ pub fn parse(reader: BufReader) -> Result { - let contexted_error = error_without_context - .to_with_context(Some((line_number, line.to_string()))); + let contexted_error = + error_without_context.to_with_context(Some(ErrorContext { + line: position.line, + offset: position.offset, + token: line.to_string(), + })); log::error!("Content error: {}", contexted_error); Err(contexted_error) } @@ -964,8 +982,8 @@ fn parse_aperture_macro_definition( let Some(line_result) = parser_context.next() else { break; }; - let line = line_result?.trim().to_string(); - macro_content.push_str(&line); + let (block, _) = line_result?; + macro_content.push_str(block.trim()); } // Extract the macro name from the AM command diff --git a/tests/component_tests.rs b/tests/component_tests.rs index 87e03ab..58806a1 100644 --- a/tests/component_tests.rs +++ b/tests/component_tests.rs @@ -1,4 +1,4 @@ -use gerber_parser::{parse, ContentError, GerberParserErrorWithContext}; +use gerber_parser::{parse, ContentError, ErrorContext, GerberParserErrorWithContext}; use gerber_types::{ Aperture, ApertureAttribute, ApertureBlock, ApertureDefinition, ApertureFunction, ApertureMacro, AxisSelect, Circle, CirclePrimitive, Command, CommentContent, @@ -617,6 +617,52 @@ fn commands_separated_by_block_terminator_on_one_line() { ) } +/// Error context reports the line, column offset, and failing token. With several commands +/// packed onto one line, the offset is what pinpoints which one failed. +#[test] +fn error_context_reports_line_offset_and_token() { + // given + logging_init(); + + // `X100Y100*` at column 21 is invalid: no D01 in modal effect (gerber spec 8.3). + let reader = gerber_to_reader("%FSLAX23Y23*%%MOMM*%X100Y100*M02*"); + + // when + let doc = parse(reader).unwrap(); + + // then + assert!(matches!( + doc.errors().first().unwrap(), + GerberParserErrorWithContext { + error: ContentError::CoordinateDataWithoutOperationCode, + context: Some(ErrorContext { line, offset, token }), + } if *line == 1 && *offset == 21 && token.eq("X100Y100*") + )); +} + +/// A truncated command makes `parse_line` bail before producing any command. That outer +/// error must still be recorded with context, not silently dropped. +#[test] +fn outer_parse_error_is_recorded_with_context() { + // given + logging_init(); + + // A lone `G` is truncated: `parse_line` returns an outer Err before any command. + let reader = gerber_to_reader("%FSLAX23Y23*%\n%MOMM*%\nG\nM02*\n"); + + // when + let doc = parse(reader).unwrap(); + + // then + assert!(doc.errors().iter().any(|error| matches!( + error, + GerberParserErrorWithContext { + error: ContentError::UnknownCommand {}, + context: Some(ErrorContext { line, token, .. }), + } if *line == 3 && token.eq("G") + ))); +} + /// Test the D01* statements (circular) #[test] #[allow(non_snake_case)] @@ -955,7 +1001,11 @@ fn test_load_scaling_zero() { error: ContentError::InvalidParameter { parameter, }, - line: Some((number, content)), + context: Some(ErrorContext { + line: number, + token: content, + .. + }), } if parameter.eq("0") && *number == 2 && content.eq("%LS0*%") )); } @@ -2433,7 +2483,11 @@ fn coordinates_not_within_format() { format, cause: GerberError::CoordinateFormatError(_) }, - line: Some((number, content)), + context: Some(ErrorContext { + line: number, + token: content, + .. + }), } if format.integer == 2 && format.decimal == 3 && *number == 8 && content.eq("X100000Y0D01*") )); } @@ -3969,7 +4023,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("C") && content.eq("%ADD10C,1X2X3*%") )); @@ -3981,7 +4035,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("C") && content.eq("%ADD10C*%") )); @@ -3993,7 +4047,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("R") && content.eq("%ADD10R,1X2X3X4*%") )); @@ -4005,7 +4059,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("R") && content.eq("%ADD10R,1*%") )); @@ -4017,7 +4071,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("R") && content.eq("%ADD10R*%") )); @@ -4029,7 +4083,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("O") && content.eq("%ADD10O,1X2X3X4*%") )); @@ -4041,7 +4095,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("O") && content.eq("%ADD10O,1*%") )); @@ -4053,7 +4107,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("O") && content.eq("%ADD10O*%") )); @@ -4065,7 +4119,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("P") && content.eq("%ADD10P,1X2X3X4X5*%") )); @@ -4077,7 +4131,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("P") && content.eq("%ADD10P,1*%") )); @@ -4089,7 +4143,7 @@ fn malformed_aperture_definition() { aperture_code, aperture_name, }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if *aperture_code == 10 && aperture_name.eq("P") && content.eq("%ADD10P*%") )); @@ -4100,7 +4154,7 @@ fn malformed_aperture_definition() { error: ContentError::UnknownApertureType { type_str }, - line: Some((_line_number, content)), + context: Some(ErrorContext { token: content, .. }), } if type_str.eq("T") && content.eq("%ADD10T*%") )); } From 5ac302c1fbd382c073fe81f8a2c711f66c2dcc7b Mon Sep 17 00:00:00 2001 From: Nico Lube Date: Thu, 21 May 2026 11:26:35 +0200 Subject: [PATCH 3/3] fix: support deprecated single-digit G-codes (G1/G2/G3) ViewMate and other tools emit G1/G2/G3 instead of G01/G02/G03 (spec 8.3 style variation). These fell through to UnknownCommand, so the interpolate never registered and modal D01 never armed, cascading into spurious CoordinateDataWithoutOperationCode errors across every region. Accept the single-digit forms, disambiguating G3 from the G36/G37 region commands. --- src/parser.rs | 141 ++++++++++++++++++++++----------------- tests/component_tests.rs | 51 ++++++++++++++ 2 files changed, 130 insertions(+), 62 deletions(-) diff --git a/src/parser.rs b/src/parser.rs index 81a6ed7..3b1b626 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -305,6 +305,32 @@ pub fn parse(reader: BufReader) -> Result Vec> { + // Sized for two: the combined form adds a second command, and these G-codes are the + // hot path in large flashed/region files. + let mut commands = Vec::with_capacity(2); + commands.push(Ok( + FunctionCode::GCode(GCode::InterpolationMode(mode)).into() + )); + // More than the trailing `*` means operation data follows on the same block. + if remaining_line.len() > 1 { + commands.push(parse_interpolate_move_or_flash( + remaining_line, + gerber_doc, + modal, + )); + } + commands +} + fn parse_line( line: &str, gerber_doc: &mut GerberDoc, @@ -316,68 +342,59 @@ fn parse_line( // Safety: already explicitly checked that the line is not empty 'G' => { match linechars.next().ok_or(ContentError::UnknownCommand {})? { - '0' => { - let remaining_line = &line[3..]; - let using_deprecated_syntax = remaining_line.len() > 1; - let mut commands = Vec::with_capacity(1); - match linechars.next().ok_or(ContentError::UnknownCommand {})? { - '1' => { - // G01 - commands.push(Ok(FunctionCode::GCode(GCode::InterpolationMode( - InterpolationMode::Linear, - )) - .into())); - if using_deprecated_syntax { - commands.push(parse_interpolate_move_or_flash( - remaining_line, - gerber_doc, - parser_context.modal_operation, - )); - } - } - '2' => { - // G02 - commands.push(Ok(FunctionCode::GCode(GCode::InterpolationMode( - InterpolationMode::ClockwiseCircular, - )) - .into())); - if using_deprecated_syntax { - commands.push(parse_interpolate_move_or_flash( - remaining_line, - gerber_doc, - parser_context.modal_operation, - )); - } - } - '3' => { - // G03 - commands.push(Ok(FunctionCode::GCode(GCode::InterpolationMode( - InterpolationMode::CounterclockwiseCircular, - )) - .into())); - if using_deprecated_syntax { - commands.push(parse_interpolate_move_or_flash( - remaining_line, - gerber_doc, - parser_context.modal_operation, - )); - } - } - '4' => { - // G04 - commands.push(parse_comment(line, parser_context)) - } - _ => commands.push(Err(ContentError::UnknownCommand {})), - } - Ok(commands) - } - '3' => Ok(vec![ - match linechars.next().ok_or(ContentError::UnknownCommand {})? { - '6' => Ok(FunctionCode::GCode(GCode::RegionMode(true)).into()), // G36 - '7' => Ok(FunctionCode::GCode(GCode::RegionMode(false)).into()), // G37 - _ => Err(ContentError::UnknownCommand {}), - }, - ]), + '0' => match linechars.next().ok_or(ContentError::UnknownCommand {})? { + '1' => Ok(interpolation_mode_commands( + InterpolationMode::Linear, + &line[3..], + gerber_doc, + parser_context.modal_operation, + )), + '2' => Ok(interpolation_mode_commands( + InterpolationMode::ClockwiseCircular, + &line[3..], + gerber_doc, + parser_context.modal_operation, + )), + '3' => Ok(interpolation_mode_commands( + InterpolationMode::CounterclockwiseCircular, + &line[3..], + gerber_doc, + parser_context.modal_operation, + )), + '4' => Ok(vec![parse_comment(line, parser_context)]), + _ => Ok(vec![Err(ContentError::UnknownCommand {})]), + }, + // Deprecated single-digit interpolation modes `G1`/`G2`/`G3` (gerber spec + // 8.3 style variations); ViewMate and others emit these instead of `G0n`. + '1' => Ok(interpolation_mode_commands( + InterpolationMode::Linear, + &line[2..], + gerber_doc, + parser_context.modal_operation, + )), + '2' => Ok(interpolation_mode_commands( + InterpolationMode::ClockwiseCircular, + &line[2..], + gerber_doc, + parser_context.modal_operation, + )), + // `G3` is ambiguous: the G36/G37 region commands, or deprecated single-digit + // `G3` (= G03). Peek the next char; `6`/`7` select a region, anything else is + // taken as G03 (matching how `G1`/`G01` tolerate trailing operation data). + '3' => match linechars.next() { + Some('6') => Ok(vec![ + Ok(FunctionCode::GCode(GCode::RegionMode(true)).into()), + ]), + Some('7') => Ok(vec![Ok( + FunctionCode::GCode(GCode::RegionMode(false)).into() + )]), + _ => Ok(interpolation_mode_commands( + InterpolationMode::CounterclockwiseCircular, + &line[2..], + gerber_doc, + parser_context.modal_operation, + )), + }, '7' => Ok(vec![ match linechars.next().ok_or(ContentError::UnknownCommand {})? { // the G74 command is technically part of the Deprecated commands diff --git a/tests/component_tests.rs b/tests/component_tests.rs index 58806a1..d7b423b 100644 --- a/tests/component_tests.rs +++ b/tests/component_tests.rs @@ -663,6 +663,57 @@ fn outer_parse_error_is_recorded_with_context() { ))); } +/// Deprecated single-digit G-codes `G1`/`G2`/`G3` (gerber spec 8.3 style variations) must +/// parse like `G01`/`G02`/`G03`, including the combined `G1X..Y..D1*` form that arms modal +/// D01. `G3` must still not shadow the `G36`/`G37` region commands. As emitted by ViewMate. +#[test] +fn deprecated_single_digit_g_codes() { + // given + logging_init(); + + // A region drawn with single-digit `G1` + combined D01, then modal-D01 coordinate lines. + let reader = gerber_to_reader( + "%FSLAX25Y25*%%MOIN*%%ADD111C,0.03937*%D111*X0Y0D2*G36*G1X200Y100D1*X200Y200*X100Y200*G37*M02*", + ); + + // when + let doc = parse(reader).unwrap(); + + // then + assert!( + doc.errors().is_empty(), + "unexpected errors: {:?}", + doc.errors() + ); + let ok_commands: Vec<_> = doc + .commands + .iter() + .filter_map(|c| c.as_ref().ok()) + .collect(); + // G36 open and G37 close survived the `G3` disambiguation. + assert!(ok_commands.iter().any(|c| matches!( + c, + Command::FunctionCode(FunctionCode::GCode(GCode::RegionMode(true))) + ))); + assert!(ok_commands.iter().any(|c| matches!( + c, + Command::FunctionCode(FunctionCode::GCode(GCode::RegionMode(false))) + ))); + // The explicit `G1...D1` plus two modal-D01 lines = three interpolations. + let interpolations = ok_commands + .iter() + .filter(|c| { + matches!( + c, + Command::FunctionCode(FunctionCode::DCode(DCode::Operation( + Operation::Interpolate(..) + ))) + ) + }) + .count(); + assert_eq!(interpolations, 3); +} + /// Test the D01* statements (circular) #[test] #[allow(non_snake_case)]