circuit-to-canvas 0.0.4 → 0.0.6

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 (25) hide show
  1. package/dist/index.d.ts +12 -2
  2. package/dist/index.js +77 -21
  3. package/lib/drawer/CircuitToCanvasDrawer.ts +11 -0
  4. package/lib/drawer/elements/index.ts +5 -0
  5. package/lib/drawer/elements/pcb-copper-text.ts +10 -5
  6. package/lib/drawer/elements/pcb-note-rect.ts +37 -0
  7. package/lib/drawer/shapes/text.ts +62 -20
  8. package/package.json +1 -1
  9. package/tests/elements/__snapshots__/fabrication-note-text-baseline-anchors.snap.png +0 -0
  10. package/tests/elements/__snapshots__/fabrication-note-text-baseline.snap.png +0 -0
  11. package/tests/elements/__snapshots__/fabrication-note-text-descenders.snap.png +0 -0
  12. package/tests/elements/__snapshots__/pcb-fabrication-note-text-rgba-color.snap.png +0 -0
  13. package/tests/elements/__snapshots__/pcb-fabrication-note-text-small.snap.png +0 -0
  14. package/tests/elements/__snapshots__/pcb-note-rect-all-features.snap.png +0 -0
  15. package/tests/elements/__snapshots__/pcb-note-rect-dashed-stroke.snap.png +0 -0
  16. package/tests/elements/__snapshots__/pcb-note-rect-filled-no-stroke.snap.png +0 -0
  17. package/tests/elements/__snapshots__/pcb-silkscreen.snap.png +0 -0
  18. package/tests/elements/__snapshots__/silkscreen-text-bottom.snap.png +0 -0
  19. package/tests/elements/pcb-fabrication-note-text-baseline-anchors.test.ts +80 -0
  20. package/tests/elements/pcb-fabrication-note-text-baseline.test.ts +43 -0
  21. package/tests/elements/pcb-fabrication-note-text-descenders.test.ts +43 -0
  22. package/tests/elements/pcb-fabrication-note-text-small.test.ts +1 -1
  23. package/tests/elements/pcb-note-rect-all-features.test.ts +38 -0
  24. package/tests/elements/pcb-note-rect-dashed-stroke.test.ts +32 -0
  25. package/tests/elements/pcb-note-rect-filled-no-stroke.test.ts +32 -0
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AnyCircuitElement, PcbPlatedHole, PCBVia, PCBHole, PcbSmtPad, PCBTrace, PcbBoard, PcbSilkscreenText, PcbSilkscreenRect, PcbSilkscreenCircle, PcbSilkscreenLine, PcbSilkscreenPath, PcbCutout, PcbCopperPour, PcbCopperText, PcbFabricationNoteText, PcbFabricationNoteRect, PcbFabricationNotePath } from 'circuit-json';
1
+ import { AnyCircuitElement, PcbPlatedHole, PCBVia, PCBHole, PcbSmtPad, PCBTrace, PcbBoard, PcbSilkscreenText, PcbSilkscreenRect, PcbSilkscreenCircle, PcbSilkscreenLine, PcbSilkscreenPath, PcbCutout, PcbCopperPour, PcbCopperText, PcbFabricationNoteText, PcbFabricationNoteRect, PcbNoteRect, PcbFabricationNotePath } from 'circuit-json';
2
2
  import { Matrix } from 'transformation-matrix';
3
3
 
