angular-my-report 0.0.0-stage → 0.1.0

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.
Files changed (46) hide show
  1. package/README.md +99 -2
  2. package/ng-package.json +7 -0
  3. package/package.json +23 -3
  4. package/src/lib/engine/barcode.ts +118 -0
  5. package/src/lib/engine/chart.ts +85 -0
  6. package/src/lib/engine/chartLayout.ts +265 -0
  7. package/src/lib/engine/crosstab.ts +96 -0
  8. package/src/lib/engine/dataSources.ts +204 -0
  9. package/src/lib/engine/expressions.ts +734 -0
  10. package/src/lib/engine/measure.ts +62 -0
  11. package/src/lib/engine/parameters.ts +96 -0
  12. package/src/lib/engine/qr.ts +26 -0
  13. package/src/lib/engine/renderer.ts +324 -0
  14. package/src/lib/engine/table.ts +58 -0
  15. package/src/lib/model/defaults.ts +249 -0
  16. package/src/lib/model/factories.ts +324 -0
  17. package/src/lib/model/types.ts +281 -0
  18. package/src/lib/ui/css.ts +12 -0
  19. package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
  20. package/src/lib/ui/designer/canvas.component.ts +281 -0
  21. package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
  22. package/src/lib/ui/designer/element-box.component.ts +138 -0
  23. package/src/lib/ui/designer/graphics.components.ts +140 -0
  24. package/src/lib/ui/designer/left-panel.component.ts +189 -0
  25. package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
  26. package/src/lib/ui/designer/table-view.component.ts +120 -0
  27. package/src/lib/ui/designer.component.ts +296 -0
  28. package/src/lib/ui/designer.store.ts +230 -0
  29. package/src/lib/ui/inputs.ts +34 -0
  30. package/src/lib/ui/myreport.css +267 -0
  31. package/src/lib/ui/properties/band-props.component.ts +86 -0
  32. package/src/lib/ui/properties/element-props.component.ts +326 -0
  33. package/src/lib/ui/properties/expr-input.component.ts +191 -0
  34. package/src/lib/ui/properties/graphics-props.component.ts +260 -0
  35. package/src/lib/ui/properties/image-picker.component.ts +62 -0
  36. package/src/lib/ui/properties/properties-panel.component.ts +37 -0
  37. package/src/lib/ui/properties/report-extras.component.ts +198 -0
  38. package/src/lib/ui/properties/report-props.component.ts +211 -0
  39. package/src/lib/ui/properties/table-props.component.ts +197 -0
  40. package/src/lib/ui/viewer/page-view.component.ts +73 -0
  41. package/src/lib/ui/viewer/render.ts +161 -0
  42. package/src/lib/ui/viewer/viewer.component.ts +555 -0
  43. package/src/lib/ui/viewer.component.ts +107 -0
  44. package/src/public-api.ts +12 -0
  45. package/tsconfig.lib.json +12 -0
  46. package/tsconfig.lib.prod.json +5 -0
