@spectratools/graphic-designer-cli 0.4.0 → 0.6.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/qa.d.ts CHANGED
@@ -1,15 +1,24 @@
1
- import { R as RenderMetadata, D as DesignSpec } from './spec.schema-BUTof436.js';
1
+ import { R as RenderMetadata, D as DesignSpec } from './spec.schema-Dm_wOLTd.js';
2
2
  import 'zod';
3
3
  import '@napi-rs/canvas';
4
4
 
5
5
  type QaSeverity = 'error' | 'warning';
6
6
  type QaIssue = {
7
- code: 'DIMENSIONS_MISMATCH' | 'ELEMENT_CLIPPED' | 'ELEMENT_OVERLAP' | 'LOW_CONTRAST' | 'FOOTER_SPACING' | 'TEXT_TRUNCATED' | 'MISSING_LAYOUT' | 'DRAW_OUT_OF_BOUNDS';
7
+ code: 'DIMENSIONS_MISMATCH' | 'ELEMENT_CLIPPED' | 'ELEMENT_OVERLAP' | 'LOW_CONTRAST' | 'FOOTER_SPACING' | 'TEXT_TRUNCATED' | 'MISSING_LAYOUT' | 'DRAW_OUT_OF_BOUNDS' | 'REFERENCE_MISMATCH';
8
8
  severity: QaSeverity;
9
9
  message: string;
10
10
  elementId?: string;
11
11
  details?: Record<string, number | string | boolean>;
12
12
  };
13
+ type QaReferenceResult = {
14
+ similarity: number;
15
+ verdict: 'match' | 'close' | 'mismatch';
16
+ regions: Array<{
17
+ label: string;
18
+ similarity: number;
19
+ description?: string;
20
+ }>;
21
+ };
13
22
  type QaReport = {
14
23
  pass: boolean;
15
24
  checkedAt: string;
@@ -27,6 +36,7 @@ type QaReport = {
27
36
  footerSpacingPx?: number;
28
37
  };
29
38
  issues: QaIssue[];
39
+ reference?: QaReferenceResult;
30
40
  };
