@schemd/core 0.3.0 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to `@schemd/core` are recorded here. Dates describe actual npm publication dates; unpublished versions deliberately use `Unreleased`.
4
4
 
5
+ ## [0.3.1] - 07/20/2026
6
+
7
+ ### Fixed
8
+
9
+ - Orthogonal routing no longer fails diagrams whose components sit closer than twice the 12-unit clearance margin. The post-routing guard now rejects only physical body clips; escape stubs may legitimately pass through a neighbor's clearance ring, so densely packed parallel wires route as straight traces instead of throwing `Orthogonal route intersects … after routing.`
10
+ - Empty `qgate` detail rows (`parameter=""`, `phase=""`, `matrix=""`) no longer reserve blank text space: layout and renderer now agree that empty details are absent, so such gates keep the canonical shared quantum shell.
11
+ - `embedded-css` output no longer emits keyboard-focusable component and wire groups beneath its `role="img"` root, which flattened them for assistive technology while leaving unlabeled tab stops. Internal `tabindex`/ARIA semantics are now exclusive to `full` mode, and every `full`-mode root is `role="group"` regardless of which semantic hooks are enabled.
12
+
13
+ ### Changed
14
+
15
+ - `renderSchematic` skips the redundant geometry revalidation pass when the parser's route cache proves the same frozen document already validated against identical bounds, and computes the AST-serializing signature hash only when no `idPrefix` is supplied. Rendered output is byte-identical; hosts that pass `idPrefix` (such as compile endpoints) no longer pay an `O(document)` serialization per render.
16
+
17
+ ### Verified release-candidate measurements
18
+
19
+ - Compiler bundle: 90,294 B minified, 26,479 B gzip — 4,241 B below the 30,720 B gate and 33 B smaller than 0.3.0.
20
+ - Coverage: 100% statements, branches, functions, and lines across 123 tests, including new regressions for sub-clearance routing, single-track barriers, and empty quantum detail rows.
21
+ - Runtime dependencies: zero. No public API, grammar, or geometry contracts changed; no migration is required.
22
+
5
23
  ## [0.3.0] - 07/19/2026
6
24
 
7
25
  ### Added
