@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.
Files changed (36) hide show
  1. package/dist/engine/conditional-format.d.ts +48 -0
  2. package/dist/engine/conditional-format.d.ts.map +1 -0
  3. package/dist/engine/conditional-format.js +167 -0
  4. package/dist/engine/conditional-format.js.map +1 -0
  5. package/dist/engine/cross-tab.d.ts +68 -0
  6. package/dist/engine/cross-tab.d.ts.map +1 -0
  7. package/dist/engine/cross-tab.js +549 -0
  8. package/dist/engine/cross-tab.js.map +1 -0
  9. package/dist/engine/expression.d.ts +46 -2
  10. package/dist/engine/expression.d.ts.map +1 -1
  11. package/dist/engine/expression.js +1415 -90
  12. package/dist/engine/expression.js.map +1 -1
  13. package/dist/engine/multi-pass-engine.d.ts +74 -0
  14. package/dist/engine/multi-pass-engine.d.ts.map +1 -0
  15. package/dist/engine/multi-pass-engine.js +1082 -0
  16. package/dist/engine/multi-pass-engine.js.map +1 -0
  17. package/dist/engine/running-totals.d.ts +74 -0
  18. package/dist/engine/running-totals.d.ts.map +1 -0
  19. package/dist/engine/running-totals.js +247 -0
  20. package/dist/engine/running-totals.js.map +1 -0
  21. package/dist/engine/subreport.d.ts +59 -0
  22. package/dist/engine/subreport.d.ts.map +1 -0
  23. package/dist/engine/subreport.js +295 -0
  24. package/dist/engine/subreport.js.map +1 -0
  25. package/dist/index.d.ts +11 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/schema/report-schema.d.ts +346 -346
  30. package/dist/serialization/json.d.ts +88 -88
  31. package/dist/templates/page-sizes.d.ts.map +1 -1
  32. package/dist/templates/page-sizes.js +95 -3
  33. package/dist/templates/page-sizes.js.map +1 -1
  34. package/dist/types.d.ts +38 -2
  35. package/dist/types.d.ts.map +1 -1
  36. 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, '&amp;')
1046
+ .replace(/</g, '&lt;')
1047
+ .replace(/>/g, '&gt;')
1048
+ .replace(/"/g, '&quot;')
1049
+ .replace(/'/g, '&#39;');
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