Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export default defineConfig({
'**/transformers*.js',
'**/*huggingface*.js',
'**/maplibre-gl*.js',
'**/xlsx*.js',
'og/*.png',
],
runtimeCaching: [
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"tailwindcss": "^3.4.19",
"turndown": "^7.2.4",
"upscaler": "^1.0.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yaml": "^2.9.0"
},
"devDependencies": {
Expand Down
132 changes: 132 additions & 0 deletions src/islands/documents/SpreadsheetViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { FileSpreadsheet } from 'lucide-react';
import { Dropzone } from '@/components/ui/Dropzone';
import { Button } from '@/components/ui/Button';
import { Alert } from '@/components/ui/Alert';
import { colLabel, readWorkbook, type SheetView } from '@/tools/documents/spreadsheet.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
intro: string; drop: string; dropSub: string; how: string;
opening: string; another: string; errRead: string; empty: string;
truncated: (r: number, c: number) => string;
}> = {
en: {
intro: 'Open a spreadsheet (.xlsx, .xls, .ods or .csv) and read every sheet as a table — right here in your browser. Nothing is uploaded.',
drop: 'Drop a spreadsheet', dropSub: '.xlsx · .xls · .ods · .csv — read on your device, no upload.',
how: 'Excel, OpenDocument and CSV files are all supported. Formulas are shown as their last-saved values.',
opening: 'Reading…', another: 'Open another', errRead: 'Could not read this spreadsheet — is it a valid .xlsx, .xls, .ods or .csv file?',
empty: 'This sheet is empty.',
truncated: (r, c) => `Large sheet — showing the first ${Math.min(500, r).toLocaleString()} rows and ${Math.min(60, c)} columns of ${r.toLocaleString()} × ${c}.`,
},
id: {
intro: 'Buka spreadsheet (.xlsx, .xls, .ods, atau .csv) dan baca setiap lembar sebagai tabel — langsung di browser Anda. Tidak ada yang diunggah.',
drop: 'Letakkan spreadsheet', dropSub: '.xlsx · .xls · .ods · .csv — dibaca di perangkat Anda, tanpa unggahan.',
how: 'Berkas Excel, OpenDocument, dan CSV semuanya didukung. Rumus ditampilkan sebagai nilai terakhir yang disimpan.',
opening: 'Membaca…', another: 'Buka yang lain', errRead: 'Tidak dapat membaca spreadsheet ini — apakah berkas .xlsx, .xls, .ods, atau .csv yang valid?',
empty: 'Lembar ini kosong.',
truncated: (r, c) => `Lembar besar — menampilkan ${Math.min(500, r).toLocaleString()} baris dan ${Math.min(60, c)} kolom pertama dari ${r.toLocaleString()} × ${c}.`,
},
};

export default function SpreadsheetViewer({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [sheets, setSheets] = useState<SheetView[]>([]);
const [active, setActive] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');

const onDrop = async (files: File[]) => {
const f = files[0];
if (!f) return;
setError('');
setBusy(true);
try {
const buf = await f.arrayBuffer();
const XLSX = await import('xlsx');
const views = readWorkbook(new Uint8Array(buf), XLSX);
setSheets(views);
setActive(0);
} catch {
setError(t.errRead);
setSheets([]);
} finally {
setBusy(false);
}
};

const reset = () => {
setSheets([]);
setActive(0);
setError('');
};

const sheet = sheets[active];
const colCount = sheet?.rows.reduce((m, r) => Math.max(m, r.length), 0) ?? 0;

return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>

{sheets.length === 0 && (
<div>
<Dropzone onDrop={onDrop} accept=".xlsx,.xlsm,.xls,.ods,.csv,text/csv" multiple={false}>
<div className="space-y-1">
<p className="flex items-center justify-center gap-2 text-lg font-bold"><FileSpreadsheet className="h-5 w-5" /> {busy ? t.opening : t.drop}</p>
<p className="text-sm text-muted-foreground">{t.dropSub}</p>
</div>
</Dropzone>
<p className="mt-2 text-xs text-muted-foreground">{t.how}</p>
</div>
)}

{error && <Alert variant="error">{error}</Alert>}

{sheet && (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{sheets.length > 1 && sheets.map((s, i) => (
<button
key={s.name + i}
onClick={() => setActive(i)}
className={`border-2 px-3 py-1 text-sm font-medium transition-all ${i === active ? 'border-border bg-accent text-accent-foreground shadow-brutal' : 'border-border hover:shadow-brutal'}`}
>
{s.name}
</button>
))}
<Button variant="ghost" onClick={reset} className="ml-auto">{t.another}</Button>
</div>

{sheet.truncated && <Alert variant="success">{t.truncated(sheet.totalRows, sheet.totalCols)}</Alert>}

{sheet.rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.empty}</p>
) : (
<div className="max-h-[75vh] overflow-auto border-2 border-border">
<table className="border-collapse text-sm tabular-nums">
<thead>
<tr>
<th className="sticky left-0 top-0 z-20 border border-border bg-muted px-2 py-1" />
{Array.from({ length: colCount }, (_, c) => (
<th key={c} className="sticky top-0 z-10 border border-border bg-muted px-2 py-1 font-mono text-xs font-semibold text-muted-foreground">{colLabel(c)}</th>
))}
</tr>
</thead>
<tbody>
{sheet.rows.map((row, r) => (
<tr key={r}>
<th className="sticky left-0 z-10 border border-border bg-muted px-2 py-1 text-right font-mono text-xs font-normal text-muted-foreground">{r + 1}</th>
{Array.from({ length: colCount }, (_, c) => (
<td key={c} className="whitespace-nowrap border border-border bg-background px-2 py-1">{row[c] ?? ''}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
34 changes: 34 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ import type { Lang } from '@/i18n/config';
* a locale entry is missing. Feeds on-page copy + HowTo/FAQPage structured data.
*/
const en: Record<string, ToolSeoContent> = {
'spreadsheet-viewer': {
title: 'Free Spreadsheet Viewer — Open XLSX, ODS & CSV',
description: 'A free spreadsheet viewer to open Excel (.xlsx/.xls), OpenDocument (.ods) and CSV files in your browser — every sheet as a table. 100% private; nothing is uploaded.',
intro: 'This free spreadsheet viewer opens Excel, OpenDocument and CSV files right in your browser and shows every sheet as a clean, scrollable table — no Excel, Google Sheets or account needed. The file is read on your device and never uploaded.',
howTo: [
'Drop a spreadsheet (.xlsx, .xls, .ods or .csv) — or click to browse.',
'It is parsed entirely in your browser; nothing is uploaded.',
'Switch between sheets with the tabs and scroll the grid to read your data.',
'Column letters and row numbers help you find any cell, just like a spreadsheet app.',
],
faqs: [
{ q: 'Is my spreadsheet uploaded anywhere?', a: 'No. The file is parsed and rendered entirely in your browser with JavaScript. It never leaves your device, so it is safe for confidential data.' },
{ q: 'Which formats are supported?', a: 'Modern Excel (.xlsx, .xlsm), older Excel (.xls), OpenDocument spreadsheets (.ods) and CSV files. Multi-sheet workbooks show a tab per sheet.' },
{ q: 'Are formulas calculated?', a: 'Formulas are displayed as their last-saved values (the result stored in the file), not recalculated. This is a fast, read-only viewer, not a spreadsheet editor.' },
{ q: 'What about very large spreadsheets?', a: 'The whole file is read, but to stay fast the grid shows the first 500 rows and 60 columns of each sheet, with a notice when a sheet is larger.' },
],
},
'docx-viewer': {
title: 'Free DOCX Viewer — Open Word Files Online',
description: 'A free DOCX viewer to open and read Microsoft Word documents in your browser — full layout, tables and images. 100% private; nothing is uploaded.',
Expand Down Expand Up @@ -1325,6 +1342,23 @@ const en: Record<string, ToolSeoContent> = {
};

const id: Record<string, ToolSeoContent> = {
'spreadsheet-viewer': {
title: 'Penampil Spreadsheet Gratis — Buka XLSX, ODS & CSV',
description: 'Penampil spreadsheet gratis untuk membuka berkas Excel (.xlsx/.xls), OpenDocument (.ods), dan CSV di browser — setiap lembar sebagai tabel. 100% privat; tidak ada yang diunggah.',
intro: 'Penampil spreadsheet gratis ini membuka berkas Excel, OpenDocument, dan CSV langsung di browser Anda serta menampilkan setiap lembar sebagai tabel yang rapi dan dapat digulir — tanpa Excel, Google Sheets, atau akun. Berkas dibaca di perangkat Anda dan tidak pernah diunggah.',
howTo: [
'Letakkan spreadsheet (.xlsx, .xls, .ods, atau .csv) — atau klik untuk menelusuri.',
'Berkas diurai sepenuhnya di browser Anda; tidak ada yang diunggah.',
'Berpindah antar lembar dengan tab dan gulir grid untuk membaca data Anda.',
'Huruf kolom dan nomor baris membantu Anda menemukan sel mana pun, seperti aplikasi spreadsheet.',
],
faqs: [
{ q: 'Apakah spreadsheet saya diunggah ke suatu tempat?', a: 'Tidak. Berkas diurai dan ditampilkan sepenuhnya di browser Anda dengan JavaScript. Berkas tidak pernah meninggalkan perangkat, jadi aman untuk data rahasia.' },
{ q: 'Format apa saja yang didukung?', a: 'Excel modern (.xlsx, .xlsm), Excel lama (.xls), spreadsheet OpenDocument (.ods), dan berkas CSV. Workbook multi-lembar menampilkan satu tab per lembar.' },
{ q: 'Apakah rumus dihitung?', a: 'Rumus ditampilkan sebagai nilai terakhir yang disimpan (hasil yang tersimpan di berkas), bukan dihitung ulang. Ini penampil baca-saja yang cepat, bukan editor spreadsheet.' },
{ q: 'Bagaimana dengan spreadsheet yang sangat besar?', a: 'Seluruh berkas dibaca, tetapi agar tetap cepat, grid menampilkan 500 baris dan 60 kolom pertama dari setiap lembar, dengan pemberitahuan saat sebuah lembar lebih besar.' },
],
},
'docx-viewer': {
title: 'Penampil DOCX Gratis — Buka Berkas Word Online',
description: 'Penampil DOCX gratis untuk membuka dan membaca dokumen Microsoft Word di browser — tata letak, tabel, dan gambar lengkap. 100% privat; tidak ada yang diunggah.',
Expand Down
13 changes: 12 additions & 1 deletion src/registry/tools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost } from 'lucide-react';
import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet } from 'lucide-react';
import type { ToolDef } from '@/types/tool';

export const tools: ToolDef[] = [
Expand Down Expand Up @@ -168,6 +168,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/documents/DocxViewer'),
status: 'beta'
},
{
id: 'spreadsheet-viewer',
name: 'Spreadsheet Viewer',
category: 'Documents',
route: '/tools/spreadsheet-viewer',
keywords: ['spreadsheet', 'excel', 'xlsx', 'xls', 'ods', 'csv', 'viewer', 'open', 'read', 'sheet', 'opendocument', 'calc'],
icon: FileSpreadsheet,
summary: 'Open Excel, OpenDocument and CSV spreadsheets in your browser',
load: () => import('@/islands/documents/SpreadsheetViewer'),
status: 'beta'
},
{
id: 'markdown',
name: 'Markdown Preview',
Expand Down
75 changes: 75 additions & 0 deletions src/tools/documents/spreadsheet.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import * as XLSX from 'xlsx';
import { colLabel, readWorkbook, sheetToView, MAX_ROWS, MAX_COLS } from './spreadsheet.lib';

/** Build a .xlsx byte array from one or more named sheets (arrays of arrays). */
function makeWorkbook(sheets: Record<string, unknown[][]>): Uint8Array {
const wb = XLSX.utils.book_new();
for (const [name, aoa] of Object.entries(sheets)) {
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), name);
}
return new Uint8Array(XLSX.write(wb, { type: 'array', bookType: 'xlsx' }));
}

describe('colLabel', () => {
it.each([
[0, 'A'], [1, 'B'], [25, 'Z'], [26, 'AA'], [27, 'AB'], [51, 'AZ'], [52, 'BA'], [701, 'ZZ'], [702, 'AAA'],
])('index %i → %s', (i, label) => {
expect(colLabel(i)).toBe(label);
});
});

describe('readWorkbook', () => {
it('reads every sheet with cells stringified', () => {
const bytes = makeWorkbook({
Alpha: [['Name', 'Age'], ['Ada', 36], ['Alan', 41]],
Beta: [['x'], ['y']],
});
const views = readWorkbook(bytes, XLSX);
expect(views.map(v => v.name)).toEqual(['Alpha', 'Beta']);
const alpha = views[0];
expect(alpha.rows[0]).toEqual(['Name', 'Age']);
expect(alpha.rows[1]).toEqual(['Ada', '36']); // numbers → strings
expect(alpha.totalRows).toBe(3);
expect(alpha.totalCols).toBe(2);
expect(alpha.truncated).toBe(false);
});

it('pads ragged rows to a rectangular grid', () => {
const bytes = makeWorkbook({ S: [['a', 'b', 'c'], ['d']] });
const [s] = readWorkbook(bytes, XLSX);
expect(s.totalCols).toBe(3);
expect(s.rows[1]).toEqual(['d', '', '']); // short row padded with empty strings
});
});

describe('cell formatting', () => {
it('renders dates as text, not a raw serial number', () => {
const sheet = XLSX.utils.aoa_to_sheet([['when'], [new Date(Date.UTC(2024, 0, 15))]], { cellDates: true });
const view = sheetToView('D', sheet, XLSX);
const cell = view.rows[1][0];
expect(cell).not.toMatch(/^4\d{4}$/); // not the ~45306 serial for Jan 2024
expect(cell).not.toContain('T00:00'); // not the raw ISO string
expect(cell).toBe('1/15/24'); // Excel-formatted date text (m/d/yy)
});
});

describe('sheetToView truncation', () => {
it('caps rows/cols and flags truncated', () => {
const rows = Array.from({ length: MAX_ROWS + 50 }, (_, r) => [r, r * 2]);
const sheet = XLSX.utils.aoa_to_sheet(rows);
const view = sheetToView('Big', sheet, XLSX);
expect(view.totalRows).toBe(MAX_ROWS + 50);
expect(view.rows.length).toBe(MAX_ROWS); // rendered rows capped
expect(view.truncated).toBe(true);
});

it('caps columns beyond MAX_COLS', () => {
const wide = [Array.from({ length: MAX_COLS + 10 }, (_, c) => `c${c}`)];
const sheet = XLSX.utils.aoa_to_sheet(wide);
const view = sheetToView('Wide', sheet, XLSX);
expect(view.totalCols).toBe(MAX_COLS + 10);
expect(view.rows[0].length).toBe(MAX_COLS);
expect(view.truncated).toBe(true);
});
});
60 changes: 60 additions & 0 deletions src/tools/documents/spreadsheet.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type * as XLSXType from 'xlsx';

/**
* Row/column caps for what we render. A workbook is parsed in full (so totals are
* accurate), but only this many cells are turned into DOM to keep large sheets
* responsive. `truncated` tells the UI to show a "showing first N" notice.
*/
export const MAX_ROWS = 500;
export const MAX_COLS = 60;

export interface SheetView {
name: string;
/** Capped, rectangular grid of stringified cells (rows × totalCols, both bounded). */
rows: string[][];
/** True used-range row count (before capping). */
totalRows: number;
/** True used-range column count (before capping). */
totalCols: number;
/** True when the sheet exceeds MAX_ROWS or MAX_COLS and the grid was capped. */
truncated: boolean;
}

/** Bijective base-26 spreadsheet column label: 0→A, 25→Z, 26→AA, 701→ZZ. */
export function colLabel(index: number): string {
let n = index;
let s = '';
do {
s = String.fromCharCode(65 + (n % 26)) + s;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return s;
}

/** Convert one parsed worksheet into a capped, rectangular string grid. */
export function sheetToView(name: string, sheet: XLSXType.WorkSheet, XLSX: typeof XLSXType): SheetView {
// raw: false → cells come back as their *formatted* text (dates, currency and
// percentages render as they appeared in Excel, not as raw serial numbers).
const aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: false, defval: '', raw: false }) as unknown[][];
const totalRows = aoa.length;
const totalCols = aoa.reduce((m, r) => Math.max(m, r.length), 0);
const truncated = totalRows > MAX_ROWS || totalCols > MAX_COLS;
const cols = Math.min(totalCols, MAX_COLS);
const rows = aoa.slice(0, MAX_ROWS).map((r) =>
Array.from({ length: cols }, (_, c) => {
const v = r[c];
return v == null ? '' : String(v);
}),
);
return { name, rows, totalRows, totalCols, truncated };
}

/**
* Parse spreadsheet bytes (.xlsx/.xlsm/.xls/.ods/.csv — anything SheetJS sniffs)
* into per-sheet views. `XLSX` is injected so the ~900KB engine stays a lazy,
* dynamically-imported dependency of the island, not a static one.
*/
export function readWorkbook(bytes: Uint8Array, XLSX: typeof XLSXType): SheetView[] {
const wb = XLSX.read(bytes, { type: 'array', cellDates: true });
return wb.SheetNames.map((name) => sheetToView(name, wb.Sheets[name], XLSX));
}
Loading