@@ -0,0 +1,62 @@
1
+ import type { ReportElement } from '../model/types';
2
+
3
+ /** Must match the line-height used by ElementBox when rendering text. */
4
+ export const LINE_HEIGHT = 1.2;
5
+
6
+ let ctx2d: CanvasRenderingContext2D | null | undefined;
7
+ function context(): CanvasRenderingContext2D | null {
8
+ if (ctx2d === undefined) {
9
+ try {
10
+ ctx2d = typeof document !== 'undefined' ? document.createElement('canvas').getContext('2d') : null;
11
+ } catch {
12
+ ctx2d = null;
13
+ }
14
+ }
15
+ return ctx2d;
16
+ }
17
+
18
+ /** Whether the element grows vertically to fit its text (default: on for text and fields). */
19
+ export const canGrow = (el: ReportElement) => (el.kind === 'text' || el.kind === 'field') && el.canGrow !== false;
20
+
21
+ /** Height (px) the element needs to show `text` fully at its current width, wrapping like the renderer. */
22
+ export function measureTextHeight(text: string, el: ReportElement): number {
23
+ const s = el.style;
24
+ const inset = s.padding * 2 + (s.borderWidth > 0 ? s.borderWidth * 2 : 0);
25
+ const width = Math.max(1, el.w - inset);
26
+ const c = context();
27
+ let measure: (t: string) => number;
28
+ if (c) {
29
+ c.font = `${s.italic ? 'italic ' : ''}${s.bold ? 700 : 400} ${s.fontSize}px ${s.fontFamily}`;
30
+ measure = (t) => c.measureText(t).width;
31
+ } else {
32
+ measure = (t) => t.length * s.fontSize * 0.55; // rough fallback outside the browser
33
+ }
34
+
35
+ let lines = 0;
36
+ for (const para of text.split('\n')) lines += countLines(para, width, measure);
37
+ // +1px slack so rounding differences between canvas and DOM never clip the last line.
38
+ return Math.ceil(lines * s.fontSize * LINE_HEIGHT + inset + 1);
39
+ }
40
+
41
+ function countLines(para: string, width: number, measure: (t: string) => number): number {
42
+ if (!para) return 1;
43
+ let n = 1;
44
+ let line = '';
45
+ for (const word of para.split(' ')) {
46
+ const candidate = line ? `${line} ${word}` : word;
47
+ if (measure(candidate) <= width) {
48
+ line = candidate;
49
+ continue;
50
+ }
51
+ if (line) n++;
52
+ const w = measure(word);
53
+ if (w > width) {
54
+ // A single word wider than the box breaks across lines (word-break: break-word).
55
+ n += Math.ceil(w / width) - 1;
56
+ line = '';
57
+ } else {
58
+ line = word;
59
+ }
60
+ }
61
+ return n;
62
+ }
@@ -0,0 +1,96 @@
1
+ import type { DataSourceDef, ReportDefinition, ReportParameter } from '../model/types';
2
+ import { evaluate, formatValue, truthy, type EvalContext, type Row } from './expressions';
3
+
4
+ export type ParamValues = Record<string, unknown>;
5
+
6
+ /** Converts a raw (text) value to the parameter's type. */
7
+ export function coerceParam(p: ReportParameter, raw: unknown): unknown {
8
+ if (raw == null || raw === '') return p.type === 'boolean' ? false : null;
9
+ switch (p.type) {
10
+ case 'number': {
11
+ const n = Number(raw);
12
+ return Number.isFinite(n) ? n : null;
13
+ }
14
+ case 'boolean':
15
+ return raw === true || /^(true|1|yes|on)$/i.test(String(raw));
16
+ default:
17
+ return String(raw); // dates stay ISO "yyyy-MM-dd" text, which compares correctly
18
+ }
19
+ }
20
+
21
+ /** Default values for all parameters, overridden by `overrides` (e.g. from the page URL). */
22
+ export function resolveParams(report: ReportDefinition, overrides: Record<string, unknown> = {}): ParamValues {
23
+ const out: ParamValues = {};
24
+ for (const p of report.parameters ?? []) {
25
+ if (!p.name) continue;
26
+ const key = Object.keys(overrides).find((k) => k.toLowerCase() === p.name.toLowerCase());
27
+ out[p.name] = coerceParam(p, key !== undefined ? overrides[key] : p.defaultValue);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export const paramChoices = (p: ReportParameter) =>
33
+ p.options
34
+ .split(',')
35
+ .map((s) => s.trim())
36
+ .filter(Boolean);
37
+
38
+ const paramCtx = (params: ParamValues): EvalContext => ({
39
+ row: undefined,
40
+ rows: [],
41
+ allRows: [],
42
+ pageRows: [],
43
+ rowNumber: 0,
44
+ pageNumber: 1,
45
+ totalPages: 1,
46
+ reportName: '',
47
+ params,
48
+ });
49
+
50
+ const NONE = { kind: 'none' as const, decimals: 0, currency: '' };
51
+
52
+ /** Replaces {…} expressions in a data-source string with parameter values. */
53
+ function substitute(text: string, params: ParamValues, encode: boolean): string {
54
+ return text.replace(/\{([^{}]+)\}/g, (_, ex: string) => {
55
+ let v: unknown;
56
+ try {
57
+ v = evaluate(ex, paramCtx(params));
58
+ } catch {
59
+ v = '';
60
+ }
61
+ const s = formatValue(v, NONE);
62
+ return encode ? encodeURIComponent(s) : s;
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Applies parameters to a data source: `{@Country}` in the URL (URL-encoded), OData options,
68
+ * headers and array path. Pasted JSON is left alone (it contains braces of its own).
69
+ */
70
+ export function applyParamsToDataSource(ds: DataSourceDef, params: ParamValues): DataSourceDef {
71
+ return {
72
+ ...ds,
73
+ url: substitute(ds.url, params, true),
74
+ entitySet: substitute(ds.entitySet, params, false),
75
+ select: substitute(ds.select, params, false),
76
+ filter: substitute(ds.filter, params, false),
77
+ orderby: substitute(ds.orderby, params, false),
78
+ expand: substitute(ds.expand, params, false),
79
+ headers: substitute(ds.headers, params, false),
80
+ dataPath: substitute(ds.dataPath, params, false),
81
+ };
82
+ }
83
+
84
+ /** Rows that pass the report filter expression (all rows when there is none). */
85
+ export function filterRows(report: ReportDefinition, rows: Row[], params: ParamValues): Row[] {
86
+ const f = report.filter?.trim();
87
+ if (!f) return rows;
88
+ const ctx = paramCtx(params);
89
+ return rows.filter((row) => {
90
+ try {
91
+ return truthy(evaluate(f, { ...ctx, row, rows, allRows: rows }));
92
+ } catch {
93
+ return true; // an invalid filter never hides data silently; the designer shows the error
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,26 @@
1
+ import qrcode from 'qrcode-generator';
2
+ import type { QrSpec } from '../model/types';
3
+
4
+ // Encode text as UTF-8 so non-Latin text (e.g. Arabic) scans correctly.
5
+ qrcode.stringToBytes = qrcode.stringToBytesFuncs['UTF-8'];
6
+
7
+ export interface QrResult {
8
+ /** size × size, true = dark module. */
9
+ modules: boolean[][];
10
+ size: number;
11
+ error?: string;
12
+ }
13
+
14
+ export function encodeQr(value: string, level: QrSpec['errorCorrection']): QrResult {
15
+ if (!value) return { modules: [], size: 0, error: 'Empty value' };
16
+ try {
17
+ const qr = qrcode(0, level);
18
+ qr.addData(value, 'Byte');
19
+ qr.make();
20
+ const size = qr.getModuleCount();
21
+ const modules = Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => qr.isDark(r, c)));
22
+ return { modules, size };
23
+ } catch (e) {
24
+ return { modules: [], size: 0, error: 'Value too long for a QR code' };
25
+ }
26
+ }
@@ -0,0 +1,324 @@
1
+ import type { Band, BandType, ReportDefinition, ReportElement } from '../model/types';
2
+ import { getPath, renderTemplate, type EvalContext, type Row } from './expressions';
3
+ import { canGrow, measureTextHeight } from './measure';
4
+ import { columnWidths, tableCellStyle } from './table';
5
+ import { crosstabAsTable } from './crosstab';
6
+ import { filterRows, type ParamValues } from './parameters';
7
+
8
+ type BandContext = Pick<EvalContext, 'row' | 'rows' | 'allRows' | 'rowNumber' | 'reportName' | 'params' | 'root'>;
9
+
10
+ /** The slice of a table rendered inside one band instance (tables can span pages). */
11
+ export interface PlacedTable {
12
+ rows: Row[];
13
+ rowHeights: number[];
14
+ /** Index of rows[0] within the full table (for striping and RowNumber). */
15
+ startIndex: number;
16
+ header: boolean;
17
+ headerHeight: number;
18
+ footer: boolean;
19
+ footerHeight: number;
20
+ }
21
+
22
+ export interface PlacedElement {
23
+ el: ReportElement;
24
+ top: number;
25
+ height: number;
26
+ table?: PlacedTable;
27
+ }
28
+
29
+ export interface PlacedBand {
30
+ band: Band;
31
+ top: number;
32
+ /** Actual height of this band instance (>= band.height when fields grew). */
33
+ height: number;
34
+ items: PlacedElement[];
35
+ ctx: BandContext;
36
+ }
37
+
38
+ export interface RenderedPage {
39
+ index: number;
40
+ bands: PlacedBand[];
41
+ rows: Row[];
42
+ /** Set for continuous (receipt) pages: the actual page height. */
43
+ height?: number;
44
+ }
45
+
46
+ const measureCtx = (ctx: BandContext): EvalContext => ({ ...ctx, pageRows: [], pageNumber: 1, totalPages: 1 });
47
+
48
+ /** Sizes one band instance: grows fields to fit their text and pushes elements below them down. */
49
+ function layoutBand(band: Band, ctx: BandContext, grow: boolean): { height: number; items: PlacedElement[] } {
50
+ const growth = new Map<string, number>();
51
+ if (grow) {
52
+ const evalCtx = measureCtx(ctx);
53
+ for (const el of band.elements) {
54
+ if (!canGrow(el)) continue;
55
+ const needed = measureTextHeight(renderTemplate(el.text, evalCtx, el.format), el);
56
+ if (needed > el.h) growth.set(el.id, needed - el.h);
57
+ }
58
+ // Cells of one row (same top and height, e.g. table columns in Detail) grow together,
59
+ // so every box in the row ends up as tall as its tallest cell.
60
+ const rows = new Map<string, typeof band.elements>();
61
+ for (const el of band.elements) {
62
+ if (el.kind === 'line') continue;
63
+ const key = `${el.y}|${el.h}`;
64
+ rows.set(key, [...(rows.get(key) ?? []), el]);
65
+ }
66
+ for (const row of rows.values()) {
67
+ if (row.length < 2) continue;
68
+ const max = Math.max(0, ...row.map((e) => growth.get(e.id) ?? 0));
69
+ if (max > 0) for (const e of row) growth.set(e.id, max);
70
+ }
71
+ }
72
+ if (!growth.size) return { height: band.height, items: band.elements.map((el) => ({ el, top: el.y, height: el.h })) };
73
+
74
+ const grown = band.elements.filter((g) => growth.has(g.id));
75
+ let height = band.height;
76
+ const items = band.elements.map((el) => {
77
+ // Shift by the largest growth of any growing element that sits entirely above this one.
78
+ const shift = Math.max(0, ...grown.filter((g) => g !== el && el.y >= g.y + g.h).map((g) => growth.get(g.id)!));
79
+ const item = { el, top: el.y + shift, height: el.h + (growth.get(el.id) ?? 0) };
80
+ height = Math.max(height, item.top + item.height + Math.max(0, band.height - (el.y + el.h)));
81
+ return item;
82
+ });
83
+ return { height, items };
84
+ }
85
+
86
+ /** Height of one table row: the fixed row height, or more if a cell's text needs it. */
87
+ function tableRowHeight(table: ReportElement, widths: number[], base: number, texts: string[], ctx: EvalContext, header = false): number {
88
+ if (table.canGrow === false) return base;
89
+ const cols = table.table!.columns;
90
+ let h = base;
91
+ texts.forEach((tpl, i) => {
92
+ if (!tpl) return;
93
+ const text = renderTemplate(tpl, ctx, header ? { kind: 'none', decimals: 0, currency: '' } : cols[i].format);
94
+ const probe: ReportElement = { ...table, w: widths[i], style: tableCellStyle(table, header) };
95
+ h = Math.max(h, measureTextHeight(text, probe));
96
+ });
97
+ return h;
98
+ }
99
+
100
+ function compare(a: unknown, b: unknown): number {
101
+ if (a == null && b == null) return 0;
102
+ if (a == null) return -1;
103
+ if (b == null) return 1;
104
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
105
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
106
+ }
107
+
108
+ function sortRows(r: ReportDefinition, rows: Row[]): Row[] {
109
+ const keys = [
110
+ ...r.groups.filter((g) => g.field).map((g) => ({ field: g.field, dir: g.direction })),
111
+ ...r.sort.filter((s) => s.field).map((s) => ({ field: s.field, dir: s.direction })),
112
+ ];
113
+ if (!keys.length) return rows;
114
+ return [...rows].sort((a, b) => {
115
+ for (const k of keys) {
116
+ const c = compare(getPath(a, k.field), getPath(b, k.field));
117
+ if (c) return k.dir === 'desc' ? -c : c;
118
+ }
119
+ return 0;
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Lays out bands onto pages. Text is evaluated later (needs TotalPages).
125
+ * `params` are the report parameter values (also used by the report filter).
126
+ */
127
+ export function paginate(report: ReportDefinition, inputRows: Row[], params: ParamValues = {}, root?: unknown): RenderedPage[] {
128
+ const visible = (t: BandType, gid?: string) =>
129
+ report.bands.find((b) => b.type === t && b.visible && (gid === undefined || b.groupId === gid));
130
+ const { page } = report;
131
+ const pageHeader = visible('pageHeader');
132
+ const pageFooter = visible('pageFooter');
133
+ const reportHeader = visible('reportHeader');
134
+ const detail = visible('detail');
135
+ // A receipt roll never breaks: the page simply gets as long as the content.
136
+ const bodyBottom = page.continuous ? Infinity : page.height - page.marginBottom - (pageFooter?.height ?? 0);
137
+ const rows = sortRows(report, filterRows(report, inputRows, params));
138
+ const base = { allRows: rows, reportName: report.name, params, root };
139
+
140
+ const pages: RenderedPage[] = [];
141
+ let cur!: RenderedPage;
142
+ let y = 0;
143
+ let contentStart = 0;
144
+ let rowNumber = 0;
145
+
146
+ const startPage = () => {
147
+ cur = { index: pages.length, bands: [], rows: [] };
148
+ pages.push(cur);
149
+ y = page.marginTop;
150
+ // Report Header comes first on page 1, above the Page Header.
151
+ if (pages.length === 1 && reportHeader) {
152
+ placeFlow(reportHeader, { ...base, row: rows[0], rows, rowNumber: 0 }, false);
153
+ // A table in the Report Header spilled onto later pages; those pages already have their Page Header.
154
+ if (pages.length > 1) return;
155
+ }
156
+ if (pageHeader && pageHeader.height > 0) {
157
+ const ctx = { ...base, row: undefined, rows: [], rowNumber };
158
+ cur.bands.push({ band: pageHeader, top: y, ctx, ...layoutBand(pageHeader, ctx, false) });
159
+ y += pageHeader.height;
160
+ }
161
+ contentStart = y;
162
+ };
163
+
164
+ /** Places a band; `canBreak` = may move to a new page when it doesn't fit. */
165
+ function placeFlow(band: Band, ctx: BandContext, canBreak: boolean, keepWith = 0, pageRow?: Row) {
166
+ if (band.height <= 0) return;
167
+ const table = band.elements.find((e) => (e.kind === 'table' && e.table) || (e.kind === 'crosstab' && e.crosstab));
168
+ if (table) return placeTable(band, table, ctx, canBreak);
169
+ const layout = layoutBand(band, ctx, true);
170
+ if (canBreak && y + layout.height + keepWith > bodyBottom && y > contentStart) startPage();
171
+ cur.bands.push({ band, top: y, ctx, ...layout });
172
+ y += layout.height;
173
+ if (pageRow) cur.rows.push(pageRow);
174
+ }
175
+
176
+ const place = (band: Band | undefined, ctx: BandContext, keepWith = 0, pageRow?: Row) => {
177
+ if (band) placeFlow(band, ctx, true, keepWith, pageRow);
178
+ };
179
+
180
+ /**
181
+ * A band holding a table: elements above the table go in the first slice, rows flow across
182
+ * as many pages as needed (header repeated), the footer row and elements below go in the last slice.
183
+ */
184
+ function placeTable(band: Band, source: ReportElement, ctx: BandContext, canBreak: boolean) {
185
+ const scope = band.type === 'detail' ? (ctx.row ? [ctx.row] : []) : ctx.rows;
186
+ // A cross table is laid out as a table of pivoted, pre-formatted rows.
187
+ const pivoted = source.kind === 'crosstab' ? crosstabAsTable(source, scope) : null;
188
+ const table = pivoted ? pivoted.table : source;
189
+ const t = table.table!;
190
+ // Detail bands repeat per record, so a table there shows just that record.
191
+ const data = pivoted ? pivoted.data : scope;
192
+ const widths = columnWidths(table);
193
+ const mctx = measureCtx(ctx);
194
+ const rowHeights = data.map((row, i) =>
195
+ tableRowHeight(table, widths, t.rowHeight, t.columns.map((c) => c.text), { ...mctx, row, rowNumber: i + 1 }),
196
+ );
197
+ const headerH = t.showHeader ? tableRowHeight(table, widths, t.headerHeight, t.columns.map((c) => c.header), mctx, true) : 0;
198
+ const footerH = t.showFooter
199
+ ? tableRowHeight(table, widths, t.rowHeight, t.columns.map((c) => c.footer), { ...mctx, rows: data, row: data[data.length - 1] })
200
+ : 0;
201
+
202
+ const tableBottom = table.y + table.h;
203
+ const aboveEls = band.elements.filter((e) => e.id !== table.id && e.y < tableBottom);
204
+ const belowEls = band.elements.filter((e) => e.id !== table.id && e.y >= tableBottom);
205
+ const tail = Math.max(0, band.height - tableBottom);
206
+
207
+ const firstNeed = table.y + headerH + (rowHeights[0] ?? footerH);
208
+ if (canBreak && y + firstNeed > bodyBottom && y > contentStart) startPage();
209
+
210
+ let i = 0;
211
+ let first = true;
212
+ let footerDone = !t.showFooter;
213
+ for (;;) {
214
+ const sliceTop = y;
215
+ const items: PlacedElement[] = [];
216
+ let cy = 0;
217
+ if (first) {
218
+ items.push(...aboveEls.map((el) => ({ el, top: el.y, height: el.h })));
219
+ cy = table.y;
220
+ }
221
+ const tableTop = cy;
222
+ const header = headerH > 0 && (first || t.repeatHeader);
223
+ if (header) cy += headerH;
224
+ const start = i;
225
+ while (i < data.length && (i === start || sliceTop + cy + rowHeights[i] <= bodyBottom)) cy += rowHeights[i++];
226
+
227
+ let footer = false;
228
+ if (i >= data.length && !footerDone && (i === start || sliceTop + cy + footerH <= bodyBottom)) {
229
+ footer = footerDone = true;
230
+ cy += footerH;
231
+ }
232
+ items.push({
233
+ el: table,
234
+ top: tableTop,
235
+ height: cy - tableTop,
236
+ table: { rows: data.slice(start, i), rowHeights: rowHeights.slice(start, i), startIndex: start, header, headerHeight: headerH, footer, footerHeight: footerH },
237
+ });
238
+
239
+ const complete = i >= data.length && footerDone;
240
+ let height = first ? Math.max(cy, ...aboveEls.map((e) => e.y + e.h)) : cy;
241
+ const tailHere = complete && sliceTop + Math.max(height, cy + tail) <= bodyBottom;
242
+ if (tailHere) {
243
+ items.push(...belowEls.map((el) => ({ el, top: cy + el.y - tableBottom, height: el.h })));
244
+ height = Math.max(height, cy + tail);
245
+ }
246
+ cur.bands.push({ band, top: sliceTop, height, ctx, items });
247
+ y = sliceTop + height;
248
+
249
+ if (complete) {
250
+ if (!tailHere && tail > 0) {
251
+ startPage();
252
+ cur.bands.push({ band, top: y, height: tail, ctx, items: belowEls.map((el) => ({ el, top: el.y - tableBottom, height: el.h })) });
253
+ y += tail;
254
+ }
255
+ cur.rows.push(...data);
256
+ return;
257
+ }
258
+ cur.rows.push(...data.slice(start, i));
259
+ startPage();
260
+ first = false;
261
+ }
262
+ }
263
+
264
+ const emit = (level: number, groupRows: Row[]) => {
265
+ if (level >= report.groups.length) {
266
+ for (const row of groupRows) {
267
+ rowNumber++;
268
+ place(detail, { ...base, row, rows: groupRows, rowNumber }, 0, row);
269
+ }
270
+ return;
271
+ }
272
+ const g = report.groups[level];
273
+ const parts: Row[][] = [];
274
+ let lastKey: unknown = Symbol();
275
+ for (const row of groupRows) {
276
+ const key = String(getPath(row, g.field) ?? '');
277
+ if (key !== lastKey) {
278
+ parts.push([]);
279
+ lastKey = key;
280
+ }
281
+ parts[parts.length - 1].push(row);
282
+ }
283
+ for (const part of parts) {
284
+ place(visible('groupHeader', g.id), { ...base, row: part[0], rows: part, rowNumber }, detail?.height ?? 0);
285
+ emit(level + 1, part);
286
+ place(visible('groupFooter', g.id), { ...base, row: part[part.length - 1], rows: part, rowNumber });
287
+ }
288
+ };
289
+
290
+ startPage();
291
+ emit(0, rows);
292
+ place(visible('reportFooter'), { ...base, row: rows[rows.length - 1], rows, rowNumber });
293
+
294
+ const footerH = pageFooter && pageFooter.height > 0 ? pageFooter.height : 0;
295
+ if (page.continuous) {
296
+ // Footer right after the content; the page ends at the bottom margin.
297
+ if (footerH) {
298
+ const ctx = { ...base, row: undefined, rows: [], rowNumber };
299
+ cur.bands.push({ band: pageFooter!, top: y, ctx, ...layoutBand(pageFooter!, ctx, false) });
300
+ }
301
+ cur.height = Math.ceil(y + footerH + page.marginBottom);
302
+ return pages;
303
+ }
304
+ if (footerH) {
305
+ const ctx = { ...base, row: undefined, rows: [], rowNumber };
306
+ const layout = layoutBand(pageFooter!, ctx, false);
307
+ for (const p of pages) {
308
+ p.bands.push({ band: pageFooter!, top: page.height - page.marginBottom - footerH, ctx, ...layout });
309
+ }
310
+ }
311
+ return pages;
312
+ }
313
+
314
+ export function contextFor(placed: PlacedBand, page: RenderedPage, totalPages: number): EvalContext {
315
+ const pageScoped = placed.band.type === 'pageHeader' || placed.band.type === 'pageFooter';
316
+ return {
317
+ ...placed.ctx,
318
+ row: pageScoped ? page.rows[0] : placed.ctx.row,
319
+ rows: pageScoped ? page.rows : placed.ctx.rows,
320
+ pageRows: page.rows,
321
+ pageNumber: page.index + 1,
322
+ totalPages,
323
+ };
324
+ }
@@ -0,0 +1,58 @@
1
+ import type { ElementStyle, FormatSpec, ReportElement, TableColumn, TableSpec } from '../model/types';
2
+
3
+ /** Already-evaluated cell strings for drawing a table (designer and preview). */
4
+ export interface TableRenderData {
5
+ header: string[] | null;
6
+ headerHeight: number;
7
+ rows: { cells: string[]; height: number; index: number }[];
8
+ footer: string[] | null;
9
+ footerHeight: number;
10
+ }
11
+
12
+ export const hasGrid = (t: TableSpec) => !!t.gridColor && t.gridColor !== 'transparent';
13
+
14
+ /** Pixel width of each column (relative widths scaled to the table width). */
15
+ export function columnWidths(el: ReportElement): number[] {
16
+ const cols = el.table?.columns ?? [];
17
+ const total = cols.reduce((s, c) => s + Math.max(0, c.width), 0) || 1;
18
+ return cols.map((c) => (el.w * Math.max(0, c.width)) / total);
19
+ }
20
+
21
+ /** Style used for measuring/drawing one cell (1px grid line counts as a border). */
22
+ export function tableCellStyle(el: ReportElement, header: boolean): ElementStyle {
23
+ const t = el.table!;
24
+ return { ...el.style, bold: header || el.style.bold, borderWidth: hasGrid(t) ? 1 : 0 };
25
+ }
26
+
27
+ /** Height shown in the designer: header + one sample row + footer. */
28
+ export function designTableHeight(t: TableSpec): number {
29
+ return (t.showHeader ? t.headerHeight : 0) + t.rowHeight + (t.showFooter ? t.rowHeight : 0);
30
+ }
31
+
32
+ export function defaultTableSpec(columns: TableColumn[]): TableSpec {
33
+ return {
34
+ columns,
35
+ showHeader: true,
36
+ repeatHeader: true,
37
+ showFooter: false,
38
+ headerHeight: 24,
39
+ rowHeight: 22,
40
+ headerBackground: '#1f3a68',
41
+ headerColor: '#ffffff',
42
+ stripeBackground: '#f3f6fb',
43
+ gridColor: '#c9d1dd',
44
+ };
45
+ }
46
+
47
+ export function createColumn(field: string, type?: string, format?: Partial<FormatSpec>): TableColumn {
48
+ const numeric = type === 'number';
49
+ return {
50
+ id: Math.random().toString(36).slice(2, 10),
51
+ header: field || 'Column',
52
+ text: field ? `{${field}}` : '',
53
+ footer: '',
54
+ width: numeric ? 1 : type === 'date' ? 1.2 : 2,
55
+ align: numeric ? 'right' : 'left',
56
+ format: { kind: type === 'date' ? 'date' : 'none', decimals: 2, currency: 'USD', ...format },
57
+ };
58
+ }