@gradio/dataframe 0.24.4 → 0.24.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { CellValue, Headers, HeadersWithIDs, TableData } from "../types";
1
+ import type { CellValue, Headers, TableData } from "../types";
2
2
  import { dsvFormat } from "d3-dsv";
3
3
 
4
4
  export function make_cell_id(row: number, col: number): string {
@@ -11,18 +11,17 @@ export function make_header_id(col: number): string {
11
11
 
12
12
  export async function copy_table_data(
13
13
  data: TableData,
14
- selected_cells: [number, number][] | null
14
+ selected_cells: [number, number][]
15
15
  ): Promise<void> {
16
16
  if (!data || !data.length) return;
17
17
 
18
- const cells_to_copy =
19
- selected_cells ||
20
- data.flatMap((row, r) => row.map((_, c) => [r, c] as [number, number]));
21
-
22
- const csv = cells_to_copy.reduce(
18
+ const csv = selected_cells.reduce(
23
19
  (acc: { [key: string]: { [key: string]: string } }, [row, col]) => {
24
20
  acc[row] = acc[row] || {};
25
- const value = String(data[row][col].value);
21
+ // only the headers are padded out to the column count on the way in, so
22
+ // a selection can name a cell a short row does not have
23
+ const cell = data[row]?.[col];
24
+ const value = cell ? String(cell.value) : "";
26
25
  acc[row][col] =
27
26
  value.includes(",") || value.includes('"') || value.includes("\n")
28
27
  ? `"${value.replace(/"/g, '""')}"`
@@ -35,7 +34,11 @@ export async function copy_table_data(
35
34
  const rows = Object.keys(csv).sort((a, b) => +a - +b);
36
35
  if (!rows.length) return;
37
36
 
38
- const cols = Object.keys(csv[rows[0]]).sort((a, b) => +a - +b);
37
+ // every column any selected row contributes: taking them from the first row
38
+ // alone drops the rest of a selection that is not rectangular there
39
+ const cols = Array.from(
40
+ new Set(rows.flatMap((r) => Object.keys(csv[r])))
41
+ ).sort((a, b) => +a - +b);
39
42
  const text = rows
40
43
  .map((r) => cols.map((c) => csv[r][c] || "").join(","))
41
44
  .join("\n");
@@ -47,49 +50,43 @@ export async function copy_table_data(
47
50
  }
48
51
  }
49
52
 
50
- export function guess_delimiter(
51
- text: string,
52
- possibleDelimiters: string[]
53
- ): string[] {
54
- return possibleDelimiters.filter(weedOut);
55
-
56
- function weedOut(delimiter: string): boolean {
57
- var cache = -1;
58
- return text.split("\n").every(checkLength);
59
-
60
- function checkLength(line: string): boolean {
61
- if (!line) return true;
62
- var length = line.split(delimiter).length;
63
- if (cache < 0) cache = length;
64
- return cache === length && length > 1;
53
+ export async function parse_table_file(
54
+ file: File
55
+ ): Promise<{ headers: Headers; values: CellValue[][] }> {
56
+ const text = await file.text();
57
+ if (!text.trim()) return { headers: [], values: [] };
58
+ // the drop zone only accepts .csv and .tsv, so the extension goes first and
59
+ // wins any tie, but a mislabeled file is still read by its content: a
60
+ // separator only takes the file if it splits every line the same way, which
61
+ // is what tells a real separator from one that sits in a header name. the
62
+ // split is d3's rather than a raw count, so a separator inside a quoted
63
+ // field does not throw the detection off.
64
+ const by_extension = file.name.toLowerCase().endsWith(".tsv") ? "\t" : ",";
65
+ const candidates = by_extension === "\t" ? ["\t", ","] : [",", "\t"];
66
+ let consistent: string[][] | undefined;
67
+ let ragged: string[][] | undefined;
68
+ let unsplit: string[][] | undefined;
69
+ for (const delimiter of candidates) {
70
+ // a blank line parses to a single empty field, which both the width
71
+ // checks below and the caller's ragged check would read as a short row
72
+ const parsed = dsvFormat(delimiter)
73
+ .parseRows(text)
74
+ .filter((row) => row.length > 1 || row[0] !== "");
75
+ const width = parsed[0]?.length ?? 0;
76
+ // the extension is first in `candidates`, so this keeps its parse for the
77
+ // single-column case rather than running it again at the end
78
+ unsplit ??= parsed;
79
+ if (width < 2) continue;
80
+ if (parsed.every((row) => row.length === width)) {
81
+ consistent = parsed;
82
+ break;
65
83
  }
84
+ // a separator that splits the header but leaves the rows uneven is
85
+ // usually the one that just happens to appear in a header name. keep it
86
+ // only in case the other does not split at all, where a ragged table is
87
+ // still better to report on than an unsplit one
88
+ ragged ??= parsed;
66
89
  }
67
- }
68
-
69
- export function data_uri_to_blob(data_uri: string): Blob {
70
- const byte_str = atob(data_uri.split(",")[1]);
71
- const mime_str = data_uri.split(",")[0].split(":")[1].split(";")[0];
72
- const ab = new ArrayBuffer(byte_str.length);
73
- const ia = new Uint8Array(ab);
74
- for (let i = 0; i < byte_str.length; i++) {
75
- ia[i] = byte_str.charCodeAt(i);
76
- }
77
- return new Blob([ab], { type: mime_str });
78
- }
79
-
80
- export function handle_file_upload(
81
- data_uri: string,
82
- update_headers: (headers: Headers) => HeadersWithIDs[],
83
- update_values: (values: CellValue[][]) => void
84
- ): void {
85
- const blob = data_uri_to_blob(data_uri);
86
- const reader = new FileReader();
87
- reader.addEventListener("loadend", (e) => {
88
- if (!e?.target?.result || typeof e.target.result !== "string") return;
89
- const [delimiter] = guess_delimiter(e.target.result, [",", "\t"]);
90
- const [head, ...rest] = dsvFormat(delimiter).parseRows(e.target.result);
91
- update_headers(head);
92
- update_values(rest);
93
- });
94
- reader.readAsText(blob);
90
+ const [head = [], ...rest] = consistent ?? ragged ?? unsplit ?? [];
91
+ return { headers: head.map((h) => h ?? ""), values: rest };
95
92
  }
@@ -15,7 +15,6 @@ export type Datatype =
15
15
  export type Metadata = {
16
16
  [key: string]: string[][] | null;
17
17
  } | null;
18
- export type HeadersWithIDs = { value: string; id: string }[];
19
18
  export type DataframeValue = {
20
19
  data: Data;
21
20
  headers: Headers;
@@ -5,6 +5,18 @@ export const default_i18n: Record<string, string> = {
5
5
  "dataframe.add_column_left": "Add column left",
6
6
  "dataframe.add_column_right": "Add column right",
7
7
  "dataframe.delete_column": "Delete column",
8
+ "dataframe.drop_to_upload":
9
+ "Drop CSV or TSV files here to import data into dataframe",
10
+ "dataframe.import_empty_file": "The dropped file is empty.",
11
+ "dataframe.import_no_column_names": "The dropped file has no column names.",
12
+ "dataframe.import_ragged_row":
13
+ "Line {line} of the file has {fields} fields, the header has {columns}.",
14
+ "dataframe.import_static_columns":
15
+ "Cannot import into a table with read-only columns.",
16
+ "dataframe.import_fixed_columns":
17
+ "This table takes exactly {expected} columns, the file has {actual}.",
18
+ "dataframe.import_fixed_rows":
19
+ "This table takes exactly {expected} rows, the file has {actual}.",
8
20
  "dataframe.sort_asc": "Sort ascending",
9
21
  "dataframe.sort_desc": "Sort descending",
10
22
  "dataframe.sort_ascending": "Sort ascending",
@@ -70,11 +70,13 @@ describe("is_cell_in_selection", () => {
70
70
 
71
71
  describe("is_cell_selected", () => {
72
72
  test("returns empty string when cell not selected", () => {
73
- expect(is_cell_selected([0, 0], [[1, 1]])).toBe("");
73
+ expect(is_cell_selected([0, 0], [[1, 1]], null, null)).toBe("");
74
74
  });
75
75
 
76
76
  test("returns 'cell-selected' for isolated cell", () => {
77
- expect(is_cell_selected([1, 1], [[1, 1]])).toBe("cell-selected");
77
+ expect(is_cell_selected([1, 1], [[1, 1]], null, null)).toBe(
78
+ "cell-selected"
79
+ );
78
80
  });
79
81
 
80
82
  test("adds 'no-top' when neighbor above is selected", () => {
@@ -83,7 +85,9 @@ describe("is_cell_selected", () => {
83
85
  [
84
86
  [0, 0],
85
87
  [1, 0]
86
- ]
88
+ ],
89
+ 0,
90
+ null
87
91
  );
88
92
  expect(result).toContain("cell-selected");
89
93
  expect(result).toContain("no-top");
@@ -95,19 +99,46 @@ describe("is_cell_selected", () => {
95
99
  [
96
100
  [0, 0],
97
101
  [1, 0]
98
- ]
102
+ ],
103
+ null,
104
+ 1
99
105
  );
100
106
  expect(result).toContain("cell-selected");
101
107
  expect(result).toContain("no-bottom");
102
108
  });
103
109
 
110
+ test("merges rows that are adjacent in the view but not in the data", () => {
111
+ // what a search leaves behind: data rows 0, 2, 4 shown next to each other
112
+ const selection: CellCoordinate[] = [
113
+ [0, 0],
114
+ [2, 0],
115
+ [4, 0]
116
+ ];
117
+ const result = is_cell_selected([2, 0], selection, 0, 4);
118
+ expect(result).toContain("no-top");
119
+ expect(result).toContain("no-bottom");
120
+ });
121
+
122
+ test("does not merge a selected row the view is not showing next to it", () => {
123
+ // row 1 is selected but the search has hidden it, so row 2's neighbours on
124
+ // screen are rows 0 and 4. Comparing row ± 1 instead would merge the top.
125
+ const selection: CellCoordinate[] = [
126
+ [1, 0],
127
+ [2, 0]
128
+ ];
129
+ const result = is_cell_selected([2, 0], selection, 0, 4);
130
+ expect(result).toBe("cell-selected");
131
+ });
132
+
104
133
  test("adds 'no-left' when neighbor left is selected", () => {
105
134
  const result = is_cell_selected(
106
135
  [0, 1],
107
136
  [
108
137
  [0, 0],
109
138
  [0, 1]
110
- ]
139
+ ],
140
+ null,
141
+ null
111
142
  );
112
143
  expect(result).toContain("cell-selected");
113
144
  expect(result).toContain("no-left");
@@ -119,7 +150,9 @@ describe("is_cell_selected", () => {
119
150
  [
120
151
  [0, 0],
121
152
  [0, 1]
122
- ]
153
+ ],
154
+ null,
155
+ null
123
156
  );
124
157
  expect(result).toContain("cell-selected");
125
158
  expect(result).toContain("no-right");
@@ -133,7 +166,7 @@ describe("is_cell_selected", () => {
133
166
  [1, 0],
134
167
  [1, 1]
135
168
  ];
136
- const result = is_cell_selected([1, 1], selection);
169
+ const result = is_cell_selected([1, 1], selection, 0, null);
137
170
  expect(result).toContain("cell-selected");
138
171
  expect(result).toContain("no-top");
139
172
  expect(result).toContain("no-left");
@@ -2,8 +2,7 @@ import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  make_cell_id,
4
4
  make_header_id,
5
- guess_delimiter,
6
- data_uri_to_blob
5
+ parse_table_file
7
6
  } from "../shared/utils/table_utils";
8
7
  import { cast_value_to_type } from "../shared/utils/utils";
9
8
 
@@ -69,41 +68,106 @@ describe("cast_value_to_type", () => {
69
68
  });
70
69
  });
71
70
 
72
- describe("guess_delimiter", () => {
73
- test("detects comma delimiter", () => {
74
- const csv = "a,b,c\n1,2,3\n4,5,6";
75
- expect(guess_delimiter(csv, [",", "\t"])).toContain(",");
71
+ describe("parse_table_file", () => {
72
+ test("detects a comma-separated file", async () => {
73
+ const file = new File(["a,b,c\n1,2,3\n4,5,6"], "data.csv");
74
+ expect(await parse_table_file(file)).toEqual({
75
+ headers: ["a", "b", "c"],
76
+ values: [
77
+ ["1", "2", "3"],
78
+ ["4", "5", "6"]
79
+ ]
80
+ });
76
81
  });
77
82
 
78
- test("detects tab delimiter", () => {
79
- const tsv = "a\tb\tc\n1\t2\t3\n4\t5\t6";
80
- expect(guess_delimiter(tsv, [",", "\t"])).toContain("\t");
83
+ test("detects a tab-separated file", async () => {
84
+ const file = new File(["a\tb\tc\n1\t2\t3\n4\t5\t6"], "data.tsv");
85
+ expect(await parse_table_file(file)).toEqual({
86
+ headers: ["a", "b", "c"],
87
+ values: [
88
+ ["1", "2", "3"],
89
+ ["4", "5", "6"]
90
+ ]
91
+ });
81
92
  });
82
93
 
83
- test("returns empty array when no consistent delimiter", () => {
84
- const text = "abc\ndef\nghi";
85
- expect(guess_delimiter(text, [",", "\t"])).toEqual([]);
94
+ test("picks the extension's delimiter when both look consistent", async () => {
95
+ const file = new File(["first,last\tage\nAlice,Smith\t30\n"], "data.tsv");
96
+ expect(await parse_table_file(file)).toEqual({
97
+ headers: ["first,last", "age"],
98
+ values: [["Alice,Smith", "30"]]
99
+ });
86
100
  });
87
101
 
88
- test("handles single-line input", () => {
89
- // single line with commas — cache set once, always matches
90
- const text = "a,b,c";
91
- expect(guess_delimiter(text, [",", "\t"])).toContain(",");
102
+ test("reads a mislabeled file by its content", async () => {
103
+ const file = new File(["name,age\nAlice,30\n"], "data.tsv");
104
+ expect(await parse_table_file(file)).toEqual({
105
+ headers: ["name", "age"],
106
+ values: [["Alice", "30"]]
107
+ });
108
+ });
109
+
110
+ // counting raw separators misses this: the quoted comma makes the comma counts
111
+ // disagree between the two lines, so nothing looks consistent
112
+ test("reads a mislabeled file whose field holds a quoted comma", async () => {
113
+ const file = new File(['name,note\nA,"x,y"\n'], "data.tsv");
114
+ expect(await parse_table_file(file)).toEqual({
115
+ headers: ["name", "note"],
116
+ values: [["A", "x,y"]]
117
+ });
118
+ });
119
+
120
+ // the extension says comma and the header does split on one, but only the
121
+ // tab splits every line, so the name loses to the file's own shape
122
+ test("reads a mislabeled file whose header holds the other separator", async () => {
123
+ const file = new File(["first,last\tage\nAlice\t30\n"], "data.csv");
124
+ expect(await parse_table_file(file)).toEqual({
125
+ headers: ["first,last", "age"],
126
+ values: [["Alice", "30"]]
127
+ });
92
128
  });
93
- });
94
129
 
95
- describe("data_uri_to_blob", () => {
96
- test("converts data URI to Blob with correct MIME type", () => {
97
- const data_uri = "data:text/plain;base64,SGVsbG8=";
98
- const blob = data_uri_to_blob(data_uri);
99
- expect(blob.type).toBe("text/plain");
130
+ // nothing splits this file consistently, so the separator that at least
131
+ // splits the header wins and the caller gets to report a useful mismatch
132
+ test("falls back to the separator that splits the header", async () => {
133
+ const file = new File(["name,age\nAlice,30,Engineer\n"], "data.tsv");
134
+ expect(await parse_table_file(file)).toEqual({
135
+ headers: ["name", "age"],
136
+ values: [["Alice", "30", "Engineer"]]
137
+ });
138
+ });
139
+
140
+ test("ignores blank lines", async () => {
141
+ const file = new File(["a,b\n1,2\n\n3,4\n\n"], "data.csv");
142
+ expect(await parse_table_file(file)).toEqual({
143
+ headers: ["a", "b"],
144
+ values: [
145
+ ["1", "2"],
146
+ ["3", "4"]
147
+ ]
148
+ });
149
+ });
150
+
151
+ test("ignores blank lines in a file with windows line endings", async () => {
152
+ const file = new File(["a,b\r\n1,2\r\n\r\n"], "data.csv");
153
+ expect(await parse_table_file(file)).toEqual({
154
+ headers: ["a", "b"],
155
+ values: [["1", "2"]]
156
+ });
157
+ });
158
+
159
+ // excel writes one in front of its csv export. Blob.text() decodes as UTF-8,
160
+ // which drops it, so this holds as long as the file is read that way
161
+ test("strips a byte order mark from the first header", async () => {
162
+ const file = new File(["name,age\nAlice,30\n"], "data.csv");
163
+ expect(await parse_table_file(file)).toEqual({
164
+ headers: ["name", "age"],
165
+ values: [["Alice", "30"]]
166
+ });
100
167
  });
101
168
 
102
- test("converts data URI to Blob with correct content", async () => {
103
- // "Hello" in base64 is "SGVsbG8="
104
- const data_uri = "data:text/plain;base64,SGVsbG8=";
105
- const blob = data_uri_to_blob(data_uri);
106
- const text = await blob.text();
107
- expect(text).toBe("Hello");
169
+ test("returns nothing for a file that holds only whitespace", async () => {
170
+ const file = new File([" \n"], "data.csv");
171
+ expect(await parse_table_file(file)).toEqual({ headers: [], values: [] });
108
172
  });
109
173
  });
package/types.ts CHANGED
@@ -7,6 +7,7 @@ export interface DataframeEvents {
7
7
  input: never;
8
8
  select: SelectData;
9
9
  edit: EditData;
10
+ error: string;
10
11
  clear_status: LoadingStatus;
11
12
  }
12
13