4
4
  /**
@@ -210,6 +210,8 @@ type AlphabetLayout = {
210
210
  letterSpacing: number;
211
211
  spaceWidth: number;
212
212
  strokeWidth: number;
213
+ baselineOffset: number;
214
+ descenderDepth: number;
213
215
  };
214
216
  declare function getAlphabetLayout(text: string, fontSize: number): AlphabetLayout;
215
217
  type AnchorAlignment = "center" | "top_left" | "top_right" | "bottom_left" | "bottom_right" | "left" | "right" | "top" | "bottom";
@@ -355,6 +357,14 @@ interface DrawPcbFabricationNoteRectParams {
355
357
  }
356
358
  declare function drawPcbFabricationNoteRect(params: DrawPcbFabricationNoteRectParams): void;
357
359
 
360
+ interface DrawPcbNoteRectParams {
361
+ ctx: CanvasContext;
362
+ rect: PcbNoteRect;
363
+ transform: Matrix;
364
+ colorMap: PcbColorMap;
365
+ }
366
+ declare function drawPcbNoteRect(params: DrawPcbNoteRectParams): void;
367
+
358
368
  interface DrawPcbFabricationNotePathParams {
359
369
  ctx: CanvasContext;
360
370
  path: PcbFabricationNotePath;
@@ -363,4 +373,4 @@ interface DrawPcbFabricationNotePathParams {
363
373
  }
364
374
  declare function drawPcbFabricationNotePath(params: DrawPcbFabricationNotePathParams): void;
365
375
 
366
- export { type AlphabetLayout, type AnchorAlignment, type CameraBounds, type CanvasContext, CircuitToCanvasDrawer, type CopperColorMap, type CopperLayerName, DEFAULT_PCB_COLOR_MAP, type DrawCircleParams, type DrawContext, type DrawElementsOptions, type DrawLineParams, type DrawOvalParams, type DrawPathParams, type DrawPcbBoardParams, type DrawPcbCopperPourParams, type DrawPcbCopperTextParams, type DrawPcbCutoutParams, type DrawPcbFabricationNotePathParams, type DrawPcbFabricationNoteRectParams, type DrawPcbFabricationNoteTextParams, type DrawPcbHoleParams, type DrawPcbPlatedHoleParams, type DrawPcbSilkscreenCircleParams, type DrawPcbSilkscreenLineParams, type DrawPcbSilkscreenPathParams, type DrawPcbSilkscreenRectParams, type DrawPcbSilkscreenTextParams, type DrawPcbSmtPadParams, type DrawPcbTraceParams, type DrawPcbViaParams, type DrawPillParams, type DrawPolygonParams, type DrawRectParams, type DrawTextParams, type DrawerConfig, type PcbColorMap, drawCircle, drawLine, drawOval, drawPath, drawPcbBoard, drawPcbCopperPour, drawPcbCopperText, drawPcbCutout, drawPcbFabricationNotePath, drawPcbFabricationNoteRect, drawPcbFabricationNoteText, drawPcbHole, drawPcbPlatedHole, drawPcbSilkscreenCircle, drawPcbSilkscreenLine, drawPcbSilkscreenPath, drawPcbSilkscreenRect, drawPcbSilkscreenText, drawPcbSmtPad, drawPcbTrace, drawPcbVia, drawPill, drawPolygon, drawRect, drawText, getAlphabetLayout, getTextStartPosition, strokeAlphabetText };
376
+ export { type AlphabetLayout, type AnchorAlignment, type CameraBounds, type CanvasContext, CircuitToCanvasDrawer, type CopperColorMap, type CopperLayerName, DEFAULT_PCB_COLOR_MAP, type DrawCircleParams, type DrawContext, type DrawElementsOptions, type DrawLineParams, type DrawOvalParams, type DrawPathParams, type DrawPcbBoardParams, type DrawPcbCopperPourParams, type DrawPcbCopperTextParams, type DrawPcbCutoutParams, type DrawPcbFabricationNotePathParams, type DrawPcbFabricationNoteRectParams, type DrawPcbFabricationNoteTextParams, type DrawPcbHoleParams, type DrawPcbNoteRectParams, type DrawPcbPlatedHoleParams, type DrawPcbSilkscreenCircleParams, type DrawPcbSilkscreenLineParams, type DrawPcbSilkscreenPathParams, type DrawPcbSilkscreenRectParams, type DrawPcbSilkscreenTextParams, type DrawPcbSmtPadParams, type DrawPcbTraceParams, type DrawPcbViaParams, type DrawPillParams, type DrawPolygonParams, type DrawRectParams, type DrawTextParams, type DrawerConfig, type PcbColorMap, drawCircle, drawLine, drawOval, drawPath, drawPcbBoard, drawPcbCopperPour, drawPcbCopperText, drawPcbCutout, drawPcbFabricationNotePath, drawPcbFabricationNoteRect, drawPcbFabricationNoteText, drawPcbHole, drawPcbNoteRect, drawPcbPlatedHole, drawPcbSilkscreenCircle, drawPcbSilkscreenLine, drawPcbSilkscreenPath, drawPcbSilkscreenRect, drawPcbSilkscreenText, drawPcbSmtPad, drawPcbTrace, drawPcbVia, drawPill, drawPolygon, drawRect, drawText, getAlphabetLayout, getTextStartPosition, strokeAlphabetText };
package/dist/index.js CHANGED
@@ -821,6 +821,16 @@ var LETTER_SPACING_RATIO = 0.3;
821
821
  var SPACE_WIDTH_RATIO = 1;
822
822
  var STROKE_WIDTH_RATIO = 0.13;
823
823
  var CURVED_GLYPHS = /* @__PURE__ */ new Set(["O", "o", "0"]);
824
+ var LOWERCASE_BASELINE_OFFSET = (() => {
825
+ const referenceLetters = ["a", "c", "e", "m", "n", "o", "r", "s", "u", "x"];
826
+ const offsets = referenceLetters.map((letter) => lineAlphabet[letter]).filter(
827
+ (lines) => lines !== void 0 && lines.length > 0
828
+ ).map(
829
+ (lines) => Math.min(...lines.map((line) => Math.min(line.y1, line.y2)))
830
+ );
831
+ return offsets.length > 0 ? Math.min(...offsets) : 0;
832
+ })();
833
+ var getBaselineOffsetForLetter = (letter) => letter >= "a" && letter <= "z" ? LOWERCASE_BASELINE_OFFSET : 0;
824
834
  function getAlphabetLayout(text, fontSize) {
825
835
  const glyphWidth = fontSize * GLYPH_WIDTH_RATIO;
826
836
  const letterSpacing = glyphWidth * LETTER_SPACING_RATIO;
@@ -833,19 +843,24 @@ function getAlphabetLayout(text, fontSize) {
833
843
  if (index < characters.length - 1) width += letterSpacing;
834
844
  });
835
845
  const strokeWidth = Math.max(fontSize * STROKE_WIDTH_RATIO, 0.35);
846
+ const hasLowercase = /[a-z]/.test(text);
847
+ const baselineOffset = hasLowercase ? (1 - LOWERCASE_BASELINE_OFFSET) * fontSize : fontSize;
848
+ const descenderDepth = hasLowercase ? LOWERCASE_BASELINE_OFFSET * fontSize : 0;
836
849
  return {
837
850
  width,
838
851
  height: fontSize,
839
852
  glyphWidth,
840
853
  letterSpacing,
841
854
  spaceWidth,
842
- strokeWidth
855
+ strokeWidth,
856
+ baselineOffset,
857
+ descenderDepth
843
858
  };
