@zombie-mermaid/svg-renderer 3.2.0 → 4.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 (45) hide show
  1. package/dist/index.cjs +45 -41
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +34 -4
  4. package/dist/index.d.ts +34 -4
  5. package/dist/index.js +2310 -1115
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
  9. package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
  10. package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
  11. package/src/__tests__/c4-text-width-1210.test.ts +43 -0
  12. package/src/__tests__/class-component-order-1249.test.ts +40 -0
  13. package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
  14. package/src/__tests__/class-namespace-1196.test.ts +165 -0
  15. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
  16. package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
  17. package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
  18. package/src/__tests__/er-default-direction-1250.test.ts +69 -0
  19. package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
  20. package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
  21. package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
  22. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
  23. package/src/c4/arial-widths.ts +59 -0
  24. package/src/c4/layout.ts +234 -44
  25. package/src/c4/metrics.ts +56 -30
  26. package/src/c4/renderer.ts +1 -1
  27. package/src/class/layout.ts +178 -13
  28. package/src/class/renderer.ts +31 -2
  29. package/src/er/layout.ts +20 -4
  30. package/src/er/renderer.ts +10 -1
  31. package/src/layout-engine/back-edges.ts +73 -0
  32. package/src/layout-engine/compound-flat.ts +916 -0
  33. package/src/layout-engine/elk-graph-builder.ts +5 -4
  34. package/src/layout-engine/from-elk.ts +7 -2
  35. package/src/layout-engine/inner-edges.ts +329 -0
  36. package/src/layout-engine/layout-hints.ts +27 -0
  37. package/src/layout-engine/to-elk.ts +170 -20
  38. package/src/layout-engine.ts +38 -1
  39. package/src/registry.ts +2 -2
  40. package/src/renderer.ts +26 -2
  41. package/src/sequence/layout.ts +72 -4
  42. package/src/sequence/renderer.ts +155 -15
  43. package/src/title-gaps.ts +146 -0
  44. package/src/xychart/layout.ts +31 -2
  45. package/src/xychart/renderer.ts +1 -0
