Skip to content

Commit aebe438

Browse files
committed
fixes #16 - handle byte range that starts in the middle of a grapheme
1 parent d50a2a1 commit aebe438

5 files changed

Lines changed: 258 additions & 10 deletions

File tree

src/types.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
/** Error structure */
22
export interface ParseError {
33
/** A generalization of the error */
4-
type: 'Quotes' | 'Delimiter' | 'FieldMismatch'
4+
type: 'Quotes' | 'Delimiter' | 'FieldMismatch' | 'Decoding'
55
/** Standardized error code */
66
code:
77
| 'MissingQuotes'
88
| 'UndetectableDelimiter'
99
| 'TooFewFields'
1010
| 'TooManyFields'
1111
| 'InvalidQuotes'
12+
| 'InvalidData'
1213
/** Human-readable details */
1314
message: string
1415
}

src/url.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { defaultChunkSize } from './options/constants'
44
import { type DelimiterError, type ParseOptions, validateAndGuessParseOptions } from './options/parseOptions'
55
import { parse } from './parser'
66
import type { ParseResult } from './types'
7+
import { decode } from './utils'
78

89
interface FetchOptions {
910
chunkSize?: number
@@ -51,10 +52,19 @@ export async function* parseURL(
5152
let parseOptions: ParseOptions | undefined = undefined
5253
let delimiterError: DelimiterError | undefined = undefined
5354

54-
const decoder = new TextDecoder('utf-8', {
55-
// don't strip the BOM, we handle it in the parse function
56-
ignoreBOM: true,
57-
})
55+
/*
56+
* Number of bytes skipped due to decoding errors
57+
* They are only accepted at the start of the range, otherwise an exception is thrown.
58+
* Note that, in the extreme case where chunkSize = 1, and multiple bytes are invalid at the start,
59+
* multiple chunk iterations may be needed until a valid byte is found.
60+
* If there are decoding errors, the first valid character will be considered the start of the text,
61+
* and the byte offsets will be adjusted accordingly. An error will be reported in the first parsed row.
62+
* It also means that the first row's byteOffset may be greater than options.firstByte.
63+
*/
64+
const invalidData = {
65+
byteCount: 0,
66+
status: 'pending' as 'pending' | 'done',
67+
}
5868
let chunkByteOffset = firstByte
5969
let bytes: Uint8Array<ArrayBufferLike> = new Uint8Array(0)
6070
while (true) {
@@ -84,20 +94,24 @@ export async function* parseURL(
8494
bytes = combinedBytes
8595
}
8696

87-
let consumedBytes = 0
88-
const text = decoder.decode(bytes)
97+
const { text, invalidByteCount } = decode(bytes, { stripInvalidBytesAtStart: invalidData.status === 'pending' })
98+
// Skip the invalid bytes at the start (should only happen if pending, else invalidByteCount should be 0)
99+
invalidData.byteCount += invalidByteCount
100+
bytes = bytes.slice(invalidByteCount)
101+
chunkByteOffset += invalidByteCount
89102

90103
if (!parseOptions) {
91104
const result = validateAndGuessParseOptions(options, { text, delimitersToGuess })
92105
parseOptions = result.parseOptions
93106
delimiterError = result.error
94107
}
95108

109+
let consumedBytes = 0
96110
for (const result of (options.parse ?? parse)(text, {
97111
...parseOptions,
98112
// the remaining bytes may not contain a full last row
99113
ignoreLastRow: true,
100-
stripBOM: isFirstChunk ? stripBOM : false,
114+
stripBOM: isFirstChunk ? stripBOM : false, // TODO(SL): only if firstByte + invalidByteCount === 0 ?
101115
})) {
102116
isFirstChunk = false
103117
consumedBytes += result.meta.byteCount
@@ -109,6 +123,17 @@ export async function* parseURL(
109123
result.errors.push(delimiterError)
110124
delimiterError = undefined
111125
}
126+
// Add invalid byte count to the first reported row only
127+
if (invalidData.status === 'pending') {
128+
invalidData.status = 'done'
129+
if (invalidData.byteCount > 0) {
130+
result.errors.push({
131+
type: 'Decoding',
132+
code: 'InvalidData',
133+
message: `Skipped ${invalidData.byteCount} invalid byte(s) at the start of the range`,
134+
})
135+
}
136+
}
112137
// Yield the result with updated byte offset
113138
yield {
114139
...result,
@@ -138,7 +163,12 @@ export async function* parseURL(
138163
}
139164

140165
// Parse the last row (even if the remaining bytes are empty)
141-
const text = decoder.decode(bytes)
166+
const { text, invalidByteCount } = decode(bytes, { stripInvalidBytesAtStart: invalidData.status === 'pending' })
167+
// Skip the invalid bytes at the start (should only happen if pending, else invalidByteCount should be 0)
168+
invalidData.byteCount += invalidByteCount
169+
bytes = bytes.slice(invalidByteCount)
170+
chunkByteOffset += invalidByteCount
171+
142172
for (const result of (options.parse ?? parse)(text, {
143173
...parseOptions,
144174
// parse until the last byte
@@ -150,6 +180,17 @@ export async function* parseURL(
150180
result.errors.push(delimiterError)
151181
delimiterError = undefined
152182
}
183+
// Add invalid byte count to the first reported row only
184+
if (invalidData.status === 'pending') {
185+
invalidData.status = 'done'
186+
if (invalidData.byteCount > 0) {
187+
result.errors.push({
188+
type: 'Decoding',
189+
code: 'InvalidData',
190+
message: `Skipped ${invalidData.byteCount} invalid byte(s) at the start of the range`,
191+
})
192+
}
193+
}
153194
yield {
154195
...result,
155196
meta: {

src/utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,44 @@ export function escapeRegExp(string: string) {
4040
export function testEmptyLine(s: string[], skipEmptyLines?: 'greedy' | boolean) {
4141
return skipEmptyLines === 'greedy' ? s.join('').trim() === '' : 0 in s && s.length === 1 && s[0].length === 0
4242
}
43+
44+
/**
45+
* Decodes the given bytes using the provided decoder.
46+
* @param bytes The bytes to decode.
47+
* @param options The options for decoding.
48+
* @param options.stripInvalidBytesAtStart Whether to strip invalid bytes at the start of the byte array.
49+
* @returns The decoded text and the number of invalid bytes skipped at the start
50+
*/
51+
export function decode(bytes: Uint8Array<ArrayBufferLike>, { stripInvalidBytesAtStart }: { stripInvalidBytesAtStart?: boolean } = {}): {
52+
text: string
53+
invalidByteCount: number
54+
} {
55+
const decoder = new TextDecoder('utf-8', {
56+
// don't strip the BOM, we handle it in the parse function
57+
ignoreBOM: true,
58+
// throw on decoding errors, see https://github.com/severo/csv-range/issues/16
59+
fatal: true,
60+
})
61+
62+
if (!stripInvalidBytesAtStart) {
63+
// Let the decoder throw on errors, since they should not occur anymore
64+
return { text: decoder.decode(bytes), invalidByteCount: 0 }
65+
}
66+
67+
for (let i = 0; i < bytes.length; i++) {
68+
try {
69+
const text = decoder.decode(bytes.subarray(i))
70+
// found the first valid byte
71+
return { text, invalidByteCount: i }
72+
}
73+
catch {
74+
// still invalid, try the next byte
75+
continue
76+
}
77+
}
78+
// the byte array is empty, or all bytes are invalid
79+
return {
80+
text: '',
81+
invalidByteCount: bytes.length,
82+
}
83+
}

tests/url.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,117 @@ describe('parseURL', () => {
196196
// includes BOM bytes, independently of stripBOM
197197
expect(result[0]?.meta.byteCount).toBe(fileSize)
198198
})
199+
200+
it.for([
201+
{ firstByte: 0, expected: { row: ['👉🏿', '1'], charCount: 6 } },
202+
// There is no way to know that '👉' and '🏿' were part of a combined emoji.
203+
{ firstByte: 0, lastByte: 3, expected: { row: ['👉'], charCount: 2 } },
204+
{ firstByte: 1, expected: { row: ['🏿', '1'], charCount: 4, invalidByteCount: 3 } },
205+
{ firstByte: 2, expected: { row: ['🏿', '1'], charCount: 4, invalidByteCount: 2 } },
206+
{ firstByte: 3, expected: { row: ['🏿', '1'], charCount: 4, invalidByteCount: 1 } },
207+
{ firstByte: 4, expected: { row: ['🏿', '1'], charCount: 4 } },
208+
{ firstByte: 4, lastByte: 7, expected: { row: ['🏿'], charCount: 2 } },
209+
{ firstByte: 5, expected: { row: ['', '1'], charCount: 2, invalidByteCount: 3 } },
210+
{ firstByte: 6, expected: { row: ['', '1'], charCount: 2, invalidByteCount: 2 } },
211+
{ firstByte: 7, expected: { row: ['', '1'], charCount: 2, invalidByteCount: 1 } },
212+
{ firstByte: 8, expected: { row: ['', '1'], charCount: 2 } },
213+
])('should support cutting 👉🏿 emoji: firstByte=$firstByte, lastByte=$lastByte', async ({ firstByte, lastByte, expected: { row, charCount, invalidByteCount } }) => {
214+
// 👉🏿 uses 8 bytes in UTF-8.
215+
const text = '👉🏿,1'
216+
const { url, fileSize, revoke } = toUrl(text)
217+
lastByte ??= fileSize - 1
218+
const result = []
219+
for await (const r of parseURL(url, { firstByte, lastByte, delimiter: ',', newline: '\n' })) {
220+
result.push(r)
221+
}
222+
revoke()
223+
224+
const expectedByteOffset = firstByte + (invalidByteCount ?? 0)
225+
expect(result.length).toBe(1)
226+
expect(result).toEqual([{
227+
errors: invalidByteCount === undefined
228+
? []
229+
: [{
230+
type: 'Decoding',
231+
code: 'InvalidData',
232+
message: `Skipped ${invalidByteCount} invalid byte(s) at the start of the range`,
233+
}],
234+
row,
235+
meta: {
236+
byteOffset: expectedByteOffset,
237+
byteCount: lastByte - expectedByteOffset + 1,
238+
charCount, // TODO(SL): define what is a "character" (UTF-16 code point? grapheme?)
239+
delimiter: ',',
240+
newline: '\n',
241+
},
242+
}])
243+
expect((result[0]?.meta.byteCount ?? -Infinity) + (result[0]?.meta.byteOffset ?? -Infinity)).toBe(lastByte + 1)
244+
})
245+
246+
it('should search invalid bytes over multiple chunks if needed', async () => {
247+
const text = '👉,1'
248+
const { url, fileSize, revoke } = toUrl(text)
249+
const result = []
250+
// There are 3 invalid bytes at start, when starting at byte 1. Using chunkSize=1 to force multiple iterations.
251+
for await (const r of parseURL(url, { chunkSize: 1, firstByte: 1, lastByte: fileSize - 1, delimiter: ',', newline: '\n' })) {
252+
result.push(r)
253+
}
254+
revoke()
255+
256+
expect(result.length).toBe(1)
257+
expect(result).toEqual([{
258+
errors: [{
259+
type: 'Decoding',
260+
code: 'InvalidData',
261+
message: 'Skipped 3 invalid byte(s) at the start of the range',
262+
}],
263+
row: ['', '1'],
264+
meta: {
265+
byteOffset: 4,
266+
byteCount: fileSize - 4,
267+
charCount: 2,
268+
delimiter: ',',
269+
newline: '\n',
270+
},
271+
}])
272+
})
273+
274+
it('should report invalid data in multiple rows', async () => {
275+
const text = '👉a,b\n1,2'
276+
const { url, fileSize, revoke } = toUrl(text)
277+
const result = []
278+
for await (const r of parseURL(url, { firstByte: 3, lastByte: fileSize - 1, delimiter: ',', newline: '\n' })) {
279+
result.push(r)
280+
}
281+
revoke()
282+
expect(result.length).toBe(2)
283+
expect(result).toEqual([
284+
{
285+
errors: [{
286+
type: 'Decoding',
287+
code: 'InvalidData',
288+
message: 'Skipped 1 invalid byte(s) at the start of the range',
289+
}],
290+
row: ['a', 'b'],
291+
meta: {
292+
byteOffset: 4,
293+
byteCount: 4,
294+
charCount: 4,
295+
delimiter: ',',
296+
newline: '\n',
297+
},
298+
},
299+
{
300+
errors: [],
301+
row: ['1', '2'],
302+
meta: {
303+
byteOffset: 8,
304+
byteCount: 3,
305+
charCount: 3,
306+
delimiter: ',',
307+
newline: '\n',
308+
},
309+
},
310+
])
311+
})
199312
})

tests/utils.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22

3-
import { escapeRegExp, testEmptyLine, toUrl } from '../src/utils'
3+
import { decode, escapeRegExp, testEmptyLine, toUrl } from '../src/utils'
44

55
describe('toUrl', () => {
66
it('creates a valid blob URL and revokes it', async () => {
@@ -75,3 +75,55 @@ describe('testEmptyLine', () => {
7575
expect(testEmptyLine(['data'], 'greedy')).toBe(false)
7676
})
7777
})
78+
79+
describe('decode', () => {
80+
it.for([
81+
'\ufeffhello, csvremote!!!',
82+
'hello, \ufeffcsvremote!!!',
83+
'hello, csvremote!!!\ufeff',
84+
])('should not strip the BOM', (text) => {
85+
expect(decode(new TextEncoder().encode(text))).toEqual({
86+
text,
87+
invalidByteCount: 0,
88+
})
89+
})
90+
91+
it('should strip invalid bytes at the start if specified', () => {
92+
// Invalid UTF-8 bytes: 0xFF, 0xFE
93+
const invalidBytes = new Uint8Array([0xFF, 0xFE, 0x68, 0x65, 0x6C, 0x6C, 0x6F]) // "hello"
94+
expect(decode(invalidBytes, { stripInvalidBytesAtStart: true })).toEqual({
95+
text: 'hello',
96+
invalidByteCount: 2,
97+
})
98+
})
99+
100+
it('should throw on invalid bytes if not stripping', () => {
101+
// Invalid UTF-8 bytes: 0xFF, 0xFE
102+
const invalidBytes = new Uint8Array([0xFF, 0xFE, 0x68, 0x65, 0x6C, 0x6C, 0x6F]) // "hello"
103+
expect(() => decode(invalidBytes)).toThrow()
104+
})
105+
106+
it('should return all bytes as invalid if all are invalid', () => {
107+
const invalidBytes = new Uint8Array([0xFF, 0xFE, 0xFF, 0xFE])
108+
expect(decode(invalidBytes, { stripInvalidBytesAtStart: true })).toEqual({
109+
text: '',
110+
invalidByteCount: 4,
111+
})
112+
})
113+
114+
it('should handle valid UTF-8 bytes correctly', () => {
115+
const validBytes = new TextEncoder().encode('Valid UTF-8 text')
116+
expect(decode(validBytes, { stripInvalidBytesAtStart: true })).toEqual({
117+
text: 'Valid UTF-8 text',
118+
invalidByteCount: 0,
119+
})
120+
})
121+
122+
it('should handle empty byte array', () => {
123+
const emptyBytes = new Uint8Array([])
124+
expect(decode(emptyBytes, { stripInvalidBytesAtStart: true })).toEqual({
125+
text: '',
126+
invalidByteCount: 0,
127+
})
128+
})
129+
})

0 commit comments

Comments
 (0)