844
859
  }
845
860
  var getGlyphLines = (char) => lineAlphabet[char] ?? lineAlphabet[char.toUpperCase()];
846
861
  function getTextStartPosition(alignment, layout) {
847
862
  const totalWidth = layout.width + layout.strokeWidth;
848
- const totalHeight = layout.height + layout.strokeWidth;
863
+ const totalHeight = layout.height + layout.descenderDepth + layout.strokeWidth;
849
864
  let x = 0;
850
865
  let y = 0;
851
866
  if (alignment === "center") {
@@ -856,26 +871,38 @@ function getTextStartPosition(alignment, layout) {
856
871
  x = -totalWidth;
857
872
  }
858
873
  if (alignment === "center") {
859
- y = -totalHeight / 2;
874
+ y = layout.baselineOffset - layout.height / 2;
860
875
  } else if (alignment === "top_left" || alignment === "top_right" || alignment === "top") {
861
- y = 0;
876
+ y = layout.baselineOffset;
862
877
  } else if (alignment === "bottom_left" || alignment === "bottom_right" || alignment === "bottom") {
863
- y = -totalHeight;
878
+ y = -layout.descenderDepth;
879
+ } else {
880
+ y = 0;
864
881
  }
865
882
  return { x, y };
866
883
  }
867
884
  function strokeAlphabetText(ctx, text, layout, startX, startY) {
868
- const { glyphWidth, letterSpacing, spaceWidth, height, strokeWidth } = layout;
869
- const yOffset = startY + height / 2;
885
+ const {
886
+ glyphWidth,
887
+ letterSpacing,
888
+ spaceWidth,
889
+ height,
890
+ strokeWidth,
891
+ baselineOffset
892
+ } = layout;
893
+ const baselineY = startY;
870
894
  const characters = Array.from(text);
871
895
  let cursor = startX + strokeWidth / 2;
872
896
  characters.forEach((char, index) => {
873
897
  const lines = getGlyphLines(char);
874
898
  const advance = char === " " ? spaceWidth : glyphWidth;
899
+ const charBaselineOffset = getBaselineOffsetForLetter(char);
875
900
  if (CURVED_GLYPHS.has(char)) {
901
+ const normalizedCenterY = 0.5;
902
+ const adjustedCenterY = normalizedCenterY - charBaselineOffset;
903
+ const centerY = baselineY - adjustedCenterY * height;
876
904
  const radiusX = Math.max(glyphWidth / 2 - strokeWidth / 2, strokeWidth);
877
905
  const radiusY = Math.max(height / 2 - strokeWidth / 2, strokeWidth);
878
- const centerY = yOffset - height / 2;
879
906
  ctx.beginPath();
880
907
  ctx.ellipse(
881
908
  cursor + glyphWidth / 2,
@@ -890,10 +917,12 @@ function strokeAlphabetText(ctx, text, layout, startX, startY) {
890
917
  } else if (lines?.length) {
891
918
  ctx.beginPath();
892
919
  for (const line of lines) {
920
+ const adjusted_y1 = line.y1 - charBaselineOffset;
921
+ const adjusted_y2 = line.y2 - charBaselineOffset;
893
922
  const x1 = cursor + line.x1 * glyphWidth;
894
- const y1 = yOffset - line.y1 * height;
923
+ const y1 = baselineY - adjusted_y1 * height;
895
924
  const x2 = cursor + line.x2 * glyphWidth;
896
- const y2 = yOffset - line.y2 * height;
925
+ const y2 = baselineY - adjusted_y2 * height;
897
926
  ctx.moveTo(x1, y1);
898
927
  ctx.lineTo(x2, y2);
899
928
  }
@@ -932,13 +961,7 @@ function drawText(params) {
932
961
  ctx.lineCap = "round";
933
962
  ctx.lineJoin = "round";
934
963
  ctx.strokeStyle = color;
935
- strokeAlphabetText(
936
- ctx,
937
- text,
938
- layout,
939
- startPos.x,
940
- startPos.y + layout.strokeWidth / 2
941
- );
964
+ strokeAlphabetText(ctx, text, layout, startPos.x, startPos.y);
942
965
  ctx.restore();
943
966
  }
944
967
 
@@ -971,11 +994,10 @@ function drawPcbCopperText(params) {
971
994
  const textColor = layerToCopperColor(text.layer, colorMap);
972
995
  const layout = getAlphabetLayout(content, fontSize);
973
996
  const totalWidth = layout.width + layout.strokeWidth;
974
- const totalHeight = layout.height + layout.strokeWidth;
975
997
  const alignment = mapAnchorAlignment2(text.anchor_alignment);
976
998
  const startPos = getTextStartPosition(alignment, layout);
977
999
  const startX = startPos.x;
978
- const startY = 0;
1000
+ const startY = startPos.y;
979
1001
  ctx.save();
980
1002
  ctx.translate(x, y);
981
1003
  if (text.is_mirrored) ctx.scale(-1, 1);
@@ -988,10 +1010,13 @@ function drawPcbCopperText(params) {
988
1010
  const paddingRight = padding.right * scale2;
989
1011
  const paddingTop = padding.top * scale2;
990
1012
  const paddingBottom = padding.bottom * scale2;
1013
+ const textBoxTop = startY - layout.baselineOffset - layout.strokeWidth / 2;
1014
+ const textBoxBottom = startY + layout.descenderDepth + layout.strokeWidth / 2;
1015
+ const textBoxHeight = textBoxBottom - textBoxTop;
991
1016
  const xOffset = startX - paddingLeft;
992
- const yOffset = -(layout.height / 2) - layout.strokeWidth / 2 - paddingTop;
1017
+ const yOffset = textBoxTop - paddingTop;
993
1018
  const knockoutWidth = totalWidth + paddingLeft + paddingRight;
994
- const knockoutHeight = totalHeight + paddingTop + paddingBottom;
1019
+ const knockoutHeight = textBoxHeight + paddingTop + paddingBottom;
995
1020
  ctx.fillStyle = textColor;
996
1021
  ctx.fillRect(xOffset, yOffset, knockoutWidth, knockoutHeight);
997
1022
  const previousCompositeOperation = ctx.globalCompositeOperation;
@@ -1055,6 +1080,28 @@ function drawPcbFabricationNoteRect(params) {
1055
1080
  });
1056
1081
  }
1057
1082
 
1083
+ // lib/drawer/elements/pcb-note-rect.ts
1084
+ function drawPcbNoteRect(params) {
1085
+ const { ctx, rect, transform, colorMap } = params;
1086
+ const defaultColor = "rgb(89, 148, 220)";
1087
+ const color = rect.color ?? defaultColor;
1088
+ const isFilled = rect.is_filled ?? false;
1089
+ const hasStroke = rect.has_stroke ?? true;
1090
+ const isStrokeDashed = rect.is_stroke_dashed ?? false;
1091
+ drawRect({
1092
+ ctx,
1093
+ center: rect.center,
1094
+ width: rect.width,
1095
+ height: rect.height,
1096
+ fill: isFilled ? color : void 0,
1097
+ stroke: hasStroke ? color : void 0,
1098
+ strokeWidth: hasStroke ? rect.stroke_width : void 0,
1099
+ borderRadius: rect.corner_radius,
1100
+ transform,
1101
+ isStrokeDashed
1102
+ });
1103
+ }
1104
+
1058
1105
  // lib/drawer/elements/pcb-fabrication-note-path.ts
1059
1106
  function drawPcbFabricationNotePath(params) {
1060
1107
  const { ctx, path, transform, colorMap } = params;
@@ -1273,6 +1320,14 @@ var CircuitToCanvasDrawer = class {
1273
1320
  colorMap: this.colorMap
1274
1321
  });
1275
1322
  }
1323
+ if (element.type === "pcb_note_rect") {
1324
+ drawPcbNoteRect({
1325
+ transform: this.realToCanvasMat,
1326
+ colorMap: this.colorMap,
1327
+ ctx: this.ctx,
1328
+ rect: element
1329
+ });
1330
+ }
1276
1331
  if (element.type === "pcb_fabrication_note_path") {
1277
1332
  drawPcbFabricationNotePath({
1278
1333
  ctx: this.ctx,
@@ -1298,6 +1353,7 @@ export {
1298
1353
  drawPcbFabricationNoteRect,
1299
1354
  drawPcbFabricationNoteText,
1300
1355
  drawPcbHole,
1356
+ drawPcbNoteRect,
1301
1357
  drawPcbPlatedHole,
1302
1358
  drawPcbSilkscreenCircle,
1303
1359
  drawPcbSilkscreenLine,
@@ -16,6 +16,7 @@ import type {
16
16
  PcbCopperText,
17
17
  PcbFabricationNoteText,
18
18
  PcbFabricationNoteRect,
19
+ PcbNoteRect,
19
20
  PcbFabricationNotePath,
20
21
  } from "circuit-json"
21
22
  import { identity, compose, translate, scale } from "transformation-matrix"
@@ -45,6 +46,7 @@ import { drawPcbCopperPour } from "./elements/pcb-copper-pour"
45
46
  import { drawPcbCopperText } from "./elements/pcb-copper-text"
46
47
  import { drawPcbFabricationNoteText } from "./elements/pcb-fabrication-note-text"
47
48
  import { drawPcbFabricationNoteRect } from "./elements/pcb-fabrication-note-rect"
49
+ import { drawPcbNoteRect } from "./elements/pcb-note-rect"
48
50
  import { drawPcbFabricationNotePath } from "./elements/pcb-fabrication-note-path"
49
51
 
50
52
  export interface DrawElementsOptions {
@@ -288,6 +290,15 @@ export class CircuitToCanvasDrawer {
288
290
  })
289
291
  }
290
292
 
293
+ if (element.type === "pcb_note_rect") {
294
+ drawPcbNoteRect({
295
+ transform: this.realToCanvasMat,
296
+ colorMap: this.colorMap,
297
+ ctx: this.ctx,
298
+ rect: element as PcbNoteRect,
299
+ })
300
+ }
301
+
291
302
  if (element.type === "pcb_fabrication_note_path") {
292
303
  drawPcbFabricationNotePath({
293
304
  ctx: this.ctx,
@@ -48,6 +48,11 @@ export {
48
48
  type DrawPcbFabricationNoteRectParams,
49
49
  } from "./pcb-fabrication-note-rect"
50
50
 
51
+ export {
52
+ drawPcbNoteRect,
53
+ type DrawPcbNoteRectParams,
54
+ } from "./pcb-note-rect"
55
+
51
56
  export {
52
57
  drawPcbFabricationNotePath,
53
58
  type DrawPcbFabricationNotePathParams,
@@ -52,12 +52,10 @@ export function drawPcbCopperText(params: DrawPcbCopperTextParams): void {
52
52
  const textColor = layerToCopperColor(text.layer, colorMap)
53
53
  const layout = getAlphabetLayout(content, fontSize)
54
54
  const totalWidth = layout.width + layout.strokeWidth
55
- const totalHeight = layout.height + layout.strokeWidth
56
55
  const alignment = mapAnchorAlignment(text.anchor_alignment)
57
56
  const startPos = getTextStartPosition(alignment, layout)
58
- // Copper text always centers vertically (startY=0), uses startPos.x for horizontal alignment
59
57
  const startX = startPos.x
60
- const startY = 0 // Centers vertically at y=0 (shared function calculates yOffset = startY + height/2)
58
+ const startY = startPos.y
61
59
 
62
60
  ctx.save()
63
61
  ctx.translate(x, y)
@@ -73,10 +71,17 @@ export function drawPcbCopperText(params: DrawPcbCopperTextParams): void {
73
71
  const paddingRight = padding.right * scale
74
72
  const paddingTop = padding.top * scale
75
73
  const paddingBottom = padding.bottom * scale
74
+ // Calculate knockout rectangle to cover the text box
75
+ // startY is the baseline position, text box extends from (baseline - baselineOffset) to (baseline + descenderDepth)
76
+ const textBoxTop = startY - layout.baselineOffset - layout.strokeWidth / 2
77
+ const textBoxBottom =
78
+ startY + layout.descenderDepth + layout.strokeWidth / 2
79
+ const textBoxHeight = textBoxBottom - textBoxTop
80
+
76
81
  const xOffset = startX - paddingLeft
77
- const yOffset = -(layout.height / 2) - layout.strokeWidth / 2 - paddingTop
82
+ const yOffset = textBoxTop - paddingTop
78
83
  const knockoutWidth = totalWidth + paddingLeft + paddingRight
79
- const knockoutHeight = totalHeight + paddingTop + paddingBottom
84
+ const knockoutHeight = textBoxHeight + paddingTop + paddingBottom
80
85
 
81
86
  ctx.fillStyle = textColor
82
87
  ctx.fillRect(xOffset, yOffset, knockoutWidth, knockoutHeight)
@@ -0,0 +1,37 @@
1
+ import type { PcbNoteRect } from "circuit-json"
2
+ import type { Matrix } from "transformation-matrix"
3
+ import type { PcbColorMap, CanvasContext } from "../types"
4
+ import { drawRect } from "../shapes/rect"
5
+
6
+ export interface DrawPcbNoteRectParams {
7
+ ctx: CanvasContext
8
+ rect: PcbNoteRect
9
+ transform: Matrix
10
+ colorMap: PcbColorMap
11
+ }
12
+
13
+ export function drawPcbNoteRect(params: DrawPcbNoteRectParams): void {
14
+ const { ctx, rect, transform, colorMap } = params
15
+
16
+ // Use the color from the rect if provided, otherwise use a default color
17
+ // Notes are typically shown in a distinct color
18
+ const defaultColor = "rgb(89, 148, 220)" // White color for notes
19
+ const color = rect.color ?? defaultColor
20
+
21
+ const isFilled = rect.is_filled ?? false
22
+ const hasStroke = rect.has_stroke ?? true
23
+ const isStrokeDashed = rect.is_stroke_dashed ?? false
24
+
25
+ drawRect({
26
+ ctx,
27
+ center: rect.center,
28
+ width: rect.width,
29
+ height: rect.height,
30
+ fill: isFilled ? color : undefined,
31
+ stroke: hasStroke ? color : undefined,
32
+ strokeWidth: hasStroke ? rect.stroke_width : undefined,
33
+ borderRadius: rect.corner_radius,
34
+ transform,
35
+ isStrokeDashed,
36
+ })
37
+ }
@@ -9,6 +9,25 @@ const SPACE_WIDTH_RATIO = 1
9
9
  const STROKE_WIDTH_RATIO = 0.13
10
10
  const CURVED_GLYPHS = new Set(["O", "o", "0"])
11
11
 
12
+ // Calculate baseline offset from reference lowercase letters (same as working implementation)
13
+ const LOWERCASE_BASELINE_OFFSET = (() => {
14
+ const referenceLetters = ["a", "c", "e", "m", "n", "o", "r", "s", "u", "x"]
15
+ const offsets = referenceLetters
16
+ .map((letter) => lineAlphabet[letter])
17
+ .filter(
18
+ (lines): lines is NonNullable<typeof lines> =>
19
+ lines !== undefined && lines.length > 0,
20
+ )
21
+ .map((lines) =>
22
+ Math.min(...lines.map((line) => Math.min(line.y1, line.y2))),
23
+ )
24
+ return offsets.length > 0 ? Math.min(...offsets) : 0
25
+ })()
26
+
27
+ // Get baseline offset for a specific letter (only lowercase letters get offset)
28
+ const getBaselineOffsetForLetter = (letter: string) =>
29
+ letter >= "a" && letter <= "z" ? LOWERCASE_BASELINE_OFFSET : 0
30
+
12
31
  export type AlphabetLayout = {
13
32
  width: number
14
33
  height: number
@@ -16,6 +35,8 @@ export type AlphabetLayout = {
16
35
  letterSpacing: number
17
36
  spaceWidth: number
18
37
  strokeWidth: number
38
+ baselineOffset: number // Distance from top to baseline
39
+ descenderDepth: number // Distance from baseline to bottom (for descenders)
19
40
  }
20
41
 
21
42
  export function getAlphabetLayout(
@@ -35,6 +56,15 @@ export function getAlphabetLayout(
35
56
  })
36
57
 
37
58
  const strokeWidth = Math.max(fontSize * STROKE_WIDTH_RATIO, 0.35)
59
+ // Calculate baseline offset: distance from top of text box to baseline
60
+ // In normalized coords: y=0 is bottom, y=1 is top
61
+ // LOWERCASE_BASELINE_OFFSET is the minimum y (baseline position) in normalized coords
62
+ const hasLowercase = /[a-z]/.test(text)
63
+ const baselineOffset = hasLowercase
64
+ ? (1 - LOWERCASE_BASELINE_OFFSET) * fontSize
65
+ : fontSize
66
+ // Descender depth: distance from baseline to bottom (for descenders like g, j, p, q, y)
67
+ const descenderDepth = hasLowercase ? LOWERCASE_BASELINE_OFFSET * fontSize : 0
38
68
 
39
69
  return {
40
70
  width,
@@ -43,6 +73,8 @@ export function getAlphabetLayout(
43
73
  letterSpacing,
44
74
  spaceWidth,
45
75
  strokeWidth,
76
+ baselineOffset,
77
+ descenderDepth,
46
78
  }
47
79
  }
48
80
 
@@ -65,7 +97,8 @@ export function getTextStartPosition(
65
97
  layout: AlphabetLayout,
66
98
  ): { x: number; y: number } {
67
99
  const totalWidth = layout.width + layout.strokeWidth
68
- const totalHeight = layout.height + layout.strokeWidth
100
+ // Total height includes descender depth for proper vertical alignment
101
+ const totalHeight = layout.height + layout.descenderDepth + layout.strokeWidth
69
102
 
70
103
  let x = 0
71
104
  let y = 0
@@ -87,21 +120,24 @@ export function getTextStartPosition(
87
120
  x = -totalWidth
88
121
  }
89
122
 
90
- // Vertical alignment
123
+ // Vertical alignment - positions relative to baseline
124
+ // Text extends from (baseline - baselineOffset) at top to (baseline + descenderDepth) at bottom
91
125
  if (alignment === "center") {
92
- y = -totalHeight / 2
126
+ y = layout.baselineOffset - layout.height / 2
93
127
  } else if (
94
128
  alignment === "top_left" ||
95
129
  alignment === "top_right" ||
96
130
  alignment === "top"
97
131
  ) {
98
- y = 0
132
+ y = layout.baselineOffset
99
133
  } else if (
100
134
  alignment === "bottom_left" ||
101
135
  alignment === "bottom_right" ||
102
136
  alignment === "bottom"
103
137
  ) {
104
- y = -totalHeight
138
+ y = -layout.descenderDepth
139
+ } else {
140
+ y = 0
105
141
  }
106
142
 
107
143
  return { x, y }
@@ -114,19 +150,31 @@ export function strokeAlphabetText(
114
150
  startX: number,
115
151
  startY: number,
116
152
  ): void {
117
- const { glyphWidth, letterSpacing, spaceWidth, height, strokeWidth } = layout
118
- const yOffset = startY + height / 2
153
+ const {
154
+ glyphWidth,
155
+ letterSpacing,
156
+ spaceWidth,
157
+ height,
158
+ strokeWidth,
159
+ baselineOffset,
160
+ } = layout
161
+ const baselineY = startY
119
162
  const characters = Array.from(text)
120
163
  let cursor = startX + strokeWidth / 2
121
164
 
122
165
  characters.forEach((char, index) => {
123
166
  const lines = getGlyphLines(char)
124
167
  const advance = char === " " ? spaceWidth : glyphWidth
168
+ // Get normalized baseline offset for this specific character (0-1 range)
169
+ const charBaselineOffset = getBaselineOffsetForLetter(char)
125
170
 
126
171
  if (CURVED_GLYPHS.has(char)) {
172
+ // For curved glyphs, adjust coordinates by baseline offset
173
+ const normalizedCenterY = 0.5
174
+ const adjustedCenterY = normalizedCenterY - charBaselineOffset
175
+ const centerY = baselineY - adjustedCenterY * height
127
176
  const radiusX = Math.max(glyphWidth / 2 - strokeWidth / 2, strokeWidth)
128
177
  const radiusY = Math.max(height / 2 - strokeWidth / 2, strokeWidth)
129
- const centerY = yOffset - height / 2
130
178
  ctx.beginPath()
131
179
  ctx.ellipse(
132
180
  cursor + glyphWidth / 2,
@@ -141,20 +189,20 @@ export function strokeAlphabetText(
141
189
  } else if (lines?.length) {
142
190
  ctx.beginPath()
143
191
  for (const line of lines) {
192
+ // Convert normalized y coordinates to canvas coordinates (inverted for canvas)
193
+ const adjusted_y1 = line.y1 - charBaselineOffset
194
+ const adjusted_y2 = line.y2 - charBaselineOffset
144
195
  const x1 = cursor + line.x1 * glyphWidth
145
- const y1 = yOffset - line.y1 * height
196
+ const y1 = baselineY - adjusted_y1 * height
146
197
  const x2 = cursor + line.x2 * glyphWidth
147
- const y2 = yOffset - line.y2 * height
198
+ const y2 = baselineY - adjusted_y2 * height
148
199
  ctx.moveTo(x1, y1)
149
200
  ctx.lineTo(x2, y2)
150
201
  }
151
202
  ctx.stroke()
152
203
  }
153
204
 
154
- // Move cursor by the character width
155
205
  cursor += advance
156
- // Add letter spacing after each character except the last one
157
- // This spacing will be before the next character, creating visible gaps
158
206
  if (index < characters.length - 1) {
159
207
  cursor += letterSpacing
160
208
  }
@@ -206,13 +254,7 @@ export function drawText(params: DrawTextParams): void {
206
254
  ctx.lineJoin = "round"
207
255
  ctx.strokeStyle = color
208
256
 
209
- strokeAlphabetText(
210
- ctx,
211
- text,
212
- layout,
213
- startPos.x,
214
- startPos.y + layout.strokeWidth / 2,
215
- )
257
+ strokeAlphabetText(ctx, text, layout, startPos.x, startPos.y)
216
258
 
217
259
  ctx.restore()
218
260
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "circuit-to-canvas",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.4",
4
+ "version": "0.0.6",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsup-node ./lib/index.ts --format esm --dts",
@@ -0,0 +1,80 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import type { PcbFabricationNoteText } from "circuit-json"
4
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
5
+
6
+ test("draw text baseline alignment with different anchor positions", async () => {
7
+ const SCALE = 4
8
+ const canvas = createCanvas(300 * SCALE, 200 * SCALE)
9
+ const ctx = canvas.getContext("2d")
10
+ ctx.scale(SCALE, SCALE)
11
+ const drawer = new CircuitToCanvasDrawer(ctx)
12
+
13
+ ctx.fillStyle = "#1a1a1a"
14
+ ctx.fillRect(0, 0, canvas.width / SCALE, canvas.height / SCALE)
15
+
16
+ // Draw reference lines
17
+ ctx.strokeStyle = "#444444"
18
+ ctx.lineWidth = 0.5
19
+ // Top line
20
+ ctx.beginPath()
21
+ ctx.moveTo(10, 50)
22
+ ctx.lineTo(290, 50)
23
+ ctx.stroke()
24
+ // Center/baseline line
25
+ ctx.beginPath()
26
+ ctx.moveTo(10, 100)
27
+ ctx.lineTo(290, 100)
28
+ ctx.stroke()
29
+ // Bottom line
30
+ ctx.beginPath()
31
+ ctx.moveTo(10, 150)
32
+ ctx.lineTo(290, 150)
33
+ ctx.stroke()
34
+
35
+ // Test with top alignment
36
+ const textTop: PcbFabricationNoteText = {
37
+ type: "pcb_fabrication_note_text",
38
+ pcb_fabrication_note_text_id: "fab-note-top",
39
+ pcb_component_id: "component1",
40
+ layer: "top",
41
+ text: "gap",
42
+ anchor_position: { x: 50, y: 50 },
43
+ anchor_alignment: "top_left",
44
+ font: "tscircuit2024",
45
+ font_size: 16,
46
+ }
47
+
48
+ // Test with center alignment (baseline at center)
49
+ const textCenter: PcbFabricationNoteText = {
50
+ type: "pcb_fabrication_note_text",
51
+ pcb_fabrication_note_text_id: "fab-note-center",
52
+ pcb_component_id: "component2",
53
+ layer: "top",
54
+ text: "gap",
55
+ anchor_position: { x: 150, y: 100 },
56
+ anchor_alignment: "center",
57
+ font: "tscircuit2024",
58
+ font_size: 16,
59
+ }
60
+
61
+ // Test with bottom alignment
62
+ const textBottom: PcbFabricationNoteText = {
63
+ type: "pcb_fabrication_note_text",
64
+ pcb_fabrication_note_text_id: "fab-note-bottom",
65
+ pcb_component_id: "component3",
66
+ layer: "top",
67
+ text: "gap",
68
+ anchor_position: { x: 250, y: 150 },
69
+ anchor_alignment: "bottom_left",
70
+ font: "tscircuit2024",
71
+ font_size: 16,
72
+ }
73
+
74
+ drawer.drawElements([textTop, textCenter, textBottom])
75
+
76
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
77
+ import.meta.path,
78
+ "fabrication-note-text-baseline-anchors",
79
+ )
80
+ })
@@ -0,0 +1,43 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import type { PcbFabricationNoteText } from "circuit-json"
4
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
5
+
6
+ test("draw text with baseline alignment and descenders", async () => {
7
+ const SCALE = 4
8
+ const canvas = createCanvas(200 * SCALE, 150 * SCALE)
9
+ const ctx = canvas.getContext("2d")
10
+ ctx.scale(SCALE, SCALE)
11
+ const drawer = new CircuitToCanvasDrawer(ctx)
12
+
13
+ ctx.fillStyle = "#1a1a1a"
14
+ ctx.fillRect(0, 0, canvas.width / SCALE, canvas.height / SCALE)
15
+
16
+ // Draw baseline reference line
17
+ ctx.strokeStyle = "#444444"
18
+ ctx.lineWidth = 0.5
19
+ ctx.beginPath()
20
+ ctx.moveTo(10, 75)
21
+ ctx.lineTo(190, 75)
22
+ ctx.stroke()
23
+
24
+ // Test text with lowercase letters and descenders (g, j, p, q, y)
25
+ const text: PcbFabricationNoteText = {
26
+ type: "pcb_fabrication_note_text",
27
+ pcb_fabrication_note_text_id: "fab-note-baseline",
28
+ pcb_component_id: "component1",
29
+ layer: "top",
30
+ text: "gap jqpy",
31
+ anchor_position: { x: 100, y: 75 },
32
+ anchor_alignment: "center",
33
+ font: "tscircuit2024",
34
+ font_size: 20,
35
+ }
36
+
37
+ drawer.drawElements([text])
38
+
39
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
40
+ import.meta.path,
41
+ "fabrication-note-text-baseline",
42
+ )
43
+ })
@@ -0,0 +1,43 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import type { PcbFabricationNoteText } from "circuit-json"
4
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
5
+
6
+ test("draw lowercase text with descenders", async () => {
7
+ const SCALE = 4
8
+ const canvas = createCanvas(250 * SCALE, 100 * SCALE)
9
+ const ctx = canvas.getContext("2d")
10
+ ctx.scale(SCALE, SCALE)
11
+ const drawer = new CircuitToCanvasDrawer(ctx)
12
+
13
+ ctx.fillStyle = "#1a1a1a"
14
+ ctx.fillRect(0, 0, canvas.width / SCALE, canvas.height / SCALE)
15
+
16
+ // Draw baseline reference line
17
+ ctx.strokeStyle = "#666666"
18
+ ctx.lineWidth = 0.5
19
+ ctx.beginPath()
20
+ ctx.moveTo(10, 50)
21
+ ctx.lineTo(240, 50)
22
+ ctx.stroke()
23
+
24
+ // Test all descender letters: g, j, p, q, y
25
+ const text: PcbFabricationNoteText = {
26
+ type: "pcb_fabrication_note_text",
27
+ pcb_fabrication_note_text_id: "fab-note-descenders",
28
+ pcb_component_id: "component1",
29
+ layer: "top",
30
+ text: "gjpqy",
31
+ anchor_position: { x: 125, y: 50 },
32
+ anchor_alignment: "center",
33
+ font: "tscircuit2024",
34
+ font_size: 24,
35
+ }
36
+
37
+ drawer.drawElements([text])
38
+
39
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
40
+ import.meta.path,
41
+ "fabrication-note-text-descenders",
42
+ )
43
+ })
@@ -18,7 +18,7 @@ test("draw fabrication note text small size", async () => {
18
18
  pcb_fabrication_note_text_id: "fab-note-small",
19
19
  pcb_component_id: "component1",
20
20
  layer: "top",
21
- text: "SMAL",
21
+ text: "Smapq876",
22
22
  anchor_position: { x: 50, y: 50 },
23
23
  anchor_alignment: "center",
24
24
  font: "tscircuit2024",
@@ -0,0 +1,38 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import type { PcbNoteRect } from "circuit-json"
4
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
5
+
6
+ test("draw pcb note rect with all features", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+ const drawer = new CircuitToCanvasDrawer(ctx)
10
+
11
+ ctx.fillStyle = "#1a1a1a"
12
+ ctx.fillRect(0, 0, 100, 100)
13
+
14
+ const rect: PcbNoteRect = {
15
+ type: "pcb_note_rect",
16
+ pcb_note_rect_id: "note_rect1",
17
+ pcb_component_id: "component1",
18
+ pcb_group_id: "group1",
19
+ subcircuit_id: "subcircuit1",
20
+ name: "Test Note",
21
+ text: "This is a test note",
22
+ center: { x: 50, y: 50 },
23
+ width: 60,
24
+ height: 40,
25
+ stroke_width: 2,
26
+ corner_radius: 5,
27
+ is_filled: true,
28
+ has_stroke: true,
29
+ is_stroke_dashed: true,
30
+ color: "#00FFFF", // Cyan color
31
+ }
32
+
33
+ drawer.drawElements([rect])
34
+
35
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
36
+ import.meta.path,
37
+ )
38
+ })
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import type { PcbNoteRect } from "circuit-json"
4
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
5
+
6
+ test("draw pcb note rect with dashed stroke and no fill", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+ const drawer = new CircuitToCanvasDrawer(ctx)
10
+
11
+ ctx.fillStyle = "#1a1a1a"
12
+ ctx.fillRect(0, 0, 100, 100)
13
+
14
+ const rect: PcbNoteRect = {
15
+ type: "pcb_note_rect",
16
+ pcb_note_rect_id: "note_rect2",
17
+ center: { x: 50, y: 50 },
18
+ width: 60,
19
+ height: 40,
20
+ stroke_width: 2,
21
+ is_filled: false,
22
+ has_stroke: true,
23
+ is_stroke_dashed: true,
24
+ color: "#0000FF", // Blue color
25
+ }
26
+
27
+ drawer.drawElements([rect])
28
+
29
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
30
+ import.meta.path,
31
+ )
32
+ })
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "canvas"
3
+ import type { PcbNoteRect } from "circuit-json"
4
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
5
+
6
+ test("draw pcb note rect with fill and no stroke", async () => {
7
+ const canvas = createCanvas(100, 100)
8
+ const ctx = canvas.getContext("2d")
9
+ const drawer = new CircuitToCanvasDrawer(ctx)
10
+
11
+ ctx.fillStyle = "#1a1a1a"
12
+ ctx.fillRect(0, 0, 100, 100)
13
+
14
+ const rect: PcbNoteRect = {
15
+ type: "pcb_note_rect",
16
+ pcb_note_rect_id: "note_rect3",
17
+ center: { x: 50, y: 50 },
18
+ width: 60,
19
+ height: 40,
20
+ stroke_width: 2,
21
+ corner_radius: 8,
22
+ is_filled: true,
23
+ has_stroke: false,
24
+ is_stroke_dashed: false,
25
+ }
26
+
27
+ drawer.drawElements([rect])
28
+
29
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
30
+ import.meta.path,
31
+ )
32
+ })