gbs-add-block 1.3.0 → 2.0.1

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # GBS Building Blocks 2.0 (v1.3.0)
1
+ # GBS Building Blocks 2.0 (v2.0.1)
2
2
 
3
3
  Latest and upgraded version of GBS building blocks with headless UI and removed dependencies.
4
4
 
@@ -7,10 +7,10 @@ Latest and upgraded version of GBS building blocks with headless UI and removed
7
7
  For detailed documentation on usage and props, Please visit: [Building Block Documentation](https://gramprokit.vercel.app)
8
8
 
9
9
  ## Beta Components
10
-
11
- New Beta Components are avilable for testing and feedback, Please visit: [Building Block Documentation v2.0.0 beta](https://gramprokit.vercel.app/2.0.0-beta)
12
10
 
13
- ## What's New 🎉 (Ver 1.3.0)
11
+ New 2.0.0 beta (current version) is available for testing and feedback, Please visit: [Building Block Documentation v2.0.0 beta](https://gramprokit.vercel.app)
12
+
13
+ ## What's New 🎉 (Ver 2.0.1)
14
14
 
15
15
  - Update Candidate for next major change 2.0.0
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbs-add-block",
3
- "version": "1.3.0",
3
+ "version": "2.0.1",
4
4
  "description": "React Component Library",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { computeLayout, resolveColumns } from "../core/columns";
3
+ import { createInitialState } from "../core/state";
4
+ import type { ColumnDef } from "../core/types";
5
+ import { createFormatters } from "../core/values";
6
+ import { encode, fit, literal, widthOf } from "../export/pdf-font";
7
+ import { buildPdf, pdfDate, textString } from "../export/pdf-writer";
8
+ import { createPdfBlob, defaultPdfTheme, renderPages } from "../export/pdf";
9
+ import { buildExportTable } from "../export/table";
10
+
11
+ interface Row {
12
+ id: number;
13
+ name: string;
14
+ salary: number;
15
+ }
16
+
17
+ const defs: ColumnDef<Row>[] = [
18
+ { field: "id", type: "number", width: 80 },
19
+ { field: "name", width: 220 },
20
+ { field: "salary", type: "number", width: 120 },
21
+ ];
22
+
23
+ function tableOf(count: number) {
24
+ const rows = Array.from({ length: count }, (_, index) => ({
25
+ id: index + 1,
26
+ name: `Person (${index + 1})`,
27
+ salary: 1000 * (index + 1),
28
+ }));
29
+
30
+ const columns = resolveColumns(defs);
31
+ const layout = computeLayout(columns, createInitialState({ data: rows, columns: defs }));
32
+ return buildExportTable(rows, layout.columns, createFormatters("en-US"));
33
+ }
34
+
35
+ const options = {
36
+ title: "Report",
37
+ orientation: "landscape",
38
+ paperSize: "A4",
39
+ margin: 36,
40
+ fontSize: 9,
41
+ header: { left: "{title}" },
42
+ footer: { right: "Page {page} of {pages}" },
43
+ theme: defaultPdfTheme,
44
+ } as const;
45
+
46
+ /** Reads the file back the way it was written: one character, one byte. */
47
+ async function latin1(blob: Blob): Promise<string> {
48
+ const bytes = new Uint8Array(await blob.arrayBuffer());
49
+ let out = "";
50
+ for (const byte of bytes) out += String.fromCharCode(byte);
51
+ return out;
52
+ }
53
+
54
+ describe("pdf fonts", () => {
55
+ it("encodes WinAnsi, replacing what the encoding cannot carry", () => {
56
+ // Latin-1 passes through; the 0x80-0x9F specials are remapped.
57
+ expect(encode("Müller")).toEqual([77, 0xfc, 108, 108, 101, 114]);
58
+ expect(encode("“quoted” — €5")).toEqual([0x93, 113, 117, 111, 116, 101, 100, 0x94, 32, 0x97, 32, 0x80, 53]);
59
+
60
+ // Malayalam and emoji have no WinAnsi code, so they read as "?".
61
+ expect(encode("ത")).toEqual([0x3f]);
62
+ expect(encode("a\tb")).toEqual([97, 32, 98]);
63
+ });
64
+
65
+ it("measures with the real Helvetica advance widths", () => {
66
+ // "A" is 667/1000 em, "i" is 222/1000, at 10pt.
67
+ expect(widthOf(encode("A"), "regular", 10)).toBeCloseTo(6.67, 5);
68
+ expect(widthOf(encode("i"), "regular", 10)).toBeCloseTo(2.22, 5);
69
+ // Bold "A" is wider at 722/1000.
70
+ expect(widthOf(encode("A"), "bold", 10)).toBeCloseTo(7.22, 5);
71
+ });
72
+
73
+ it("truncates to an ellipsis and never overruns the cell", () => {
74
+ const long = encode("Engineering Department");
75
+ const clipped = fit(long, "regular", 9, 40);
76
+
77
+ expect(clipped.length).toBeLessThan(long.length);
78
+ expect(clipped[clipped.length - 1]).toBe(0x85);
79
+ expect(widthOf(clipped, "regular", 9)).toBeLessThanOrEqual(40);
80
+
81
+ // Text that already fits is returned untouched.
82
+ expect(fit(encode("ok"), "regular", 9, 400)).toEqual(encode("ok"));
83
+ // Not even the ellipsis fits.
84
+ expect(fit(long, "regular", 9, 1)).toEqual([]);
85
+ });
86
+
87
+ it("escapes only the three string delimiters", () => {
88
+ expect(literal(encode("a(b)c\\d"))).toBe("a\\(b\\)c\\\\d");
89
+ expect(literal(encode("plain"))).toBe("plain");
90
+ });
91
+ });
92
+
93
+ describe("pdf writer", () => {
94
+ it("writes a header, an xref whose offsets are right, and a trailer", async () => {
95
+ const blob = buildPdf(["<</Type/Catalog/Pages 2 0 R>>", { stream: "hello" }], {
96
+ root: 1,
97
+ info: 1,
98
+ });
99
+ const text = await latin1(blob);
100
+
101
+ expect(text.startsWith("%PDF-1.7\n")).toBe(true);
102
+ expect(text.trimEnd().endsWith("%%EOF")).toBe(true);
103
+ expect(text).toContain("<</Length 5>>\nstream\nhello\nendstream");
104
+
105
+ // Every xref offset must land exactly on its object header.
106
+ const start = Number(text.slice(text.lastIndexOf("startxref") + 10).split("\n")[0]);
107
+ expect(text.startsWith("xref\n", start)).toBe(true);
108
+
109
+ // Skip "xref" and the "0 N" subsection line to reach the 20-byte entries.
110
+ const first = text.indexOf("\n", text.indexOf("\n", start) + 1) + 1;
111
+
112
+ for (let id = 1; id <= 2; id++) {
113
+ const at = Number(text.slice(first + id * 20, first + id * 20 + 10));
114
+ expect(text.startsWith(`${id} 0 obj`, at)).toBe(true);
115
+ }
116
+ });
117
+
118
+ it("writes document info as UTF-16BE, which /Title needs", () => {
119
+ // A bare literal would be read as PDFDocEncoding, where 0x97 is S-caron
120
+ // rather than the em dash the caller passed.
121
+ expect(textString("A—B")).toBe("<FEFF004120140042>");
122
+ expect(textString("Hi")).toBe("<FEFF00480069>");
123
+ });
124
+
125
+ it("formats dates the way /CreationDate wants them", () => {
126
+ expect(pdfDate(new Date(Date.UTC(2026, 8, 22, 9, 5, 3)))).toBe("D:20260922090503+00'00'");
127
+ });
128
+ });
129
+
130
+ describe("pdf layout", () => {
131
+ it("repeats the header row on every page and fills page tokens", () => {
132
+ const pages = renderPages(tableOf(120), options);
133
+
134
+ expect(pages.length).toBeGreaterThan(1);
135
+
136
+ for (const page of pages) {
137
+ expect(page).toContain("(Name) Tj");
138
+ expect(page).toContain("/F2 9 Tf");
139
+ }
140
+
141
+ expect(pages[0]).toContain(`(Page 1 of ${pages.length}) Tj`);
142
+ expect(pages[1]).toContain(`(Page 2 of ${pages.length}) Tj`);
143
+ expect(pages[0]).toContain("(Report) Tj");
144
+ });
145
+
146
+ it("puts every row on exactly one page, in order", () => {
147
+ const table = tableOf(120);
148
+ const pages = renderPages(table, options);
149
+ const drawn = pages.flatMap((page) => [...page.matchAll(/\(Person \\\((\d+)\\\)\) Tj/g)].map((m) => Number(m[1])));
150
+
151
+ expect(drawn).toEqual(table.text.map((_, index) => index + 1));
152
+ });
153
+
154
+ it("right-aligns numbers and left-aligns text", () => {
155
+ const [page] = renderPages(tableOf(1), options);
156
+ const at = (label: string) => {
157
+ const match = page.match(new RegExp(`([\\d.]+) [\\d.]+ Td \\(${label}\\) Tj`));
158
+ return Number(match![1]);
159
+ };
160
+
161
+ // Compare each cell against its own column header, which is always drawn
162
+ // from the left edge. A numeric cell is pushed right; a text cell is not.
163
+ expect(at("1")).toBeGreaterThan(at("Id"));
164
+ expect(at("1000")).toBeGreaterThan(at("Salary"));
165
+ expect(at("Person \\\\\\(1\\\\\\)")).toBeCloseTo(at("Name"), 5);
166
+ });
167
+
168
+ it("honours paper size and orientation", async () => {
169
+ const table = tableOf(3);
170
+ const portrait = await latin1(await createPdfBlob(table, { ...options, orientation: "portrait" }));
171
+ const landscape = await latin1(await createPdfBlob(table, { ...options, orientation: "landscape" }));
172
+ const a5 = await latin1(await createPdfBlob(table, { ...options, paperSize: "A5" }));
173
+
174
+ expect(portrait).toContain("/MediaBox[0 0 595.28 841.89]");
175
+ expect(landscape).toContain("/MediaBox[0 0 841.89 595.28]");
176
+ expect(a5).toContain("/MediaBox[0 0 595.28 419.53]");
177
+ });
178
+
179
+ it("drops the bands when they are null, and the stripe when it is null", () => {
180
+ const bare = renderPages(tableOf(2), {
181
+ ...options,
182
+ header: null,
183
+ footer: null,
184
+ theme: { ...defaultPdfTheme, stripe: null },
185
+ });
186
+
187
+ expect(bare[0]).not.toContain("(Report) Tj");
188
+ expect(bare[0]).not.toContain("Page 1 of");
189
+ // One `re f` remains: the header row's fill.
190
+ expect(bare[0].match(/re f/g)).toHaveLength(1);
191
+ });
192
+
193
+ it("produces a single valid file for the whole table", async () => {
194
+ const blob = await createPdfBlob(tableOf(60), options);
195
+ const text = await latin1(blob);
196
+
197
+ expect(blob.type).toBe("application/pdf");
198
+ expect(text.startsWith("%PDF-1.7")).toBe(true);
199
+ expect(text).toContain("/BaseFont/Helvetica/Encoding/WinAnsiEncoding");
200
+ expect(text).toContain("/BaseFont/Helvetica-Bold/Encoding/WinAnsiEncoding");
201
+ expect(text).toContain(`/Title ${textString("Report")}`);
202
+
203
+ const count = Number(text.match(/\/Type\/Pages\/Count (\d+)/)![1]);
204
+ expect(text.match(/\/Type\/Page\//g)).toHaveLength(count);
205
+
206
+ // Node has CompressionStream, so the streams should be Flate encoded.
207
+ expect(text).toContain("/Filter/FlateDecode");
208
+ });
209
+ });
@@ -24,6 +24,7 @@ import type {
24
24
  GridRow,
25
25
  GridState,
26
26
  PdfExportOptions,
27
+ PrintExportOptions,
27
28
  PinSide,
28
29
  ResolvedColumn,
29
30
  RowId,
@@ -105,7 +106,10 @@ export interface GridApi<T> {
105
106
  getRows(scope?: ExportScope): T[];
106
107
  exportCsv(options?: ExportOptions<T>): Promise<void>;
107
108
  exportExcel(options?: ExportOptions<T>): Promise<void>;
109
+ /** Renders a PDF and downloads it. No print dialog, no browser pagination. */
108
110
  exportPdf(options?: PdfExportOptions<T>): Promise<void>;
111
+ /** Opens the browser's print dialog with the table laid out for paper. */
112
+ print(options?: PrintExportOptions<T>): Promise<void>;
109
113
  /** Copies selected rows as TSV, or the active cell when nothing is selected. */
110
114
  copyToClipboard(): Promise<void>;
111
115
  }
@@ -551,6 +555,24 @@ export function createGridEngine<T>(initialOptions: GridOptions<T>) {
551
555
  downloadBlob(createXlsxBlob(table), `${fileName(exportOptions)}.xlsx`);
552
556
  },
553
557
  async exportPdf(exportOptions = {}) {
558
+ const [{ createPdfBlob }, { downloadBlob }, table] = await Promise.all([
559
+ import("../export/pdf"),
560
+ import("../export/download"),
561
+ buildTable(exportOptions),
562
+ ]);
563
+ const blob = await createPdfBlob(table, {
564
+ title: exportOptions.title ?? fileName(exportOptions),
565
+ orientation: exportOptions.orientation ?? "landscape",
566
+ paperSize: exportOptions.paperSize ?? "A4",
567
+ margin: exportOptions.margin,
568
+ fontSize: exportOptions.fontSize,
569
+ header: exportOptions.header,
570
+ footer: exportOptions.footer,
571
+ theme: exportOptions.theme,
572
+ });
573
+ downloadBlob(blob, `${fileName(exportOptions)}.pdf`);
574
+ },
575
+ async print(exportOptions = {}) {
554
576
  const [{ printTable }, table] = await Promise.all([
555
577
  import("../export/pdf"),
556
578
  buildTable(exportOptions),
@@ -225,10 +225,46 @@ export interface ExportOptions<T> {
225
225
  rows?: readonly T[];
226
226
  }
227
227
 
228
- export interface PdfExportOptions<T> extends ExportOptions<T> {
228
+ export type PdfPaperSize = "A3" | "A4" | "A5" | "letter" | "legal";
229
+
230
+ /**
231
+ * A repeating page band. Each slot takes plain text with `{title}`, `{page}`,
232
+ * `{pages}`, `{date}` and `{time}` substituted as the page is drawn.
233
+ */
234
+ export interface PdfBand {
235
+ left?: string;
236
+ center?: string;
237
+ right?: string;
238
+ }
239
+
240
+ /** Colors for the generated PDF, as `#rgb` or `#rrggbb`. */
241
+ export interface PdfTheme {
242
+ text: string;
243
+ /** Header and footer bands. */
244
+ muted: string;
245
+ border: string;
246
+ headerBg: string;
247
+ headerText: string;
248
+ /** Every other body row, or null for no striping. */
249
+ stripe: string | null;
250
+ }
251
+
252
+ export interface PrintExportOptions<T> extends ExportOptions<T> {
229
253
  title?: string;
230
254
  orientation?: "portrait" | "landscape";
231
- paperSize?: "A3" | "A4" | "A5" | "letter" | "legal";
255
+ paperSize?: PdfPaperSize;
256
+ }
257
+
258
+ export interface PdfExportOptions<T> extends PrintExportOptions<T> {
259
+ /** Page margin in points. 72 points to the inch; defaults to 36. */
260
+ margin?: number;
261
+ /** Body text size in points; defaults to 9. */
262
+ fontSize?: number;
263
+ /** Top of every page. Defaults to the title on the left; null removes it. */
264
+ header?: PdfBand | null;
265
+ /** Bottom of every page. Defaults to date and page numbers; null removes it. */
266
+ footer?: PdfBand | null;
267
+ theme?: Partial<PdfTheme>;
232
268
  }
233
269
 
234
270
  export interface GridOptions<T> {
@@ -0,0 +1,139 @@
1
+ /*
2
+ * Metrics for the two standard-14 fonts the PDF exporter uses.
3
+ *
4
+ * Helvetica and Helvetica-Bold are built into every conforming PDF viewer, so
5
+ * nothing is embedded and the export stays dependency-free. The trade is
6
+ * WinAnsi (CP1252) coverage — Latin-1 plus a few typographic characters. Text
7
+ * outside it is replaced with "?" rather than silently dropped, so a wrong
8
+ * glyph is visible instead of a missing column.
9
+ *
10
+ * Widths are advance widths in 1/1000 em, indexed from character code 32. A
11
+ * zero means the code is unused in WinAnsi; `encode` never produces one.
12
+ */
13
+
14
+ // prettier-ignore
15
+ const HELVETICA = [
16
+ 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
17
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
18
+ 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
19
+ 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
20
+ 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
21
+ 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0,
22
+ 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
23
+ 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667,
24
+ 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333,
25
+ 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611,
26
+ 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
27
+ 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
28
+ 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278,
29
+ 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500,
30
+ ];
31
+
32
+ // prettier-ignore
33
+ const HELVETICA_BOLD = [
34
+ 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278,
35
+ 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
36
+ 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778,
37
+ 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
38
+ 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611,
39
+ 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0,
40
+ 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0,
41
+ 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667,
42
+ 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333,
43
+ 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611,
44
+ 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278,
45
+ 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611,
46
+ 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278,
47
+ 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556,
48
+ ];
49
+
50
+ export type PdfFont = "regular" | "bold";
51
+
52
+ /** Code points WinAnsi keeps in 0x80-0x9F, where Latin-1 has control codes. */
53
+ const HIGH = new Map<number, number>([
54
+ [0x20ac, 0x80], [0x201a, 0x82], [0x0192, 0x83], [0x201e, 0x84],
55
+ [0x2026, 0x85], [0x2020, 0x86], [0x2021, 0x87], [0x02c6, 0x88],
56
+ [0x2030, 0x89], [0x0160, 0x8a], [0x2039, 0x8b], [0x0152, 0x8c],
57
+ [0x017d, 0x8e], [0x2018, 0x91], [0x2019, 0x92], [0x201c, 0x93],
58
+ [0x201d, 0x94], [0x2022, 0x95], [0x2013, 0x96], [0x2014, 0x97],
59
+ [0x02dc, 0x98], [0x2122, 0x99], [0x0161, 0x9a], [0x203a, 0x9b],
60
+ [0x0153, 0x9c], [0x017e, 0x9e], [0x0178, 0x9f],
61
+ ]);
62
+
63
+ const QUESTION = 0x3f;
64
+ const ELLIPSIS = 0x85;
65
+ const SPACE = 0x20;
66
+
67
+ /**
68
+ * Encodes text as WinAnsi bytes. Characters the encoding cannot carry become
69
+ * "?", and control characters become spaces so a stray tab or newline in a cell
70
+ * cannot break the line layout.
71
+ */
72
+ export function encode(text: string): number[] {
73
+ const bytes: number[] = [];
74
+
75
+ for (const char of text) {
76
+ const code = char.codePointAt(0) ?? QUESTION;
77
+
78
+ if (code === 0x09 || code === 0x0a || code === 0x0d) bytes.push(SPACE);
79
+ else if (code >= 0x20 && code <= 0x7e) bytes.push(code);
80
+ else if (code >= 0xa0 && code <= 0xff) bytes.push(code);
81
+ else bytes.push(HIGH.get(code) ?? QUESTION);
82
+ }
83
+
84
+ return bytes;
85
+ }
86
+
87
+ /** Width of already-encoded bytes, in points, at `size`. */
88
+ export function widthOf(bytes: readonly number[], font: PdfFont, size: number): number {
89
+ const widths = font === "bold" ? HELVETICA_BOLD : HELVETICA;
90
+ let total = 0;
91
+
92
+ for (const byte of bytes) total += widths[byte - 32] ?? 0;
93
+
94
+ return (total * size) / 1000;
95
+ }
96
+
97
+ /**
98
+ * Trims bytes to `max` points, ending in an ellipsis when anything was cut.
99
+ * Returns an empty run when not even the ellipsis fits.
100
+ */
101
+ export function fit(
102
+ bytes: readonly number[],
103
+ font: PdfFont,
104
+ size: number,
105
+ max: number,
106
+ ): number[] {
107
+ if (widthOf(bytes, font, size) <= max) return [...bytes];
108
+
109
+ const room = max - widthOf([ELLIPSIS], font, size);
110
+ if (room < 0) return [];
111
+
112
+ const widths = font === "bold" ? HELVETICA_BOLD : HELVETICA;
113
+ let used = 0;
114
+ let end = 0;
115
+
116
+ while (end < bytes.length) {
117
+ const next = used + ((widths[bytes[end] - 32] ?? 0) * size) / 1000;
118
+ if (next > room) break;
119
+ used = next;
120
+ end++;
121
+ }
122
+
123
+ return [...bytes.slice(0, end), ELLIPSIS];
124
+ }
125
+
126
+ /**
127
+ * A PDF literal string body, without the surrounding parentheses. Only the
128
+ * three delimiters need escaping; every other byte is legal as-is.
129
+ */
130
+ export function literal(bytes: readonly number[]): string {
131
+ let out = "";
132
+
133
+ for (const byte of bytes) {
134
+ if (byte === 0x28 || byte === 0x29 || byte === 0x5c) out += "\\";
135
+ out += String.fromCharCode(byte);
136
+ }
137
+
138
+ return out;
139
+ }
@@ -0,0 +1,113 @@
1
+ /*
2
+ * Minimal PDF 1.7 writer: a header, numbered objects, a cross-reference table
3
+ * of byte offsets and a trailer. Enough for a paginated table, and nothing
4
+ * more.
5
+ *
6
+ * Everything that is not stream data is built as a string whose character
7
+ * codes are all <= 255, so one character is one byte and offsets stay exact.
8
+ * Using UTF-8 here would silently shift every offset past the first accented
9
+ * character and produce a file that some viewers repair and others reject.
10
+ */
11
+
12
+ /**
13
+ * A body such as `<</Type/Catalog …>>`, or a stream. A stream's `/Length` is
14
+ * measured by the writer; `extra` carries anything else the dictionary needs,
15
+ * such as `/Filter/FlateDecode`.
16
+ */
17
+ export type PdfObject = string | { extra?: string; stream: string | Uint8Array };
18
+
19
+ const HEADER = "%PDF-1.7\n%âãÏÓ\n";
20
+
21
+ function latin1(text: string): Uint8Array {
22
+ const bytes = new Uint8Array(text.length);
23
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff;
24
+ return bytes;
25
+ }
26
+
27
+ /**
28
+ * Deflates with the platform's own compressor, which emits exactly the zlib
29
+ * wrapper `/FlateDecode` expects. Returns null where CompressionStream is
30
+ * missing, and the caller writes the stream uncompressed instead.
31
+ */
32
+ export async function deflate(data: Uint8Array): Promise<Uint8Array | null> {
33
+ if (typeof CompressionStream === "undefined") return null;
34
+
35
+ const stream = new Blob([data as BlobPart]).stream().pipeThrough(new CompressionStream("deflate"));
36
+ return new Uint8Array(await new Response(stream).arrayBuffer());
37
+ }
38
+
39
+ /**
40
+ * A PDF text string, for values like /Title that a viewer shows as chrome.
41
+ *
42
+ * These are NOT the page's WinAnsi strings: a bare literal is read as
43
+ * PDFDocEncoding, which disagrees with WinAnsi across 0x80-0x9F — an em dash
44
+ * would arrive as "S-caron". UTF-16BE behind a byte-order mark is unambiguous.
45
+ */
46
+ export function textString(value: string): string {
47
+ let hex = "FEFF";
48
+ for (let i = 0; i < value.length; i++) {
49
+ hex += value.charCodeAt(i).toString(16).padStart(4, "0").toUpperCase();
50
+ }
51
+ return `<${hex}>`;
52
+ }
53
+
54
+ function pad(value: number, length: number): string {
55
+ return String(value).padStart(length, "0");
56
+ }
57
+
58
+ /** A PDF date string, as `/CreationDate` wants it. */
59
+ export function pdfDate(at: Date): string {
60
+ return (
61
+ `D:${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1, 2)}${pad(at.getUTCDate(), 2)}` +
62
+ `${pad(at.getUTCHours(), 2)}${pad(at.getUTCMinutes(), 2)}${pad(at.getUTCSeconds(), 2)}` +
63
+ `+00'00'`
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Assembles the file. Objects are numbered by position, so `objects[0]` is
69
+ * object 1 — the same numbers the bodies reference.
70
+ */
71
+ export function buildPdf(
72
+ objects: readonly PdfObject[],
73
+ trailer: { root: number; info: number },
74
+ ): Blob {
75
+ const parts: Uint8Array[] = [latin1(HEADER)];
76
+ const offsets: number[] = [];
77
+ let offset = HEADER.length;
78
+
79
+ const push = (chunk: Uint8Array) => {
80
+ parts.push(chunk);
81
+ offset += chunk.length;
82
+ };
83
+
84
+ objects.forEach((object, index) => {
85
+ offsets.push(offset);
86
+ push(latin1(`${index + 1} 0 obj\n`));
87
+
88
+ if (typeof object === "string") {
89
+ push(latin1(`${object}\n`));
90
+ } else {
91
+ const data = typeof object.stream === "string" ? latin1(object.stream) : object.stream;
92
+ push(latin1(`<</Length ${data.length}${object.extra ?? ""}>>\nstream\n`));
93
+ push(data);
94
+ push(latin1("\nendstream\n"));
95
+ }
96
+
97
+ push(latin1("endobj\n"));
98
+ });
99
+
100
+ const start = offset;
101
+ // Entries are exactly 20 bytes each; the table is unusable otherwise.
102
+ let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
103
+ for (const at of offsets) xref += `${pad(at, 10)} 00000 n \n`;
104
+
105
+ push(
106
+ latin1(
107
+ `${xref}trailer\n<</Size ${objects.length + 1}/Root ${trailer.root} 0 R` +
108
+ `/Info ${trailer.info} 0 R>>\nstartxref\n${start}\n%%EOF\n`,
109
+ ),
110
+ );
111
+
112
+ return new Blob(parts as BlobPart[], { type: "application/pdf" });
113
+ }
@@ -1,9 +1,326 @@
1
+ import type { PdfBand, PdfPaperSize, PdfTheme } from "../core/types";
2
+ import { encode, fit, literal, widthOf, type PdfFont } from "./pdf-font";
3
+ import { buildPdf, deflate, pdfDate, textString, type PdfObject } from "./pdf-writer";
1
4
  import type { ExportTable } from "./table";
2
5
 
3
6
  export interface PrintOptions {
4
7
  title: string;
5
8
  orientation: "portrait" | "landscape";
6
- paperSize: "A3" | "A4" | "A5" | "letter" | "legal";
9
+ paperSize: PdfPaperSize;
10
+ }
11
+
12
+ /** Fully resolved, as the layout needs it. */
13
+ export interface PdfOptions extends PrintOptions {
14
+ /** Page margin in points. 72 points to the inch. */
15
+ margin: number;
16
+ /** Body text size in points; the header row and bands follow from it. */
17
+ fontSize: number;
18
+ header: PdfBand | null;
19
+ footer: PdfBand | null;
20
+ theme: PdfTheme;
21
+ }
22
+
23
+ /** What callers pass. Everything but the print options has a default. */
24
+ export interface CreatePdfOptions extends PrintOptions {
25
+ margin?: number;
26
+ fontSize?: number;
27
+ header?: PdfBand | null;
28
+ footer?: PdfBand | null;
29
+ theme?: Partial<PdfTheme>;
30
+ }
31
+
32
+ /** Portrait dimensions in points. Landscape swaps them. */
33
+ const PAPER: Record<PdfPaperSize, readonly [number, number]> = {
34
+ A3: [841.89, 1190.55],
35
+ A4: [595.28, 841.89],
36
+ A5: [419.53, 595.28],
37
+ letter: [612, 792],
38
+ legal: [612, 1008],
39
+ };
40
+
41
+ export const defaultPdfTheme: PdfTheme = {
42
+ text: "#18181b",
43
+ muted: "#71717a",
44
+ border: "#d4d4d8",
45
+ headerBg: "#f4f4f5",
46
+ headerText: "#18181b",
47
+ stripe: "#fafafa",
48
+ };
49
+
50
+ /** The grid measures columns in CSS pixels; PDF works in points. */
51
+ const PX_TO_PT = 0.75;
52
+ const CELL_PAD = 4;
53
+
54
+ function hex(color: string, stroke: boolean): string {
55
+ const value = color.replace("#", "");
56
+ const full =
57
+ value.length === 3
58
+ ? value
59
+ .split("")
60
+ .map((c) => c + c)
61
+ .join("")
62
+ : value;
63
+
64
+ const channel = (at: number) => (parseInt(full.slice(at, at + 2), 16) / 255).toFixed(3);
65
+
66
+ return `${channel(0)} ${channel(2)} ${channel(4)} ${stroke ? "RG" : "rg"}`;
67
+ }
68
+
69
+ function round(value: number): string {
70
+ return value.toFixed(2);
71
+ }
72
+
73
+ /**
74
+ * Fills the tokens a header or footer may use. `{pages}` is known before any
75
+ * page is drawn because every body row is the same height.
76
+ */
77
+ function resolveBand(
78
+ band: PdfBand | null,
79
+ context: { title: string; page: number; pages: number; at: Date },
80
+ ): { left: string; center: string; right: string } | null {
81
+ if (!band) return null;
82
+
83
+ const swap = (text: string | undefined) =>
84
+ (text ?? "")
85
+ .replace(/\{title\}/g, context.title)
86
+ .replace(/\{page\}/g, String(context.page))
87
+ .replace(/\{pages\}/g, String(context.pages))
88
+ .replace(/\{date\}/g, context.at.toLocaleDateString())
89
+ .replace(/\{time\}/g, context.at.toLocaleTimeString());
90
+
91
+ return { left: swap(band.left), center: swap(band.center), right: swap(band.right) };
92
+ }
93
+
94
+ interface TextRun {
95
+ text: string;
96
+ x: number;
97
+ y: number;
98
+ font: PdfFont;
99
+ size: number;
100
+ align: "start" | "end";
101
+ max: number;
102
+ }
103
+
104
+ function drawText(runs: readonly TextRun[]): string {
105
+ let out = "";
106
+
107
+ for (const run of runs) {
108
+ if (!run.text) continue;
109
+
110
+ const bytes = fit(encode(run.text), run.font, run.size, run.max);
111
+ if (bytes.length === 0) continue;
112
+
113
+ // Measure what is actually drawn, not the input: `fit` may have trimmed it.
114
+ const x =
115
+ run.align === "end" ? run.x - widthOf(bytes, run.font, run.size) : run.x;
116
+ const clipped = literal(bytes);
117
+
118
+ out += `BT /${run.font === "bold" ? "F2" : "F1"} ${run.size} Tf `;
119
+ out += `${round(x)} ${round(run.y)} Td (${clipped}) Tj ET\n`;
120
+ }
121
+
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Lays the table out across pages and returns one content stream per page.
127
+ *
128
+ * Rows are a single line each and clipped with an ellipsis, so every row is the
129
+ * same height and page breaks never land inside one.
130
+ */
131
+ export function renderPages(table: ExportTable, options: PdfOptions): string[] {
132
+ const [shortSide, longSide] = PAPER[options.paperSize];
133
+ const [pageWidth, pageHeight] =
134
+ options.orientation === "landscape" ? [longSide, shortSide] : [shortSide, longSide];
135
+
136
+ const { margin, fontSize, theme } = options;
137
+ const bandHeight = fontSize * 1.6;
138
+ const rowHeight = fontSize * 1.5 + 4;
139
+
140
+ const left = margin;
141
+ const right = pageWidth - margin;
142
+ const available = right - left;
143
+
144
+ const top = pageHeight - margin - (options.header ? bandHeight : 0);
145
+ const bottom = margin + (options.footer ? bandHeight : 0);
146
+
147
+ // Match the grid's proportions, scaled to fill the page width.
148
+ const raw = table.columns.map((column) => Math.max(column.width * PX_TO_PT, 1));
149
+ const total = raw.reduce((sum, width) => sum + width, 0);
150
+ const widths = raw.map((width) => (width / total) * available);
151
+
152
+ const edges = [left];
153
+ for (const width of widths) edges.push(edges[edges.length - 1] + width);
154
+
155
+ const numeric = table.columns.map((column) => column.type === "number");
156
+ const bodyRows = Math.max(1, Math.floor((top - bottom - rowHeight) / rowHeight));
157
+ const pages = Math.max(1, Math.ceil(table.text.length / bodyRows));
158
+ const at = new Date();
159
+
160
+ const streams: string[] = [];
161
+
162
+ for (let page = 0; page < pages; page++) {
163
+ const rows = table.text.slice(page * bodyRows, (page + 1) * bodyRows);
164
+ const tableBottom = top - rowHeight - rows.length * rowHeight;
165
+
166
+ let fills = "";
167
+ let lines = "";
168
+ const runs: TextRun[] = [];
169
+
170
+ // Header row.
171
+ fills += `${hex(theme.headerBg, false)} ${round(left)} ${round(top - rowHeight)} `;
172
+ fills += `${round(available)} ${round(rowHeight)} re f\n`;
173
+
174
+ // Zebra striping, drawn before the rules so the rules stay crisp.
175
+ if (theme.stripe) {
176
+ fills += `${hex(theme.stripe, false)}\n`;
177
+ rows.forEach((_, index) => {
178
+ if (index % 2 === 0) return;
179
+ const y = top - rowHeight - (index + 1) * rowHeight;
180
+ fills += `${round(left)} ${round(y)} ${round(available)} ${round(rowHeight)} re f\n`;
181
+ });
182
+ }
183
+
184
+ lines += `${hex(theme.border, true)} 0.5 w\n`;
185
+ for (let index = 0; index <= rows.length + 1; index++) {
186
+ const y = top - index * rowHeight;
187
+ lines += `${round(left)} ${round(y)} m ${round(right)} ${round(y)} l\n`;
188
+ }
189
+ for (const edge of edges) {
190
+ lines += `${round(edge)} ${round(top)} m ${round(edge)} ${round(tableBottom)} l\n`;
191
+ }
192
+ lines += "S\n";
193
+
194
+ const baseline = (rowTop: number) =>
195
+ rowTop - rowHeight + (rowHeight - fontSize * 0.72) / 2;
196
+
197
+ table.columns.forEach((column, index) => {
198
+ runs.push({
199
+ text: column.header,
200
+ x: edges[index] + CELL_PAD,
201
+ y: baseline(top),
202
+ font: "bold",
203
+ size: fontSize,
204
+ align: "start",
205
+ max: widths[index] - CELL_PAD * 2,
206
+ });
207
+ });
208
+
209
+ rows.forEach((row, rowIndex) => {
210
+ const rowTop = top - rowHeight - rowIndex * rowHeight;
211
+
212
+ row.forEach((text, index) => {
213
+ const alignEnd = numeric[index];
214
+ runs.push({
215
+ text,
216
+ x: alignEnd ? edges[index + 1] - CELL_PAD : edges[index] + CELL_PAD,
217
+ y: baseline(rowTop),
218
+ font: "regular",
219
+ size: fontSize,
220
+ align: alignEnd ? "end" : "start",
221
+ max: widths[index] - CELL_PAD * 2,
222
+ });
223
+ });
224
+ });
225
+
226
+ const context = { title: options.title, page: page + 1, pages, at };
227
+ const bands: TextRun[] = [];
228
+
229
+ const band = (
230
+ resolved: { left: string; center: string; right: string } | null,
231
+ y: number,
232
+ ) => {
233
+ if (!resolved) return;
234
+
235
+ const third = available / 3;
236
+ bands.push(
237
+ { text: resolved.left, x: left, y, font: "regular", size: fontSize * 0.9, align: "start", max: third },
238
+ { text: resolved.center, x: left + available / 2 - third / 2, y, font: "regular", size: fontSize * 0.9, align: "start", max: third },
239
+ { text: resolved.right, x: right, y, font: "regular", size: fontSize * 0.9, align: "end", max: third },
240
+ );
241
+ };
242
+
243
+ band(resolveBand(options.header, context), pageHeight - margin - fontSize);
244
+ band(resolveBand(options.footer, context), margin);
245
+
246
+ streams.push(
247
+ `q\n${fills}${lines}${hex(theme.headerText, false)}\n` +
248
+ drawText(runs.slice(0, table.columns.length)) +
249
+ `${hex(theme.text, false)}\n` +
250
+ drawText(runs.slice(table.columns.length)) +
251
+ `${hex(theme.muted, false)}\n` +
252
+ drawText(bands) +
253
+ "Q\n",
254
+ );
255
+ }
256
+
257
+ return streams;
258
+ }
259
+
260
+ const DEFAULTS = {
261
+ margin: 36,
262
+ fontSize: 9,
263
+ header: { left: "{title}" },
264
+ footer: { left: "{date}", right: "Page {page} of {pages}" },
265
+ } as const;
266
+
267
+ /**
268
+ * Renders the table as a real PDF file — no print dialog, no browser
269
+ * pagination, the same output in every engine.
270
+ */
271
+ export async function createPdfBlob(
272
+ table: ExportTable,
273
+ options: CreatePdfOptions,
274
+ ): Promise<Blob> {
275
+ const resolved: PdfOptions = {
276
+ ...options,
277
+ margin: options.margin ?? DEFAULTS.margin,
278
+ fontSize: options.fontSize ?? DEFAULTS.fontSize,
279
+ header: options.header === undefined ? DEFAULTS.header : options.header,
280
+ footer: options.footer === undefined ? DEFAULTS.footer : options.footer,
281
+ theme: { ...defaultPdfTheme, ...options.theme },
282
+ };
283
+
284
+ const [shortSide, longSide] = PAPER[resolved.paperSize];
285
+ const [width, height] =
286
+ resolved.orientation === "landscape" ? [longSide, shortSide] : [shortSide, longSide];
287
+
288
+ const streams = renderPages(table, resolved);
289
+
290
+ // 1 catalog, 2 pages, 3 regular font, 4 bold font, 5 info, then a page object
291
+ // and a content stream for each page.
292
+ const pageId = (index: number) => 6 + index * 2;
293
+ const kids = streams.map((_, index) => `${pageId(index)} 0 R`).join(" ");
294
+
295
+ const objects: PdfObject[] = [
296
+ "<</Type/Catalog/Pages 2 0 R>>",
297
+ `<</Type/Pages/Count ${streams.length}/Kids[${kids}]>>`,
298
+ "<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>",
299
+ "<</Type/Font/Subtype/Type1/BaseFont/Helvetica-Bold/Encoding/WinAnsiEncoding>>",
300
+ `<</Title ${textString(resolved.title)}/Producer ${textString("GramproKit DataGrid")}` +
301
+ `/CreationDate (${pdfDate(new Date())})>>`,
302
+ ];
303
+
304
+ for (const [index, stream] of streams.entries()) {
305
+ const data = await deflate(toBytes(stream));
306
+
307
+ objects.push(
308
+ `<</Type/Page/Parent 2 0 R/MediaBox[0 0 ${round(width)} ${round(height)}]` +
309
+ `/Resources<</Font<</F1 3 0 R/F2 4 0 R>>>>/Contents ${pageId(index) + 1} 0 R>>`,
310
+ data
311
+ ? { extra: "/Filter/FlateDecode", stream: data }
312
+ : { stream },
313
+ );
314
+ }
315
+
316
+ return buildPdf(objects, { root: 1, info: 5 });
317
+ }
318
+
319
+ /** Content streams are latin1 by construction, so one character is one byte. */
320
+ function toBytes(text: string): Uint8Array {
321
+ const bytes = new Uint8Array(text.length);
322
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff;
323
+ return bytes;
7
324
  }
8
325
 
9
326
  const escapeHtml = (text: string) =>
@@ -37,8 +354,9 @@ td.num { text-align: end; font-variant-numeric: tabular-nums; }
37
354
  }
38
355
 
39
356
  /**
40
- * Opens the browser's print dialog with the table laid out for paper, where
41
- * the user picks "Save as PDF". No PDF library is bundled.
357
+ * Opens the browser's print dialog with the table laid out for paper. Kept
358
+ * alongside `createPdfBlob` for printing to paper, and for text the standard
359
+ * fonts cannot encode.
42
360
  */
43
361
  export function printTable(table: ExportTable, options: PrintOptions): void {
44
362
  const iframe = document.createElement("iframe");
@@ -127,6 +127,7 @@ export function Toolbar<T>({ options, globalFilter, filters, density, columns, c
127
127
  [locale.exportCsv, () => api.exportCsv()],
128
128
  [locale.exportExcel, () => api.exportExcel()],
129
129
  [locale.exportPdf, () => api.exportPdf()],
130
+ [locale.print, () => api.print()],
130
131
  ] as const
131
132
  ).map(([label, action]) => (
132
133
  <button
@@ -10,6 +10,7 @@ export interface LocaleText {
10
10
  exportCsv: string;
11
11
  exportExcel: string;
12
12
  exportPdf: string;
13
+ print: string;
13
14
  density: string;
14
15
  densityCompact: string;
15
16
  densityStandard: string;
@@ -64,7 +65,8 @@ export const defaultLocaleText: LocaleText = {
64
65
  export: "Export",
65
66
  exportCsv: "CSV",
66
67
  exportExcel: "Excel (.xlsx)",
67
- exportPdf: "PDF (print)",
68
+ exportPdf: "PDF (.pdf)",
69
+ print: "Print…",
68
70
  density: "Density",
69
71
  densityCompact: "Compact",
70
72
  densityStandard: "Standard",