gbs-add-block 2.0.0 → 2.0.2
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/.gbs/skills/gbs-components/SKILL.md +132 -0
- package/.gbs/skills/gbs-components/references/accessibility.md +102 -0
- package/.gbs/skills/gbs-components/references/data-display.md +185 -0
- package/.gbs/skills/gbs-components/references/data-grid.md +117 -0
- package/.gbs/skills/gbs-components/references/forms.md +202 -0
- package/.gbs/skills/gbs-components/references/install.md +190 -0
- package/.gbs/skills/gbs-components/references/overlays.md +209 -0
- package/.gbs/skills/gbs-components/references/pickers.md +83 -0
- package/.gbs/skills/gbs-components/references/styling.md +180 -0
- package/.gbs/skills/gbs-components/references/toaster.md +58 -0
- package/README.md +42 -19
- package/index.cjs +1019 -665
- package/package.json +3 -2
- package/source/beta-components/data-grid/README.md +4 -3
- package/source/beta-components/data-grid/__tests__/pdf.test.ts +209 -0
- package/source/beta-components/data-grid/core/grid.ts +22 -0
- package/source/beta-components/data-grid/core/types.ts +38 -2
- package/source/beta-components/data-grid/export/pdf-font.ts +139 -0
- package/source/beta-components/data-grid/export/pdf-writer.ts +113 -0
- package/source/beta-components/data-grid/export/pdf.ts +321 -3
- package/source/beta-components/data-grid/react/Toolbar.tsx +1 -0
- package/source/beta-components/data-grid/react/locale.ts +3 -1
|
@@ -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:
|
|
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
|
|
41
|
-
*
|
|
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 (
|
|
68
|
+
exportPdf: "PDF (.pdf)",
|
|
69
|
+
print: "Print…",
|
|
68
70
|
density: "Density",
|
|
69
71
|
densityCompact: "Compact",
|
|
70
72
|
densityStandard: "Standard",
|