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.
@@ -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",