package/src/c4/layout.ts CHANGED
@@ -17,7 +17,9 @@ import type { Point, RenderOptions } from '@zombie-mermaid/core'
17
17
  import {
18
18
  C4,
19
19
  C4_TEXT_WIDTH,
20
+ c4CylinderCap,
20
21
  c4PersonGeometry,
22
+ c4QueueCap,
21
23
  c4ShapeSize,
22
24
  c4TextWidth,
23
25
  wrapToWidth,
@@ -129,9 +131,9 @@ interface MeasuredElement extends Placed {
129
131
  }
130
132
 
131
133
  function measureElement(el: C4Element): MeasuredElement {
132
- const nameLines = wrapToWidth(el.label, C4_TEXT_WIDTH, C4.nameSize, 700)
134
+ const nameLines = wrapToWidth(el.label, C4_TEXT_WIDTH, C4.nameSize)
133
135
  const descriptionLines = el.description
134
- ? wrapToWidth(el.description, C4_TEXT_WIDTH, C4.descrSize, 400)
136
+ ? wrapToWidth(el.description, C4_TEXT_WIDTH, C4.descrSize)
135
137
  : []
136
138
  const textWidth = Math.max(
137
139
  ...nameLines.map((l) => c4TextWidth(l, C4.nameSize, 700)),
@@ -313,55 +315,150 @@ function rectIntersect(box: Placed, toward: Point): Point {
313
315
  }
314
316
 
315
317
  /**
316
- * Where the line from a person's centre toward `toward` leaves the figure,
317
- * which is a round head over a pill, not the box around both (Mermaid clips
318
- * a relationship against what is drawn).
318
+ * Where a line from the centre of a person toward `toward` leaves the figure.
319
+ * Mermaid clips against the polygon it builds for the person (a round head
320
+ * over a rounded body, 24 points on the head and 12 on each body corner), so
321
+ * this builds the same polygon and returns the crossing nearest `toward`.
319
322
  */
320
323
  function personIntersect(box: Placed, toward: Point): Point {
321
- const g = c4PersonGeometry(box.width)
324
+ const g = c4PersonGeometry(box.width, box.height)
322
325
  const cx = box.x + box.width / 2
323
326
  const cy = box.y + box.height / 2
324
- const headY = box.y + g.headRadius
325
- const pillTop = box.y + g.pillTop
326
- const pillBottom = box.y + box.height
327
- const inside = (x: number, y: number): boolean => {
328
- if (Math.hypot(x - cx, y - headY) <= g.headRadius) return true
329
- if (y < pillTop || y > pillBottom) return false
330
- // Round the pill's ends: each is a circle of radius `rx` (clamped).
331
- const r = Math.min(g.rx, (pillBottom - pillTop) / 2)
332
- const left = box.x + r
333
- const right = box.x + box.width - r
334
- const nearY = Math.min(Math.max(y, pillTop + r), pillBottom - r)
335
- const nearX = Math.min(Math.max(x, left), right)
336
- return (
337
- x >= box.x &&
338
- x <= box.x + box.width &&
339
- Math.hypot(x - nearX, y - nearY) <= r
340
- )
341
- }
342
- const len = Math.hypot(toward.x - cx, toward.y - cy)
343
- if (len === 0 || !inside(cx, cy)) return rectIntersect(box, toward)
344
- const ux = (toward.x - cx) / len
345
- const uy = (toward.y - cy) / len
346
- const at = (t: number): boolean => inside(cx + ux * t, cy + uy * t)
347
- let lo = 0
348
- let hi = 0.5
349
- while (at(hi) && hi < box.width + box.height) {
350
- lo = hi
351
- hi += 0.5
352
- }
353
- for (let i = 0; i < 30; i++) {
354
- const mid = (lo + hi) / 2
355
- if (at(mid)) lo = mid
356
- else hi = mid
357
- }
358
- return { x: cx + ux * lo, y: cy + uy * lo }
327
+ const w = box.width
328
+ const h = box.height
329
+ const headCentre = -h / 2 + g.headRadius
330
+ const bodyTop = -h / 2 + g.pillTop
331
+ const br = g.rx
332
+ // `n` points on the circle at (ox, oy), sweeping from angle `from` to `to`
333
+ // in degrees, with Mermaid's sign convention (`generateCirclePoints`).
334
+ const arc = (
335
+ ox: number,
336
+ oy: number,
337
+ r: number,
338
+ n: number,
339
+ from: number,
340
+ to: number,
341
+ ): Point[] =>
342
+ Array.from({ length: n }, (_, i) => {
343
+ const a = ((from + (i * (to - from)) / (n - 1)) * Math.PI) / 180
344
+ return { x: ox - r * Math.cos(a), y: oy - r * Math.sin(a) }
345
+ })
346
+ // Where the head meets the body's top edge, below the head's centre.
347
+ const phi =
348
+ (Math.asin(Math.min(1, (bodyTop - headCentre) / g.headRadius)) * 180) /
349
+ Math.PI
350
+ const outline: Point[] = [
351
+ ...arc(0, headCentre, g.headRadius, 24, 180 + phi, -phi),
352
+ ...arc(-w / 2 + br, bodyTop + br, br, 12, 90, 0),
353
+ ...arc(-w / 2 + br, h / 2 - br, br, 12, 360, 270),
354
+ ...arc(w / 2 - br, h / 2 - br, br, 12, 270, 180),
355
+ ...arc(w / 2 - br, bodyTop + br, br, 12, 180, 90),
356
+ ]
357
+ return nearestCrossing(
358
+ { x: cx, y: cy },
359
+ toward,
360
+ outline.map((p) => ({ x: cx + p.x, y: cy + p.y })),
361
+ )
362
+ }
363
+
364
+ /**
365
+ * The crossing of segment `from`-`toward` with an edge `q1`-`q2`, as Mermaid's
366
+ * (dagre's) `intersectLine` computes it: it adds half the determinant before
367
+ * dividing, which rounds the result by up to half a pixel.
368
+ */
369
+ function lineCrossing(
370
+ from: Point,
371
+ toward: Point,
372
+ q1: Point,
373
+ q2: Point,
374
+ ): Point | undefined {
375
+ const a1 = toward.y - from.y
376
+ const b1 = from.x - toward.x
377
+ const c1 = toward.x * from.y - from.x * toward.y
378
+ const r3 = a1 * q1.x + b1 * q1.y + c1
379
+ const r4 = a1 * q2.x + b1 * q2.y + c1
380
+ if (r3 !== 0 && r4 !== 0 && r3 * r4 > 0) return undefined
381
+ const a2 = q2.y - q1.y
382
+ const b2 = q1.x - q2.x
383
+ const c2 = q2.x * q1.y - q1.x * q2.y
384
+ const r1 = a2 * from.x + b2 * from.y + c2
385
+ const r2 = a2 * toward.x + b2 * toward.y + c2
386
+ if (r1 !== 0 && r2 !== 0 && r1 * r2 > 0) return undefined
387
+ const denom = a1 * b2 - a2 * b1
388
+ if (denom === 0) return undefined
389
+ const offset = Math.abs(denom / 2)
390
+ let num = b1 * c2 - b2 * c1
391
+ const x = num < 0 ? (num - offset) / denom : (num + offset) / denom
392
+ num = a2 * c1 - a1 * c2
393
+ const y = num < 0 ? (num - offset) / denom : (num + offset) / denom
394
+ return { x, y }
395
+ }
396
+
397
+ /** The crossing of segment `from`-`toward` with the polygon nearest `toward`. */
398
+ function nearestCrossing(from: Point, toward: Point, poly: Point[]): Point {
399
+ let best: Point | undefined
400
+ let bestDist = Infinity
401
+ poly.forEach((p, i) => {
402
+ const hit = lineCrossing(from, toward, p, poly[(i + 1) % poly.length]!)
403
+ if (!hit) return
404
+ const d = Math.hypot(hit.x - toward.x, hit.y - toward.y)
405
+ if (d < bestDist) {
406
+ best = hit
407
+ bestDist = d
408
+ }
409
+ })
410
+ return best ?? from
411
+ }
412
+
413
+ /** Slack for deciding that a point sits exactly on a box edge. */
414
+ const EDGE = 1e-6
415
+
416
+ /**
417
+ * A cylinder (`SystemDb`, `ContainerDb`) clips like Mermaid's: the point on
418
+ * the bounding rectangle, then moved along the curve of the top or bottom cap
419
+ * where the line meets a cap rather than the straight side.
420
+ */
421
+ function cylinderIntersect(box: Placed, toward: Point): Point {
422
+ const pos = rectIntersect(box, toward)
423
+ const cx = box.x + box.width / 2
424
+ const cy = box.y + box.height / 2
425
+ const { rx, ry } = c4CylinderCap(box.width)
426
+ const x = pos.x - cx
427
+ const onCap =
428
+ Math.abs(x) < box.width / 2 - EDGE ||
429
+ (Math.abs(Math.abs(x) - box.width / 2) <= EDGE &&
430
+ Math.abs(pos.y - cy) > box.height / 2 - ry)
431
+ if (rx === 0 || !onCap) return pos
432
+ const under = ry * ry * (1 - (x * x) / (rx * rx))
433
+ let drop = ry - (under > 0 ? Math.sqrt(under) : under)
434
+ if (toward.y - cy > 0) drop = -drop
435
+ return { x: pos.x, y: pos.y + drop }
436
+ }
437
+
438
+ /** The same for a queue (`SystemQueue`, `ContainerQueue`), a cylinder on its side. */
439
+ function queueIntersect(box: Placed, toward: Point): Point {
440
+ const pos = rectIntersect(box, toward)
441
+ const cx = box.x + box.width / 2
442
+ const cy = box.y + box.height / 2
443
+ const { rx, ry } = c4QueueCap(box.height)
444
+ const y = pos.y - cy
445
+ const onCap =
446
+ Math.abs(y) < box.height / 2 - EDGE ||
447
+ (Math.abs(Math.abs(y) - box.height / 2) <= EDGE &&
448
+ Math.abs(pos.x - cx) > box.width / 2 - rx)
449
+ if (ry === 0 || !onCap) return pos
450
+ const under = rx * rx * (1 - (y * y) / (ry * ry))
451
+ let drop = rx - Math.sqrt(Math.abs(under))
452
+ if (toward.x - cx > 0) drop = -drop
453
+ return { x: pos.x + drop, y: pos.y }
359
454
  }
360
455
 
361
456
  function leave(box: Placed, toward: Point): Point {
362
- return (box as Partial<MeasuredElement>).el?.kind === 'person'
363
- ? personIntersect(box, toward)
364
- : rectIntersect(box, toward)
457
+ const el = (box as Partial<MeasuredElement>).el
458
+ if (el?.kind === 'person') return personIntersect(box, toward)
459
+ if (el?.shape === 'db') return cylinderIntersect(box, toward)
460
+ if (el?.shape === 'queue') return queueIntersect(box, toward)
461
+ return rectIntersect(box, toward)
365
462
  }
366
463
 
367
464
  const centre = (b: Placed): Point => ({
@@ -416,6 +513,90 @@ function routeRelationship(
416
513
  return out
417
514
  }
418
515
 
516
+ /** A text line's box: centre x, centre y, width and height. */
517
+ interface TextBox {
518
+ cx: number
519
+ cy: number
520
+ w: number
521
+ h: number
522
+ }
523
+
524
+ /** Clear space kept between a label and a shape or another label. */
525
+ const LABEL_CLEARANCE = 2
526
+ /** Extra space kept between a label and a shape, for the arrowhead. */
527
+ const SHAPE_CLEARANCE = 6
528
+ /** How far along the chord each trial position is from the last. */
529
+ const LABEL_STEP = 2
530
+
531
+ /** The boxes a relationship's label lines occupy, as the renderer draws them. */
532
+ function labelBoxes(rel: PositionedC4Relationship, dx: number): TextBox[] {
533
+ const pos = rel.labelPosition
534
+ if (!pos) return []
535
+ return c4RelLabelLines(rel).map((line, i, all) => {
536
+ const isTech = rel.technology !== undefined && i === all.length - 1
537
+ return {
538
+ cx:
539
+ (isTech && rel.technologyX !== undefined ? rel.technologyX : pos.x) +
540
+ dx,
541
+ cy: pos.y + (i === 0 ? 0 : C4.messageSize + 5),
542
+ w: c4TextWidth(line, C4.messageSize, 400),
543
+ h: C4.messageSize,
544
+ }
545
+ })
546
+ }
547
+
548
+ const boxesOverlap = (a: TextBox, b: TextBox): boolean =>
549
+ Math.abs(a.cx - b.cx) < (a.w + b.w) / 2 + LABEL_CLEARANCE &&
550
+ Math.abs(a.cy - b.cy) < (a.h + b.h) / 2 + LABEL_CLEARANCE
551
+
552
+ /**
553
+ * Mermaid starts a label at the chord's midpoint, so a label can land on a
554
+ * shape the line passes beside or through (#1290). Slide it along the chord,
555
+ * nearest the midpoint first, to the first spot clear of every shape and of
556
+ * the labels already placed; if there is none, leave it where Mermaid does.
557
+ */
558
+ function clearLabel(
559
+ rel: PositionedC4Relationship,
560
+ shapes: TextBox[],
561
+ placed: TextBox[],
562
+ ): void {
563
+ const start = rel.points[0]
564
+ const end = rel.points[rel.points.length - 1]
565
+ if (!rel.labelPosition || !start || !end) return
566
+ const dx = end.x - start.x
567
+ const dy = end.y - start.y
568
+ const len = Math.hypot(dx, dy)
569
+ if (len === 0) return
570
+ const ux = dx / len
571
+ const uy = dy / len
572
+ const clear = (t: number): TextBox[] | undefined => {
573
+ const boxes = labelBoxes(rel, t * ux).map((b) => ({
574
+ ...b,
575
+ cy: b.cy + t * uy,
576
+ }))
577
+ const hit = boxes.some((b) =>
578
+ [...shapes, ...placed].some((o) => boxesOverlap(b, o)),
579
+ )
580
+ return hit ? undefined : boxes
581
+ }
582
+ for (let t = 0; t <= len / 2; t += LABEL_STEP) {
583
+ for (const s of t === 0 ? [1] : [1, -1]) {
584
+ const boxes = clear(s * t)
585
+ if (!boxes) continue
586
+ if (t !== 0) {
587
+ rel.labelPosition = {
588
+ x: rel.labelPosition.x + s * t * ux,
589
+ y: rel.labelPosition.y + s * t * uy,
590
+ }
591
+ if (rel.technologyX !== undefined) rel.technologyX += s * t * ux
592
+ }
593
+ placed.push(...boxes)
594
+ return
595
+ }
596
+ }
597
+ placed.push(...labelBoxes(rel, 0))
598
+ }
599
+
419
600
  /** Lay out a parsed C4 diagram the way Mermaid's C4 renderer does. */
420
601
  export function layoutC4DiagramSync(
421
602
  diagram: C4Diagram,
@@ -466,6 +647,15 @@ export function layoutC4DiagramSync(
466
647
  const relationships = diagram.relationships.map((r, i) =>
467
648
  routeRelationship(r, i, boxes, shift),
468
649
  )
650
+ const shapeBoxes: TextBox[] = elements.map((e) => ({
651
+ cx: e.x + e.width / 2,
652
+ cy: e.y + e.height / 2,
653
+ // Padded so a label also keeps off the arrowhead that ends a line on it.
654
+ w: e.width + 2 * SHAPE_CLEARANCE,
655
+ h: e.height + 2 * SHAPE_CLEARANCE,
656
+ }))
657
+ const placedLabels: TextBox[] = []
658
+ for (const r of relationships) clearLabel(r, shapeBoxes, placedLabels)
469
659
 
470
660
  const width = layout.maxX + C4.diagramMarginX
471
661
  const height = layout.maxY + C4.diagramMarginY + titleExtra
package/src/c4/metrics.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { C4Element } from '@zombie-mermaid/mermaid-parser'
2
2
  import { estimateTextWidth } from '../styles.ts'
3
+ import { arialAdvance } from './arial-widths.ts'
3
4
 
4
5
  // ============================================================================
5
6
  // C4 SVG geometry, shared by the layout (which sizes and places shapes) and
@@ -9,8 +10,8 @@ import { estimateTextWidth } from '../styles.ts'
9
10
  // The numbers follow Mermaid 11.17's C4 renderer: its default `c4` config for
10
11
  // spacing, and sizes fitted to what it draws for each shape (a fixed 216px
11
12
  // width, 20px padding, a 14px name, a 0.75em type line and a 0.82em
12
- // description). Text is measured with this package's own width estimate
13
- // instead of the browser, so sizes agree with Mermaid's to within a few pixels.
13
+ // description). Text is measured by summing Arial advances instead of in the
14
+ // browser, which matches the recorded Mermaid diagrams to a fraction of a pixel.
14
15
  // ============================================================================
15
16
 
16
17
  export const C4 = {
@@ -42,9 +43,8 @@ export const C4 = {
42
43
  /** Each wrapped description line after the first (one `1.1em` row). */
43
44
  descrExtraLine: 12.48,
44
45
  /**
45
- * Mermaid's text is about 8% narrower than this package's width estimate
46
- * (its font is Open Sans, this package's is Inter); fitted on a measured
47
- * name.
46
+ * For text outside printable ASCII, which has no Arial advance table:
47
+ * Arial is about 8% narrower than this package's width estimate (Inter).
48
48
  */
49
49
  textScale: 0.922,
50
50
  // Heights Mermaid measures for a boundary's heading text (fitted): the
@@ -60,14 +60,17 @@ export const C4 = {
60
60
  cornerRadius: 12,
61
61
  strokeWidth: 2,
62
62
  /**
63
- * Person proportions at the standard width (Mermaid scales the pill's
64
- * corners and head with the width, and the head's rise a little faster).
63
+ * Person proportions, from Mermaid's person shape: the pill's corner is
64
+ * 17.7% of the width (at most 45% of the pill's height), the head's radius
65
+ * is 23% of the width clamped to 16..56, and the head overlaps the pill by
66
+ * 27% of its radius.
65
67
  */
66
- personRx: 38.232,
67
- personHeadRadius: 49.68,
68
- /** Distance from the top of a person to the top of its pill (under the head). */
69
- personPillTop: 85.946,
70
- personPillTopPerExtraWidth: 0.4,
68
+ personRx: 0.177,
69
+ personRxMaxOfHeight: 0.45,
70
+ personHeadRatio: 0.23,
71
+ personHeadMin: 16,
72
+ personHeadMax: 56,
73
+ personHeadOverlap: 0.27,
71
74
  /** Cylinder height lost where its text block meets the lower ellipse. */
72
75
  dbTrim: 3.91,
73
76
  /** Queue height over its text block. */
@@ -75,27 +78,27 @@ export const C4 = {
75
78
  } as const
76
79
 
77
80
  /**
78
- * Width the text of an element wraps to. Fitted to Mermaid: a 173px name
79
- * stays on one line and a 231px one wraps, and a 188px name stays on one.
81
+ * Width the text of an element wraps to: the shape's width less its padding
82
+ * on both sides (Mermaid's `c4LabelHelper`).
80
83
  */
81
- export const C4_TEXT_WIDTH = C4.width - C4.shapePadding
84
+ export const C4_TEXT_WIDTH = C4.width - 2 * C4.shapePadding
82
85
 
83
- /** Greedy word wrap by estimated pixel width. */
86
+ /**
87
+ * Greedy word wrap by estimated pixel width. Mermaid decides where to break
88
+ * with the text at regular weight and draws a name in bold afterwards, so a
89
+ * bold name can come out wider than the limit.
90
+ */
84
91
  export function wrapToWidth(
85
92
  text: string,
86
93
  maxWidth: number,
87
94
  fontSize: number,
88
- weight: number,
89
95
  ): string[] {
90
96
  const lines: string[] = []
91
97
  for (const paragraph of text.split('\n')) {
92
98
  let cur = ''
93
99
  for (const word of paragraph.split(/\s+/).filter(Boolean)) {
94
100
  const next = cur ? `${cur} ${word}` : word
95
- if (
96
- cur &&
97
- estimateTextWidth(next, fontSize, weight) * C4.textScale > maxWidth
98
- ) {
101
+ if (cur && c4TextWidth(next, fontSize, 400) > maxWidth) {
99
102
  lines.push(cur)
100
103
  cur = word
101
104
  } else {
@@ -107,18 +110,29 @@ export function wrapToWidth(
107
110
  return lines
108
111
  }
109
112
 
110
- /** A person's pill corner, head radius and pill top for a box of this width. */
111
- export function c4PersonGeometry(width: number): {
113
+ /**
114
+ * A person's pill corner, head radius and pill top for a box of this width
115
+ * (and, when known, this height: a short pill gets smaller corners).
116
+ */
117
+ export function c4PersonGeometry(
118
+ width: number,
119
+ height?: number,
120
+ ): {
112
121
  rx: number
113
122
  headRadius: number
114
123
  pillTop: number
115
124
  } {
116
- const scale = width / C4.width
125
+ const headRadius = Math.min(
126
+ Math.max(width * C4.personHeadRatio, C4.personHeadMin),
127
+ C4.personHeadMax,
128
+ )
129
+ // The head's diameter less the part that overlaps the pill.
130
+ const pillTop = headRadius * (2 - C4.personHeadOverlap)
131
+ const pillHeight = height === undefined ? Infinity : height - pillTop
117
132
  return {
118
- rx: C4.personRx * scale,
119
- headRadius: C4.personHeadRadius * scale,
120
- pillTop:
121
- C4.personPillTop + C4.personPillTopPerExtraWidth * (width - C4.width),
133
+ rx: Math.min(width * C4.personRx, pillHeight * C4.personRxMaxOfHeight),
134
+ headRadius,
135
+ pillTop,
122
136
  }
123
137
  }
124
138
 
@@ -158,13 +172,25 @@ export function c4QueueCap(height: number): { rx: number; ry: number } {
158
172
  return { rx: ry / (2.5 + height / 50), ry }
159
173
  }
160
174
 
161
- /** Estimated width of a text line as Mermaid would measure it. */
175
+ /**
176
+ * Estimated width of a text line as Mermaid would measure it: sans-serif
177
+ * advances for printable ASCII, this package's own estimate (scaled to match)
178
+ * for anything else.
179
+ */
162
180
  export function c4TextWidth(
163
181
  text: string,
164
182
  fontSize: number,
165
183
  weight: number,
166
184
  ): number {
167
- return estimateTextWidth(text, fontSize, weight) * C4.textScale
185
+ let width = 0
186
+ for (const ch of text) {
187
+ const em = arialAdvance(ch, weight >= 600)
188
+ width +=
189
+ em === undefined
190
+ ? estimateTextWidth(ch, fontSize, weight) * C4.textScale
191
+ : em * fontSize
192
+ }
193
+ return width
168
194
  }
169
195
 
170
196
  /**
@@ -252,7 +252,7 @@ function renderElement(el: PositionedC4Element): string {
252
252
  /** A rounded pill with a round head rising out of its top, as Mermaid draws a person. */
253
253
  function personShape(el: PositionedC4Element, pal: C4Palette): string[] {
254
254
  const { x, width: w } = el
255
- const { rx, headRadius, pillTop: rise } = c4PersonGeometry(w)
255
+ const { rx, headRadius, pillTop: rise } = c4PersonGeometry(w, el.height)
256
256
  const y = el.y
257
257
  const cx = x + w / 2
258
258
  const pillTop = y + rise