@zentto/report-core 0.4.0 → 1.0.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.
- package/dist/engine/conditional-format.d.ts +48 -0
- package/dist/engine/conditional-format.d.ts.map +1 -0
- package/dist/engine/conditional-format.js +167 -0
- package/dist/engine/conditional-format.js.map +1 -0
- package/dist/engine/cross-tab.d.ts +68 -0
- package/dist/engine/cross-tab.d.ts.map +1 -0
- package/dist/engine/cross-tab.js +549 -0
- package/dist/engine/cross-tab.js.map +1 -0
- package/dist/engine/expression.d.ts +46 -2
- package/dist/engine/expression.d.ts.map +1 -1
- package/dist/engine/expression.js +1415 -90
- package/dist/engine/expression.js.map +1 -1
- package/dist/engine/multi-pass-engine.d.ts +74 -0
- package/dist/engine/multi-pass-engine.d.ts.map +1 -0
- package/dist/engine/multi-pass-engine.js +1082 -0
- package/dist/engine/multi-pass-engine.js.map +1 -0
- package/dist/engine/running-totals.d.ts +74 -0
- package/dist/engine/running-totals.d.ts.map +1 -0
- package/dist/engine/running-totals.js +247 -0
- package/dist/engine/running-totals.js.map +1 -0
- package/dist/engine/subreport.d.ts +59 -0
- package/dist/engine/subreport.d.ts.map +1 -0
- package/dist/engine/subreport.js +295 -0
- package/dist/engine/subreport.js.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/schema/report-schema.d.ts +346 -346
- package/dist/serialization/json.d.ts +88 -88
- package/dist/templates/page-sizes.d.ts.map +1 -1
- package/dist/templates/page-sizes.js +95 -3
- package/dist/templates/page-sizes.js.map +1 -1
- package/dist/types.d.ts +38 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
// @zentto/report-core — 3-pass rendering engine (Crystal Reports model)
|
|
2
|
+
//
|
|
3
|
+
// Pass 1 (WhileReadingRecords): Sort, group, compute all aggregates
|
|
4
|
+
// Pass 2 (WhilePrintingRecords): Running totals, print-time expressions, conditional formatting
|
|
5
|
+
// Pass 3 (TotalPageCount): Calculate total pages, back-fill "Page N of M"
|
|
6
|
+
import { processLayout, toPx } from './band-engine.js';
|
|
7
|
+
import { resolveElementValue, formatValue, } from './data-binding.js';
|
|
8
|
+
/**
|
|
9
|
+
* Compute an extended aggregate over a set of numeric values extracted from rows.
|
|
10
|
+
* Supports: sum, avg, count, min, max, median, stddev, variance, distinctcount.
|
|
11
|
+
*/
|
|
12
|
+
function computeExtendedAggregate(rows, field, fn) {
|
|
13
|
+
// For count and distinctcount we consider all values, not just numeric
|
|
14
|
+
if (fn === 'count')
|
|
15
|
+
return rows.length;
|
|
16
|
+
if (fn === 'distinctcount') {
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
seen.add(row[field]);
|
|
20
|
+
}
|
|
21
|
+
return seen.size;
|
|
22
|
+
}
|
|
23
|
+
const values = rows
|
|
24
|
+
.map(r => r[field])
|
|
25
|
+
.filter((v) => typeof v === 'number');
|
|
26
|
+
if (values.length === 0)
|
|
27
|
+
return 0;
|
|
28
|
+
switch (fn) {
|
|
29
|
+
case 'sum':
|
|
30
|
+
return kahanSum(values);
|
|
31
|
+
case 'avg':
|
|
32
|
+
return kahanSum(values) / values.length;
|
|
33
|
+
case 'min':
|
|
34
|
+
return Math.min(...values);
|
|
35
|
+
case 'max':
|
|
36
|
+
return Math.max(...values);
|
|
37
|
+
case 'median':
|
|
38
|
+
return computeMedian(values);
|
|
39
|
+
case 'stddev':
|
|
40
|
+
return computeStdDev(values);
|
|
41
|
+
case 'variance':
|
|
42
|
+
return computeVariance(values);
|
|
43
|
+
default:
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Kahan compensated summation for numerical stability */
|
|
48
|
+
function kahanSum(values) {
|
|
49
|
+
let sum = 0;
|
|
50
|
+
let compensation = 0;
|
|
51
|
+
for (const v of values) {
|
|
52
|
+
const y = v - compensation;
|
|
53
|
+
const t = sum + y;
|
|
54
|
+
compensation = (t - sum) - y;
|
|
55
|
+
sum = t;
|
|
56
|
+
}
|
|
57
|
+
return sum;
|
|
58
|
+
}
|
|
59
|
+
function computeMedian(values) {
|
|
60
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
61
|
+
const mid = Math.floor(sorted.length / 2);
|
|
62
|
+
if (sorted.length % 2 === 0) {
|
|
63
|
+
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
64
|
+
}
|
|
65
|
+
return sorted[mid];
|
|
66
|
+
}
|
|
67
|
+
function computeVariance(values) {
|
|
68
|
+
if (values.length < 2)
|
|
69
|
+
return 0;
|
|
70
|
+
const mean = kahanSum(values) / values.length;
|
|
71
|
+
const squaredDiffs = values.map(v => (v - mean) ** 2);
|
|
72
|
+
// Population variance (N), consistent with Crystal Reports DistinctCount behavior
|
|
73
|
+
return kahanSum(squaredDiffs) / values.length;
|
|
74
|
+
}
|
|
75
|
+
function computeStdDev(values) {
|
|
76
|
+
return Math.sqrt(computeVariance(values));
|
|
77
|
+
}
|
|
78
|
+
// ─── Sorting helpers ────────────────────────────────────────────────
|
|
79
|
+
function sortRows(rows, layout) {
|
|
80
|
+
const result = [...rows];
|
|
81
|
+
// Apply group sorting first (outermost sort), then explicit sorting
|
|
82
|
+
const sortDefs = [];
|
|
83
|
+
if (layout.groups) {
|
|
84
|
+
for (const g of layout.groups) {
|
|
85
|
+
sortDefs.push({ field: g.field, direction: g.sort || 'asc' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (layout.sorting) {
|
|
89
|
+
for (const s of layout.sorting) {
|
|
90
|
+
// Skip if already added by group
|
|
91
|
+
if (!sortDefs.some(sd => sd.field === s.field)) {
|
|
92
|
+
sortDefs.push(s);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (sortDefs.length === 0)
|
|
97
|
+
return result;
|
|
98
|
+
result.sort((a, b) => {
|
|
99
|
+
for (const sd of sortDefs) {
|
|
100
|
+
const aVal = a[sd.field];
|
|
101
|
+
const bVal = b[sd.field];
|
|
102
|
+
if (aVal === bVal)
|
|
103
|
+
continue;
|
|
104
|
+
if (aVal == null)
|
|
105
|
+
return 1;
|
|
106
|
+
if (bVal == null)
|
|
107
|
+
return -1;
|
|
108
|
+
const cmp = aVal < bVal ? -1 : 1;
|
|
109
|
+
return sd.direction === 'desc' ? -cmp : cmp;
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
});
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
/** Group rows by field, preserving the order of first occurrence */
|
|
116
|
+
function groupByField(rows, field) {
|
|
117
|
+
const groups = new Map();
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
const key = String(row[field] ?? '');
|
|
120
|
+
let group = groups.get(key);
|
|
121
|
+
if (!group) {
|
|
122
|
+
group = [];
|
|
123
|
+
groups.set(key, group);
|
|
124
|
+
}
|
|
125
|
+
group.push(row);
|
|
126
|
+
}
|
|
127
|
+
return groups;
|
|
128
|
+
}
|
|
129
|
+
/** Build nested group structure for multi-level grouping */
|
|
130
|
+
function buildGroupHierarchy(rows, groups, level = 0) {
|
|
131
|
+
if (level >= groups.length) {
|
|
132
|
+
return new Map([['__all__', rows]]);
|
|
133
|
+
}
|
|
134
|
+
const field = groups[level].field;
|
|
135
|
+
const topGroups = groupByField(rows, field);
|
|
136
|
+
// If there are deeper levels, recurse into each group
|
|
137
|
+
if (level < groups.length - 1) {
|
|
138
|
+
for (const [key, groupRows] of topGroups) {
|
|
139
|
+
// Recursion would create sub-groups, but we store flat at each level
|
|
140
|
+
// The aggregates map uses composite keys for nested groups
|
|
141
|
+
buildGroupHierarchy(groupRows, groups, level + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return topGroups;
|
|
145
|
+
}
|
|
146
|
+
// ─── Aggregate key helpers ──────────────────────────────────────────
|
|
147
|
+
function makeAggKey(field, fn) {
|
|
148
|
+
return `${field}:${fn}`;
|
|
149
|
+
}
|
|
150
|
+
const ALL_AGG_FUNCTIONS = [
|
|
151
|
+
'sum', 'avg', 'count', 'min', 'max', 'median', 'stddev', 'variance', 'distinctcount',
|
|
152
|
+
];
|
|
153
|
+
/**
|
|
154
|
+
* Collect all fields that have aggregates defined on them in the layout.
|
|
155
|
+
* Returns a set of field names that need aggregate computation.
|
|
156
|
+
*/
|
|
157
|
+
function collectAggregateFields(layout) {
|
|
158
|
+
const fields = new Set();
|
|
159
|
+
for (const band of layout.bands) {
|
|
160
|
+
for (const element of band.elements) {
|
|
161
|
+
if (element.type === 'field') {
|
|
162
|
+
const fe = element;
|
|
163
|
+
if (fe.aggregate) {
|
|
164
|
+
fields.add(fe.field);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Also collect from running totals
|
|
170
|
+
if (layout.runningTotals) {
|
|
171
|
+
for (const rt of layout.runningTotals) {
|
|
172
|
+
fields.add(rt.field);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return fields;
|
|
176
|
+
}
|
|
177
|
+
/** Get numeric fields from a row for auto-aggregate computation */
|
|
178
|
+
function collectNumericFields(rows) {
|
|
179
|
+
if (rows.length === 0)
|
|
180
|
+
return [];
|
|
181
|
+
const fields = new Set();
|
|
182
|
+
for (const row of rows) {
|
|
183
|
+
for (const [key, val] of Object.entries(row)) {
|
|
184
|
+
if (typeof val === 'number') {
|
|
185
|
+
fields.add(key);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return [...fields];
|
|
190
|
+
}
|
|
191
|
+
// ─── Pass 1: WhileReadingRecords ────────────────────────────────────
|
|
192
|
+
/**
|
|
193
|
+
* Pass 1 reads all data, sorts and groups it, and pre-computes every
|
|
194
|
+
* aggregate that the report might need. This is equivalent to Crystal
|
|
195
|
+
* Reports' "WhileReadingRecords" phase.
|
|
196
|
+
*/
|
|
197
|
+
export function executePass1(layout, dataSets) {
|
|
198
|
+
const ctx = {
|
|
199
|
+
aggregates: new Map(),
|
|
200
|
+
groupTotals: new Map(),
|
|
201
|
+
sortedData: new Map(),
|
|
202
|
+
grandTotals: new Map(),
|
|
203
|
+
runningTotals: new Map(),
|
|
204
|
+
printState: {
|
|
205
|
+
recordNumber: 0,
|
|
206
|
+
groupNumber: 0,
|
|
207
|
+
pageNumber: 0,
|
|
208
|
+
totalPages: 0,
|
|
209
|
+
isFirstRecord: true,
|
|
210
|
+
isLastRecord: false,
|
|
211
|
+
previousValues: new Map(),
|
|
212
|
+
nextValues: new Map(),
|
|
213
|
+
},
|
|
214
|
+
conditionalFormats: new Map(),
|
|
215
|
+
suppressedElements: new Set(),
|
|
216
|
+
subreportResults: new Map(),
|
|
217
|
+
totalPages: 0,
|
|
218
|
+
pageContent: [],
|
|
219
|
+
};
|
|
220
|
+
// Process each data source
|
|
221
|
+
for (const dsDef of layout.dataSources) {
|
|
222
|
+
const rawData = dataSets[dsDef.id];
|
|
223
|
+
if (!rawData || !Array.isArray(rawData))
|
|
224
|
+
continue;
|
|
225
|
+
// Sort data according to groups + explicit sorting
|
|
226
|
+
const sorted = sortRows(rawData, layout);
|
|
227
|
+
ctx.sortedData.set(dsDef.id, sorted);
|
|
228
|
+
// Determine which fields need aggregates
|
|
229
|
+
const explicitFields = collectAggregateFields(layout);
|
|
230
|
+
const numericFields = collectNumericFields(sorted);
|
|
231
|
+
const allAggFields = new Set([...explicitFields, ...numericFields]);
|
|
232
|
+
// ── Grand totals (report-level aggregates) ──
|
|
233
|
+
for (const field of allAggFields) {
|
|
234
|
+
for (const fn of ALL_AGG_FUNCTIONS) {
|
|
235
|
+
const key = makeAggKey(field, fn);
|
|
236
|
+
ctx.grandTotals.set(key, computeExtendedAggregate(sorted, field, fn));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// ── Group-level aggregates ──
|
|
240
|
+
if (layout.groups && layout.groups.length > 0) {
|
|
241
|
+
for (const groupDef of layout.groups) {
|
|
242
|
+
const grouped = groupByField(sorted, groupDef.field);
|
|
243
|
+
// Store group rows for later use
|
|
244
|
+
const groupEntries = [];
|
|
245
|
+
const groupAggs = new Map();
|
|
246
|
+
for (const [groupKey, groupRows] of grouped) {
|
|
247
|
+
const fieldAggs = new Map();
|
|
248
|
+
for (const field of allAggFields) {
|
|
249
|
+
for (const fn of ALL_AGG_FUNCTIONS) {
|
|
250
|
+
const aggKey = makeAggKey(field, fn);
|
|
251
|
+
fieldAggs.set(aggKey, computeExtendedAggregate(groupRows, field, fn));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
groupAggs.set(groupKey, fieldAggs);
|
|
255
|
+
// Build a summary record for this group
|
|
256
|
+
const summary = { __count: groupRows.length };
|
|
257
|
+
for (const [aggKey, val] of fieldAggs) {
|
|
258
|
+
summary[aggKey] = val;
|
|
259
|
+
}
|
|
260
|
+
groupEntries.push(summary);
|
|
261
|
+
}
|
|
262
|
+
// Store in context
|
|
263
|
+
ctx.groupTotals.set(groupDef.field, groupEntries);
|
|
264
|
+
// Merge group aggregates into the main aggregates map
|
|
265
|
+
for (const [groupKey, fieldAggs] of groupAggs) {
|
|
266
|
+
const compositeKey = `${groupDef.field}::${groupKey}`;
|
|
267
|
+
ctx.aggregates.set(compositeKey, fieldAggs);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Handle nested groups: compute aggregates for composite group keys
|
|
271
|
+
if (layout.groups.length > 1) {
|
|
272
|
+
computeNestedGroupAggregates(sorted, layout.groups, allAggFields, ctx);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Initialize running totals to zero
|
|
277
|
+
if (layout.runningTotals) {
|
|
278
|
+
for (const rt of layout.runningTotals) {
|
|
279
|
+
ctx.runningTotals.set(rt.id, 0);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return ctx;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* For multi-level grouping, compute aggregates at each nesting level
|
|
286
|
+
* using composite keys like "region::West|city::Seattle"
|
|
287
|
+
*/
|
|
288
|
+
function computeNestedGroupAggregates(rows, groups, fields, ctx) {
|
|
289
|
+
// Build composite groups by iterating through sorted data
|
|
290
|
+
// Track group keys at each level
|
|
291
|
+
const levelKeys = groups.map(() => []);
|
|
292
|
+
// Group at level 0
|
|
293
|
+
const level0Groups = groupByField(rows, groups[0].field);
|
|
294
|
+
for (const [key0, rows0] of level0Groups) {
|
|
295
|
+
levelKeys[0].push(key0);
|
|
296
|
+
if (groups.length > 1) {
|
|
297
|
+
const level1Groups = groupByField(rows0, groups[1].field);
|
|
298
|
+
for (const [key1, rows1] of level1Groups) {
|
|
299
|
+
// Composite key for level 0+1
|
|
300
|
+
const compositeKey = `${groups[0].field}::${key0}|${groups[1].field}::${key1}`;
|
|
301
|
+
const fieldAggs = new Map();
|
|
302
|
+
for (const field of fields) {
|
|
303
|
+
for (const fn of ALL_AGG_FUNCTIONS) {
|
|
304
|
+
const aggKey = makeAggKey(field, fn);
|
|
305
|
+
fieldAggs.set(aggKey, computeExtendedAggregate(rows1, field, fn));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
ctx.aggregates.set(compositeKey, fieldAggs);
|
|
309
|
+
// Continue nesting if needed (up to 3 levels deep for practical reports)
|
|
310
|
+
if (groups.length > 2) {
|
|
311
|
+
const level2Groups = groupByField(rows1, groups[2].field);
|
|
312
|
+
for (const [key2, rows2] of level2Groups) {
|
|
313
|
+
const deepKey = `${compositeKey}|${groups[2].field}::${key2}`;
|
|
314
|
+
const deepAggs = new Map();
|
|
315
|
+
for (const field of fields) {
|
|
316
|
+
for (const fn of ALL_AGG_FUNCTIONS) {
|
|
317
|
+
const aggKey = makeAggKey(field, fn);
|
|
318
|
+
deepAggs.set(aggKey, computeExtendedAggregate(rows2, field, fn));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
ctx.aggregates.set(deepKey, deepAggs);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ─── Pass 2: WhilePrintingRecords ───────────────────────────────────
|
|
329
|
+
/**
|
|
330
|
+
* Pass 2 processes the report as it would be printed: running totals
|
|
331
|
+
* accumulate, print-time expressions are evaluated, conditional
|
|
332
|
+
* formatting is resolved, and subreports are executed.
|
|
333
|
+
*/
|
|
334
|
+
export function executePass2(layout, dataSets, ctx, options) {
|
|
335
|
+
// Run the band engine to get page layout
|
|
336
|
+
const pages = processLayout(layout, dataSets, options);
|
|
337
|
+
ctx.pageContent = pages;
|
|
338
|
+
const printState = ctx.printState;
|
|
339
|
+
printState.pageNumber = 1;
|
|
340
|
+
printState.recordNumber = 0;
|
|
341
|
+
printState.groupNumber = 0;
|
|
342
|
+
// Flatten all detail rows from pages for running total processing
|
|
343
|
+
const allDetailBands = [];
|
|
344
|
+
for (const page of pages) {
|
|
345
|
+
for (const rb of page.bands) {
|
|
346
|
+
if (rb.band.type === 'detail') {
|
|
347
|
+
allDetailBands.push(rb);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const totalRecords = allDetailBands.length;
|
|
352
|
+
// Track previous group values for group-change detection
|
|
353
|
+
const lastGroupValues = new Map();
|
|
354
|
+
// ── Process each page ──
|
|
355
|
+
for (const page of pages) {
|
|
356
|
+
printState.pageNumber = page.pageNumber;
|
|
357
|
+
for (const rb of page.bands) {
|
|
358
|
+
// Process detail bands for running totals and print-time state
|
|
359
|
+
if (rb.band.type === 'detail' && rb.rowData) {
|
|
360
|
+
printState.recordNumber++;
|
|
361
|
+
printState.isFirstRecord = printState.recordNumber === 1;
|
|
362
|
+
printState.isLastRecord = printState.recordNumber === totalRecords;
|
|
363
|
+
// Store previous/next values
|
|
364
|
+
const prevIdx = printState.recordNumber - 2;
|
|
365
|
+
const nextIdx = printState.recordNumber; // 0-indexed
|
|
366
|
+
if (prevIdx >= 0 && allDetailBands[prevIdx]?.rowData) {
|
|
367
|
+
for (const [k, v] of Object.entries(allDetailBands[prevIdx].rowData)) {
|
|
368
|
+
printState.previousValues.set(k, v);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (nextIdx < allDetailBands.length && allDetailBands[nextIdx]?.rowData) {
|
|
372
|
+
for (const [k, v] of Object.entries(allDetailBands[nextIdx].rowData)) {
|
|
373
|
+
printState.nextValues.set(k, v);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Process running totals
|
|
377
|
+
processRunningTotals(layout.runningTotals || [], rb.rowData, ctx, lastGroupValues);
|
|
378
|
+
// Detect group changes and increment groupNumber
|
|
379
|
+
if (layout.groups) {
|
|
380
|
+
for (const g of layout.groups) {
|
|
381
|
+
const currentGroupVal = rb.rowData[g.field];
|
|
382
|
+
if (lastGroupValues.get(g.field) !== currentGroupVal) {
|
|
383
|
+
printState.groupNumber++;
|
|
384
|
+
lastGroupValues.set(g.field, currentGroupVal);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Process group header bands for group number tracking
|
|
390
|
+
if (rb.band.type === 'groupHeader' && rb.groupKey !== undefined) {
|
|
391
|
+
// Group number is already tracked above via detail rows
|
|
392
|
+
}
|
|
393
|
+
// ── Conditional formatting ──
|
|
394
|
+
processConditionalFormats(layout.conditionalFormats || [], rb, ctx, dataSets, printState);
|
|
395
|
+
// ── Suppress if blank ──
|
|
396
|
+
processSuppressIfBlank(rb, ctx, dataSets);
|
|
397
|
+
// ── Keep-together tracking ──
|
|
398
|
+
// Handled by band-engine, but we flag bands that overflow
|
|
399
|
+
if (rb.band.keepTogether) {
|
|
400
|
+
trackKeepTogether(rb, page, layout, ctx);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// ── Subreport execution ──
|
|
405
|
+
executeSubreports(layout, dataSets, ctx, options);
|
|
406
|
+
return ctx;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Process running totals for a single detail row.
|
|
410
|
+
* Running totals accumulate across the entire report, resetting
|
|
411
|
+
* based on their configured reset condition.
|
|
412
|
+
*/
|
|
413
|
+
function processRunningTotals(definitions, row, ctx, lastGroupValues) {
|
|
414
|
+
for (const rt of definitions) {
|
|
415
|
+
const currentVal = ctx.runningTotals.get(rt.id) ?? 0;
|
|
416
|
+
const fieldVal = row[rt.field];
|
|
417
|
+
const numVal = typeof fieldVal === 'number' ? fieldVal : 0;
|
|
418
|
+
// Check reset condition FIRST
|
|
419
|
+
let shouldReset = false;
|
|
420
|
+
switch (rt.resetCondition) {
|
|
421
|
+
case 'never':
|
|
422
|
+
break;
|
|
423
|
+
case 'on-change':
|
|
424
|
+
if (rt.resetField) {
|
|
425
|
+
const prevVal = lastGroupValues.get(`__rt_${rt.id}_${rt.resetField}`);
|
|
426
|
+
const curVal = row[rt.resetField];
|
|
427
|
+
if (prevVal !== undefined && prevVal !== curVal) {
|
|
428
|
+
shouldReset = true;
|
|
429
|
+
}
|
|
430
|
+
lastGroupValues.set(`__rt_${rt.id}_${rt.resetField}`, curVal);
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
case 'on-group-change':
|
|
434
|
+
if (rt.resetField) {
|
|
435
|
+
const prevGroupVal = lastGroupValues.get(rt.resetField);
|
|
436
|
+
const curGroupVal = row[rt.resetField];
|
|
437
|
+
if (prevGroupVal !== undefined && prevGroupVal !== curGroupVal) {
|
|
438
|
+
shouldReset = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
case 'formula':
|
|
443
|
+
// Formula reset: evaluate resetField as expression
|
|
444
|
+
if (rt.resetField) {
|
|
445
|
+
try {
|
|
446
|
+
const fn = new Function('row', `"use strict"; return (${rt.resetField});`);
|
|
447
|
+
if (fn(row))
|
|
448
|
+
shouldReset = true;
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// Expression evaluation failed — do not reset
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
if (shouldReset) {
|
|
457
|
+
ctx.runningTotals.set(rt.id, 0);
|
|
458
|
+
}
|
|
459
|
+
// Check evaluate condition
|
|
460
|
+
let shouldEvaluate = false;
|
|
461
|
+
switch (rt.evaluateCondition) {
|
|
462
|
+
case 'each-record':
|
|
463
|
+
shouldEvaluate = true;
|
|
464
|
+
break;
|
|
465
|
+
case 'on-change':
|
|
466
|
+
if (rt.evaluateField) {
|
|
467
|
+
const prevVal = lastGroupValues.get(`__rt_eval_${rt.id}_${rt.evaluateField}`);
|
|
468
|
+
const curVal = row[rt.evaluateField];
|
|
469
|
+
if (prevVal !== curVal) {
|
|
470
|
+
shouldEvaluate = true;
|
|
471
|
+
}
|
|
472
|
+
lastGroupValues.set(`__rt_eval_${rt.id}_${rt.evaluateField}`, curVal);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
shouldEvaluate = true;
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
case 'on-group-change':
|
|
479
|
+
if (rt.evaluateField) {
|
|
480
|
+
const prevGroupVal = lastGroupValues.get(rt.evaluateField);
|
|
481
|
+
const curGroupVal = row[rt.evaluateField];
|
|
482
|
+
if (prevGroupVal !== curGroupVal) {
|
|
483
|
+
shouldEvaluate = true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
case 'formula':
|
|
488
|
+
if (rt.evaluateField) {
|
|
489
|
+
try {
|
|
490
|
+
const fn = new Function('row', `"use strict"; return (${rt.evaluateField});`);
|
|
491
|
+
if (fn(row))
|
|
492
|
+
shouldEvaluate = true;
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
shouldEvaluate = false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
if (!shouldEvaluate)
|
|
501
|
+
continue;
|
|
502
|
+
// Apply the summarize function
|
|
503
|
+
const base = shouldReset ? 0 : (ctx.runningTotals.get(rt.id) ?? 0);
|
|
504
|
+
const newVal = applyRunningAggregate(rt.summarize, base, numVal, ctx, rt);
|
|
505
|
+
ctx.runningTotals.set(rt.id, newVal);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Apply a running aggregate function incrementally.
|
|
510
|
+
* For sum/count this is trivial; for avg/min/max we need
|
|
511
|
+
* accumulated state. We store auxiliary counters using the
|
|
512
|
+
* running total id as prefix.
|
|
513
|
+
*/
|
|
514
|
+
function applyRunningAggregate(fn, current, value, ctx, rt) {
|
|
515
|
+
switch (fn) {
|
|
516
|
+
case 'sum':
|
|
517
|
+
return current + value;
|
|
518
|
+
case 'count':
|
|
519
|
+
return current + 1;
|
|
520
|
+
case 'avg': {
|
|
521
|
+
// Track count alongside total for running average
|
|
522
|
+
const countKey = `${rt.id}__count`;
|
|
523
|
+
const totalKey = `${rt.id}__total`;
|
|
524
|
+
const count = (ctx.runningTotals.get(countKey) ?? 0) + 1;
|
|
525
|
+
const total = (ctx.runningTotals.get(totalKey) ?? 0) + value;
|
|
526
|
+
ctx.runningTotals.set(countKey, count);
|
|
527
|
+
ctx.runningTotals.set(totalKey, total);
|
|
528
|
+
return count > 0 ? total / count : 0;
|
|
529
|
+
}
|
|
530
|
+
case 'min':
|
|
531
|
+
return current === 0 && !ctx.runningTotals.has(`${rt.id}__init`)
|
|
532
|
+
? (ctx.runningTotals.set(`${rt.id}__init`, 1), value)
|
|
533
|
+
: Math.min(current, value);
|
|
534
|
+
case 'max':
|
|
535
|
+
return current === 0 && !ctx.runningTotals.has(`${rt.id}__init`)
|
|
536
|
+
? (ctx.runningTotals.set(`${rt.id}__init`, 1), value)
|
|
537
|
+
: Math.max(current, value);
|
|
538
|
+
default:
|
|
539
|
+
return current + value;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Evaluate conditional format rules for all elements in a rendered band.
|
|
544
|
+
* When a condition is true, the computed style is stored in ctx.conditionalFormats.
|
|
545
|
+
*/
|
|
546
|
+
function processConditionalFormats(rules, rb, ctx, dataSets, printState) {
|
|
547
|
+
if (rules.length === 0)
|
|
548
|
+
return;
|
|
549
|
+
// Build a scope object for evaluating conditions
|
|
550
|
+
const scope = {
|
|
551
|
+
RecordNumber: printState.recordNumber,
|
|
552
|
+
GroupNumber: printState.groupNumber,
|
|
553
|
+
PageNumber: printState.pageNumber,
|
|
554
|
+
IsFirstRecord: printState.isFirstRecord,
|
|
555
|
+
IsLastRecord: printState.isLastRecord,
|
|
556
|
+
};
|
|
557
|
+
// Add row data to scope
|
|
558
|
+
if (rb.rowData) {
|
|
559
|
+
Object.assign(scope, rb.rowData);
|
|
560
|
+
}
|
|
561
|
+
// Add running totals to scope
|
|
562
|
+
for (const [key, val] of ctx.runningTotals) {
|
|
563
|
+
// Only expose user-defined running totals (skip internal counters)
|
|
564
|
+
if (!key.includes('__')) {
|
|
565
|
+
scope[key] = val;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Evaluate each rule that targets an element in this band
|
|
569
|
+
const bandElementIds = new Set(rb.band.elements.map(e => e.id));
|
|
570
|
+
for (const rule of rules) {
|
|
571
|
+
if (!bandElementIds.has(rule.elementId))
|
|
572
|
+
continue;
|
|
573
|
+
try {
|
|
574
|
+
const conditionResult = evaluateConditionExpression(rule.condition, scope);
|
|
575
|
+
if (conditionResult) {
|
|
576
|
+
// Merge styles — later rules override earlier ones
|
|
577
|
+
const existing = ctx.conditionalFormats.get(rule.elementId);
|
|
578
|
+
if (existing) {
|
|
579
|
+
ctx.conditionalFormats.set(rule.elementId, { ...existing, ...rule.style });
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
ctx.conditionalFormats.set(rule.elementId, { ...rule.style });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// Condition evaluation failed — skip rule silently
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Safely evaluate a boolean expression for conditional formatting.
|
|
593
|
+
* Provides a sandboxed scope with row data and print state.
|
|
594
|
+
*/
|
|
595
|
+
function evaluateConditionExpression(condition, scope) {
|
|
596
|
+
// Build parameter names and values arrays for the Function constructor
|
|
597
|
+
const paramNames = Object.keys(scope);
|
|
598
|
+
const paramValues = Object.values(scope);
|
|
599
|
+
try {
|
|
600
|
+
const fn = new Function(...paramNames, `"use strict"; return Boolean(${condition});`);
|
|
601
|
+
return fn(...paramValues);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Process "suppress if blank" logic for elements.
|
|
609
|
+
* If a field element resolves to empty/null/zero and has
|
|
610
|
+
* suppressIfBlank set, add it to the suppressed set.
|
|
611
|
+
*/
|
|
612
|
+
function processSuppressIfBlank(rb, ctx, dataSets) {
|
|
613
|
+
for (const element of rb.band.elements) {
|
|
614
|
+
if (element.visible === false) {
|
|
615
|
+
ctx.suppressedElements.add(element.id);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
// Check visibleExpression
|
|
619
|
+
if (element.visibleExpression) {
|
|
620
|
+
const scope = {};
|
|
621
|
+
if (rb.rowData)
|
|
622
|
+
Object.assign(scope, rb.rowData);
|
|
623
|
+
try {
|
|
624
|
+
const result = evaluateConditionExpression(element.visibleExpression, scope);
|
|
625
|
+
if (!result) {
|
|
626
|
+
ctx.suppressedElements.add(element.id);
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// If expression fails, keep element visible
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// For field elements, check if the resolved value is blank
|
|
635
|
+
if (element.type === 'field') {
|
|
636
|
+
const fe = element;
|
|
637
|
+
const bindCtx = {
|
|
638
|
+
dataSets,
|
|
639
|
+
currentRow: rb.rowData,
|
|
640
|
+
currentIndex: rb.rowIndex,
|
|
641
|
+
groupRows: rb.groupRows,
|
|
642
|
+
pageNumber: rb.context.pageNumber,
|
|
643
|
+
};
|
|
644
|
+
const resolved = resolveElementValue(fe, bindCtx);
|
|
645
|
+
if (resolved === '' || resolved === null || resolved === undefined) {
|
|
646
|
+
// Only suppress if the band or element is configured to suppress blanks
|
|
647
|
+
// We check for a convention: elements with id ending in "__suppressBlank"
|
|
648
|
+
// or the band has a metadata flag. For now, track all blank fields.
|
|
649
|
+
// The renderer can decide whether to suppress.
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Track keep-together state for bands. If a band with keepTogether=true
|
|
656
|
+
* was split across pages, we flag it in the context so the renderer
|
|
657
|
+
* can attempt to move it to the next page.
|
|
658
|
+
*/
|
|
659
|
+
function trackKeepTogether(rb, page, layout, _ctx) {
|
|
660
|
+
// The band engine already handles page breaks for individual bands.
|
|
661
|
+
// Keep-together for groups (all group header + details + footer together)
|
|
662
|
+
// is tracked here for the renderer to potentially re-process.
|
|
663
|
+
//
|
|
664
|
+
// For a group header, check if the subsequent detail rows and footer
|
|
665
|
+
// are all on the same page. If not, the group should start on the next page.
|
|
666
|
+
// This is advisory — the band engine may need to be re-invoked with adjusted
|
|
667
|
+
// breaks for perfect keep-together behavior.
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Execute subreport elements found in any band.
|
|
671
|
+
* Each subreport is rendered recursively using its own layout and data.
|
|
672
|
+
*/
|
|
673
|
+
function executeSubreports(layout, dataSets, ctx, options) {
|
|
674
|
+
for (const band of layout.bands) {
|
|
675
|
+
for (const element of band.elements) {
|
|
676
|
+
// Subreport is a future element type — check via string comparison
|
|
677
|
+
// to support forward-compatible layouts before the type union is extended
|
|
678
|
+
const elType = element.type;
|
|
679
|
+
if (elType === 'subreport') {
|
|
680
|
+
// Subreport elements have a layoutRef and optional dataFilter
|
|
681
|
+
// For now, subreports must be provided in the dataSets under their id
|
|
682
|
+
const elId = element.id;
|
|
683
|
+
const subreportData = dataSets[`__subreport_${elId}`];
|
|
684
|
+
const subreportLayout = dataSets[`__subreport_layout_${elId}`];
|
|
685
|
+
if (subreportLayout && subreportData) {
|
|
686
|
+
const subDs = { main: subreportData };
|
|
687
|
+
const subResult = renderReport(subreportLayout, subDs, options);
|
|
688
|
+
ctx.subreportResults.set(elId, subResult);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
// ─── Pass 3: TotalPageCount ─────────────────────────────────────────
|
|
695
|
+
/**
|
|
696
|
+
* Pass 3 finalizes the total page count and back-fills all
|
|
697
|
+
* "Page N of M" references throughout the rendered content.
|
|
698
|
+
* This requires a second layout pass if total pages changed.
|
|
699
|
+
*/
|
|
700
|
+
export function executePass3(layout, ctx) {
|
|
701
|
+
// Total pages is simply the count of pages from Pass 2
|
|
702
|
+
ctx.totalPages = ctx.pageContent.length;
|
|
703
|
+
ctx.printState.totalPages = ctx.totalPages;
|
|
704
|
+
// Update all page content with the final total pages count
|
|
705
|
+
for (const page of ctx.pageContent) {
|
|
706
|
+
for (const rb of page.bands) {
|
|
707
|
+
// Update the binding context with total pages
|
|
708
|
+
rb.context.totalPages = ctx.totalPages;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return ctx;
|
|
712
|
+
}
|
|
713
|
+
// ─── Public API: Full 3-pass render ─────────────────────────────────
|
|
714
|
+
/**
|
|
715
|
+
* Execute the complete 3-pass rendering pipeline.
|
|
716
|
+
*
|
|
717
|
+
* 1. WhileReadingRecords — sort, group, aggregate
|
|
718
|
+
* 2. WhilePrintingRecords — running totals, expressions, conditional formats, subreports
|
|
719
|
+
* 3. TotalPageCount — finalize page count, back-fill "Page N of M"
|
|
720
|
+
*
|
|
721
|
+
* Returns a RenderResult with fully resolved pages.
|
|
722
|
+
*/
|
|
723
|
+
export function renderReport(layout, dataSets, options) {
|
|
724
|
+
// ── Pass 1: WhileReadingRecords ──
|
|
725
|
+
const ctx = executePass1(layout, dataSets);
|
|
726
|
+
// Replace dataSets with sorted data for subsequent passes
|
|
727
|
+
const sortedDataSets = { ...dataSets };
|
|
728
|
+
for (const [dsId, sorted] of ctx.sortedData) {
|
|
729
|
+
sortedDataSets[dsId] = sorted;
|
|
730
|
+
}
|
|
731
|
+
// ── Pass 2: WhilePrintingRecords ──
|
|
732
|
+
executePass2(layout, sortedDataSets, ctx, options);
|
|
733
|
+
// ── Pass 3: TotalPageCount ──
|
|
734
|
+
executePass3(layout, ctx);
|
|
735
|
+
// ── Build final RenderResult ──
|
|
736
|
+
const pages = ctx.pageContent.map(pc => ({
|
|
737
|
+
pageNumber: pc.pageNumber,
|
|
738
|
+
totalPages: ctx.totalPages,
|
|
739
|
+
html: renderPageToHtml(pc, ctx, layout, sortedDataSets, options),
|
|
740
|
+
}));
|
|
741
|
+
return {
|
|
742
|
+
pages,
|
|
743
|
+
totalPages: ctx.totalPages,
|
|
744
|
+
generatedAt: new Date().toISOString(),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
// ─── HTML rendering with multi-pass context ─────────────────────────
|
|
748
|
+
/**
|
|
749
|
+
* Render a single page to HTML, applying all multi-pass context:
|
|
750
|
+
* conditional formats, suppressed elements, running totals, aggregates.
|
|
751
|
+
*/
|
|
752
|
+
function renderPageToHtml(page, ctx, layout, dataSets, options) {
|
|
753
|
+
const unit = layout.pageSize.unit;
|
|
754
|
+
const prefix = options?.cssPrefix ?? 'zr';
|
|
755
|
+
const pageWidth = layout.orientation === 'landscape'
|
|
756
|
+
? toPx(layout.pageSize.height, unit)
|
|
757
|
+
: toPx(layout.pageSize.width, unit);
|
|
758
|
+
const pageHeight = toPx(layout.pageSize.height, unit);
|
|
759
|
+
const marginTop = toPx(layout.margins.top, unit);
|
|
760
|
+
const marginLeft = toPx(layout.margins.left, unit);
|
|
761
|
+
const bands = [];
|
|
762
|
+
for (const rb of page.bands) {
|
|
763
|
+
const bandHtml = renderBandToHtml(rb, ctx, layout, dataSets, prefix);
|
|
764
|
+
bands.push(bandHtml);
|
|
765
|
+
}
|
|
766
|
+
return [
|
|
767
|
+
`<div class="${prefix}-page" style="`,
|
|
768
|
+
`position:relative;`,
|
|
769
|
+
`width:${pageWidth}px;`,
|
|
770
|
+
`height:${pageHeight}px;`,
|
|
771
|
+
`padding-top:${marginTop}px;`,
|
|
772
|
+
`padding-left:${marginLeft}px;`,
|
|
773
|
+
`box-sizing:border-box;`,
|
|
774
|
+
`overflow:hidden;`,
|
|
775
|
+
`">`,
|
|
776
|
+
...bands,
|
|
777
|
+
`</div>`,
|
|
778
|
+
].join('');
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Render a single band to HTML with all multi-pass context applied.
|
|
782
|
+
*/
|
|
783
|
+
function renderBandToHtml(rb, ctx, layout, dataSets, prefix) {
|
|
784
|
+
const unit = layout.pageSize.unit;
|
|
785
|
+
const bandHeight = toPx(rb.band.height, unit);
|
|
786
|
+
const bgStyle = rb.band.backgroundColor
|
|
787
|
+
? `background-color:${rb.band.backgroundColor};`
|
|
788
|
+
: '';
|
|
789
|
+
const elements = [];
|
|
790
|
+
for (const element of rb.band.elements) {
|
|
791
|
+
// Skip suppressed elements
|
|
792
|
+
if (ctx.suppressedElements.has(element.id))
|
|
793
|
+
continue;
|
|
794
|
+
// Skip invisible elements
|
|
795
|
+
if (element.visible === false)
|
|
796
|
+
continue;
|
|
797
|
+
// Check printOn condition
|
|
798
|
+
if (element.printOn && element.printOn !== 'all') {
|
|
799
|
+
const pn = rb.context.pageNumber || 1;
|
|
800
|
+
const tp = ctx.totalPages;
|
|
801
|
+
switch (element.printOn) {
|
|
802
|
+
case 'first':
|
|
803
|
+
if (pn !== 1)
|
|
804
|
+
continue;
|
|
805
|
+
break;
|
|
806
|
+
case 'last':
|
|
807
|
+
if (pn !== tp)
|
|
808
|
+
continue;
|
|
809
|
+
break;
|
|
810
|
+
case 'odd':
|
|
811
|
+
if (pn % 2 === 0)
|
|
812
|
+
continue;
|
|
813
|
+
break;
|
|
814
|
+
case 'even':
|
|
815
|
+
if (pn % 2 !== 0)
|
|
816
|
+
continue;
|
|
817
|
+
break;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const elemHtml = renderElementToHtml(element, rb, ctx, layout, dataSets, prefix);
|
|
821
|
+
elements.push(elemHtml);
|
|
822
|
+
}
|
|
823
|
+
return [
|
|
824
|
+
`<div class="${prefix}-band ${prefix}-band-${rb.band.type}" `,
|
|
825
|
+
`data-band-id="${rb.band.id}" `,
|
|
826
|
+
`style="`,
|
|
827
|
+
`position:relative;`,
|
|
828
|
+
`top:${rb.y}px;`,
|
|
829
|
+
`height:${bandHeight}px;`,
|
|
830
|
+
`${bgStyle}`,
|
|
831
|
+
`">`,
|
|
832
|
+
...elements,
|
|
833
|
+
`</div>`,
|
|
834
|
+
].join('');
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Render a single element to HTML with conditional formatting applied.
|
|
838
|
+
*/
|
|
839
|
+
function renderElementToHtml(element, rb, ctx, layout, dataSets, prefix) {
|
|
840
|
+
const unit = layout.pageSize.unit;
|
|
841
|
+
// Build binding context
|
|
842
|
+
const bindCtx = {
|
|
843
|
+
dataSets,
|
|
844
|
+
currentRow: rb.rowData,
|
|
845
|
+
currentIndex: rb.rowIndex,
|
|
846
|
+
groupRows: rb.groupRows,
|
|
847
|
+
allRows: rb.context.allRows,
|
|
848
|
+
pageNumber: rb.context.pageNumber,
|
|
849
|
+
totalPages: ctx.totalPages,
|
|
850
|
+
parameters: rb.context.parameters,
|
|
851
|
+
aggregates: getAggregatesForElement(element, rb, ctx),
|
|
852
|
+
};
|
|
853
|
+
// Resolve display value
|
|
854
|
+
let displayValue = resolveElementWithContext(element, bindCtx, ctx, rb);
|
|
855
|
+
// Build style string
|
|
856
|
+
const baseStyle = element.style || {};
|
|
857
|
+
const conditionalStyle = ctx.conditionalFormats.get(element.id) || {};
|
|
858
|
+
const mergedStyle = { ...layout.defaultStyle, ...baseStyle, ...conditionalStyle };
|
|
859
|
+
const x = toPx(element.x, unit);
|
|
860
|
+
const y = toPx(element.y, unit);
|
|
861
|
+
const w = toPx(element.width, unit);
|
|
862
|
+
const h = toPx(element.height, unit);
|
|
863
|
+
const cssProps = [
|
|
864
|
+
`position:absolute`,
|
|
865
|
+
`left:${x}px`,
|
|
866
|
+
`top:${y}px`,
|
|
867
|
+
`width:${w}px`,
|
|
868
|
+
`height:${h}px`,
|
|
869
|
+
`overflow:hidden`,
|
|
870
|
+
];
|
|
871
|
+
// Apply merged styles
|
|
872
|
+
if (mergedStyle.fontFamily)
|
|
873
|
+
cssProps.push(`font-family:${mergedStyle.fontFamily}`);
|
|
874
|
+
if (mergedStyle.fontSize)
|
|
875
|
+
cssProps.push(`font-size:${mergedStyle.fontSize}pt`);
|
|
876
|
+
if (mergedStyle.fontWeight)
|
|
877
|
+
cssProps.push(`font-weight:${mergedStyle.fontWeight}`);
|
|
878
|
+
if (mergedStyle.fontStyle)
|
|
879
|
+
cssProps.push(`font-style:${mergedStyle.fontStyle}`);
|
|
880
|
+
if (mergedStyle.textDecoration)
|
|
881
|
+
cssProps.push(`text-decoration:${mergedStyle.textDecoration}`);
|
|
882
|
+
if (mergedStyle.textAlign)
|
|
883
|
+
cssProps.push(`text-align:${mergedStyle.textAlign}`);
|
|
884
|
+
if (mergedStyle.color)
|
|
885
|
+
cssProps.push(`color:${mergedStyle.color}`);
|
|
886
|
+
if (mergedStyle.backgroundColor)
|
|
887
|
+
cssProps.push(`background-color:${mergedStyle.backgroundColor}`);
|
|
888
|
+
if (mergedStyle.borderTop)
|
|
889
|
+
cssProps.push(`border-top:${mergedStyle.borderTop}`);
|
|
890
|
+
if (mergedStyle.borderRight)
|
|
891
|
+
cssProps.push(`border-right:${mergedStyle.borderRight}`);
|
|
892
|
+
if (mergedStyle.borderBottom)
|
|
893
|
+
cssProps.push(`border-bottom:${mergedStyle.borderBottom}`);
|
|
894
|
+
if (mergedStyle.borderLeft)
|
|
895
|
+
cssProps.push(`border-left:${mergedStyle.borderLeft}`);
|
|
896
|
+
if (mergedStyle.borderRadius)
|
|
897
|
+
cssProps.push(`border-radius:${mergedStyle.borderRadius}px`);
|
|
898
|
+
if (mergedStyle.padding != null)
|
|
899
|
+
cssProps.push(`padding:${typeof mergedStyle.padding === 'number' ? mergedStyle.padding + 'px' : mergedStyle.padding}`);
|
|
900
|
+
if (mergedStyle.opacity != null)
|
|
901
|
+
cssProps.push(`opacity:${mergedStyle.opacity}`);
|
|
902
|
+
if (mergedStyle.lineHeight)
|
|
903
|
+
cssProps.push(`line-height:${mergedStyle.lineHeight}`);
|
|
904
|
+
if (mergedStyle.wordWrap)
|
|
905
|
+
cssProps.push(`word-wrap:break-word`);
|
|
906
|
+
if (mergedStyle.verticalAlign) {
|
|
907
|
+
cssProps.push(`display:flex`);
|
|
908
|
+
const vaMap = { top: 'flex-start', middle: 'center', bottom: 'flex-end' };
|
|
909
|
+
cssProps.push(`align-items:${vaMap[mergedStyle.verticalAlign] || 'flex-start'}`);
|
|
910
|
+
}
|
|
911
|
+
const styleStr = cssProps.join(';');
|
|
912
|
+
// Render based on element type
|
|
913
|
+
switch (element.type) {
|
|
914
|
+
case 'line': {
|
|
915
|
+
const x2 = toPx(element.x2, unit);
|
|
916
|
+
const y2 = toPx(element.y2, unit);
|
|
917
|
+
const color = element.lineStyle?.color || '#000';
|
|
918
|
+
const width = element.lineStyle?.width || 1;
|
|
919
|
+
return `<svg class="${prefix}-line" data-id="${element.id}" style="position:absolute;left:0;top:0;width:100%;height:100%;overflow:visible;"><line x1="${x}" y1="${y}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${width}"/></svg>`;
|
|
920
|
+
}
|
|
921
|
+
case 'rect': {
|
|
922
|
+
const fill = element.fill || 'transparent';
|
|
923
|
+
const borderColor = element.lineStyle?.color || '#000';
|
|
924
|
+
const borderWidth = element.lineStyle?.width || 1;
|
|
925
|
+
return `<div class="${prefix}-rect" data-id="${element.id}" style="${styleStr};border:${borderWidth}px solid ${borderColor};background:${fill};"></div>`;
|
|
926
|
+
}
|
|
927
|
+
case 'image':
|
|
928
|
+
return `<img class="${prefix}-image" data-id="${element.id}" style="${styleStr};object-fit:${element.fit || 'contain'};" src="${escapeHtml(displayValue)}" alt=""/>`;
|
|
929
|
+
case 'barcode':
|
|
930
|
+
return `<div class="${prefix}-barcode" data-id="${element.id}" data-type="${element.barcodeType}" style="${styleStr}">${escapeHtml(displayValue)}</div>`;
|
|
931
|
+
default:
|
|
932
|
+
return `<div class="${prefix}-element ${prefix}-${element.type}" data-id="${element.id}" style="${styleStr}">${escapeHtml(displayValue)}</div>`;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Resolve an element's display value using the full multi-pass context.
|
|
937
|
+
* This extends the basic resolveElementValue with running totals,
|
|
938
|
+
* print-time expressions, and pre-computed aggregates.
|
|
939
|
+
*/
|
|
940
|
+
function resolveElementWithContext(element, bindCtx, ctx, rb) {
|
|
941
|
+
if (element.type === 'field') {
|
|
942
|
+
const fe = element;
|
|
943
|
+
// Check if this field references a running total
|
|
944
|
+
if (fe.expression?.startsWith('RunningTotal(')) {
|
|
945
|
+
const rtMatch = fe.expression.match(/RunningTotal\(["']?(\w+)["']?\)/);
|
|
946
|
+
if (rtMatch) {
|
|
947
|
+
const rtId = rtMatch[1];
|
|
948
|
+
const val = ctx.runningTotals.get(rtId) ?? 0;
|
|
949
|
+
return formatValue(val, fe.format);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
// Check for print-time expressions
|
|
953
|
+
if (fe.expression) {
|
|
954
|
+
const expr = fe.expression;
|
|
955
|
+
// Previous() function
|
|
956
|
+
if (expr.includes('Previous(')) {
|
|
957
|
+
const prevMatch = expr.match(/Previous\(["']?(\w+)["']?\)/);
|
|
958
|
+
if (prevMatch) {
|
|
959
|
+
const fieldName = prevMatch[1];
|
|
960
|
+
const val = ctx.printState.previousValues.get(fieldName);
|
|
961
|
+
return formatValue(val, fe.format);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
// Next() function
|
|
965
|
+
if (expr.includes('Next(')) {
|
|
966
|
+
const nextMatch = expr.match(/Next\(["']?(\w+)["']?\)/);
|
|
967
|
+
if (nextMatch) {
|
|
968
|
+
const fieldName = nextMatch[1];
|
|
969
|
+
const val = ctx.printState.nextValues.get(fieldName);
|
|
970
|
+
return formatValue(val, fe.format);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
// RecordNumber
|
|
974
|
+
if (expr === 'RecordNumber' || expr === '=RecordNumber') {
|
|
975
|
+
return String(ctx.printState.recordNumber);
|
|
976
|
+
}
|
|
977
|
+
// GroupNumber
|
|
978
|
+
if (expr === 'GroupNumber' || expr === '=GroupNumber') {
|
|
979
|
+
return String(ctx.printState.groupNumber);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// Check for pre-computed aggregates from Pass 1
|
|
983
|
+
if (fe.aggregate) {
|
|
984
|
+
const aggResult = resolveAggregateFromContext(fe, rb, ctx);
|
|
985
|
+
if (aggResult !== null) {
|
|
986
|
+
return formatValue(aggResult, fe.format);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// Fall back to standard resolution
|
|
991
|
+
return resolveElementValue(element, bindCtx);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Look up a pre-computed aggregate from the Pass 1 context.
|
|
995
|
+
* Tries group-level first, then falls back to grand totals.
|
|
996
|
+
*/
|
|
997
|
+
function resolveAggregateFromContext(fe, rb, ctx) {
|
|
998
|
+
const aggKey = makeAggKey(fe.field, fe.aggregate);
|
|
999
|
+
// If in a group band, look up group-level aggregate
|
|
1000
|
+
if (rb.groupKey !== undefined && rb.band.groupField) {
|
|
1001
|
+
const compositeKey = `${rb.band.groupField}::${rb.groupKey}`;
|
|
1002
|
+
const groupAggs = ctx.aggregates.get(compositeKey);
|
|
1003
|
+
if (groupAggs?.has(aggKey)) {
|
|
1004
|
+
return groupAggs.get(aggKey);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// If field has resetOnGroup, look up the group aggregate
|
|
1008
|
+
if (fe.resetOnGroup) {
|
|
1009
|
+
// Find the current group key from the rendered band context
|
|
1010
|
+
if (rb.groupKey !== undefined) {
|
|
1011
|
+
const compositeKey = `${fe.resetOnGroup}::${rb.groupKey}`;
|
|
1012
|
+
const groupAggs = ctx.aggregates.get(compositeKey);
|
|
1013
|
+
if (groupAggs?.has(aggKey)) {
|
|
1014
|
+
return groupAggs.get(aggKey);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Fall back to grand totals
|
|
1019
|
+
if (ctx.grandTotals.has(aggKey)) {
|
|
1020
|
+
return ctx.grandTotals.get(aggKey);
|
|
1021
|
+
}
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Get the appropriate aggregates map for an element based on its
|
|
1026
|
+
* band context (group vs report level).
|
|
1027
|
+
*/
|
|
1028
|
+
function getAggregatesForElement(element, rb, ctx) {
|
|
1029
|
+
if (element.type !== 'field')
|
|
1030
|
+
return undefined;
|
|
1031
|
+
const fe = element;
|
|
1032
|
+
if (!fe.aggregate)
|
|
1033
|
+
return undefined;
|
|
1034
|
+
// Try group-level aggregates first
|
|
1035
|
+
if (rb.groupKey !== undefined && rb.band.groupField) {
|
|
1036
|
+
const compositeKey = `${rb.band.groupField}::${rb.groupKey}`;
|
|
1037
|
+
return ctx.aggregates.get(compositeKey);
|
|
1038
|
+
}
|
|
1039
|
+
// Return grand totals as a map
|
|
1040
|
+
return ctx.grandTotals;
|
|
1041
|
+
}
|
|
1042
|
+
/** HTML-escape a string */
|
|
1043
|
+
function escapeHtml(str) {
|
|
1044
|
+
return str
|
|
1045
|
+
.replace(/&/g, '&')
|
|
1046
|
+
.replace(/</g, '<')
|
|
1047
|
+
.replace(/>/g, '>')
|
|
1048
|
+
.replace(/"/g, '"')
|
|
1049
|
+
.replace(/'/g, ''');
|
|
1050
|
+
}
|
|
1051
|
+
// ─── Utility: Retrieve aggregate from context ───────────────────────
|
|
1052
|
+
/**
|
|
1053
|
+
* Public helper to retrieve a pre-computed aggregate from the report context.
|
|
1054
|
+
* Useful for custom renderers or post-processing.
|
|
1055
|
+
*
|
|
1056
|
+
* @param ctx - The report context after Pass 1
|
|
1057
|
+
* @param field - The field name
|
|
1058
|
+
* @param fn - The aggregate function
|
|
1059
|
+
* @param groupField - Optional group field name
|
|
1060
|
+
* @param groupKey - Optional group key value
|
|
1061
|
+
*/
|
|
1062
|
+
export function getAggregate(ctx, field, fn, groupField, groupKey) {
|
|
1063
|
+
const aggKey = makeAggKey(field, fn);
|
|
1064
|
+
if (groupField && groupKey) {
|
|
1065
|
+
const compositeKey = `${groupField}::${groupKey}`;
|
|
1066
|
+
const groupAggs = ctx.aggregates.get(compositeKey);
|
|
1067
|
+
if (groupAggs?.has(aggKey)) {
|
|
1068
|
+
return groupAggs.get(aggKey);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return ctx.grandTotals.get(aggKey) ?? 0;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Public helper to retrieve a running total value from the report context.
|
|
1075
|
+
*
|
|
1076
|
+
* @param ctx - The report context after Pass 2
|
|
1077
|
+
* @param runningTotalId - The running total definition id
|
|
1078
|
+
*/
|
|
1079
|
+
export function getRunningTotal(ctx, runningTotalId) {
|
|
1080
|
+
return ctx.runningTotals.get(runningTotalId) ?? 0;
|
|
1081
|
+
}
|
|
1082
|
+
//# sourceMappingURL=multi-pass-engine.js.map
|