@nowline/renderer 0.8.5 → 0.9.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/image-size.d.ts +9 -0
- package/dist/image-size.d.ts.map +1 -0
- package/dist/image-size.js +104 -0
- package/dist/image-size.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/logo.d.ts +24 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +60 -0
- package/dist/logo.js.map +1 -0
- package/dist/stage.d.ts +78 -0
- package/dist/stage.d.ts.map +1 -0
- package/dist/stage.js +88 -0
- package/dist/stage.js.map +1 -0
- package/dist/svg/edges.d.ts +3 -0
- package/dist/svg/edges.d.ts.map +1 -0
- package/dist/svg/edges.js +144 -0
- package/dist/svg/edges.js.map +1 -0
- package/dist/svg/footnotes.d.ts +4 -0
- package/dist/svg/footnotes.d.ts.map +1 -0
- package/dist/svg/footnotes.js +81 -0
- package/dist/svg/footnotes.js.map +1 -0
- package/dist/svg/header.d.ts +6 -0
- package/dist/svg/header.d.ts.map +1 -0
- package/dist/svg/header.js +215 -0
- package/dist/svg/header.js.map +1 -0
- package/dist/svg/ids.d.ts.map +1 -1
- package/dist/svg/ids.js.map +1 -1
- package/dist/svg/include-region.d.ts +4 -0
- package/dist/svg/include-region.d.ts.map +1 -0
- package/dist/svg/include-region.js +157 -0
- package/dist/svg/include-region.js.map +1 -0
- package/dist/svg/item.d.ts +36 -0
- package/dist/svg/item.d.ts.map +1 -0
- package/dist/svg/item.js +549 -0
- package/dist/svg/item.js.map +1 -0
- package/dist/svg/markers.d.ts +7 -0
- package/dist/svg/markers.d.ts.map +1 -0
- package/dist/svg/markers.js +151 -0
- package/dist/svg/markers.js.map +1 -0
- package/dist/svg/nowline.d.ts +4 -0
- package/dist/svg/nowline.d.ts.map +1 -0
- package/dist/svg/nowline.js +133 -0
- package/dist/svg/nowline.js.map +1 -0
- package/dist/svg/primitives.d.ts +7 -0
- package/dist/svg/primitives.d.ts.map +1 -0
- package/dist/svg/primitives.js +70 -0
- package/dist/svg/primitives.js.map +1 -0
- package/dist/svg/render.d.ts +13 -7
- package/dist/svg/render.d.ts.map +1 -1
- package/dist/svg/render.js +64 -1701
- package/dist/svg/render.js.map +1 -1
- package/dist/svg/sanitize.d.ts +10 -0
- package/dist/svg/sanitize.d.ts.map +1 -1
- package/dist/svg/sanitize.js +264 -16
- package/dist/svg/sanitize.js.map +1 -1
- package/dist/svg/swimlanes.d.ts +5 -0
- package/dist/svg/swimlanes.d.ts.map +1 -0
- package/dist/svg/swimlanes.js +228 -0
- package/dist/svg/swimlanes.js.map +1 -0
- package/dist/svg/timeline.d.ts +24 -0
- package/dist/svg/timeline.d.ts.map +1 -0
- package/dist/svg/timeline.js +284 -0
- package/dist/svg/timeline.js.map +1 -0
- package/dist/svg/waves.d.ts +62 -0
- package/dist/svg/waves.d.ts.map +1 -0
- package/dist/svg/waves.js +397 -0
- package/dist/svg/waves.js.map +1 -0
- package/package.json +7 -7
- package/src/image-size.ts +113 -0
- package/src/index.ts +10 -1
- package/src/logo.ts +82 -0
- package/src/stage.ts +191 -0
- package/src/svg/edges.ts +154 -0
- package/src/svg/footnotes.ts +115 -0
- package/src/svg/header.ts +285 -0
- package/src/svg/include-region.ts +201 -0
- package/src/svg/item.ts +672 -0
- package/src/svg/markers.ts +178 -0
- package/src/svg/nowline.ts +152 -0
- package/src/svg/primitives.ts +86 -0
- package/src/svg/render.ts +94 -2110
- package/src/svg/sanitize.ts +286 -16
- package/src/svg/swimlanes.ts +317 -0
- package/src/svg/timeline.ts +343 -0
- package/src/svg/waves.ts +502 -0
package/src/svg/sanitize.ts
CHANGED
|
@@ -75,7 +75,6 @@ const ALLOWED_ATTRIBUTES = new Set([
|
|
|
75
75
|
'transform',
|
|
76
76
|
'points',
|
|
77
77
|
'id',
|
|
78
|
-
'class',
|
|
79
78
|
'clip-path',
|
|
80
79
|
'mask',
|
|
81
80
|
'filter',
|
|
@@ -89,7 +88,6 @@ const ALLOWED_ATTRIBUTES = new Set([
|
|
|
89
88
|
'rotate',
|
|
90
89
|
'letter-spacing',
|
|
91
90
|
'word-spacing',
|
|
92
|
-
'style',
|
|
93
91
|
'xmlns',
|
|
94
92
|
'href',
|
|
95
93
|
'xlink:href',
|
|
@@ -114,8 +112,129 @@ const ALLOWED_ATTRIBUTES = new Set([
|
|
|
114
112
|
'patternContentUnits',
|
|
115
113
|
'role',
|
|
116
114
|
'aria-label',
|
|
115
|
+
// Presentation attributes beyond the ones above, so the same set can
|
|
116
|
+
// arrive from a `style` declaration (see PRESENTATION_PROPERTIES).
|
|
117
|
+
'display',
|
|
118
|
+
'visibility',
|
|
119
|
+
'clip-rule',
|
|
120
|
+
'color',
|
|
121
|
+
'font-variant',
|
|
122
|
+
'font-stretch',
|
|
123
|
+
'text-decoration',
|
|
124
|
+
'dominant-baseline',
|
|
125
|
+
'alignment-baseline',
|
|
126
|
+
'baseline-shift',
|
|
127
|
+
'writing-mode',
|
|
128
|
+
'paint-order',
|
|
129
|
+
'vector-effect',
|
|
130
|
+
'shape-rendering',
|
|
131
|
+
'text-rendering',
|
|
132
|
+
'image-rendering',
|
|
133
|
+
'color-interpolation',
|
|
134
|
+
'color-interpolation-filters',
|
|
117
135
|
]);
|
|
118
136
|
|
|
137
|
+
// CSS properties a `style` attribute or `<style>` class rule may set. Each
|
|
138
|
+
// becomes the presentation attribute of the same name, then goes through
|
|
139
|
+
// the same attribute checks as one written directly. Editors (Inkscape,
|
|
140
|
+
// Illustrator, Figma) put a logo's colors here, so dropping them would draw
|
|
141
|
+
// the logo black.
|
|
142
|
+
const PRESENTATION_PROPERTIES = new Set([
|
|
143
|
+
'fill',
|
|
144
|
+
'fill-opacity',
|
|
145
|
+
'fill-rule',
|
|
146
|
+
'stroke',
|
|
147
|
+
'stroke-width',
|
|
148
|
+
'stroke-opacity',
|
|
149
|
+
'stroke-linecap',
|
|
150
|
+
'stroke-linejoin',
|
|
151
|
+
'stroke-miterlimit',
|
|
152
|
+
'stroke-dasharray',
|
|
153
|
+
'stroke-dashoffset',
|
|
154
|
+
'opacity',
|
|
155
|
+
'clip-path',
|
|
156
|
+
'clip-rule',
|
|
157
|
+
'mask',
|
|
158
|
+
'filter',
|
|
159
|
+
'font-family',
|
|
160
|
+
'font-size',
|
|
161
|
+
'font-weight',
|
|
162
|
+
'font-style',
|
|
163
|
+
'font-variant',
|
|
164
|
+
'font-stretch',
|
|
165
|
+
'text-anchor',
|
|
166
|
+
'text-decoration',
|
|
167
|
+
'letter-spacing',
|
|
168
|
+
'word-spacing',
|
|
169
|
+
'dominant-baseline',
|
|
170
|
+
'alignment-baseline',
|
|
171
|
+
'baseline-shift',
|
|
172
|
+
'writing-mode',
|
|
173
|
+
'stop-color',
|
|
174
|
+
'stop-opacity',
|
|
175
|
+
'flood-color',
|
|
176
|
+
'flood-opacity',
|
|
177
|
+
'display',
|
|
178
|
+
'visibility',
|
|
179
|
+
'color',
|
|
180
|
+
'paint-order',
|
|
181
|
+
'vector-effect',
|
|
182
|
+
'shape-rendering',
|
|
183
|
+
'text-rendering',
|
|
184
|
+
'image-rendering',
|
|
185
|
+
'color-interpolation',
|
|
186
|
+
'color-interpolation-filters',
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
// CSS properties dropped without a warning: they have no effect on how an
|
|
190
|
+
// SVG 1.1 renderer draws the logo (editor hints, typographic settings for
|
|
191
|
+
// features we do not draw, text layout that `text-anchor` already carries).
|
|
192
|
+
const IGNORED_STYLE_PROPERTIES = new Set([
|
|
193
|
+
'line-height',
|
|
194
|
+
'text-align',
|
|
195
|
+
'font-variant-ligatures',
|
|
196
|
+
'font-variant-caps',
|
|
197
|
+
'font-variant-numeric',
|
|
198
|
+
'font-variant-east-asian',
|
|
199
|
+
'font-variant-position',
|
|
200
|
+
'font-variant-alternates',
|
|
201
|
+
'font-feature-settings',
|
|
202
|
+
'font-variation-settings',
|
|
203
|
+
'font-kerning',
|
|
204
|
+
'white-space',
|
|
205
|
+
'text-orientation',
|
|
206
|
+
'overflow',
|
|
207
|
+
'isolation',
|
|
208
|
+
'solid-color',
|
|
209
|
+
'solid-opacity',
|
|
210
|
+
'enable-background',
|
|
211
|
+
'color-rendering',
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
// Values that leave an unsupported property at its default, so dropping
|
|
215
|
+
// it changes nothing (`mix-blend-mode:normal`, `marker:none`).
|
|
216
|
+
const NO_OP_VALUES = new Set(['none', 'normal', 'auto', 'initial', 'inherit', 'unset']);
|
|
217
|
+
|
|
218
|
+
// Attributes dropped without a warning: editor and document bookkeeping
|
|
219
|
+
// that never draws (`inkscape:label`, `sodipodi:docname`, `xmlns:*`,
|
|
220
|
+
// `xml:space`, `data-name`, `version`).
|
|
221
|
+
function isMetadataAttribute(key: string): boolean {
|
|
222
|
+
if (key === 'xlink:href') return false;
|
|
223
|
+
return (
|
|
224
|
+
key.includes(':') ||
|
|
225
|
+
key.startsWith('data-') ||
|
|
226
|
+
key === 'version' ||
|
|
227
|
+
key === 'baseProfile' ||
|
|
228
|
+
key === 'enable-background'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Elements dropped without a warning: `<metadata>` and editor-namespaced
|
|
233
|
+
// elements (`<sodipodi:namedview>`, `<inkscape:page>`, `<rdf:RDF>`).
|
|
234
|
+
function isMetadataElement(name: string): boolean {
|
|
235
|
+
return name === 'metadata' || (name.includes(':') && !name.startsWith('svg:'));
|
|
236
|
+
}
|
|
237
|
+
|
|
119
238
|
export interface SanitizeOptions {
|
|
120
239
|
// Rewrite internal ids under this prefix to avoid collisions with the
|
|
121
240
|
// host document. The rewrite is both on `id=` and on references
|
|
@@ -123,8 +242,13 @@ export interface SanitizeOptions {
|
|
|
123
242
|
idPrefix?: string;
|
|
124
243
|
// Called when the sanitizer rejects a construct, for test/diagnostic use.
|
|
125
244
|
onWarn?: (message: string) => void;
|
|
245
|
+
// Drop `x`, `y`, `width` and `height` from the root `<svg>` so it fills
|
|
246
|
+
// whatever viewport the host wraps it in (logo scaling).
|
|
247
|
+
stripRootSize?: boolean;
|
|
126
248
|
}
|
|
127
249
|
|
|
250
|
+
const ROOT_SIZE_ATTRIBUTES = new Set(['x', 'y', 'width', 'height']);
|
|
251
|
+
|
|
128
252
|
interface Token {
|
|
129
253
|
kind: 'open' | 'close' | 'self' | 'text' | 'cdata' | 'comment' | 'decl';
|
|
130
254
|
name?: string;
|
|
@@ -282,6 +406,17 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
|
|
|
282
406
|
const warn = options.onWarn ?? ((): void => {});
|
|
283
407
|
const tokens = tokenize(input);
|
|
284
408
|
|
|
409
|
+
// Class rules from every `<style>` element, applied below as attributes.
|
|
410
|
+
// The `<style>` element itself is never emitted.
|
|
411
|
+
let sheet = '';
|
|
412
|
+
let styleDepth = 0;
|
|
413
|
+
for (const t of tokens) {
|
|
414
|
+
if (t.kind === 'open' && t.name === 'style') styleDepth++;
|
|
415
|
+
else if (t.kind === 'close' && t.name === 'style') styleDepth = Math.max(0, styleDepth - 1);
|
|
416
|
+
else if (styleDepth > 0 && (t.kind === 'text' || t.kind === 'cdata')) sheet += t.text ?? '';
|
|
417
|
+
}
|
|
418
|
+
const classRules = parseClassRules(sheet, (what) => warn(`sanitizer: dropping ${what}`));
|
|
419
|
+
|
|
285
420
|
// First pass: discover ids so the second pass can rewrite references.
|
|
286
421
|
const idMap = new Map<string, string>();
|
|
287
422
|
let idCounter = 0;
|
|
@@ -329,19 +464,31 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
|
|
|
329
464
|
if (t.kind === 'open') stack.push(false);
|
|
330
465
|
continue;
|
|
331
466
|
}
|
|
467
|
+
if (name === 'style' || isMetadataElement(name)) {
|
|
468
|
+
if (t.kind === 'open') stack.push(false);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
332
471
|
if (!ALLOWED_ELEMENTS.has(name)) {
|
|
333
472
|
warn(`sanitizer: dropping unknown element <${name}>`);
|
|
334
473
|
if (t.kind === 'open') stack.push(false);
|
|
335
474
|
continue;
|
|
336
475
|
}
|
|
337
476
|
const parts: string[] = [`<${name}`];
|
|
338
|
-
const
|
|
477
|
+
const isRoot = name === 'svg' && stack.length === 0;
|
|
478
|
+
const merged = applyStyles(t.attrs ?? {}, classRules, warn);
|
|
479
|
+
const keys = Object.keys(merged).sort();
|
|
339
480
|
for (const key of keys) {
|
|
340
|
-
const value =
|
|
481
|
+
const value = merged[key];
|
|
482
|
+
if (isRoot && options.stripRootSize && ROOT_SIZE_ATTRIBUTES.has(key)) continue;
|
|
341
483
|
if (/^on/i.test(key)) {
|
|
342
484
|
warn(`sanitizer: dropping event handler ${key}`);
|
|
343
485
|
continue;
|
|
344
486
|
}
|
|
487
|
+
if (isMetadataAttribute(key)) continue;
|
|
488
|
+
// `applyStyles` has folded the `<style>` rules into attributes,
|
|
489
|
+
// so `class` is spent bookkeeping. Keeping it would let CSS on
|
|
490
|
+
// the host page (another inline SVG's `.cls-1`) restyle the logo.
|
|
491
|
+
if (key === 'class') continue;
|
|
345
492
|
if (!ALLOWED_ATTRIBUTES.has(key)) {
|
|
346
493
|
warn(`sanitizer: dropping attribute ${key}`);
|
|
347
494
|
continue;
|
|
@@ -355,30 +502,33 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
|
|
|
355
502
|
parts.push(` ${key}="${escAttrValue(rewritten)}"`);
|
|
356
503
|
continue;
|
|
357
504
|
}
|
|
358
|
-
if (key === 'style') {
|
|
359
|
-
// CSS can smuggle `expression()` or url() — strip entirely.
|
|
360
|
-
warn('sanitizer: dropping inline style');
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
505
|
if (key === 'id') {
|
|
364
506
|
const mapped = idMap.get(value);
|
|
365
507
|
if (mapped) parts.push(` id="${escAttrValue(mapped)}"`);
|
|
366
508
|
continue;
|
|
367
509
|
}
|
|
510
|
+
// Reject raster data URIs, script URLs, any url() that is not
|
|
511
|
+
// a local fragment (an external fetch), and CSS escapes or
|
|
512
|
+
// constructs a style value could smuggle in. Local
|
|
513
|
+
// `url(#foo)` references are checked out of the value first.
|
|
514
|
+
const localRef = /url\(#([^)]+)\)/gi;
|
|
515
|
+
if (
|
|
516
|
+
/data:|javascript:|vbscript:|url\(|expression\s*\(|[\\{}]/i.test(
|
|
517
|
+
value.replace(localRef, ''),
|
|
518
|
+
)
|
|
519
|
+
) {
|
|
520
|
+
warn(`sanitizer: dropping suspicious value at ${key}`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
368
523
|
// Rewrite any `url(#foo)` embedded inside fill/stroke/etc.
|
|
369
|
-
if (
|
|
370
|
-
const rewritten = value.replace(
|
|
524
|
+
if (/url\(#/i.test(value)) {
|
|
525
|
+
const rewritten = value.replace(localRef, (_m, id) => {
|
|
371
526
|
const mapped = idMap.get(id);
|
|
372
527
|
return mapped ? `url(#${mapped})` : 'none';
|
|
373
528
|
});
|
|
374
529
|
parts.push(` ${key}="${escAttrValue(rewritten)}"`);
|
|
375
530
|
continue;
|
|
376
531
|
}
|
|
377
|
-
// Reject raster data URIs at non-href attributes.
|
|
378
|
-
if (/data:|javascript:|vbscript:/i.test(value)) {
|
|
379
|
-
warn(`sanitizer: dropping suspicious value at ${key}`);
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
532
|
parts.push(` ${key}="${escAttrValue(value)}"`);
|
|
383
533
|
}
|
|
384
534
|
parts.push(t.kind === 'self' ? '/>' : '>');
|
|
@@ -393,3 +543,123 @@ export function sanitizeSvg(input: string, options: SanitizeOptions = {}): strin
|
|
|
393
543
|
}
|
|
394
544
|
return output.join('');
|
|
395
545
|
}
|
|
546
|
+
|
|
547
|
+
// A unitless or `px` length, else undefined (percentages and absolute units
|
|
548
|
+
// carry no intrinsic pixel size).
|
|
549
|
+
function parsePxLength(raw: string | undefined): number | undefined {
|
|
550
|
+
if (raw === undefined) return undefined;
|
|
551
|
+
const m = /^\s*([0-9]*\.?[0-9]+(?:e[+-]?[0-9]+)?)\s*(px)?\s*$/i.exec(raw);
|
|
552
|
+
if (!m) return undefined;
|
|
553
|
+
const n = Number(m[1]);
|
|
554
|
+
return n > 0 && Number.isFinite(n) ? n : undefined;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Intrinsic size of an SVG document from its root `<svg>`: `width` and
|
|
559
|
+
* `height` when both are plain lengths, else the `viewBox` size. Undefined
|
|
560
|
+
* when neither gives a positive size (no root, `%` sizes and no viewBox).
|
|
561
|
+
*/
|
|
562
|
+
export function svgIntrinsicSize(input: string): { width: number; height: number } | undefined {
|
|
563
|
+
const root = tokenize(input).find(
|
|
564
|
+
(t) => (t.kind === 'open' || t.kind === 'self') && t.name === 'svg',
|
|
565
|
+
);
|
|
566
|
+
if (!root) return undefined;
|
|
567
|
+
const attrs = root.attrs ?? {};
|
|
568
|
+
const width = parsePxLength(attrs.width);
|
|
569
|
+
const height = parsePxLength(attrs.height);
|
|
570
|
+
if (width !== undefined && height !== undefined) return { width, height };
|
|
571
|
+
const vb = (attrs.viewBox ?? '')
|
|
572
|
+
.trim()
|
|
573
|
+
.split(/[\s,]+/)
|
|
574
|
+
.map(Number);
|
|
575
|
+
if (vb.length === 4 && vb.every(Number.isFinite) && vb[2] > 0 && vb[3] > 0) {
|
|
576
|
+
return { width: vb[2], height: vb[3] };
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
interface StyleDeclaration {
|
|
582
|
+
property: string;
|
|
583
|
+
value: string;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// `a: b; c: d` into declarations, `!important` stripped. Malformed parts
|
|
587
|
+
// (no colon) are skipped.
|
|
588
|
+
function parseDeclarations(text: string): StyleDeclaration[] {
|
|
589
|
+
const out: StyleDeclaration[] = [];
|
|
590
|
+
for (const part of text.split(';')) {
|
|
591
|
+
const colon = part.indexOf(':');
|
|
592
|
+
if (colon < 0) continue;
|
|
593
|
+
const property = part.slice(0, colon).trim().toLowerCase();
|
|
594
|
+
const value = part
|
|
595
|
+
.slice(colon + 1)
|
|
596
|
+
.replace(/!\s*important\s*$/i, '')
|
|
597
|
+
.trim();
|
|
598
|
+
if (property && value) out.push({ property, value });
|
|
599
|
+
}
|
|
600
|
+
return out;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
interface ClassRule {
|
|
604
|
+
classes: string[];
|
|
605
|
+
declarations: StyleDeclaration[];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The subset of a `<style>` sheet we apply: rules whose selectors are all
|
|
609
|
+
// plain class selectors (`.cls-1, .cls-2 { ... }`), the shape Illustrator
|
|
610
|
+
// and Figma export. Anything else (element, id or compound selectors,
|
|
611
|
+
// at-rules) is reported through `onUnsupported` and not applied.
|
|
612
|
+
function parseClassRules(sheet: string, onUnsupported: (what: string) => void): ClassRule[] {
|
|
613
|
+
const rules: ClassRule[] = [];
|
|
614
|
+
const text = sheet.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
615
|
+
const rulePattern = /([^{}]+)\{([^{}]*)\}/g;
|
|
616
|
+
for (const m of text.matchAll(rulePattern)) {
|
|
617
|
+
const selectorText = m[1].trim();
|
|
618
|
+
const selectors = selectorText.split(',').map((sel) => sel.trim());
|
|
619
|
+
if (
|
|
620
|
+
selectorText.startsWith('@') ||
|
|
621
|
+
!selectors.every((sel) => /^\.[A-Za-z_][\w-]*$/.test(sel))
|
|
622
|
+
) {
|
|
623
|
+
onUnsupported(`<style> rule ${selectorText}`);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
rules.push({
|
|
627
|
+
classes: selectors.map((sel) => sel.slice(1)),
|
|
628
|
+
declarations: parseDeclarations(m[2]),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
if (/@[a-z-]+[^{;]*;/i.test(text)) onUnsupported('<style> at-rule');
|
|
632
|
+
return rules;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// An element's attributes with its styles folded in, in CSS cascade order:
|
|
636
|
+
// presentation attributes, then matching class rules in sheet order, then
|
|
637
|
+
// the inline `style`. Only PRESENTATION_PROPERTIES survive as attributes;
|
|
638
|
+
// other properties are dropped, with a warning unless ignorable.
|
|
639
|
+
function applyStyles(
|
|
640
|
+
attrs: Record<string, string>,
|
|
641
|
+
classRules: ClassRule[],
|
|
642
|
+
warn: (message: string) => void,
|
|
643
|
+
): Record<string, string> {
|
|
644
|
+
const classes = new Set((attrs.class ?? '').split(/\s+/).filter(Boolean));
|
|
645
|
+
const declarations: StyleDeclaration[] = [];
|
|
646
|
+
for (const rule of classRules) {
|
|
647
|
+
if (rule.classes.some((c) => classes.has(c))) declarations.push(...rule.declarations);
|
|
648
|
+
}
|
|
649
|
+
if (attrs.style !== undefined) declarations.push(...parseDeclarations(attrs.style));
|
|
650
|
+
if (declarations.length === 0 && attrs.style === undefined) return attrs;
|
|
651
|
+
const merged: Record<string, string> = { ...attrs };
|
|
652
|
+
delete merged.style;
|
|
653
|
+
for (const { property, value } of declarations) {
|
|
654
|
+
if (PRESENTATION_PROPERTIES.has(property)) {
|
|
655
|
+
merged[property] = value;
|
|
656
|
+
} else if (
|
|
657
|
+
!property.startsWith('-') &&
|
|
658
|
+
!IGNORED_STYLE_PROPERTIES.has(property) &&
|
|
659
|
+
!NO_OP_VALUES.has(value.toLowerCase())
|
|
660
|
+
) {
|
|
661
|
+
warn(`sanitizer: dropping style property ${property}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return merged;
|
|
665
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// The swimlane layers: the lane background tint and tri-state utilization
|
|
2
|
+
// underline, the lane frame tab and badges, and the group and parallel
|
|
3
|
+
// containers that wrap a lane's items.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
PositionedGroup,
|
|
7
|
+
PositionedParallel,
|
|
8
|
+
PositionedSwimlane,
|
|
9
|
+
PositionedTrackChild,
|
|
10
|
+
Theme,
|
|
11
|
+
} from '@nowline/layout';
|
|
12
|
+
import {
|
|
13
|
+
CONTAINER_BODY_FILL_OPACITY,
|
|
14
|
+
CONTAINER_BRACKET_LINE_OFFSET_PX,
|
|
15
|
+
CONTAINER_HEADER_TITLE_BASELINE_PX,
|
|
16
|
+
CONTAINER_HEADER_TITLE_FONT_SIZE_PX,
|
|
17
|
+
CONTAINER_MAX_FILLED_DEPTH,
|
|
18
|
+
CONTAINER_NESTED_BODY_FILL_OPACITY,
|
|
19
|
+
containerHeaderTitleX,
|
|
20
|
+
FRAME_TAB_HEIGHT_PX,
|
|
21
|
+
FRAME_TAB_LABEL_BASELINE_OFFSET_PX,
|
|
22
|
+
FRAME_TAB_OWNER_GLYPH_PX,
|
|
23
|
+
} from '@nowline/layout';
|
|
24
|
+
import { CAPACITY_ICON_SVG } from './icons.js';
|
|
25
|
+
import { hasAfterPin, renderCapacitySuffix, renderInlineDatePins, renderItem } from './item.js';
|
|
26
|
+
import { fontAttrs } from './primitives.js';
|
|
27
|
+
import type { FontFamilies, RenderOptions } from './render.js';
|
|
28
|
+
import { escAttr, num, tag, textTag } from './xml.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A group or parallel, drawn above and around its content
|
|
32
|
+
* (specs/true-width-layout.md §3.3): a body shaded with the container's `bg`
|
|
33
|
+
* spanning exactly its dates, a header bracket over those dates (solid for
|
|
34
|
+
* a group, dashed for a parallel by default; `bracket:` styles it), the
|
|
35
|
+
* title at the container's start with any inline-date glyphs beside it,
|
|
36
|
+
* then the children. A wave-only group draws only its children.
|
|
37
|
+
*
|
|
38
|
+
* `depth` is the container's nesting depth inside its lane, 1 for a
|
|
39
|
+
* top-level container. A nested body takes a lighter tint, and bodies deeper
|
|
40
|
+
* than `CONTAINER_MAX_FILLED_DEPTH` are outlined instead of filled, so tints
|
|
41
|
+
* never compound three deep (§8 O4).
|
|
42
|
+
*/
|
|
43
|
+
function renderContainer(
|
|
44
|
+
c: PositionedGroup | PositionedParallel,
|
|
45
|
+
depth: number,
|
|
46
|
+
options: RenderOptions,
|
|
47
|
+
idPrefix: string,
|
|
48
|
+
palette: Theme,
|
|
49
|
+
fonts: FontFamilies,
|
|
50
|
+
): string {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
const isDrawn = !(c.kind === 'group' && c.waveOnly);
|
|
53
|
+
if (isDrawn) {
|
|
54
|
+
const bodyY = c.box.y + c.headerHeight;
|
|
55
|
+
const bodyHeight = c.box.height - c.headerHeight;
|
|
56
|
+
if (c.style.bg !== 'none') {
|
|
57
|
+
const isFilled = depth <= CONTAINER_MAX_FILLED_DEPTH;
|
|
58
|
+
parts.push(
|
|
59
|
+
tag('rect', {
|
|
60
|
+
x: num(c.box.x),
|
|
61
|
+
y: num(bodyY),
|
|
62
|
+
width: num(c.box.width),
|
|
63
|
+
height: num(bodyHeight),
|
|
64
|
+
fill: isFilled ? c.style.bg : 'none',
|
|
65
|
+
'fill-opacity': isFilled
|
|
66
|
+
? depth === 1
|
|
67
|
+
? CONTAINER_BODY_FILL_OPACITY
|
|
68
|
+
: CONTAINER_NESTED_BODY_FILL_OPACITY
|
|
69
|
+
: null,
|
|
70
|
+
stroke: isFilled ? null : c.style.bg,
|
|
71
|
+
'stroke-opacity': isFilled ? null : 0.6,
|
|
72
|
+
'stroke-width': isFilled ? null : 1,
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
// The title and the bracket share one ink, the header's: a group's
|
|
77
|
+
// `fg`, a parallel's bracket stroke. The title sits on the lane band,
|
|
78
|
+
// never on the body, so a `text:` colour chosen to read on `bg`
|
|
79
|
+
// (white on a filled chiclet, before the header) would vanish.
|
|
80
|
+
const ink = c.kind === 'group' ? c.style.fg : palette.parallel.bracketStroke;
|
|
81
|
+
if (c.headerHeight > 0 && c.style.bracket !== 'none') {
|
|
82
|
+
const lineY = c.box.y + CONTAINER_BRACKET_LINE_OFFSET_PX;
|
|
83
|
+
const x0 = c.box.x;
|
|
84
|
+
const x1 = c.box.x + c.box.width;
|
|
85
|
+
parts.push(
|
|
86
|
+
tag('path', {
|
|
87
|
+
d: `M${num(x0)} ${num(bodyY)} V${num(lineY)} H${num(x1)} V${num(bodyY)}`,
|
|
88
|
+
fill: 'none',
|
|
89
|
+
stroke: ink,
|
|
90
|
+
'stroke-width': 1.2,
|
|
91
|
+
'stroke-dasharray': c.style.bracket === 'dashed' ? '3 2' : null,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (c.title && c.headerHeight > 0) {
|
|
96
|
+
parts.push(
|
|
97
|
+
textTag(
|
|
98
|
+
{
|
|
99
|
+
// Past the `after:` glyph when there is one; layout
|
|
100
|
+
// clamps the `before:` glyph past this text.
|
|
101
|
+
x: num(containerHeaderTitleX(c.box.x, hasAfterPin(c.inlineDatePins))),
|
|
102
|
+
y: num(c.box.y + CONTAINER_HEADER_TITLE_BASELINE_PX),
|
|
103
|
+
...fontAttrs(c.style, fonts, CONTAINER_HEADER_TITLE_FONT_SIZE_PX),
|
|
104
|
+
'font-weight': 600,
|
|
105
|
+
fill: ink,
|
|
106
|
+
},
|
|
107
|
+
c.title,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
// The glyphs sit in the header's title row, never on a child, so
|
|
112
|
+
// painting them before the children is safe.
|
|
113
|
+
parts.push(renderInlineDatePins(c.inlineDatePins, c.style.fg));
|
|
114
|
+
}
|
|
115
|
+
for (const child of c.children) {
|
|
116
|
+
parts.push(renderTrackChild(child, options, idPrefix, palette, fonts, depth));
|
|
117
|
+
}
|
|
118
|
+
return tag('g', { 'data-layer': c.kind, 'data-id': c.id ?? null }, parts.join(''));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderTrackChild(
|
|
122
|
+
c: PositionedTrackChild,
|
|
123
|
+
options: RenderOptions,
|
|
124
|
+
idPrefix: string,
|
|
125
|
+
palette: Theme,
|
|
126
|
+
fonts: FontFamilies,
|
|
127
|
+
parentDepth = 0,
|
|
128
|
+
): string {
|
|
129
|
+
if (c.kind === 'item') return renderItem(c, options, idPrefix, palette, fonts);
|
|
130
|
+
return renderContainer(c, parentDepth + 1, options, idPrefix, palette, fonts);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Renders only the swimlane's background tint rect. Emitted before the
|
|
134
|
+
// chart-body grid lines so those lines visibly span the full chart width.
|
|
135
|
+
// The frame tab (chiclet at top-left) and item content are emitted later,
|
|
136
|
+
// in renderSwimlaneContent, so they appear on top of the grid.
|
|
137
|
+
// Tri-state lane utilization underline. Painted along the bottom edge of
|
|
138
|
+
// the band when the lane has `capacity:` AND at least one item contributing
|
|
139
|
+
// load AND has not opted out of every color band via `utilization-*-at:none`.
|
|
140
|
+
// Geometry per specs/rendering.md § Lane utilization underline:
|
|
141
|
+
// - height: 2px (matches the milestone-line stroke weight)
|
|
142
|
+
// - y: flush with the bottom edge of the band, fully inside it
|
|
143
|
+
// - x: aligned to the segment boundaries the layout already pre-coalesced
|
|
144
|
+
// One <rect> per coalesced segment; classification → palette token mapping
|
|
145
|
+
// is the only renderer-side decision.
|
|
146
|
+
const LANE_UTILIZATION_HEIGHT_PX = 2;
|
|
147
|
+
|
|
148
|
+
function utilizationColor(classification: 'green' | 'yellow' | 'red', palette: Theme): string {
|
|
149
|
+
switch (classification) {
|
|
150
|
+
case 'green':
|
|
151
|
+
return palette.swimlane.utilizationOk;
|
|
152
|
+
case 'yellow':
|
|
153
|
+
return palette.swimlane.utilizationWarn;
|
|
154
|
+
case 'red':
|
|
155
|
+
return palette.swimlane.utilizationOver;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderLaneUtilization(s: PositionedSwimlane, palette: Theme): string {
|
|
160
|
+
if (!s.utilization || s.utilization.segments.length === 0) return '';
|
|
161
|
+
const y = s.box.y + s.box.height - LANE_UTILIZATION_HEIGHT_PX;
|
|
162
|
+
const rects = s.utilization.segments.map((seg) => {
|
|
163
|
+
const width = seg.endX - seg.startX;
|
|
164
|
+
if (width <= 0) return '';
|
|
165
|
+
return tag('rect', {
|
|
166
|
+
x: num(seg.startX),
|
|
167
|
+
y: num(y),
|
|
168
|
+
width: num(width),
|
|
169
|
+
height: LANE_UTILIZATION_HEIGHT_PX,
|
|
170
|
+
fill: utilizationColor(seg.classification, palette),
|
|
171
|
+
'data-utilization': seg.classification,
|
|
172
|
+
'data-load': num(seg.load),
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
return tag(
|
|
176
|
+
'g',
|
|
177
|
+
{
|
|
178
|
+
'data-layer': 'lane-utilization',
|
|
179
|
+
'data-id': s.id ?? null,
|
|
180
|
+
},
|
|
181
|
+
rects.join(''),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function renderSwimlaneBg(s: PositionedSwimlane, palette: Theme): string {
|
|
186
|
+
const tint = s.bandIndex % 2 === 0 ? palette.swimlane.rowTintEven : palette.swimlane.rowTintOdd;
|
|
187
|
+
const borderColor = palette.swimlane.border;
|
|
188
|
+
return tag(
|
|
189
|
+
'g',
|
|
190
|
+
{ 'data-layer': 'swimlane-bg', 'data-id': s.id ?? null },
|
|
191
|
+
tag('rect', {
|
|
192
|
+
x: num(s.box.x),
|
|
193
|
+
y: num(s.box.y),
|
|
194
|
+
width: num(s.box.width),
|
|
195
|
+
height: num(s.box.height),
|
|
196
|
+
fill: tint,
|
|
197
|
+
stroke: borderColor,
|
|
198
|
+
'stroke-width': 1,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function renderSwimlane(
|
|
204
|
+
s: PositionedSwimlane,
|
|
205
|
+
options: RenderOptions,
|
|
206
|
+
idPrefix: string,
|
|
207
|
+
palette: Theme,
|
|
208
|
+
fonts: FontFamilies,
|
|
209
|
+
): string {
|
|
210
|
+
const tabFill = palette.swimlane.tabFill;
|
|
211
|
+
const tabStroke = palette.swimlane.tabStroke;
|
|
212
|
+
const tabText = palette.swimlane.tabText;
|
|
213
|
+
const ownerText = palette.swimlane.ownerText;
|
|
214
|
+
const footnoteColor = palette.swimlane.footnoteIndicator;
|
|
215
|
+
const parts: string[] = [];
|
|
216
|
+
// Frame-tab chiclet at the top-left of the band, auto-sized to fit
|
|
217
|
+
// title, owner, capacity badge and footnote indicators. Layout sized it
|
|
218
|
+
// (`PositionedSwimlane.tab`, the same rectangle it reserved for the
|
|
219
|
+
// row packer) and placed each element inside it; this only draws.
|
|
220
|
+
const tab = s.tab;
|
|
221
|
+
if (tab) {
|
|
222
|
+
const LANE_BADGE_FONT_SIZE_PX = 10;
|
|
223
|
+
const footnoteIndicatorText =
|
|
224
|
+
s.footnoteIndicators.length > 0 ? s.footnoteIndicators.join(',') : '';
|
|
225
|
+
const tabH = FRAME_TAB_HEIGHT_PX;
|
|
226
|
+
const tabY = s.box.y + 10;
|
|
227
|
+
const labelY = tabY + FRAME_TAB_LABEL_BASELINE_OFFSET_PX;
|
|
228
|
+
parts.push(
|
|
229
|
+
tag('rect', {
|
|
230
|
+
x: num(tab.tabX),
|
|
231
|
+
y: num(tabY),
|
|
232
|
+
width: num(tab.tabW),
|
|
233
|
+
height: num(tabH),
|
|
234
|
+
rx: 4,
|
|
235
|
+
ry: 4,
|
|
236
|
+
fill: tabFill,
|
|
237
|
+
stroke: tabStroke,
|
|
238
|
+
'stroke-width': 1,
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
parts.push(
|
|
242
|
+
textTag(
|
|
243
|
+
{
|
|
244
|
+
x: num(tab.titleX),
|
|
245
|
+
y: num(labelY),
|
|
246
|
+
'font-family': fonts[s.style.font],
|
|
247
|
+
'font-size': 12,
|
|
248
|
+
'font-weight': 600,
|
|
249
|
+
fill: tabText,
|
|
250
|
+
},
|
|
251
|
+
s.title,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
if (s.owner) {
|
|
255
|
+
// A person or people glyph leads the owner name, lifted onto
|
|
256
|
+
// the text's x-height like the capacity badge's glyph.
|
|
257
|
+
const glyph = CAPACITY_ICON_SVG[s.ownerKind === 'person' ? 'person' : 'people'];
|
|
258
|
+
const glyphSize = FRAME_TAB_OWNER_GLYPH_PX;
|
|
259
|
+
parts.push(
|
|
260
|
+
`<svg x="${num(tab.ownerX)}" y="${num(labelY - glyphSize * 0.85)}" width="${num(glyphSize)}" height="${num(glyphSize)}" viewBox="${glyph.viewBox}" style="color:${escAttr(ownerText)}" aria-hidden="true">${glyph.body}</svg>`,
|
|
261
|
+
);
|
|
262
|
+
parts.push(
|
|
263
|
+
textTag(
|
|
264
|
+
{
|
|
265
|
+
x: num(tab.ownerTextX),
|
|
266
|
+
y: num(labelY),
|
|
267
|
+
'font-family': fonts[s.style.font],
|
|
268
|
+
'font-size': 10,
|
|
269
|
+
fill: ownerText,
|
|
270
|
+
},
|
|
271
|
+
s.owner,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (s.capacity) {
|
|
276
|
+
// Re-uses the same `renderCapacitySuffix` helper that paints
|
|
277
|
+
// item-level suffixes (m6) so multiplier / built-in SVG /
|
|
278
|
+
// inline literal / dereferenced-custom-glyph paths stay
|
|
279
|
+
// consistent across both contexts.
|
|
280
|
+
parts.push(
|
|
281
|
+
renderCapacitySuffix(
|
|
282
|
+
s.capacity,
|
|
283
|
+
tab.badgeX,
|
|
284
|
+
labelY,
|
|
285
|
+
LANE_BADGE_FONT_SIZE_PX,
|
|
286
|
+
fonts[s.style.font],
|
|
287
|
+
ownerText,
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (footnoteIndicatorText) {
|
|
292
|
+
parts.push(
|
|
293
|
+
textTag(
|
|
294
|
+
{
|
|
295
|
+
x: num(tab.footnoteRightX),
|
|
296
|
+
y: num(tabY + 14),
|
|
297
|
+
'font-family': fonts.sans,
|
|
298
|
+
'font-size': LANE_BADGE_FONT_SIZE_PX,
|
|
299
|
+
'font-weight': 700,
|
|
300
|
+
fill: footnoteColor,
|
|
301
|
+
'text-anchor': 'end',
|
|
302
|
+
},
|
|
303
|
+
footnoteIndicatorText,
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const c of s.children) {
|
|
309
|
+
parts.push(renderTrackChild(c, options, idPrefix, palette, fonts));
|
|
310
|
+
}
|
|
311
|
+
// m13: tri-state utilization underline along the band's bottom edge.
|
|
312
|
+
// Painted after items so it overlays any item that happens to extend
|
|
313
|
+
// to the band's bottom; under cut-lines / now-line which run as
|
|
314
|
+
// separate top-level passes.
|
|
315
|
+
parts.push(renderLaneUtilization(s, palette));
|
|
316
|
+
return tag('g', { 'data-layer': 'swimlane', 'data-id': s.id ?? null }, parts.join(''));
|
|
317
|
+
}
|