@@ -7,6 +7,11 @@ type RuneStats = {
77 last : number
88}
99
10+ type CountRunesResult = {
11+ runeCounts : Map < number , RuneStats >
12+ decodeErrorCount : number
13+ }
14+
1015type CharsetKey = 'utf8' | 'utf16' | 'latin1'
1116
1217const charsetToEncoding : Record < CharsetKey , string > = {
@@ -22,9 +27,9 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
2227
2328 <section class="rounded-box border border-base-300 bg-base-100 p-8 shadow-sm">
2429 <h1 class="text-3xl font-bold">Character Count</h1>
25- <p class="mt-3 text-base-content/70">Choose a text file and start counting Unicode code points.</p>
30+ <p class="mt-3 text-base-content/70">Choose a text file to start counting Unicode code points.</p>
2631
27- <form id="runecount -form" class="mt-6 flex flex-col gap-4" action="#" method="post">
32+ <form id="charcount -form" class="mt-6 flex flex-col gap-4" action="#" method="post">
2833 <label class="form-control w-full">
2934 <div class="label">
3035 <span class="label-text">Input file</span>
@@ -44,10 +49,6 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
4449 </label>
4550
4651 <div id="form-error" class="alert alert-error hidden" role="alert" aria-live="polite"></div>
47-
48- <div>
49- <button id="start-button" type="submit" class="btn btn-primary">Start</button>
50- </div>
5152 </form>
5253
5354 <section id="results" class="mt-8 hidden">
@@ -74,11 +75,9 @@ document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
7475</main>
7576`
7677
77- const form = document . querySelector < HTMLFormElement > ( '#runecount-form' )
7878const fileInput = document . querySelector < HTMLInputElement > ( '#input-file' )
7979const charsetSelect = document . querySelector < HTMLSelectElement > ( '#charset-select' )
8080const formError = document . querySelector < HTMLDivElement > ( '#form-error' )
81- const startButton = document . querySelector < HTMLButtonElement > ( '#start-button' )
8281const resultsSection = document . querySelector < HTMLElement > ( '#results' )
8382const resultsSummary = document . querySelector < HTMLParagraphElement > ( '#results-summary' )
8483const resultsBody = document . querySelector < HTMLTableSectionElement > ( '#results-body' )
@@ -119,10 +118,11 @@ const showError = (message: string) => {
119118
120119const isCharsetKey = ( value : string ) : value is CharsetKey => value in charsetToEncoding
121120
122- const countRunes = async ( file : File , charset : CharsetKey ) : Promise < Map < number , RuneStats > > => {
121+ const countRunes = async ( file : File , charset : CharsetKey ) : Promise < CountRunesResult > => {
123122 const runeCounts = new Map < number , RuneStats > ( )
124123 const fileBuffer = await file . arrayBuffer ( )
125124 const text = new TextDecoder ( charsetToEncoding [ charset ] ) . decode ( fileBuffer )
125+ let decodeErrorCount = 0
126126
127127 let runeOffset = 0
128128 for ( const character of text ) {
@@ -132,6 +132,10 @@ const countRunes = async (file: File, charset: CharsetKey): Promise<Map<number,
132132 continue
133133 }
134134
135+ if ( codePoint === 0xfffd ) {
136+ decodeErrorCount += 1
137+ }
138+
135139 const existing = runeCounts . get ( codePoint )
136140
137141 if ( existing ) {
@@ -150,7 +154,7 @@ const countRunes = async (file: File, charset: CharsetKey): Promise<Map<number,
150154 runeOffset += 1
151155 }
152156
153- return runeCounts
157+ return { runeCounts, decodeErrorCount }
154158}
155159
156160const formatOffsetHex = ( offset : number ) : string => `0x${ offset . toString ( 16 ) . toUpperCase ( ) . padStart ( 4 , '0' ) } `
@@ -201,7 +205,7 @@ const toCodePointUrl = (codePoint: number): string => {
201205 return `https://www.fileformat.info/info/unicode/char/${ hex } /index.htm`
202206}
203207
204- const renderRuneTable = ( runeCounts : Map < number , RuneStats > , totalRunes : number ) => {
208+ const renderRuneTable = ( runeCounts : Map < number , RuneStats > , totalRunes : number , decodeErrorCount : number ) => {
205209 if ( ! resultsBody || ! resultsSummary || ! resultsSection ) {
206210 return
207211 }
@@ -214,23 +218,21 @@ const renderRuneTable = (runeCounts: Map<number, RuneStats>, totalRunes: number)
214218 )
215219
216220 resultsBody . innerHTML = rows . join ( '' )
217- resultsSummary . textContent = `${ totalRunes . toLocaleString ( ) } total code points, ${ runeCounts . size } distinct values.`
221+ const baseSummary = `${ totalRunes . toLocaleString ( ) } total code points, ${ runeCounts . size } distinct values.`
222+ resultsSummary . innerHTML =
223+ decodeErrorCount > 0
224+ ? `${ baseSummary } <span class="inline-flex items-center gap-1"><span aria-hidden="true">⚠️</span><span>${ decodeErrorCount . toLocaleString ( ) } decoding errors</span></span>`
225+ : baseSummary
226+
218227 resultsSection . classList . remove ( 'hidden' )
219228}
220229
221- form ?. addEventListener ( 'submit' , async ( event ) => {
222- event . preventDefault ( )
223-
230+ const runCount = async ( ) => {
224231 const selectedFile = fileInput ?. files ?. [ 0 ]
225232 const selectedCharset = charsetSelect ?. value ?? 'utf8'
226233
227234 if ( ! selectedFile ) {
228- if ( resultsSection ) {
229- resultsSection . classList . add ( 'hidden' )
230- }
231-
232- showError ( 'Please choose a file before starting.' )
233-
235+ resultsSection ?. classList . add ( 'hidden' )
234236 return
235237 }
236238
@@ -241,21 +243,35 @@ form?.addEventListener('submit', async (event) => {
241243
242244 hideError ( )
243245
244- if ( startButton ) {
245- startButton . disabled = true
246- startButton . textContent = 'Counting...'
246+ if ( fileInput ) {
247+ fileInput . disabled = true
248+ }
249+
250+ if ( charsetSelect ) {
251+ charsetSelect . disabled = true
247252 }
248253
249254 try {
250- const runeCounts = await countRunes ( selectedFile , selectedCharset )
251- const totalRunes = Array . from ( runeCounts . values ( ) ) . reduce ( ( total , stats ) => total + stats . count , 0 )
252- renderRuneTable ( runeCounts , totalRunes )
255+ const countResult = await countRunes ( selectedFile , selectedCharset )
256+ const totalRunes = Array . from ( countResult . runeCounts . values ( ) ) . reduce ( ( total , stats ) => total + stats . count , 0 )
257+ renderRuneTable ( countResult . runeCounts , totalRunes , countResult . decodeErrorCount )
253258 } catch {
254259 showError ( 'Unable to read the selected file.' )
255260 } finally {
256- if ( startButton ) {
257- startButton . disabled = false
258- startButton . textContent = 'Start'
261+ if ( fileInput ) {
262+ fileInput . disabled = false
263+ }
264+
265+ if ( charsetSelect ) {
266+ charsetSelect . disabled = false
259267 }
260268 }
269+ }
270+
271+ fileInput ?. addEventListener ( 'change' , ( ) => {
272+ void runCount ( )
273+ } )
274+
275+ charsetSelect ?. addEventListener ( 'change' , ( ) => {
276+ void runCount ( )
261277} )
0 commit comments