package/dist/layout.d.ts CHANGED
@@ -107,6 +107,18 @@ export declare function rotateQuarterExtents(extents: SchematicHalfExtents, turn
107
107
  * @returns Relative coordinate between negative and positive half-span.
108
108
  */
109
109
  export declare function distributedCoordinate(index: number, count: number, span: number): number;
110
+ /** Total centered height of a quantum glyph carrying `tracks` qubit lines. */
111
+ export declare function quantumTrackSpan(tracks: number): number;
112
+ /**
113
+ * Vertical offset of quantum track `index` within a `tracks`-line glyph.
114
+ *
115
+ * Unlike {@link distributedCoordinate}, tracks are spread *edge-to-edge* so the
116
+ * outermost rails land exactly at ±span/2 — where the connecting bar and body
117
+ * extent terminate. That keeps control dots, targets, and swap crosses on rails
118
+ * a full pitch apart instead of collapsing a two-qubit swap into an overlapped
119
+ * star. Renderer and port geometry both call this, so marks and wires align.
120
+ */
121
+ export declare function quantumTrackCoordinate(index: number, tracks: number): number;
110
122
  /**
111
123
  * Compute a classical gate height that scales with its busiest terminal side.
112
124
  *
package/dist/layout.js CHANGED
@@ -83,11 +83,20 @@ function formatNumber(value) {
83
83
  export function distributedCoordinate(index, count, span) {
84
84
  return ((index + 1) * span) / (count + 1) - span / 2;
85
85
  }
86
+ export function quantumTrackSpan(tracks) {
87
+ return Math.max(16, (tracks - 1) * 18);
88
+ }
89
+ export function quantumTrackCoordinate(index, tracks) {
90
+ if (tracks <= 1)
91
+ return 0;
92
+ const span = quantumTrackSpan(tracks);
93
+ return -span / 2 + (index * span) / (tracks - 1);
94
+ }
86
95
  export function classicalGateHeight(component) {
87
96
  return Math.max(48, Math.max(component.inputs, component.outputs) * 16);
88
97
  }
89
98
  export function quantumGateDimensions(component) {
90
- const details = [component.parameter, component.phase, component.matrix].filter((value) => value !== undefined);
99
+ const details = [component.parameter, component.phase, component.matrix].filter((value) => value !== undefined && value !== '');
91
100
  let widest = 16;
92
101
  if (details.length > 0 && component.kind === 'qgate')
93
102
  widest = mathLabelTextWidth(component.label, 8);
@@ -454,12 +463,12 @@ export function resolvePortPoint(component, port) {
454
463
  index += component.controls;
455
464
  const tracks = Math.max(component.wires, component.controls + component.targets);
456
465
  if (port.startsWith('control') || port.startsWith('target')) {
457
- local = { x: 0, y: distributedCoordinate(index, Math.max(1, tracks), Math.max(16, (tracks - 1) * 18)) };
466
+ local = { x: 0, y: quantumTrackCoordinate(index, tracks) };
458
467
  }
459
468
  else {
460
469
  local = {
461
470
  x: port.startsWith('out') ? 42 : -42,
462
- y: distributedCoordinate(index, Math.max(1, tracks), Math.max(16, (tracks - 1) * 18))
471
+ y: quantumTrackCoordinate(index, tracks)
463
472
  };
464
473
  }
465
474
  break;
@@ -1091,7 +1100,7 @@ function routeConnectionInternal(connection, components, bounds, cachedObstacles
1091
1100
  for (const obstacle of obstacleCache) {
1092
1101
  if (!(allowFrom && obstacle.id === from.component.id) &&
1093
1102
  !(allowTo && obstacle.id === to.component.id) &&
1094
- segmentIntersectsRectangle(points[index - 1], points[index], obstacleRectangle(obstacle))) {
1103
+ segmentIntersectsRectangle(points[index - 1], points[index], obstacle.body)) {
1095
1104
  throw new SchematicSyntaxError(`Orthogonal route intersects ${obstacle.id} after routing.`, connection.line);
1096
1105
  }
1097
1106
  }
@@ -57,9 +57,11 @@ export declare function mathLabelTextWidth(value: string, cellAdvance?: number):
57
57
  /**
58
58
  * Emit escaped inline SVG text with explicit baseline restoration.
59
59
  *
60
- * Plain labels avoid `<tspan>` allocation entirely. Shifted segments carry
61
- * absolute scale and baseline metrics; each emitted `dy` is the delta from the
62
- * preceding run, so nested and consecutive scripts cannot accumulate drift.
60
+ * Baseline shifts are tracked in root-em units, but SVG resolves a `<tspan>`'s
61
+ * `dy="…em"` against *that tspan's own* (scaled) font-size. Each emitted `dy` is
62
+ * therefore the root-em delta divided by the tspan's font scale, so a shift of
63
+ * `d` root-em applied on a `s`-scaled run renders as exactly `d` — nested and
64
+ * consecutive scripts return to the parent baseline with zero drift.
63
65
  *
64
66
  * @param value - Raw micro-math label.
65
67
  * @returns Trusted compiler-owned XML text suitable inside an SVG `<text>` node.
@@ -191,33 +191,19 @@ export function renderMathLabelTspans(value) {
191
191
  if (segments.length === 1 && segments[0]?.kind === 'text' && segments[0].value === value) {
192
192
  return escapeXml(value);
193
193
  }
194
- const nested = segments.some((segment) => segment.fontScale !== undefined);
195
- if (nested) {
196
- let previousShift = 0;
197
- let markup = '';
198
- for (const segment of segments) {
199
- const shift = segment.baselineShiftEm ??
200
- (segment.kind === 'subscript' ? 0.35 : segment.kind === 'superscript' ? -0.55 : 0);
201
- const scale = segment.fontScale ?? (segment.kind === 'text' ? 1 : 0.7);
202
- const delta = Number((shift - previousShift).toFixed(4));
203
- const fontPercent = Number((scale * 100).toFixed(2));
204
- markup += `<tspan dy="${delta}em" font-size="${fontPercent}%">${escapeXml(segment.value)}</tspan>`;
205
- previousShift = shift;
206
- }
207
- if (previousShift !== 0) {
208
- markup += `<tspan dy="${Number((-previousShift).toFixed(4))}em" font-size="100%"></tspan>`;
209
- }
210
- return markup;
194
+ let previousShift = 0;
195
+ let markup = '';
196
+ for (const segment of segments) {
197
+ const shift = segment.baselineShiftEm ??
198
+ (segment.kind === 'subscript' ? 0.35 : segment.kind === 'superscript' ? -0.55 : 0);
199
+ const scale = segment.fontScale ?? (segment.kind === 'text' ? 1 : 0.7);
200
+ const delta = Number(((shift - previousShift) / scale).toFixed(4));
201
+ const fontPercent = Number((scale * 100).toFixed(2));
202
+ markup += `<tspan dy="${delta}em" font-size="${fontPercent}%">${escapeXml(segment.value)}</tspan>`;
203
+ previousShift = shift;
211
204
  }
212
- return segments
213
- .map((segment) => {
214
- const content = escapeXml(segment.value);
215
- if (segment.kind === 'text')
216
- return `<tspan dy="0">${content}</tspan>`;
217
- if (segment.kind === 'subscript') {
218
- return `<tspan dy="0.35em" font-size="70%">${content}</tspan><tspan dy="-0.35em" font-size="100%"></tspan>`;
219
- }
220
- return `<tspan dy="-0.55em" font-size="70%">${content}</tspan><tspan dy="0.55em" font-size="100%"></tspan>`;
221
- })
222
- .join('');
205
+ if (previousShift !== 0) {
206
+ markup += `<tspan dy="${Number((-previousShift).toFixed(4))}em" font-size="100%"></tspan>`;
207
+ }
208
+ return markup;
223
209
  }
package/dist/renderer.js CHANGED
@@ -1,4 +1,4 @@
1
- import { classicalGateHeight, componentTextAnchors, distributedCoordinate, enumerateComponentPorts, PORT_HOTSPOT_RADIUS, quantumGateDimensions, routeConnections, validateDocumentGeometry } from './layout.js';
1
+ import { classicalGateHeight, componentTextAnchors, distributedCoordinate, enumerateComponentPorts, PORT_HOTSPOT_RADIUS, quantumGateDimensions, quantumTrackCoordinate, quantumTrackSpan, routeConnections, validateDocumentGeometry } from './layout.js';
2
2
  import { MAX_SCHEMATIC_SVG_OUTPUT_BYTES, utf8ByteLength } from './limits.js';
3
3
  import { assertParsedSchematicDocument } from './parser.js';
4
4
  import { parsedSchematicRoutes } from './route-cache.js';
@@ -560,14 +560,14 @@ function quantumSpecialShape(component, paint, nodePaint) {
560
560
  if (component.kind === 'classical-bit' || component.kind === 'classical-register')
561
561
  return `<path ${paint} d="M -40 -2 H 40 M -40 2 H 40" />`;
562
562
  const tracks = Math.max(component.wires, component.controls + component.targets);
563
- const span = Math.max(16, (tracks - 1) * 18);
564
- const lines = Array.from({ length: tracks }, (_, index) => `<path ${paint} d="M -42 ${svgNumber(distributedCoordinate(index, tracks, span))} H 42" />`).join('');
563
+ const span = quantumTrackSpan(tracks);
564
+ const lines = Array.from({ length: tracks }, (_, index) => `<path ${paint} d="M -42 ${svgNumber(quantumTrackCoordinate(index, tracks))} H 42" />`).join('');
565
565
  if (component.kind === 'barrier')
566
566
  return `${lines}<path ${paint} stroke-dasharray="4 3" d="M 0 ${svgNumber(-span / 2 - 8)} V ${svgNumber(span / 2 + 8)}" />`;
567
567
  if (component.kind === 'delay')
568
568
  return `${lines}<rect ${paint} x="-12" y="${svgNumber(-span / 2 - 7)}" width="24" height="${svgNumber(span + 14)}" rx="2" />`;
569
569
  const marks = Array.from({ length: tracks }, (_, index) => {
570
- const y = distributedCoordinate(index, tracks, span);
570
+ const y = quantumTrackCoordinate(index, tracks);
571
571
  if (component.kind === 'swap')
572
572
  return `<path ${paint} d="M -6 ${svgNumber(y - 6)} L 6 ${svgNumber(y + 6)} M 6 ${svgNumber(y - 6)} L -6 ${svgNumber(y + 6)}" />`;
573
573
  if (component.kind === 'cz')
@@ -790,7 +790,7 @@ function compactConnectionMarkup(connections, routes, idPrefix, embedVisuals, gl
790
790
  const labels = batch.labels.join('');
791
791
  if (!embedVisuals)
792
792
  return trace + endpoints + labels;
793
- return `<g class="schematic-wire" tabindex="0" role="group" aria-label="${batch.traces.length} grouped connection${batch.traces.length === 1 ? '' : 's'}">${trace}<use class="schematic-glow-layer" href="#${traceId}" filter="url(#${glowId})" aria-hidden="true" pointer-events="none" />${endpoints}${labels}</g>`;
793
+ return `<g class="schematic-wire">${trace}<use class="schematic-glow-layer" href="#${traceId}" filter="url(#${glowId})" aria-hidden="true" pointer-events="none" />${endpoints}${labels}</g>`;
794
794
  }).join('');
795
795
  }
796
796
  function componentMetadata(component) {
@@ -861,7 +861,7 @@ function componentMarkup(component, index, idPrefix, glowId, mode, nodeHooks, po
861
861
  ? ` data-node-id="${id}" data-node-kind="${component.kind}" data-node-label="${label}" data-component="${id}" data-kind="${component.kind}" data-source-line="${component.line}"${'orientation' in component && component.orientation !== undefined ? ` data-orientation="${component.orientation}"` : ''}`
862
862
  : '';
863
863
  let accessibility = '';
864
- if (styles) {
864
+ if (mode === 'full') {
865
865
  const metadata = componentMetadata(component);
866
866
  const ariaLabel = escapeXml(`${component.id}, ${component.kind}, ${component.label}${metadata === '' ? '' : `, ${metadata}`}`);
867
867
  accessibility = `${portHooks ? '' : ' tabindex="0"'} role="group" aria-label="${ariaLabel}"`;
@@ -883,15 +883,13 @@ function componentMarkup(component, index, idPrefix, glowId, mode, nodeHooks, po
883
883
  export function renderSchematic(document, options) {
884
884
  assertParsedSchematicDocument(document);
885
885
  const normalized = normalizeCompileOptions(options);
886
- const components = new Map(document.components.map((component) => [component.id, component]));
887
886
  const routedConnections = parsedSchematicRoutes(document, normalized.bounds) ??
888
- routeConnections(document.connections, components, normalized.bounds);
889
- validateDocumentGeometry(document, normalized, routedConnections);
890
- const signature = `${normalized.bounds.width}x${normalized.bounds.height}:${normalized.title}:${JSON.stringify(document)}`;
891
- const candidatePrefix = (normalized.idPrefix ?? `schematic-${stableHash(signature)}`).replace(/[^A-Za-z0-9_-]/g, '-');
887
+ validateDocumentGeometry(document, normalized, routeConnections(document.connections, new Map(document.components.map((component) => [component.id, component])), normalized.bounds));
888
+ const signatureHash = () => stableHash(`${normalized.bounds.width}x${normalized.bounds.height}:${normalized.title}:${JSON.stringify(document)}`);
889
+ const candidatePrefix = (normalized.idPrefix ?? `schematic-${signatureHash()}`).replace(/[^A-Za-z0-9_-]/g, '-');
892
890
  const safePrefix = /[A-Za-z0-9]/.test(candidatePrefix)
893
891
  ? candidatePrefix
894
- : `schematic-${stableHash(signature)}`;
892
+ : `schematic-${signatureHash()}`;
895
893
  const titleId = `${safePrefix}-title`;
896
894
  const descriptionId = `${safePrefix}-description`;
897
895
  const gridId = `${safePrefix}-grid`;
@@ -924,7 +922,7 @@ export function renderSchematic(document, options) {
924
922
  ? `<style>${STATIC_SVG_STYLES}${hookStyles}${interactionStyles}</style><pattern id="${gridId}" width="20" height="20" patternUnits="userSpaceOnUse"><path class="schematic-grid-line" d="M20 0H0V20" /></pattern>${glowFilter}`
925
923
  : '';
926
924
  const definitions = `${embeddedDefinitions}${markerDefinitions}${symbolDefinitions}`;
927
- const svgRole = portHooks ? 'group' : 'img';
925
+ const svgRole = hooks ? 'group' : 'img';
928
926
  writer.append(`<figure class="schematic-frame"${hooks ? ' data-schematic' : ''}><svg class="schematic-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${normalized.bounds.width} ${normalized.bounds.height}" width="${normalized.bounds.width}" height="${normalized.bounds.height}" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" role="${svgRole}" aria-labelledby="${titleId} ${descriptionId}" preserveAspectRatio="xMidYMid meet"><title id="${titleId}">${title}</title><desc id="${descriptionId}">${description}</desc>${definitions === '' ? '' : `<defs>${definitions}</defs>`}${styles ? `<rect class="schematic-surface" width="100%" height="100%" /><rect class="schematic-grid" width="100%" height="100%" fill="url(#${gridId})" />` : ''}<g class="schematic-vectors">`);
929
927
  if (wireHooks) {
930
928
  for (const [index, connection] of document.connections.entries()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schemd/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Zero-dependency text-to-SVG compiler for schematics and UML.",
5
5
  "homepage": "https://schemd.johnowolabiidogun.dev",
6
6
  "bugs": {