31
41
  /**
32
42
  * Read and parse a sidecar `.meta.json` file produced by
@@ -57,6 +67,7 @@ declare function runQa(options: {
57
67
  imagePath: string;
58
68
  spec: DesignSpec;
59
69
  metadata?: RenderMetadata;
70
+ referencePath?: string;
60
71
  }): Promise<QaReport>;
61
72
 
62
- export { type QaIssue, type QaReport, type QaSeverity, readMetadata, runQa };
73
+ export { type QaIssue, type QaReferenceResult, type QaReport, type QaSeverity, readMetadata, runQa };
package/dist/qa.js CHANGED
@@ -1,7 +1,154 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/compare.ts
12
+ var compare_exports = {};
13
+ __export(compare_exports, {
14
+ compareImages: () => compareImages
15
+ });
16
+ import sharp from "sharp";
17
+ function clampUnit(value) {
18
+ if (value < 0) {
19
+ return 0;
20
+ }
21
+ if (value > 1) {
22
+ return 1;
23
+ }
24
+ return value;
25
+ }
26
+ function toRegionLabel(row, column) {
27
+ const letter = String.fromCharCode(65 + row);
28
+ return `${letter}${column + 1}`;
29
+ }
30
+ function validateGrid(grid) {
31
+ if (!Number.isInteger(grid) || grid <= 0) {
32
+ throw new Error(`Invalid grid value "${grid}". Expected a positive integer.`);
33
+ }
34
+ if (grid > 26) {
35
+ throw new Error(`Invalid grid value "${grid}". Maximum supported grid is 26.`);
36
+ }
37
+ return grid;
38
+ }
39
+ function validateThreshold(threshold) {
40
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
41
+ throw new Error(`Invalid threshold value "${threshold}". Expected a number between 0 and 1.`);
42
+ }
43
+ return threshold;
44
+ }
45
+ async function readDimensions(path) {
46
+ const metadata = await sharp(path).metadata();
47
+ if (!metadata.width || !metadata.height) {
48
+ throw new Error(`Unable to read image dimensions for "${path}".`);
49
+ }
50
+ return {
51
+ width: metadata.width,
52
+ height: metadata.height
53
+ };
54
+ }
55
+ async function normalizeToRaw(path, width, height) {
56
+ const normalized = await sharp(path).rotate().resize(width, height, {
57
+ fit: "contain",
58
+ position: "centre",
59
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
60
+ }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
61
+ return {
62
+ data: normalized.data,
63
+ width: normalized.info.width,
64
+ height: normalized.info.height
65
+ };
66
+ }
67
+ function scorePixelDifference(a, b, offset) {
68
+ const redDiff = Math.abs(a.data[offset] - b.data[offset]);
69
+ const greenDiff = Math.abs(a.data[offset + 1] - b.data[offset + 1]);
70
+ const blueDiff = Math.abs(a.data[offset + 2] - b.data[offset + 2]);
71
+ const alphaDiff = Math.abs(a.data[offset + 3] - b.data[offset + 3]);
72
+ const rgbDelta = (redDiff + greenDiff + blueDiff) / (3 * 255);
73
+ const alphaDelta = alphaDiff / 255;
74
+ return rgbDelta * 0.75 + alphaDelta * 0.25;
75
+ }
76
+ async function compareImages(target, rendered, options = {}) {
77
+ const grid = validateGrid(options.grid ?? DEFAULT_GRID);
78
+ const threshold = validateThreshold(options.threshold ?? DEFAULT_THRESHOLD);
79
+ const closeThreshold = clampUnit(threshold - (options.closeMargin ?? DEFAULT_CLOSE_MARGIN));
80
+ const targetDimensions = await readDimensions(target);
81
+ const renderedDimensions = await readDimensions(rendered);
82
+ const normalizedWidth = Math.max(targetDimensions.width, renderedDimensions.width);
83
+ const normalizedHeight = Math.max(targetDimensions.height, renderedDimensions.height);
84
+ const [targetImage, renderedImage] = await Promise.all([
85
+ normalizeToRaw(target, normalizedWidth, normalizedHeight),
86
+ normalizeToRaw(rendered, normalizedWidth, normalizedHeight)
87
+ ]);
88
+ const regionDiffSums = new Array(grid * grid).fill(0);
89
+ const regionCounts = new Array(grid * grid).fill(0);
90
+ let totalDiff = 0;
91
+ for (let y = 0; y < normalizedHeight; y += 1) {
92
+ const row = Math.min(Math.floor(y * grid / normalizedHeight), grid - 1);
93
+ for (let x = 0; x < normalizedWidth; x += 1) {
94
+ const column = Math.min(Math.floor(x * grid / normalizedWidth), grid - 1);
95
+ const regionIndex = row * grid + column;
96
+ const offset = (y * normalizedWidth + x) * 4;
97
+ const diff = scorePixelDifference(targetImage, renderedImage, offset);
98
+ totalDiff += diff;
99
+ regionDiffSums[regionIndex] += diff;
100
+ regionCounts[regionIndex] += 1;
101
+ }
102
+ }
103
+ const pixelCount = normalizedWidth * normalizedHeight;
104
+ const similarity = clampUnit(1 - totalDiff / pixelCount);
105
+ const regions = [];
106
+ for (let row = 0; row < grid; row += 1) {
107
+ for (let column = 0; column < grid; column += 1) {
108
+ const regionIndex = row * grid + column;
109
+ const regionCount = regionCounts[regionIndex];
110
+ const regionSimilarity = regionCount > 0 ? clampUnit(1 - regionDiffSums[regionIndex] / regionCount) : 1;
111
+ regions.push({
112
+ label: toRegionLabel(row, column),
113
+ row,
114
+ column,
115
+ similarity: regionSimilarity
116
+ });
117
+ }
118
+ }
119
+ const verdict = similarity >= threshold ? "match" : similarity >= closeThreshold ? "close" : "mismatch";
120
+ return {
121
+ targetPath: target,
122
+ renderedPath: rendered,
123
+ targetDimensions,
124
+ renderedDimensions,
125
+ normalizedDimensions: {
126
+ width: normalizedWidth,
127
+ height: normalizedHeight
128
+ },
129
+ dimensionMismatch: targetDimensions.width !== renderedDimensions.width || targetDimensions.height !== renderedDimensions.height,
130
+ grid,
131
+ threshold,
132
+ closeThreshold,
133
+ similarity,
134
+ verdict,
135
+ regions
136
+ };
137
+ }
138
+ var DEFAULT_GRID, DEFAULT_THRESHOLD, DEFAULT_CLOSE_MARGIN;
139
+ var init_compare = __esm({
140
+ "src/compare.ts"() {
141
+ "use strict";
142
+ DEFAULT_GRID = 3;
143
+ DEFAULT_THRESHOLD = 0.8;
144
+ DEFAULT_CLOSE_MARGIN = 0.1;
145
+ }
146
+ });
147
+
1
148
  // src/qa.ts
2
149
  import { readFile } from "fs/promises";
3
150
  import { resolve } from "path";
4
- import sharp from "sharp";
151
+ import sharp2 from "sharp";
5
152
 
6
153
  // src/code-style.ts
7
154
  var CARBON_SURROUND_COLOR = "rgba(171, 184, 195, 1)";
@@ -520,10 +667,26 @@ var cardElementSchema = z2.object({
520
667
  tone: z2.enum(["neutral", "accent", "success", "warning", "error"]).default("neutral"),
521
668
  icon: z2.string().min(1).max(64).optional()
522
669
  }).strict();
670
+ var flowNodeShadowSchema = z2.object({
671
+ color: colorHexSchema2.optional(),
672
+ blur: z2.number().min(0).max(64).default(8),
673
+ offsetX: z2.number().min(-32).max(32).default(0),
674
+ offsetY: z2.number().min(-32).max(32).default(0),
675
+ opacity: z2.number().min(0).max(1).default(0.3)
676
+ }).strict();
523
677
  var flowNodeElementSchema = z2.object({
524
678
  type: z2.literal("flow-node"),
525
679
  id: z2.string().min(1).max(120),
526
- shape: z2.enum(["box", "rounded-box", "diamond", "circle", "pill", "cylinder", "parallelogram"]),
680
+ shape: z2.enum([
681
+ "box",
682
+ "rounded-box",
683
+ "diamond",
684
+ "circle",
685
+ "pill",
686
+ "cylinder",
687
+ "parallelogram",
688
+ "hexagon"
689
+ ]).default("rounded-box"),
527
690
  label: z2.string().min(1).max(200),
528
691
  sublabel: z2.string().min(1).max(300).optional(),
529
692
  sublabelColor: colorHexSchema2.optional(),
@@ -543,20 +706,25 @@ var flowNodeElementSchema = z2.object({
543
706
  badgeText: z2.string().min(1).max(32).optional(),
544
707
  badgeColor: colorHexSchema2.optional(),
545
708
  badgeBackground: colorHexSchema2.optional(),
546
- badgePosition: z2.enum(["top", "inside-top"]).default("inside-top")
709
+ badgePosition: z2.enum(["top", "inside-top"]).default("inside-top"),
710
+ shadow: flowNodeShadowSchema.optional()
547
711
  }).strict();
548
712
  var connectionElementSchema = z2.object({
549
713
  type: z2.literal("connection"),
550
714
  from: z2.string().min(1).max(120),
551
715
  to: z2.string().min(1).max(120),
552
716
  style: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
717
+ strokeStyle: z2.enum(["solid", "dashed", "dotted"]).default("solid"),
553
718
  arrow: z2.enum(["end", "start", "both", "none"]).default("end"),
554
719
  label: z2.string().min(1).max(200).optional(),
555
720
  labelPosition: z2.enum(["start", "middle", "end"]).default("middle"),
556
721
  color: colorHexSchema2.optional(),
557
- width: z2.number().min(0.5).max(8).optional(),
722
+ width: z2.number().min(0.5).max(10).optional(),
723
+ strokeWidth: z2.number().min(0.5).max(10).default(2),
558
724
  arrowSize: z2.number().min(4).max(32).optional(),
559
- opacity: z2.number().min(0).max(1).default(1)
725
+ opacity: z2.number().min(0).max(1).default(1),
726
+ routing: z2.enum(["auto", "orthogonal", "curve", "arc"]).default("auto"),
727
+ tension: z2.number().min(0.1).max(0.8).default(0.35)
560
728
  }).strict();
561
729
  var codeBlockStyleSchema = z2.object({
562
730
  paddingVertical: z2.number().min(0).max(128).default(56),
@@ -625,6 +793,10 @@ var elementSchema = z2.discriminatedUnion("type", [
625
793
  shapeElementSchema,
626
794
  imageElementSchema
627
795
  ]);
796
+ var diagramCenterSchema = z2.object({
797
+ x: z2.number(),
798
+ y: z2.number()
799
+ }).strict();
628
800
  var autoLayoutConfigSchema = z2.object({
629
801
  mode: z2.literal("auto"),
630
802
  algorithm: z2.enum(["layered", "stress", "force", "radial", "box"]).default("layered"),
@@ -640,7 +812,9 @@ var autoLayoutConfigSchema = z2.object({
640
812
  /** Compaction strategy for radial layout. Only relevant when algorithm is 'radial'. */
