@@ -87,7 +87,7 @@ export function parseDelimitedTextLayer(
8787 }
8888 if ( ! wantsLatitude && ! wantsLongitude ) {
8989 const tableFeatures : Feature < Geometry | null , GeoJsonProperties > [ ] = [ ] ;
90- for ( const row of body . rows ( ) ) {
90+ for ( const row of body . rows ) {
9191 tableFeatures . push ( {
9292 type : "Feature" ,
9393 geometry : null ,
@@ -124,7 +124,7 @@ export function parseDelimitedTextLayer(
124124 let totalRows = 0 ;
125125 const features : Feature < Point , GeoJsonProperties > [ ] = [ ] ;
126126
127- for ( const row of body . rows ( ) ) {
127+ for ( const row of body . rows ) {
128128 totalRows += 1 ;
129129 // For a projected CRS, treat a bare comma as a thousands separator (large
130130 // easting/northing) rather than a decimal point.
@@ -188,7 +188,7 @@ export function parseDelimitedTextRows(
188188
189189 const fields = body . fields ;
190190 const rows : Record < string , string > [ ] = [ ] ;
191- for ( const row of body . rows ( ) ) {
191+ for ( const row of body . rows ) {
192192 const record : Record < string , string > = { } ;
193193 fields . forEach ( ( field , index ) => {
194194 record [ field ] = ( row [ index ] ?? "" ) . trim ( ) ;
@@ -315,12 +315,14 @@ function* iterateDelimitedRows(text: string, delimiter: string): Generator<strin
315315 * dropped. Returns `null` when the text has no header row or no data rows, so
316316 * each caller can raise its own error.
317317 *
318- * `rows()` streams from the same underlying scan and so can only be consumed
319- * once; that is what keeps a large file from being held in memory twice.
318+ * `rows` is the live generator over the same underlying scan, not a factory that
319+ * restarts it: consuming it twice would split the rows between the consumers
320+ * rather than replay them. It is exposed as the generator itself so that
321+ * single-use contract is visible at every call site.
320322 */
321323interface DelimitedTextBody {
322324 fields : string [ ] ;
323- rows : ( ) => Generator < string [ ] > ;
325+ rows : Generator < string [ ] > ;
324326}
325327
326328function readDelimitedTextBody ( text : string , delimiter : string ) : DelimitedTextBody | null {
@@ -338,10 +340,10 @@ function readDelimitedTextBody(text: string, delimiter: string): DelimitedTextBo
338340
339341 return {
340342 fields : uniqueFieldNames ( headerRow . map ( ( field ) => field . trim ( ) ) ) ,
341- rows : function * ( ) {
343+ rows : ( function * ( ) {
342344 yield firstDataRow ;
343345 for ( let row = nextRow ( ) ; row !== null ; row = nextRow ( ) ) yield row ;
344- } ,
346+ } ) ( ) ,
345347 } ;
346348}
347349
@@ -464,6 +466,35 @@ export function countDelimitedTextRows(text: string, delimiter: string): number
464466 * blank.
465467 */
466468export function firstDelimitedTextLine ( text : string ) : string {
469+ const bounds = headerLineBounds ( text ) ;
470+ return bounds ? text . slice ( bounds . start , bounds . end ) : "" ;
471+ }
472+
473+ /**
474+ * Whether `text` holds a *complete* header line, meaning the first non-blank
475+ * line ends at a line break within `text` rather than running off its end.
476+ *
477+ * A caller that read only a prefix of a file uses this to tell "the header fits
478+ * in what I read" from "my prefix was cut mid-header". Merely testing the
479+ * prefix for a line break is not equivalent: leading blank lines contribute
480+ * line breaks of their own, so a header that overruns the prefix can still look
481+ * terminated.
482+ *
483+ * @param text - A prefix of a delimited file, optionally starting with a BOM.
484+ * @returns True when the header line's own terminator is present.
485+ */
486+ export function hasCompleteHeaderLine ( text : string ) : boolean {
487+ return headerLineBounds ( text ) ?. terminated ?? false ;
488+ }
489+
490+ /** The first non-blank line's span, and whether its terminator was reached. */
491+ interface HeaderLineBounds {
492+ start : number ;
493+ end : number ;
494+ terminated : boolean ;
495+ }
496+
497+ function headerLineBounds ( text : string ) : HeaderLineBounds | null {
467498 let start = contentStart ( text ) ;
468499 while ( start < text . length ) {
469500 // Blankness is tracked during the same scan that finds the terminator, so
@@ -477,14 +508,14 @@ export function firstDelimitedTextLine(text: string): string {
477508 if ( ! hasContent && ! isTrimmableCode ( code , text , end ) ) hasContent = true ;
478509 end += 1 ;
479510 }
480- if ( hasContent ) return text . slice ( start , end ) ;
481- if ( end >= text . length ) return "" ;
511+ if ( hasContent ) return { start, end, terminated : end < text . length } ;
512+ if ( end >= text . length ) return null ;
482513 start =
483514 text . charCodeAt ( end ) === CARRIAGE_RETURN_CODE && text . charCodeAt ( end + 1 ) === LINE_FEED_CODE
484515 ? end + 2
485516 : end + 1 ;
486517 }
487- return "" ;
518+ return null ;
488519}
489520
490521/**
0 commit comments