runlocally

runlocally engineering notes

CSV Viewer

How CSV Viewer is built

By Geppetto · · Open CSV Viewer →

These are the engineering notes for CSV Viewer: the technologies it is built on, what each one is, and how it is used in the tool.

Tech used

Reading the file as bytes, and detecting the encoding

A CSV is text, but a browser is handed a file as bytes — and the same bytes mean different characters depending on the encoding. Files exported from a Japanese copy of Excel are usually Shift_JIS, while most other tooling emits UTF-8. Read the wrong one and every non-ASCII cell is mojibake.

The decoding is done with the browser’s built-in TextDecoder, introduced in the Fix ZIP Filenames notes, where it re-decodes garbled ZIP entry names. Here it does double duty as an encoding detector. TextDecoder has a strict mode: new TextDecoder('utf-8', { fatal: true }) throws on any byte sequence that is not valid UTF-8. Valid UTF-8 is a tightly-specified bit pattern, so a decode that does not throw is strong evidence the file really is UTF-8; if it throws, the file is treated as Shift_JIS:

export function detectEncoding(bytes) {
  try {
    new TextDecoder('utf-8', { fatal: true }).decode(bytes);
    return 'utf-8';
  } catch {
    return 'shift-jis';
  }
}

The actual decode for display is lenient — unmappable bytes become the replacement character (U+FFFD) rather than throwing — so a stray byte never turns the whole table into an error. The detected encoding is shown in the toolbar and can be overridden by hand, which re-decodes the same in-memory bytes. Detection is only a default, not a lock-in.

papaparse — parsing delimited text

Splitting a CSV on commas looks trivial until a field contains a comma inside quotes ("Smith, John"), an escaped quote (""), or a newline inside a quoted field. Those are all legal RFC 4180 CSV, and a naive split(',') mangles every one of them. papaparse is a mature CSV parser that implements those quoting rules, so the tool doesn’t reinvent them.

Two of its features carry the tool. First, delimiter guessing: real files are separated by commas, tabs (.tsv), or semicolons (common in locales where the comma is the decimal separator). papaparse can be handed a candidate set and asked to pick:

Papa.parse(text, {
  header: false,
  skipEmptyLines: 'greedy',
  delimiter: delimiter ?? '',        // '' = auto-detect
  delimitersToGuess: [',', '\t', ';'],
});

Second, header: false. The parser returns a plain matrix of string rows and does not consume a header row — whether row 0 is a header is a display choice the UI owns, so toggling “first row is a header” re-renders instantly without re-parsing the file. The detected delimiter is read back from result.meta.delimiter to show in the toolbar, and the parser is import()-ed lazily the first time a file is opened, so it stays off the initial page load.

A windowed table

The DOM does not enjoy a <table> with 50,000 <tr>s. Building that many nodes stalls the main thread and the page janks on every scroll. The fix is windowing (also called virtualization): keep all the data in memory but only put the rows that are currently on screen into the DOM, and fake the rest of the scrollbar with two empty spacers.

Given a fixed row height, the visible slice is pure arithmetic from the scroll position:

const firstVisible = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(viewportHeight / rowHeight);
const startIndex   = Math.max(0, firstVisible - overscan);
const endIndex     = Math.min(rowCount, firstVisible + visibleCount + overscan);

Only rows [startIndex, endIndex) are rendered. Above and below them sit two spacer elements whose heights are startIndex * rowHeight and (rowCount - endIndex) * rowHeight, so the scrollbar behaves as if the whole table were there while the DOM holds only a dozen-odd rows. A small overscan (a few extra rows past each edge) hides the swap so fast scrolling doesn’t flash blank rows. The window is a pure function of scroll state, which makes it straightforward to unit-test at the boundaries — top, bottom, and an empty file.

The shell: Astro, Preact islands, and an offline PWA

The page itself is the same static shell as the other tools — an Astro page that server-renders the content and hydrates a single Preact island for the interactive part, with a Service Worker that caches the app for offline use. That machinery is described in the HEIC to JPG notes and is unchanged here; what is specific to this tool is everything above, plus the fact that there is no worker and no network call at all — the whole pipeline is bytes → decode → parse → window, on the main thread, on the file you picked.

Implementation & operational notes

  • Detection is strict, display is lenient. The UTF-8 probe uses fatal: true precisely because it must fail on non-UTF-8 input to be a useful signal; the decode that feeds the table is the default lenient TextDecoder, which degrades a bad byte to U+FFFD instead of aborting. Using the strict decoder for display would let one malformed byte blank the whole view.
  • Parse once, header is a view concern. Because the parser runs with header: false, the “first row is a header” toggle and re-detection of encoding/delimiter never require re-reading the file except when the user deliberately changes encoding or delimiter.
  • Windowing needs a known row height. The spacer math assumes a fixed row height; that is the price of not measuring every row. It keeps scrolling O(viewport) instead of O(rows), which is the whole point, but it means variable-height content (wrapped multi-line cells) is a deliberate non-goal for the viewer.
  • Column count is the widest row. Ragged CSVs (rows with different field counts) are common; the table’s column count is taken as the maximum row length so no cell is dropped, and short rows render blank trailing cells.
  • Nothing leaves the device. There is no upload step because there is no server component — the file is read with File.arrayBuffer() and everything downstream is local. It is served as static files behind the runlocally front-router, and the *.pages.dev origin is marked noindex so only the runlocally.app/csv-viewer/ surface is indexed.

Try it / source

CSV Viewer · source