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.
- package/README.md +99 -2
- package/ng-package.json +7 -0
- package/package.json +23 -3
- package/src/lib/engine/barcode.ts +118 -0
- package/src/lib/engine/chart.ts +85 -0
- package/src/lib/engine/chartLayout.ts +265 -0
- package/src/lib/engine/crosstab.ts +96 -0
- package/src/lib/engine/dataSources.ts +204 -0
- package/src/lib/engine/expressions.ts +734 -0
- package/src/lib/engine/measure.ts +62 -0
- package/src/lib/engine/parameters.ts +96 -0
- package/src/lib/engine/qr.ts +26 -0
- package/src/lib/engine/renderer.ts +324 -0
- package/src/lib/engine/table.ts +58 -0
- package/src/lib/model/defaults.ts +249 -0
- package/src/lib/model/factories.ts +324 -0
- package/src/lib/model/types.ts +281 -0
- package/src/lib/ui/css.ts +12 -0
- package/src/lib/ui/designer/banded-table-dialog.component.ts +127 -0
- package/src/lib/ui/designer/canvas.component.ts +281 -0
- package/src/lib/ui/designer/data-source-dialog.component.ts +201 -0
- package/src/lib/ui/designer/element-box.component.ts +138 -0
- package/src/lib/ui/designer/graphics.components.ts +140 -0
- package/src/lib/ui/designer/left-panel.component.ts +189 -0
- package/src/lib/ui/designer/safe-image.pipe.ts +16 -0
- package/src/lib/ui/designer/table-view.component.ts +120 -0
- package/src/lib/ui/designer.component.ts +296 -0
- package/src/lib/ui/designer.store.ts +230 -0
- package/src/lib/ui/inputs.ts +34 -0
- package/src/lib/ui/myreport.css +267 -0
- package/src/lib/ui/properties/band-props.component.ts +86 -0
- package/src/lib/ui/properties/element-props.component.ts +326 -0
- package/src/lib/ui/properties/expr-input.component.ts +191 -0
- package/src/lib/ui/properties/graphics-props.component.ts +260 -0
- package/src/lib/ui/properties/image-picker.component.ts +62 -0
- package/src/lib/ui/properties/properties-panel.component.ts +37 -0
- package/src/lib/ui/properties/report-extras.component.ts +198 -0
- package/src/lib/ui/properties/report-props.component.ts +211 -0
- package/src/lib/ui/properties/table-props.component.ts +197 -0
- package/src/lib/ui/viewer/page-view.component.ts +73 -0
- package/src/lib/ui/viewer/render.ts +161 -0
- package/src/lib/ui/viewer/viewer.component.ts +555 -0
- package/src/lib/ui/viewer.component.ts +107 -0
- package/src/public-api.ts +12 -0
- package/tsconfig.lib.json +12 -0
- package/tsconfig.lib.prod.json +5 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
2
|
+
import type { ReportElement } from '../../model/types';
|
|
3
|
+
import type { Row } from '../../engine/expressions';
|
|
4
|
+
import { layoutChart } from '../../engine/chartLayout';
|
|
5
|
+
import { encodeBarcode } from '../../engine/barcode';
|
|
6
|
+
import { encodeQr } from '../../engine/qr';
|
|
7
|
+
|
|
8
|
+
/** Chart element: SVG drawn from the engine's layout primitives. Marks carry a hover tooltip. */
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'rpt-chart-view',
|
|
11
|
+
standalone: true,
|
|
12
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
13
|
+
host: { style: 'position:absolute;inset:0;display:block' },
|
|
14
|
+
template: `
|
|
15
|
+
<svg [attr.width]="el().w" [attr.height]="el().h" [attr.viewBox]="'0 0 ' + el().w + ' ' + el().h" [attr.font-family]="el().style.fontFamily" style="display:block;overflow:visible">
|
|
16
|
+
@for (p of prims(); track $index) {
|
|
17
|
+
@switch (p.t) {
|
|
18
|
+
@case ('path') {
|
|
19
|
+
<path [attr.d]="p.d" [attr.fill]="p.fill" [attr.stroke]="p.stroke ?? null" [attr.stroke-width]="p.sw ?? null" [attr.fill-opacity]="p.opacity ?? null">
|
|
20
|
+
@if (p.title) { <title>{{ p.title }}</title> }
|
|
21
|
+
</path>
|
|
22
|
+
}
|
|
23
|
+
@case ('line') {
|
|
24
|
+
<line [attr.x1]="p.x1" [attr.y1]="p.y1" [attr.x2]="p.x2" [attr.y2]="p.y2" [attr.stroke]="p.stroke" [attr.stroke-width]="p.sw" shape-rendering="crispEdges" />
|
|
25
|
+
}
|
|
26
|
+
@case ('poly') {
|
|
27
|
+
<polyline [attr.points]="p.points" fill="none" [attr.stroke]="p.stroke" [attr.stroke-width]="p.sw" stroke-linejoin="round" stroke-linecap="round" />
|
|
28
|
+
}
|
|
29
|
+
@case ('circle') {
|
|
30
|
+
<circle [attr.cx]="p.cx" [attr.cy]="p.cy" [attr.r]="p.r" [attr.fill]="p.fill" [attr.stroke]="p.stroke" [attr.stroke-width]="p.sw">
|
|
31
|
+
@if (p.title) { <title>{{ p.title }}</title> }
|
|
32
|
+
</circle>
|
|
33
|
+
}
|
|
34
|
+
@case ('text') {
|
|
35
|
+
<text [attr.x]="p.x" [attr.y]="p.y" [attr.text-anchor]="p.anchor" [attr.font-size]="p.size" [attr.font-weight]="p.weight" [attr.fill]="p.fill">{{ p.text }}</text>
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</svg>
|
|
40
|
+
`,
|
|
41
|
+
})
|
|
42
|
+
export class ChartViewComponent {
|
|
43
|
+
readonly el = input.required<ReportElement>();
|
|
44
|
+
readonly rows = input<Row[]>([]);
|
|
45
|
+
readonly prims = computed(() => {
|
|
46
|
+
const el = this.el();
|
|
47
|
+
return el.chart ? layoutChart(el.chart, this.rows(), el.w, el.h, el.style.fontSize, el.style.color) : [];
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Barcode element (Code 128 / EAN-13): bars as one crisp SVG path, optional human-readable text. */
|
|
52
|
+
@Component({
|
|
53
|
+
selector: 'rpt-barcode-view',
|
|
54
|
+
standalone: true,
|
|
55
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
56
|
+
host: { style: 'position:absolute;inset:0;display:block' },
|
|
57
|
+
template: `
|
|
58
|
+
<svg [attr.width]="el().w" [attr.height]="el().h" style="display:block">
|
|
59
|
+
@if (model(); as m) {
|
|
60
|
+
@if (m.error) {
|
|
61
|
+
<text [attr.x]="el().w / 2" [attr.y]="el().h / 2" text-anchor="middle" font-size="11" fill="#c4372f">{{ m.error }}</text>
|
|
62
|
+
} @else {
|
|
63
|
+
<path [attr.d]="m.d" [attr.fill]="color()" shape-rendering="crispEdges" />
|
|
64
|
+
@if (m.showText) {
|
|
65
|
+
<text [attr.x]="el().w / 2" [attr.y]="el().h - 2" text-anchor="middle" [attr.font-size]="el().style.fontSize" [attr.font-family]="el().style.fontFamily" [attr.fill]="el().style.color">{{ m.text }}</text>
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
</svg>
|
|
70
|
+
`,
|
|
71
|
+
})
|
|
72
|
+
export class BarcodeViewComponent {
|
|
73
|
+
readonly el = input.required<ReportElement>();
|
|
74
|
+
readonly value = input('');
|
|
75
|
+
readonly color = computed(() => this.el().barcode?.color || '#000000');
|
|
76
|
+
readonly model = computed(() => {
|
|
77
|
+
const el = this.el();
|
|
78
|
+
const spec = el.barcode ?? { symbology: 'code128' as const, showText: true, color: '#000' };
|
|
79
|
+
const res = encodeBarcode(spec.symbology, this.value());
|
|
80
|
+
if (res.error) return { error: res.error, d: '', text: '', showText: false };
|
|
81
|
+
const quiet = 10; // quiet zone in modules, each side
|
|
82
|
+
const total = res.modules.length + quiet * 2;
|
|
83
|
+
const mw = el.w / total;
|
|
84
|
+
const textH = spec.showText ? el.style.fontSize + 4 : 0;
|
|
85
|
+
const barH = Math.max(4, el.h - textH);
|
|
86
|
+
let d = '';
|
|
87
|
+
let i = 0;
|
|
88
|
+
while (i < res.modules.length) {
|
|
89
|
+
if (res.modules[i] !== '1') {
|
|
90
|
+
i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
let j = i;
|
|
94
|
+
while (j < res.modules.length && res.modules[j] === '1') j++;
|
|
95
|
+
const x = +((quiet + i) * mw).toFixed(3);
|
|
96
|
+
const bw = +((j - i) * mw).toFixed(3);
|
|
97
|
+
d += `M${x},0h${bw}v${barH}h${-bw}Z`;
|
|
98
|
+
i = j;
|
|
99
|
+
}
|
|
100
|
+
return { error: '', d, text: res.text, showText: spec.showText };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** QR code element: dark modules as one SVG path, centred, with a 4-module quiet zone. */
|
|
105
|
+
@Component({
|
|
106
|
+
selector: 'rpt-qr-view',
|
|
107
|
+
standalone: true,
|
|
108
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
109
|
+
host: { style: 'position:absolute;inset:0;display:block' },
|
|
110
|
+
template: `
|
|
111
|
+
<svg [attr.width]="el().w" [attr.height]="el().h" style="display:block">
|
|
112
|
+
@if (model(); as m) {
|
|
113
|
+
@if (m.error) {
|
|
114
|
+
<text [attr.x]="el().w / 2" [attr.y]="el().h / 2" text-anchor="middle" font-size="11" fill="#c4372f">{{ m.error }}</text>
|
|
115
|
+
} @else {
|
|
116
|
+
<path [attr.d]="m.d" [attr.fill]="el().qr?.color || '#000000'" shape-rendering="crispEdges" />
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
</svg>
|
|
120
|
+
`,
|
|
121
|
+
})
|
|
122
|
+
export class QrViewComponent {
|
|
123
|
+
readonly el = input.required<ReportElement>();
|
|
124
|
+
readonly value = input('');
|
|
125
|
+
readonly model = computed(() => {
|
|
126
|
+
const el = this.el();
|
|
127
|
+
const res = encodeQr(this.value(), el.qr?.errorCorrection ?? 'M');
|
|
128
|
+
if (res.error) return { error: res.error, d: '' };
|
|
129
|
+
const quiet = 4;
|
|
130
|
+
const cell = Math.min(el.w, el.h) / (res.size + quiet * 2);
|
|
131
|
+
const ox = (el.w - cell * res.size) / 2;
|
|
132
|
+
const oy = (el.h - cell * res.size) / 2;
|
|
133
|
+
const c3 = +cell.toFixed(3);
|
|
134
|
+
let d = '';
|
|
135
|
+
for (let r = 0; r < res.size; r++)
|
|
136
|
+
for (let c = 0; c < res.size; c++)
|
|
137
|
+
if (res.modules[r][c]) d += `M${+(ox + c * cell).toFixed(3)},${+(oy + r * cell).toFixed(3)}h${c3}v${c3}h${-c3}Z`;
|
|
138
|
+
return { error: '', d };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core';
|
|
2
|
+
import { contentWidth } from '../../model/defaults';
|
|
3
|
+
import { DesignerStore } from '../designer.store';
|
|
4
|
+
import { DRAG_MIME, elementFromPayload, type DragPayload } from './canvas.component';
|
|
5
|
+
|
|
6
|
+
const TOOLS: { kind: 'text' | 'line' | 'rect' | 'image'; label: string; icon: string }[] = [
|
|
7
|
+
{ kind: 'text', label: 'Label', icon: 'A' },
|
|
8
|
+
{ kind: 'line', label: 'Line', icon: '―' },
|
|
9
|
+
{ kind: 'rect', label: 'Rectangle', icon: '▭' },
|
|
10
|
+
{ kind: 'image', label: 'Image', icon: '🖼' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
type DataToolKind = 'table' | 'chart' | 'crosstab' | 'barcode' | 'qrcode';
|
|
14
|
+
const DATA_TOOLS: { kind: DataToolKind; label: string; icon: string; hint: string }[] = [
|
|
15
|
+
{ kind: 'chart', label: 'Chart', icon: '📊', hint: 'Bar, line, area, pie or doughnut chart over the rows of its band' },
|
|
16
|
+
{ kind: 'crosstab', label: 'Cross table', icon: '⊞', hint: 'Pivot: rows × columns of a value, with totals' },
|
|
17
|
+
{ kind: 'barcode', label: 'Barcode', icon: '║', hint: 'Code 128 or EAN-13; value can be a field like {OrderID}' },
|
|
18
|
+
{ kind: 'qrcode', label: 'QR code', icon: '▣', hint: 'QR code; value can contain fields' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SPECIALS: { label: string; template: string }[] = [
|
|
22
|
+
{ label: 'Page N of M', template: 'Page {PageNumber} of {TotalPages}' },
|
|
23
|
+
{ label: 'Page number', template: '{PageNumber}' },
|
|
24
|
+
{ label: 'Current date/time', template: '{Now}' },
|
|
25
|
+
{ label: 'Row number', template: '{RowNumber}' },
|
|
26
|
+
{ label: 'Report name', template: '{ReportName}' },
|
|
27
|
+
{ label: 'Record count', template: '{count()}' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const TYPE_ICON: Record<string, string> = { number: '#', string: 'Ab', date: '📅', boolean: '✓', object: '{}' };
|
|
31
|
+
|
|
32
|
+
@Component({
|
|
33
|
+
selector: 'rpt-left-panel',
|
|
34
|
+
standalone: true,
|
|
35
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
36
|
+
template: `
|
|
37
|
+
<aside class="panel left-panel">
|
|
38
|
+
<section>
|
|
39
|
+
<h3>Toolbox</h3>
|
|
40
|
+
<div class="toolbox">
|
|
41
|
+
<div
|
|
42
|
+
class="tool"
|
|
43
|
+
draggable="true"
|
|
44
|
+
title="Self-contained table: lists the rows of its band (all rows in Report Header/Footer, group rows in group bands)"
|
|
45
|
+
(dragstart)="dragStart($event, dataPayload('table'))"
|
|
46
|
+
(click)="add(dataPayload('table'))"
|
|
47
|
+
>
|
|
48
|
+
<span class="tool-icon">▦</span>Table
|
|
49
|
+
</div>
|
|
50
|
+
<div class="tool" title="Column titles in the header band, one field per column in Detail" (click)="openBandedTable.emit()">
|
|
51
|
+
<span class="tool-icon">☰</span>Header/Detail
|
|
52
|
+
</div>
|
|
53
|
+
@for (t of dataTools; track t.kind) {
|
|
54
|
+
<div class="tool" draggable="true" [title]="t.hint" (dragstart)="dragStart($event, dataPayload(t.kind))" (click)="add(dataPayload(t.kind))">
|
|
55
|
+
<span class="tool-icon">{{ t.icon }}</span>{{ t.label }}
|
|
56
|
+
</div>
|
|
57
|
+
}
|
|
58
|
+
@for (t of tools; track t.kind) {
|
|
59
|
+
<div class="tool" draggable="true" (dragstart)="dragStart($event, { kind: t.kind })" (click)="add({ kind: t.kind })">
|
|
60
|
+
<span class="tool-icon">{{ t.icon }}</span>{{ t.label }}
|
|
61
|
+
</div>
|
|
62
|
+
}
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<section class="grow">
|
|
67
|
+
<h3>
|
|
68
|
+
Data
|
|
69
|
+
<button class="link" (click)="openDataSource.emit()">Configure…</button>
|
|
70
|
+
</h3>
|
|
71
|
+
<div class="ds-summary">
|
|
72
|
+
{{ ds().type === 'odata' ? 'OData' : 'JSON' }}:
|
|
73
|
+
<span class="muted">{{ dsLabel() }}</span>
|
|
74
|
+
@if (ds().entitySet) {
|
|
75
|
+
<span class="muted"> / {{ ds().entitySet }}</span>
|
|
76
|
+
}
|
|
77
|
+
</div>
|
|
78
|
+
<div class="ds-status {{ data().status }}">
|
|
79
|
+
@switch (data().status) {
|
|
80
|
+
@case ('loading') { Loading… }
|
|
81
|
+
@case ('ready') { {{ data().rows.length }} rows, {{ data().fields.length }} fields }
|
|
82
|
+
@case ('error') { {{ data().error }} }
|
|
83
|
+
@default { No data source configured }
|
|
84
|
+
}
|
|
85
|
+
@if (data().status !== 'idle') {
|
|
86
|
+
<button class="link" (click)="store.reloadData()">Refresh</button>
|
|
87
|
+
}
|
|
88
|
+
</div>
|
|
89
|
+
<div class="field-list">
|
|
90
|
+
@for (f of data().fields; track f.name) {
|
|
91
|
+
<div
|
|
92
|
+
class="field-item"
|
|
93
|
+
draggable="true"
|
|
94
|
+
title="Drag onto a band or table (double-click adds to selected band). Numbers dropped in footers become sum()."
|
|
95
|
+
(dragstart)="dragStart($event, { kind: 'field', field: f.name, type: f.type })"
|
|
96
|
+
(dblclick)="add({ kind: 'field', field: f.name, type: f.type })"
|
|
97
|
+
>
|
|
98
|
+
<span class="field-type">{{ typeIcon[f.type] }}</span>{{ f.name }}
|
|
99
|
+
</div>
|
|
100
|
+
}
|
|
101
|
+
</div>
|
|
102
|
+
@if (data().docFields?.length) {
|
|
103
|
+
<h4>Document fields</h4>
|
|
104
|
+
<div class="field-list">
|
|
105
|
+
@for (f of data().docFields; track f.name) {
|
|
106
|
+
<div
|
|
107
|
+
class="field-item"
|
|
108
|
+
draggable="true"
|
|
109
|
+
title="A value of the whole document (not per row), e.g. a receipt number or shop name"
|
|
110
|
+
(dragstart)="dragStart($event, { kind: 'field', field: f.name, type: f.type })"
|
|
111
|
+
(dblclick)="add({ kind: 'field', field: f.name, type: f.type })"
|
|
112
|
+
>
|
|
113
|
+
<span class="field-type">{{ typeIcon[f.type] }}</span>{{ f.name.slice(5) }}
|
|
114
|
+
</div>
|
|
115
|
+
}
|
|
116
|
+
</div>
|
|
117
|
+
}
|
|
118
|
+
@if (params().length) {
|
|
119
|
+
<h4>Parameters</h4>
|
|
120
|
+
<div class="field-list">
|
|
121
|
+
@for (p of params(); track p.name) {
|
|
122
|
+
<div
|
|
123
|
+
class="field-item special"
|
|
124
|
+
draggable="true"
|
|
125
|
+
(dragstart)="dragStart($event, { kind: 'field', field: '', template: '{@' + p.name + '}' })"
|
|
126
|
+
(dblclick)="add({ kind: 'field', field: '', template: '{@' + p.name + '}' })"
|
|
127
|
+
>
|
|
128
|
+
<span class="field-type">@</span>{{ p.label || p.name }}
|
|
129
|
+
</div>
|
|
130
|
+
}
|
|
131
|
+
</div>
|
|
132
|
+
}
|
|
133
|
+
<h4>Special fields</h4>
|
|
134
|
+
<div class="field-list">
|
|
135
|
+
@for (s of specials; track s.label) {
|
|
136
|
+
<div
|
|
137
|
+
class="field-item special"
|
|
138
|
+
draggable="true"
|
|
139
|
+
(dragstart)="dragStart($event, { kind: 'field', field: '', template: s.template })"
|
|
140
|
+
(dblclick)="add({ kind: 'field', field: '', template: s.template })"
|
|
141
|
+
>
|
|
142
|
+
<span class="field-type">ƒ</span>{{ s.label }}
|
|
143
|
+
</div>
|
|
144
|
+
}
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
</aside>
|
|
148
|
+
`,
|
|
149
|
+
})
|
|
150
|
+
export class LeftPanelComponent {
|
|
151
|
+
protected readonly store = inject(DesignerStore);
|
|
152
|
+
readonly openDataSource = output<void>();
|
|
153
|
+
readonly openBandedTable = output<void>();
|
|
154
|
+
protected readonly dataTools = DATA_TOOLS;
|
|
155
|
+
|
|
156
|
+
protected readonly tools = TOOLS;
|
|
157
|
+
protected readonly specials = SPECIALS;
|
|
158
|
+
protected readonly typeIcon = TYPE_ICON;
|
|
159
|
+
protected readonly data = this.store.data;
|
|
160
|
+
protected readonly params = computed(() => (this.store.report().parameters ?? []).filter((p) => p.name));
|
|
161
|
+
protected readonly ds = computed(() => this.store.report().dataSource);
|
|
162
|
+
protected readonly dsLabel = computed(() => {
|
|
163
|
+
const ds = this.ds();
|
|
164
|
+
return ds.type === 'json' && ds.inline.trim() ? '(pasted JSON)' : ds.url || '(not set)';
|
|
165
|
+
});
|
|
166
|
+
/** Toolbox items that are pre-configured from the current data fields. */
|
|
167
|
+
protected dataPayload(kind: DataToolKind): DragPayload {
|
|
168
|
+
return { kind, fields: this.data().fields.filter((f) => f.type !== 'object') };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
protected dragStart(e: DragEvent, payload: DragPayload): void {
|
|
172
|
+
e.dataTransfer?.setData(DRAG_MIME, JSON.stringify(payload));
|
|
173
|
+
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Click / double-click: add to the selected band (or Detail) at the top-left. */
|
|
177
|
+
protected add(payload: DragPayload): void {
|
|
178
|
+
const report = this.store.report();
|
|
179
|
+
const band =
|
|
180
|
+
report.bands.find((b) => b.id === this.store.selection().bandId) ?? report.bands.find((b) => b.type === 'detail')!;
|
|
181
|
+
const el = elementFromPayload(payload, band, 0, 0, contentWidth(report.page));
|
|
182
|
+
this.store.update((r) => {
|
|
183
|
+
const b = r.bands.find((bb) => bb.id === band.id)!;
|
|
184
|
+
b.elements.push(el);
|
|
185
|
+
b.height = Math.max(b.height, el.h);
|
|
186
|
+
});
|
|
187
|
+
this.store.select({ elementIds: [el.id], bandId: band.id });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Pipe, PipeTransform, inject } from '@angular/core';
|
|
2
|
+
import { DomSanitizer, type SafeUrl } from '@angular/platform-browser';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Image source for <img [src]>. Angular's sanitizer rejects some data: images (e.g. SVG); uploads
|
|
6
|
+
* are embedded as data:image/… URLs, so those are trusted here. An <img> never runs scripts.
|
|
7
|
+
*/
|
|
8
|
+
@Pipe({ name: 'safeImage', standalone: true })
|
|
9
|
+
export class SafeImagePipe implements PipeTransform {
|
|
10
|
+
private readonly sanitizer = inject(DomSanitizer);
|
|
11
|
+
|
|
12
|
+
transform(url: string | null | undefined): SafeUrl | string {
|
|
13
|
+
if (!url) return '';
|
|
14
|
+
return /^data:image\//i.test(url) ? this.sanitizer.bypassSecurityTrustUrl(url) : url;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
2
|
+
import type { ReportElement } from '../../model/types';
|
|
3
|
+
import { columnWidths, hasGrid, type TableRenderData } from '../../engine/table';
|
|
4
|
+
import { LINE_HEIGHT } from '../../engine/measure';
|
|
5
|
+
import { css } from '../css';
|
|
6
|
+
|
|
7
|
+
export type { TableRenderData };
|
|
8
|
+
|
|
9
|
+
/** Data for drawing a table in the designer: the column templates themselves. */
|
|
10
|
+
export function designTableData(el: ReportElement): TableRenderData {
|
|
11
|
+
const t = el.table!;
|
|
12
|
+
return {
|
|
13
|
+
header: t.showHeader ? t.columns.map((c) => c.header) : null,
|
|
14
|
+
headerHeight: t.headerHeight,
|
|
15
|
+
rows: [{ cells: t.columns.map((c) => c.text), height: t.rowHeight, index: 0 }],
|
|
16
|
+
footer: t.showFooter ? t.columns.map((c) => c.footer) : null,
|
|
17
|
+
footerHeight: t.rowHeight,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const JUSTIFY = { left: 'flex-start', center: 'center', right: 'flex-end' } as const;
|
|
22
|
+
const ALIGN_ITEMS = { top: 'flex-start', middle: 'center', bottom: 'flex-end' } as const;
|
|
23
|
+
|
|
24
|
+
/** Draws the inside of a table element (fills its parent box). Shared by designer and preview. */
|
|
25
|
+
@Component({
|
|
26
|
+
selector: 'rpt-table-view',
|
|
27
|
+
standalone: true,
|
|
28
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
29
|
+
host: { '[style]': 'hostStyle()' },
|
|
30
|
+
template: `
|
|
31
|
+
@if (data().header; as header) {
|
|
32
|
+
<div [style]="headerRowStyle()">
|
|
33
|
+
@for (h of header; track $index) {
|
|
34
|
+
<div [style]="cellStyles()[$index]"><span style="width:100%">{{ h }}</span></div>
|
|
35
|
+
}
|
|
36
|
+
</div>
|
|
37
|
+
}
|
|
38
|
+
@for (r of data().rows; track r.index) {
|
|
39
|
+
<div [style]="rowStyle(r.index, r.height)">
|
|
40
|
+
@for (c of r.cells; track $index) {
|
|
41
|
+
<div [style]="cellStyles()[$index]"><span style="width:100%">{{ c }}</span></div>
|
|
42
|
+
}
|
|
43
|
+
</div>
|
|
44
|
+
}
|
|
45
|
+
@if (data().footer; as footer) {
|
|
46
|
+
<div [style]="footerRowStyle()">
|
|
47
|
+
@for (f of footer; track $index) {
|
|
48
|
+
<div [style]="cellStyles()[$index]"><span style="width:100%">{{ f }}</span></div>
|
|
49
|
+
}
|
|
50
|
+
</div>
|
|
51
|
+
}
|
|
52
|
+
`,
|
|
53
|
+
})
|
|
54
|
+
export class TableViewComponent {
|
|
55
|
+
readonly el = input.required<ReportElement>();
|
|
56
|
+
readonly data = input.required<TableRenderData>();
|
|
57
|
+
|
|
58
|
+
private readonly line = computed(() => {
|
|
59
|
+
const t = this.el().table!;
|
|
60
|
+
return hasGrid(t) ? `1px solid ${t.gridColor}` : undefined;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
readonly hostStyle = computed(() => {
|
|
64
|
+
const s = this.el().style;
|
|
65
|
+
return css({
|
|
66
|
+
position: 'absolute',
|
|
67
|
+
inset: 0,
|
|
68
|
+
display: 'block',
|
|
69
|
+
borderTop: this.line(),
|
|
70
|
+
borderLeft: this.line(),
|
|
71
|
+
fontFamily: s.fontFamily,
|
|
72
|
+
fontSize: s.fontSize,
|
|
73
|
+
fontWeight: s.bold ? 700 : 400,
|
|
74
|
+
fontStyle: s.italic ? 'italic' : 'normal',
|
|
75
|
+
color: s.color,
|
|
76
|
+
overflow: 'hidden',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
readonly cellStyles = computed(() => {
|
|
81
|
+
const el = this.el();
|
|
82
|
+
const t = el.table!;
|
|
83
|
+
const widths = columnWidths(el);
|
|
84
|
+
return t.columns.map((c, i) =>
|
|
85
|
+
css({
|
|
86
|
+
width: widths[i],
|
|
87
|
+
flex: 'none',
|
|
88
|
+
display: 'flex',
|
|
89
|
+
justifyContent: JUSTIFY[c.align],
|
|
90
|
+
alignItems: ALIGN_ITEMS[el.style.vAlign],
|
|
91
|
+
textAlign: c.align,
|
|
92
|
+
padding: el.style.padding,
|
|
93
|
+
borderRight: this.line(),
|
|
94
|
+
borderBottom: this.line(),
|
|
95
|
+
overflow: 'hidden',
|
|
96
|
+
whiteSpace: 'pre-wrap',
|
|
97
|
+
wordBreak: 'break-word',
|
|
98
|
+
lineHeight: LINE_HEIGHT,
|
|
99
|
+
boxSizing: 'border-box',
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
readonly headerRowStyle = computed(() => {
|
|
105
|
+
const t = this.el().table!;
|
|
106
|
+
return css({ display: 'flex', height: this.data().headerHeight, background: t.headerBackground, color: t.headerColor, fontWeight: 700 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
readonly footerRowStyle = computed(() => {
|
|
110
|
+
const t = this.el().table!;
|
|
111
|
+
const border = hasGrid(t) ? t.gridColor : this.el().style.color;
|
|
112
|
+
return css({ display: 'flex', height: this.data().footerHeight, fontWeight: 700, borderTop: `2px solid ${border}`, boxSizing: 'border-box' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
rowStyle(index: number, height: number): string {
|
|
116
|
+
const t = this.el().table!;
|
|
117
|
+
const bg = index % 2 === 1 && t.stripeBackground !== 'transparent' ? t.stripeBackground : this.el().style.background;
|
|
118
|
+
return css({ display: 'flex', height, background: bg });
|
|
119
|
+
}
|
|
120
|
+
}
|