@diagrammo/dgmo 0.23.0 → 0.25.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/LICENSE +1 -1
- package/dist/advanced.cjs +166 -91
- package/dist/advanced.d.cts +35 -19
- package/dist/advanced.d.ts +35 -19
- package/dist/advanced.js +166 -91
- package/dist/auto.cjs +167 -92
- package/dist/auto.js +110 -110
- package/dist/auto.mjs +167 -92
- package/dist/cli.cjs +150 -150
- package/dist/index.cjs +298 -91
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +296 -91
- package/dist/internal.cjs +166 -91
- package/dist/internal.d.cts +35 -19
- package/dist/internal.d.ts +35 -19
- package/dist/internal.js +166 -91
- package/docs/language-reference.md +3 -2
- package/package.json +1 -1
- package/src/boxes-and-lines/renderer.ts +98 -51
- package/src/d3.ts +22 -10
- package/src/diagnostics.ts +0 -19
- package/src/index.ts +8 -0
- package/src/map/dimensions.ts +21 -5
- package/src/map/geo.ts +0 -5
- package/src/map/layout.ts +57 -46
- package/src/map/legend-band.ts +99 -0
- package/src/map/renderer.ts +10 -28
- package/src/map/resolver.ts +43 -1
- package/src/map/types.ts +20 -0
- package/src/sequence/parser.ts +0 -4
- package/src/utils/brand.ts +0 -17
- package/src/utils/legend-d3.ts +18 -8
- package/src/utils/parsing.ts +0 -16
- package/src/utils/reserved-key-registry.ts +0 -12
- package/src/utils/svg-embed.ts +193 -0
package/src/map/resolver.ts
CHANGED
|
@@ -64,6 +64,16 @@ const WORLD_LAT_NORTH = 78;
|
|
|
64
64
|
// the dots. A tight cluster (e.g. Bay Area cities) therefore frames as ≈ its
|
|
65
65
|
// home state + neighbours rather than the whole nation. Tunable.
|
|
66
66
|
const POI_ZOOM_FLOOR_DEG = 7;
|
|
67
|
+
// POI-only container framing reveals the region(s) that CONTAIN the dots, but a
|
|
68
|
+
// single POI near the edge of a tall/wide country (e.g. Cartagena at the north
|
|
69
|
+
// tip of Colombia) would otherwise drag the frame to that country's far edge —
|
|
70
|
+
// all the way to the Amazon, ~15° below the southernmost dot. Clamp the container
|
|
71
|
+
// union so it reveals at most this many degrees of container BEYOND the POI
|
|
72
|
+
// cluster on each side: northern Colombia stays for orientation, the empty
|
|
73
|
+
// interior is cropped. Sized so an edge cluster still reaches across a US
|
|
74
|
+
// state-scale container (a Bay-Area cluster sits on the coast, ~8° from the
|
|
75
|
+
// Nevada border, and must still show the whole of California). Tunable.
|
|
76
|
+
const CONTAINER_OVERSHOOT_DEG = 8;
|
|
67
77
|
// Above this longitudinal span a US POI-only extent is "national" — use the
|
|
68
78
|
// albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
|
|
69
79
|
// CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
|
|
@@ -798,7 +808,11 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
|
|
|
798
808
|
if (bb && !isWholeSphere(bb)) containerBoxes.push(bb);
|
|
799
809
|
}
|
|
800
810
|
const containerUnion = unionExtent(containerBoxes, points);
|
|
801
|
-
if (containerUnion)
|
|
811
|
+
if (containerUnion)
|
|
812
|
+
extent = pad(
|
|
813
|
+
clampContainerToCluster(containerUnion, points),
|
|
814
|
+
PAD_FRACTION
|
|
815
|
+
);
|
|
802
816
|
}
|
|
803
817
|
|
|
804
818
|
// POI-only fit-to-cluster zoom floor. With region framing above, the extent is
|
|
@@ -934,6 +948,34 @@ function mostCommonCountry(
|
|
|
934
948
|
return best;
|
|
935
949
|
}
|
|
936
950
|
|
|
951
|
+
/** Asymmetric container clamp (R-poi-region overshoot guard). Container framing
|
|
952
|
+
* reveals the region(s) holding the POIs, but one POI at the edge of a tall/wide
|
|
953
|
+
* country drags the frame to that country's far edge. Cap how far the frame
|
|
954
|
+
* extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG. Latitude
|
|
955
|
+
* always clamps; longitude clamps only when neither extent crosses the
|
|
956
|
+
* antimeridian seam (a wrapped extent carries east > 180), where naive min/max
|
|
957
|
+
* would be wrong. Never tightens past the cluster itself, so the dots stay
|
|
958
|
+
* framed, and never widens it — the container edge is still the outer bound. */
|
|
959
|
+
function clampContainerToCluster(
|
|
960
|
+
container: GeoExtent,
|
|
961
|
+
points: Array<[number, number]>
|
|
962
|
+
): GeoExtent {
|
|
963
|
+
const poi = unionExtent([], points);
|
|
964
|
+
if (!poi) return container;
|
|
965
|
+
let [[west, south], [east, north]] = container;
|
|
966
|
+
const [[pWest, pSouth], [pEast, pNorth]] = poi;
|
|
967
|
+
south = Math.max(south, pSouth - CONTAINER_OVERSHOOT_DEG);
|
|
968
|
+
north = Math.min(north, pNorth + CONTAINER_OVERSHOOT_DEG);
|
|
969
|
+
if (east <= 180 && pEast <= 180) {
|
|
970
|
+
west = Math.max(west, pWest - CONTAINER_OVERSHOOT_DEG);
|
|
971
|
+
east = Math.min(east, pEast + CONTAINER_OVERSHOOT_DEG);
|
|
972
|
+
}
|
|
973
|
+
return [
|
|
974
|
+
[west, south],
|
|
975
|
+
[east, north],
|
|
976
|
+
];
|
|
977
|
+
}
|
|
978
|
+
|
|
937
979
|
function pad(e: GeoExtent, frac: number): GeoExtent {
|
|
938
980
|
const dLon = (e[1][0] - e[0][0]) * frac || 1;
|
|
939
981
|
const dLat = (e[1][1] - e[0][1]) * frac || 1;
|
package/src/map/types.ts
CHANGED
|
@@ -142,3 +142,23 @@ export interface ParsedMap {
|
|
|
142
142
|
readonly diagnostics: readonly DgmoError[];
|
|
143
143
|
readonly error: string | null;
|
|
144
144
|
}
|
|
145
|
+
|
|
146
|
+
/** Legend descriptor for a rendered map (a layout-stage output, re-exported from
|
|
147
|
+
* `layout.ts`). It lives here so the `legend-band` helper can consume it without
|
|
148
|
+
* importing `layout` — `layout` already value-imports `mapLegendBand`, so the
|
|
149
|
+
* reverse type import would form a layout↔legend-band cycle. */
|
|
150
|
+
export interface MapLayoutLegend {
|
|
151
|
+
readonly tagGroups: ReadonlyArray<{
|
|
152
|
+
name: string;
|
|
153
|
+
entries: ReadonlyArray<{ value: string; color: string }>;
|
|
154
|
+
}>;
|
|
155
|
+
readonly activeGroup: string | null;
|
|
156
|
+
readonly ramp?: {
|
|
157
|
+
metric?: string;
|
|
158
|
+
min: number;
|
|
159
|
+
max: number;
|
|
160
|
+
hue: string;
|
|
161
|
+
/** Low end of the ramp gradient (the land colour the fills blend from). */
|
|
162
|
+
base: string;
|
|
163
|
+
};
|
|
164
|
+
}
|
package/src/sequence/parser.ts
CHANGED
|
@@ -200,10 +200,6 @@ export type SequenceElement =
|
|
|
200
200
|
| SequenceSection
|
|
201
201
|
| SequenceNote;
|
|
202
202
|
|
|
203
|
-
export function isSequenceMessage(el: SequenceElement): el is SequenceMessage {
|
|
204
|
-
return el.kind === 'message';
|
|
205
|
-
}
|
|
206
|
-
|
|
207
203
|
export function isSequenceBlock(el: SequenceElement): el is SequenceBlock {
|
|
208
204
|
return el.kind === 'block';
|
|
209
205
|
}
|
package/src/utils/brand.ts
CHANGED
|
@@ -27,23 +27,6 @@
|
|
|
27
27
|
*/
|
|
28
28
|
export type Brand<T, B extends string> = T & { readonly __brand: B };
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* Cast a raw value to a branded type. The only legal "mint" point —
|
|
32
|
-
* call this at the boundary where unbranded data (parser input,
|
|
33
|
-
* external API) enters branded territory.
|
|
34
|
-
*
|
|
35
|
-
* const id = asBrand<NodeId>(rawString);
|
|
36
|
-
*
|
|
37
|
-
* Inverts trivially: a `Brand<T, B>` is assignable to `T` without a
|
|
38
|
-
* cast, so consumers that want the underlying primitive lose the
|
|
39
|
-
* brand naturally.
|
|
40
|
-
*/
|
|
41
|
-
export function asBrand<B>(
|
|
42
|
-
value: B extends Brand<infer T, string> ? T : never
|
|
43
|
-
): B {
|
|
44
|
-
return value as B;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
30
|
// ============================================================
|
|
48
31
|
// Writable<T> — escape hatch for parsers that need a mutable
|
|
49
32
|
// construction phase before returning a `readonly`-typed value.
|
package/src/utils/legend-d3.ts
CHANGED
|
@@ -31,6 +31,16 @@ import type {
|
|
|
31
31
|
D3Sel,
|
|
32
32
|
} from './legend-types';
|
|
33
33
|
|
|
34
|
+
// Vertically center an SVG <text> across engines. WebKit drops
|
|
35
|
+
// `dominant-baseline` on <text>, and resvg has limited support too
|
|
36
|
+
// (see legend-svg.ts), so we use the alphabetic baseline (the shared
|
|
37
|
+
// default) plus an em-relative dy. 0.32em matches legend-svg.ts's
|
|
38
|
+
// proven pill offset (fontSize/2 - 2 = 0.318em at 11px).
|
|
39
|
+
const LEGEND_TEXT_DY = '0.32em';
|
|
40
|
+
function centerText(sel: D3Sel): D3Sel {
|
|
41
|
+
return sel.attr('dy', LEGEND_TEXT_DY);
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
// ── Main renderer ───────────────────────────────────────────
|
|
35
45
|
|
|
36
46
|
export function renderLegendD3(
|
|
@@ -190,7 +200,7 @@ function renderCapsule(
|
|
|
190
200
|
.attr('x', pill.x + pill.width / 2)
|
|
191
201
|
.attr('y', LEGEND_HEIGHT / 2)
|
|
192
202
|
.attr('text-anchor', 'middle')
|
|
193
|
-
.
|
|
203
|
+
.call(centerText)
|
|
194
204
|
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
195
205
|
.attr('font-weight', 500)
|
|
196
206
|
.attr('fill', palette.text)
|
|
@@ -211,7 +221,7 @@ function renderCapsule(
|
|
|
211
221
|
g.append('text')
|
|
212
222
|
.attr('x', gr.minX)
|
|
213
223
|
.attr('y', gr.textY)
|
|
214
|
-
.
|
|
224
|
+
.call(centerText)
|
|
215
225
|
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
216
226
|
.attr('fill', palette.textMuted)
|
|
217
227
|
.attr('pointer-events', 'none')
|
|
@@ -232,7 +242,7 @@ function renderCapsule(
|
|
|
232
242
|
g.append('text')
|
|
233
243
|
.attr('x', gr.maxX)
|
|
234
244
|
.attr('y', gr.textY)
|
|
235
|
-
.
|
|
245
|
+
.call(centerText)
|
|
236
246
|
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
237
247
|
.attr('fill', palette.textMuted)
|
|
238
248
|
.attr('pointer-events', 'none')
|
|
@@ -259,7 +269,7 @@ function renderCapsule(
|
|
|
259
269
|
.append('text')
|
|
260
270
|
.attr('x', entry.textX)
|
|
261
271
|
.attr('y', entry.textY)
|
|
262
|
-
.
|
|
272
|
+
.call(centerText)
|
|
263
273
|
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
264
274
|
.attr('fill', palette.textMuted)
|
|
265
275
|
.attr('font-family', FONT_FAMILY)
|
|
@@ -318,7 +328,7 @@ function renderPill(
|
|
|
318
328
|
.attr('x', pill.width / 2)
|
|
319
329
|
.attr('y', pill.height / 2)
|
|
320
330
|
.attr('text-anchor', 'middle')
|
|
321
|
-
.
|
|
331
|
+
.call(centerText)
|
|
322
332
|
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
323
333
|
.attr('font-weight', 500)
|
|
324
334
|
.attr('fill', palette.textMuted)
|
|
@@ -387,7 +397,7 @@ function renderControl(
|
|
|
387
397
|
.attr('x', textX)
|
|
388
398
|
.attr('y', ctrl.height / 2)
|
|
389
399
|
.attr('text-anchor', 'middle')
|
|
390
|
-
.
|
|
400
|
+
.call(centerText)
|
|
391
401
|
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
392
402
|
.attr('font-weight', 500)
|
|
393
403
|
.attr('fill', palette.textMuted)
|
|
@@ -422,7 +432,7 @@ function renderControl(
|
|
|
422
432
|
.attr('x', child.width / 2)
|
|
423
433
|
.attr('y', ctrl.height / 2)
|
|
424
434
|
.attr('text-anchor', 'middle')
|
|
425
|
-
.
|
|
435
|
+
.call(centerText)
|
|
426
436
|
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
427
437
|
.attr('fill', child.isActive ? palette.bg : palette.textMuted)
|
|
428
438
|
.attr('font-family', FONT_FAMILY)
|
|
@@ -585,7 +595,7 @@ function renderControlsGroup(
|
|
|
585
595
|
.append('text')
|
|
586
596
|
.attr('x', tl.textX)
|
|
587
597
|
.attr('y', tl.textY)
|
|
588
|
-
.
|
|
598
|
+
.call(centerText)
|
|
589
599
|
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
590
600
|
.attr('fill', palette.textMuted)
|
|
591
601
|
.attr('opacity', tl.active ? 1 : LEGEND_TOGGLE_OFF_OPACITY)
|
package/src/utils/parsing.ts
CHANGED
|
@@ -79,22 +79,6 @@ export const ALL_CHART_TYPES = new Set([
|
|
|
79
79
|
'map',
|
|
80
80
|
]);
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
* Heuristic: pipe-metadata content is structured `key: value, …` form when
|
|
84
|
-
* the first token is a bare identifier followed by `:`. Used by parsers that
|
|
85
|
-
* accept both shorthand-description-after-pipe and structured key-value
|
|
86
|
-
* (pyramid, ring) to disambiguate the two.
|
|
87
|
-
*/
|
|
88
|
-
export const PIPE_KEY_VALUE_PREFIX_RE = /^\s*[A-Za-z][A-Za-z0-9_-]*\s*:/;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Heuristic to detect a likely-structured tail inside an otherwise-shorthand
|
|
92
|
-
* pipe: `, key:` somewhere in the string. Used to flag user errors like
|
|
93
|
-
* `Inner | bare desc, color: blue` where `color: blue` is silently swallowed
|
|
94
|
-
* into the description.
|
|
95
|
-
*/
|
|
96
|
-
export const PIPE_LIKELY_STRUCTURED_TAIL_RE = /,\s*[A-Za-z][A-Za-z0-9_-]*\s*:/;
|
|
97
|
-
|
|
98
82
|
/** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
|
|
99
83
|
export function measureIndent(line: string): number {
|
|
100
84
|
let indent = 0;
|
|
@@ -113,11 +113,6 @@ export const ER_REGISTRY: ReservedKeyRegistry = staticRegistry([
|
|
|
113
113
|
'domain',
|
|
114
114
|
]);
|
|
115
115
|
|
|
116
|
-
export const CLASS_REGISTRY: ReservedKeyRegistry = staticRegistry([
|
|
117
|
-
'color',
|
|
118
|
-
'description',
|
|
119
|
-
]);
|
|
120
|
-
|
|
121
116
|
export const KANBAN_REGISTRY: ReservedKeyRegistry = staticRegistry([
|
|
122
117
|
'color',
|
|
123
118
|
'description',
|
|
@@ -221,10 +216,3 @@ export const RACI_REGISTRY: ReservedKeyRegistry = staticRegistry([
|
|
|
221
216
|
'color',
|
|
222
217
|
'description',
|
|
223
218
|
]);
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Wireframe uses a trailing-keyword flag list (§19.5), not key-value
|
|
227
|
-
* metadata. This empty registry exists so callers can still pass a
|
|
228
|
-
* registry to shared helpers without a special-case.
|
|
229
|
-
*/
|
|
230
|
-
export const WIREFRAME_REGISTRY: ReservedKeyRegistry = staticRegistry([]);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make an SVG produced by `@diagrammo/dgmo`'s static `render()` suitable for
|
|
3
|
+
* responsive inline embedding in any host (Obsidian, remark/markdown, web
|
|
4
|
+
* pages):
|
|
5
|
+
*
|
|
6
|
+
* - dgmo renders diagrams inside a fixed export canvas (e.g.
|
|
7
|
+
* `viewBox="0 0 1200 800"`), with content often occupying only a fraction
|
|
8
|
+
* of it. We compute a tight content bounding box from element coordinates
|
|
9
|
+
* and set the root `viewBox` to bbox+padding, so the diagram's intrinsic
|
|
10
|
+
* aspect ratio matches its CONTENT — no dead space above/below or beside it.
|
|
11
|
+
* - Ensure the root `<svg>` has a `viewBox` so it scales responsively.
|
|
12
|
+
* - Strip fixed `width="N"` / `height="N"` so CSS (e.g. `width:100%;
|
|
13
|
+
* height:auto`, or an aspect-ratio derived from the tight viewBox) controls
|
|
14
|
+
* sizing.
|
|
15
|
+
* - Remove any inline `background:` from the root style so the page
|
|
16
|
+
* background shows through.
|
|
17
|
+
*
|
|
18
|
+
* This is intentionally a string transform, not a DOM `getBBox()` step: dgmo
|
|
19
|
+
* can dual-render light/dark SVGs where one is hidden by color-mode CSS, and
|
|
20
|
+
* `getBBox()` returns 0 for the hidden copy. Parsing coordinates from the
|
|
21
|
+
* markup measures both copies reliably and works server-side (Node).
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeSvgForEmbed(input: string): string {
|
|
24
|
+
let svg = input;
|
|
25
|
+
const rootMatch = svg.match(/<svg[^>]*>/);
|
|
26
|
+
const rootTag = rootMatch?.[0] ?? '';
|
|
27
|
+
if (rootTag && !rootTag.includes('viewBox')) {
|
|
28
|
+
const wh = rootTag.match(/width="(\d+)"[^>]*height="(\d+)"/);
|
|
29
|
+
if (wh) {
|
|
30
|
+
svg = svg.replace(/<svg/, `<svg viewBox="0 0 ${wh[1]} ${wh[2]}"`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tight = computeBBox(svg);
|
|
35
|
+
if (tight && tight.width > 0 && tight.height > 0) {
|
|
36
|
+
const pad = 16;
|
|
37
|
+
const vb = `${tight.x - pad} ${tight.y - pad} ${tight.width + pad * 2} ${tight.height + pad * 2}`;
|
|
38
|
+
svg = svg.replace(/(<svg[^>]*?)viewBox="[^"]*"/, `$1viewBox="${vb}"`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
svg = svg.replace(/(<svg[^>]*?) width="[^"]*"/g, '$1');
|
|
42
|
+
svg = svg.replace(/(<svg[^>]*?) height="[^"]*"/g, '$1');
|
|
43
|
+
svg = svg.replace(/(<svg[^>]*?style="[^"]*?)background:[^;"]*;?\s*/g, '$1');
|
|
44
|
+
svg = svg.replace(/<svg\s{2,}/g, '<svg ');
|
|
45
|
+
return svg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse the content bounding box of a normalized embed SVG, if one can be
|
|
50
|
+
* derived. Returns `null` when no usable coordinates are found (e.g. an empty
|
|
51
|
+
* diagram). Useful for hosts that want to set an explicit `aspect-ratio` from
|
|
52
|
+
* the tight viewBox.
|
|
53
|
+
*/
|
|
54
|
+
export function getEmbedSvgViewBox(
|
|
55
|
+
svg: string
|
|
56
|
+
): { x: number; y: number; width: number; height: number } | null {
|
|
57
|
+
const tight = computeBBox(svg);
|
|
58
|
+
if (!tight || tight.width <= 0 || tight.height <= 0) return null;
|
|
59
|
+
const pad = 16;
|
|
60
|
+
return {
|
|
61
|
+
x: tight.x - pad,
|
|
62
|
+
y: tight.y - pad,
|
|
63
|
+
width: tight.width + pad * 2,
|
|
64
|
+
height: tight.height + pad * 2,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compute an approximate content bounding box from raw element coordinates.
|
|
70
|
+
*
|
|
71
|
+
* This is a regex walk, not a real SVG layout — it ignores `transform`
|
|
72
|
+
* attributes and uses a heuristic for text widths. dgmo's renderers mostly use
|
|
73
|
+
* absolute coordinates within their viewBox, so the approximation is close
|
|
74
|
+
* enough that the rendered output reliably fills the visible area.
|
|
75
|
+
*/
|
|
76
|
+
function computeBBox(
|
|
77
|
+
svg: string
|
|
78
|
+
): { x: number; y: number; width: number; height: number } | null {
|
|
79
|
+
const xs: number[] = [];
|
|
80
|
+
const ys: number[] = [];
|
|
81
|
+
|
|
82
|
+
function push(x: number, y: number): void {
|
|
83
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
84
|
+
xs.push(x);
|
|
85
|
+
ys.push(y);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function attr(tag: string, name: string): number | null {
|
|
90
|
+
const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
|
|
91
|
+
if (!m) return null;
|
|
92
|
+
const n = parseFloat(m[1]!);
|
|
93
|
+
return Number.isFinite(n) ? n : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// <rect x y width height>
|
|
97
|
+
for (const m of svg.matchAll(/<rect\b[^>]*?\/?>/g)) {
|
|
98
|
+
const tag = m[0];
|
|
99
|
+
const x = attr(tag, 'x');
|
|
100
|
+
const y = attr(tag, 'y');
|
|
101
|
+
const w = attr(tag, 'width');
|
|
102
|
+
const h = attr(tag, 'height');
|
|
103
|
+
if (x !== null && y !== null && w !== null && h !== null) {
|
|
104
|
+
push(x, y);
|
|
105
|
+
push(x + w, y + h);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// <line x1 y1 x2 y2>
|
|
110
|
+
for (const m of svg.matchAll(/<line\b[^>]*?\/?>/g)) {
|
|
111
|
+
const tag = m[0];
|
|
112
|
+
const x1 = attr(tag, 'x1');
|
|
113
|
+
const y1 = attr(tag, 'y1');
|
|
114
|
+
const x2 = attr(tag, 'x2');
|
|
115
|
+
const y2 = attr(tag, 'y2');
|
|
116
|
+
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
|
|
117
|
+
push(x1, y1);
|
|
118
|
+
push(x2, y2);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// <circle cx cy r>
|
|
123
|
+
for (const m of svg.matchAll(/<circle\b[^>]*?\/?>/g)) {
|
|
124
|
+
const tag = m[0];
|
|
125
|
+
const cx = attr(tag, 'cx');
|
|
126
|
+
const cy = attr(tag, 'cy');
|
|
127
|
+
const r = attr(tag, 'r');
|
|
128
|
+
if (cx !== null && cy !== null && r !== null) {
|
|
129
|
+
push(cx - r, cy - r);
|
|
130
|
+
push(cx + r, cy + r);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// <ellipse cx cy rx ry>
|
|
135
|
+
for (const m of svg.matchAll(/<ellipse\b[^>]*?\/?>/g)) {
|
|
136
|
+
const tag = m[0];
|
|
137
|
+
const cx = attr(tag, 'cx');
|
|
138
|
+
const cy = attr(tag, 'cy');
|
|
139
|
+
const rx = attr(tag, 'rx');
|
|
140
|
+
const ry = attr(tag, 'ry');
|
|
141
|
+
if (cx !== null && cy !== null && rx !== null && ry !== null) {
|
|
142
|
+
push(cx - rx, cy - ry);
|
|
143
|
+
push(cx + rx, cy + ry);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// <text x y>some content</text>
|
|
148
|
+
// Approximate width: text content length × an empirical font width factor.
|
|
149
|
+
// dgmo uses Inter ~14px by default; ~7px per character is a usable rough
|
|
150
|
+
// estimate that won't drastically under- or over-count.
|
|
151
|
+
for (const m of svg.matchAll(/<text\b([^>]*?)>([\s\S]*?)<\/text>/g)) {
|
|
152
|
+
const tag = `<text${m[1]}>`;
|
|
153
|
+
const text = m[2]!.replace(/<[^>]+>/g, ''); // strip inner tags (tspan, etc.)
|
|
154
|
+
const x = attr(tag, 'x');
|
|
155
|
+
const y = attr(tag, 'y');
|
|
156
|
+
if (x !== null && y !== null) {
|
|
157
|
+
const w = text.length * 7;
|
|
158
|
+
// text-anchor may be start/middle/end; assume worst case (middle).
|
|
159
|
+
push(x - w / 2, y - 14);
|
|
160
|
+
push(x + w / 2, y + 4);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// <path d="..."> — pull every coordinate pair out of the d attribute.
|
|
165
|
+
for (const m of svg.matchAll(/<path\b[^>]*?\bd="([^"]+)"/g)) {
|
|
166
|
+
const d = m[1]!;
|
|
167
|
+
const nums = d.match(/-?\d+(?:\.\d+)?/g);
|
|
168
|
+
if (!nums) continue;
|
|
169
|
+
for (let i = 0; i + 1 < nums.length; i += 2) {
|
|
170
|
+
push(parseFloat(nums[i]!), parseFloat(nums[i + 1]!));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// <polygon points="x,y x,y ..."> and <polyline>
|
|
175
|
+
for (const m of svg.matchAll(
|
|
176
|
+
/<(?:polygon|polyline)\b[^>]*?\bpoints="([^"]+)"/g
|
|
177
|
+
)) {
|
|
178
|
+
const nums = m[1]!.match(/-?\d+(?:\.\d+)?/g);
|
|
179
|
+
if (!nums) continue;
|
|
180
|
+
for (let i = 0; i + 1 < nums.length; i += 2) {
|
|
181
|
+
push(parseFloat(nums[i]!), parseFloat(nums[i + 1]!));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (xs.length === 0 || ys.length === 0) return null;
|
|
186
|
+
|
|
187
|
+
const minX = Math.min(...xs);
|
|
188
|
+
const maxX = Math.max(...xs);
|
|
189
|
+
const minY = Math.min(...ys);
|
|
190
|
+
const maxY = Math.max(...ys);
|
|
191
|
+
|
|
192
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
193
|
+
}
|