diff --git a/astro.config.mjs b/astro.config.mjs index 626f03a..2fc08f3 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -117,6 +117,7 @@ export default defineConfig({ '**/transformers*.js', '**/*huggingface*.js', '**/maplibre-gl*.js', + '**/xlsx*.js', 'og/*.png', ], runtimeCaching: [ diff --git a/package-lock.json b/package-lock.json index adff87b..b32d6c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,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": { @@ -21474,6 +21475,18 @@ } } }, + "node_modules/xlsx": { + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", + "license": "Apache-2.0", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index a3deeb2..da16034 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/islands/documents/SpreadsheetViewer.tsx b/src/islands/documents/SpreadsheetViewer.tsx new file mode 100644 index 0000000..8071867 --- /dev/null +++ b/src/islands/documents/SpreadsheetViewer.tsx @@ -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 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([]); + 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 ( +
+

{t.intro}

+ + {sheets.length === 0 && ( +
+ +
+

{busy ? t.opening : t.drop}

+

{t.dropSub}

+
+
+

{t.how}

+
+ )} + + {error && {error}} + + {sheet && ( +
+
+ {sheets.length > 1 && sheets.map((s, i) => ( + + ))} + +
+ + {sheet.truncated && {t.truncated(sheet.totalRows, sheet.totalCols)}} + + {sheet.rows.length === 0 ? ( +

{t.empty}

+ ) : ( +
+ + + + + ))} + + + + {sheet.rows.map((row, r) => ( + + + {Array.from({ length: colCount }, (_, c) => ( + + ))} + + ))} + +
+ {Array.from({ length: colCount }, (_, c) => ( + {colLabel(c)}
{r + 1}{row[c] ?? ''}
+
+ )} +
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index bd2db89..a928b6b 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -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 = { + '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.', @@ -1325,6 +1342,23 @@ const en: Record = { }; const id: Record = { + '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.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index eed9ea0..517a3c9 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -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[] = [ @@ -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', diff --git a/src/tools/documents/spreadsheet.lib.test.ts b/src/tools/documents/spreadsheet.lib.test.ts new file mode 100644 index 0000000..ee8e294 --- /dev/null +++ b/src/tools/documents/spreadsheet.lib.test.ts @@ -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): 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); + }); +}); diff --git a/src/tools/documents/spreadsheet.lib.ts b/src/tools/documents/spreadsheet.lib.ts new file mode 100644 index 0000000..ecdef2f --- /dev/null +++ b/src/tools/documents/spreadsheet.lib.ts @@ -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)); +}