641
813
  radialCompaction: z2.enum(["none", "radial", "wedge"]).optional(),
642
814
  /** Sort strategy for radial layout node ordering. Only relevant when algorithm is 'radial'. */
643
- radialSortBy: z2.enum(["id", "connections"]).optional()
815
+ radialSortBy: z2.enum(["id", "connections"]).optional(),
816
+ /** Explicit center used by curve/arc connection routing. */
817
+ diagramCenter: diagramCenterSchema.optional()
644
818
  }).strict();
645
819
  var gridLayoutConfigSchema = z2.object({
646
820
  mode: z2.literal("grid"),
@@ -648,13 +822,17 @@ var gridLayoutConfigSchema = z2.object({
648
822
  gap: z2.number().int().min(0).max(256).default(24),
649
823
  cardMinHeight: z2.number().int().min(32).max(4096).optional(),
650
824
  cardMaxHeight: z2.number().int().min(32).max(4096).optional(),
651
- equalHeight: z2.boolean().default(false)
825
+ equalHeight: z2.boolean().default(false),
826
+ /** Explicit center used by curve/arc connection routing. */
827
+ diagramCenter: diagramCenterSchema.optional()
652
828
  }).strict();
653
829
  var stackLayoutConfigSchema = z2.object({
654
830
  mode: z2.literal("stack"),
655
831
  direction: z2.enum(["vertical", "horizontal"]).default("vertical"),
656
832
  gap: z2.number().int().min(0).max(256).default(24),
657
- alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch")
833
+ alignment: z2.enum(["start", "center", "end", "stretch"]).default("stretch"),
834
+ /** Explicit center used by curve/arc connection routing. */
835
+ diagramCenter: diagramCenterSchema.optional()
658
836
  }).strict();
659
837
  var manualPositionSchema = z2.object({
660
838
  x: z2.number().int(),
@@ -664,7 +842,9 @@ var manualPositionSchema = z2.object({
664
842
  }).strict();
665
843
  var manualLayoutConfigSchema = z2.object({
666
844
  mode: z2.literal("manual"),
667
- positions: z2.record(z2.string().min(1), manualPositionSchema).default({})
845
+ positions: z2.record(z2.string().min(1), manualPositionSchema).default({}),
846
+ /** Explicit center used by curve/arc connection routing. */
847
+ diagramCenter: diagramCenterSchema.optional()
668
848
  }).strict();
669
849
  var layoutConfigSchema = z2.discriminatedUnion("mode", [
670
850
  autoLayoutConfigSchema,
@@ -716,6 +896,31 @@ var canvasSchema = z2.object({
716
896
  padding: z2.number().int().min(0).max(256).default(defaultCanvas.padding)
717
897
  }).strict();
718
898
  var themeInputSchema = z2.union([builtInThemeSchema, themeSchema]);
899
+ var diagramPositionSchema = z2.object({
900
+ x: z2.number(),
901
+ y: z2.number(),
902
+ width: z2.number().positive(),
903
+ height: z2.number().positive()
904
+ }).strict();
905
+ var diagramElementSchema = z2.discriminatedUnion("type", [
906
+ flowNodeElementSchema,
907
+ connectionElementSchema
908
+ ]);
909
+ var diagramLayoutSchema = z2.object({
910
+ mode: z2.enum(["manual", "auto"]).default("manual"),
911
+ positions: z2.record(z2.string(), diagramPositionSchema).optional(),
912
+ diagramCenter: diagramCenterSchema.optional()
913
+ }).strict();
914
+ var diagramSpecSchema = z2.object({
915
+ version: z2.literal(1),
916
+ canvas: z2.object({
917
+ width: z2.number().int().min(320).max(4096).default(1200),
918
+ height: z2.number().int().min(180).max(4096).default(675)
919
+ }).default({ width: 1200, height: 675 }),
920
+ theme: themeSchema.optional(),
921
+ elements: z2.array(diagramElementSchema).min(1),
922
+ layout: diagramLayoutSchema.default({ mode: "manual" })
923
+ }).strict();
719
924
  var designSpecSchema = z2.object({
720
925
  version: z2.literal(2).default(2),
721
926
  canvas: canvasSchema.default(defaultCanvas),
@@ -789,7 +994,7 @@ async function runQa(options) {
789
994
  const imagePath = resolve(options.imagePath);
790
995
  const expectedSafeFrame = deriveSafeFrame(spec);
791
996
  const expectedCanvas = canvasRect(spec);
792
- const imageMetadata = await sharp(imagePath).metadata();
997
+ const imageMetadata = await sharp2(imagePath).metadata();
793
998
  const issues = [];
794
999
  const expectedScale = options.metadata?.canvas.scale ?? resolveRenderScale(spec);
795
1000
  const expectedWidth = spec.canvas.width * expectedScale;
@@ -940,6 +1145,31 @@ async function runQa(options) {
940
1145
  });
941
1146
  }
942
1147
  }
1148
+ let referenceResult;
1149
+ if (options.referencePath) {
1150
+ const { compareImages: compareImages2 } = await Promise.resolve().then(() => (init_compare(), compare_exports));
1151
+ const comparison = await compareImages2(options.referencePath, imagePath);
1152
+ referenceResult = {
1153
+ similarity: comparison.similarity,
1154
+ verdict: comparison.verdict,
1155
+ regions: comparison.regions.map((region) => ({
1156
+ label: region.label,
1157
+ similarity: region.similarity
1158
+ }))
1159
+ };
1160
+ if (comparison.verdict === "mismatch") {
1161
+ const severity = comparison.similarity < 0.5 ? "error" : "warning";
1162
+ issues.push({
1163
+ code: "REFERENCE_MISMATCH",
1164
+ severity,
1165
+ message: `Reference image comparison ${severity === "error" ? "failed" : "warned"}: similarity ${comparison.similarity.toFixed(4)} with verdict "${comparison.verdict}".`,
1166
+ details: {
1167
+ similarity: comparison.similarity,
1168
+ verdict: comparison.verdict
1169
+ }
1170
+ });
1171
+ }
1172
+ }
943
1173
  const footerSpacingPx = options.metadata?.layout.elements ? (() => {
944
1174
  const footer = options.metadata.layout.elements.find((element) => element.id === "footer");
945
1175
  if (!footer) {
@@ -972,7 +1202,8 @@ async function runQa(options) {
972
1202
  ...imageMetadata.height !== void 0 ? { height: imageMetadata.height } : {},
973
1203
  ...footerSpacingPx !== void 0 ? { footerSpacingPx } : {}
974
1204
  },
975
- issues
1205
+ issues,
1206
+ ...referenceResult ? { reference: referenceResult } : {}
976
1207
  };
977
1208
  }
978
1209
  export {
@@ -1,3 +1,3 @@
1
- export { h as DEFAULT_GENERATOR_VERSION, z as LayoutSnapshot, a as Rect, R as RenderMetadata, J as RenderResult, d as RenderedElement, W as WrittenArtifacts, X as computeSpecHash, a9 as inferSidecarPath, ab as renderDesign, ad as writeRenderArtifacts } from './spec.schema-BUTof436.js';
1
+ export { h as DEFAULT_GENERATOR_VERSION, N as LayoutSnapshot, a as Rect, R as RenderMetadata, Q as RenderResult, d as RenderedElement, Z as WrittenArtifacts, a0 as computeSpecHash, aj as inferSidecarPath, am as renderDesign, ao as writeRenderArtifacts } from './spec.schema-Dm_wOLTd.js';
2
2
  import 'zod';
3
3
  import '@napi-rs/canvas';