chess2img 0.1.4 → 0.2.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/dist/index.js CHANGED
@@ -55,8 +55,10 @@ function createEmptyBoardPosition() {
55
55
  }
56
56
 
57
57
  // src/core/validators.ts
58
+ import { createCanvas } from "canvas";
58
59
  var SQUARE_PATTERN = /^[a-h][1-8]$/;
59
60
  var PIECE_PATTERN = /^[prnbqkPRNBQK]$/;
61
+ var colorValidationContext = createCanvas(1, 1).getContext("2d");
60
62
  function validateSize(size) {
61
63
  if (!Number.isFinite(size) || size <= 0) {
62
64
  throw new ValidationError(`Invalid board size: ${size}`);
@@ -109,6 +111,88 @@ function validateThemeName(name) {
109
111
  }
110
112
  return normalized;
111
113
  }
114
+ function validateBorderSize(borderSize, size) {
115
+ const normalizedSize = validateSize(size);
116
+ const normalizedBorderSize = Math.round(borderSize);
117
+ const maxBorderSize = Math.floor(normalizedSize / 8);
118
+ if (!Number.isFinite(borderSize) || normalizedBorderSize < 0) {
119
+ throw new ValidationError(`Invalid borderSize: ${borderSize}`);
120
+ }
121
+ if (normalizedBorderSize > maxBorderSize) {
122
+ throw new ValidationError(
123
+ `Invalid borderSize: ${borderSize}. Maximum allowed for size ${normalizedSize} is ${maxBorderSize}`
124
+ );
125
+ }
126
+ return normalizedBorderSize;
127
+ }
128
+ function validateColorString(color, label = "color") {
129
+ if (typeof color !== "string" || color.trim() === "") {
130
+ throw new ValidationError(`Invalid ${label}: ${String(color)}`);
131
+ }
132
+ const normalized = color.trim();
133
+ colorValidationContext.fillStyle = "#010203";
134
+ colorValidationContext.fillStyle = normalized;
135
+ const firstPass = String(colorValidationContext.fillStyle);
136
+ colorValidationContext.fillStyle = "#fefefe";
137
+ colorValidationContext.fillStyle = normalized;
138
+ const secondPass = String(colorValidationContext.fillStyle);
139
+ if (firstPass !== secondPass) {
140
+ throw new ValidationError(`Invalid ${label}: ${color}`);
141
+ }
142
+ return normalized;
143
+ }
144
+ function validateBoardColors(colors) {
145
+ if (!colors) {
146
+ return;
147
+ }
148
+ if (colors.lightSquare !== void 0) {
149
+ validateColorString(colors.lightSquare, "lightSquare color");
150
+ }
151
+ if (colors.darkSquare !== void 0) {
152
+ validateColorString(colors.darkSquare, "darkSquare color");
153
+ }
154
+ if (colors.highlight !== void 0) {
155
+ validateColorString(colors.highlight, "highlight color");
156
+ }
157
+ }
158
+ function isCoordinatesOptions(value) {
159
+ return typeof value === "object" && value !== null && !Array.isArray(value);
160
+ }
161
+ function isCoordinatesPosition(value) {
162
+ return value === "border" || value === "inside";
163
+ }
164
+ function validateCoordinatesOption(coordinates, borderSize) {
165
+ if (coordinates === void 0 || typeof coordinates === "boolean") {
166
+ return;
167
+ }
168
+ if (isCoordinatesPosition(coordinates)) {
169
+ if (coordinates === "border" && borderSize === 0) {
170
+ throw new ValidationError(
171
+ "coordinates position 'border' requires borderSize > 0"
172
+ );
173
+ }
174
+ return;
175
+ }
176
+ if (!isCoordinatesOptions(coordinates)) {
177
+ throw new ValidationError(
178
+ "coordinates must be false, true, 'border', 'inside', or an options object"
179
+ );
180
+ }
181
+ if (coordinates.enabled !== void 0 && typeof coordinates.enabled !== "boolean") {
182
+ throw new ValidationError("coordinates.enabled must be a boolean");
183
+ }
184
+ if (coordinates.position !== void 0 && !isCoordinatesPosition(coordinates.position)) {
185
+ throw new ValidationError("coordinates.position must be 'border' or 'inside'");
186
+ }
187
+ if (coordinates.enabled !== false && coordinates.position === "border" && borderSize === 0) {
188
+ throw new ValidationError(
189
+ "coordinates position 'border' requires borderSize > 0"
190
+ );
191
+ }
192
+ if (coordinates.color !== void 0) {
193
+ validateColorString(coordinates.color, "coordinates.color");
194
+ }
195
+ }
112
196
 
113
197
  // src/core/parsers.ts
114
198
  var PIECE_SYMBOL_TO_KEY = {
@@ -178,32 +262,103 @@ function normalizeHighlights(input) {
178
262
  }
179
263
 
180
264
  // src/render/canvas-renderer.ts
181
- import { createCanvas as createCanvas2 } from "canvas";
265
+ import { createCanvas as createCanvas3 } from "canvas";
182
266
 
183
267
  // src/core/geometry.ts
184
268
  function createBoardGeometry({
185
269
  size,
186
270
  padding,
271
+ borderSize,
187
272
  flipped
188
273
  }) {
189
274
  const [top, right, bottom, left] = padding;
190
- const squareSize = size / 8;
275
+ const boardOuterSize = size;
276
+ const boardOuterX = left;
277
+ const boardOuterY = top;
278
+ const boardX = left + borderSize;
279
+ const boardY = top + borderSize;
280
+ const boardSize = size - borderSize * 2;
281
+ const squareSize = boardSize / 8;
191
282
  const squares = Object.fromEntries(
192
283
  SQUARES.map((square, index) => {
193
284
  const fileIndex = index % 8;
194
285
  const rankIndex = Math.floor(index / 8);
195
- const x = left + (flipped ? 7 - fileIndex : fileIndex) * squareSize;
196
- const y = top + (flipped ? 7 - rankIndex : rankIndex) * squareSize;
286
+ const x = boardX + (flipped ? 7 - fileIndex : fileIndex) * squareSize;
287
+ const y = boardY + (flipped ? 7 - rankIndex : rankIndex) * squareSize;
197
288
  return [square, { x, y, size: squareSize }];
198
289
  })
199
290
  );
291
+ const displayedFiles = flipped ? [...FILES].reverse() : [...FILES];
292
+ const displayedRanks = flipped ? [...RANKS].reverse() : [...RANKS];
293
+ const bottomEdgeRank = flipped ? "8" : "1";
294
+ const leftEdgeFile = flipped ? "h" : "a";
295
+ const borderFileLabels = borderSize ? displayedFiles.map((file, fileIndex) => ({
296
+ text: file,
297
+ x: boardX + fileIndex * squareSize + squareSize / 2,
298
+ y: boardOuterY + boardOuterSize - borderSize / 2,
299
+ square: `${file}${bottomEdgeRank}`,
300
+ textAlign: "center",
301
+ textBaseline: "middle"
302
+ })) : [];
303
+ const borderRankLabels = borderSize ? displayedRanks.map((rank, rankIndex) => ({
304
+ text: rank,
305
+ x: boardOuterX + borderSize / 2,
306
+ y: boardY + rankIndex * squareSize + squareSize / 2,
307
+ square: `${leftEdgeFile}${rank}`,
308
+ textAlign: "center",
309
+ textBaseline: "middle"
310
+ })) : [];
311
+ const insideFileInsetX = squareSize * 0.14;
312
+ const insideFileInsetY = squareSize * 0.1;
313
+ const insideRankInsetX = squareSize * 0.14;
314
+ const insideRankInsetY = squareSize * 0.1;
315
+ const insideLabelMaxWidth = squareSize * 0.26;
316
+ const insideLabelMaxHeight = squareSize * 0.24;
317
+ const insideFileLabels = displayedFiles.map((file) => {
318
+ const square = `${file}${bottomEdgeRank}`;
319
+ const squareGeometry = squares[square];
320
+ return {
321
+ text: file,
322
+ x: squareGeometry.x + insideFileInsetX,
323
+ y: squareGeometry.y + squareGeometry.size - insideFileInsetY,
324
+ square,
325
+ textAlign: "left",
326
+ textBaseline: "bottom"
327
+ };
328
+ });
329
+ const insideRankLabels = displayedRanks.map((rank) => {
330
+ const square = `${leftEdgeFile}${rank}`;
331
+ const squareGeometry = squares[square];
332
+ return {
333
+ text: rank,
334
+ x: squareGeometry.x + insideRankInsetX,
335
+ y: squareGeometry.y + insideRankInsetY,
336
+ square,
337
+ textAlign: "left",
338
+ textBaseline: "top"
339
+ };
340
+ });
200
341
  return {
201
342
  imageWidth: left + size + right,
202
343
  imageHeight: top + size + bottom,
203
344
  squareSize,
204
- boardX: left,
205
- boardY: top,
206
- boardSize: size,
345
+ borderSize,
346
+ boardOuterX,
347
+ boardOuterY,
348
+ boardOuterSize,
349
+ boardX,
350
+ boardY,
351
+ boardSize,
352
+ borderFileLabels,
353
+ borderRankLabels,
354
+ insideFileLabels,
355
+ insideRankLabels,
356
+ insideFileInsetX,
357
+ insideFileInsetY,
358
+ insideRankInsetX,
359
+ insideRankInsetY,
360
+ insideLabelMaxWidth,
361
+ insideLabelMaxHeight,
207
362
  squares
208
363
  };
209
364
  }
@@ -233,7 +388,7 @@ var SourceAssetCache = class {
233
388
 
234
389
  // src/render/rasterizer.ts
235
390
  import { readFile } from "fs/promises";
236
- import { createCanvas, loadImage } from "canvas";
391
+ import { createCanvas as createCanvas2, loadImage } from "canvas";
237
392
  import { Resvg } from "@resvg/resvg-js";
238
393
  var svgSourceCache = new SourceAssetCache();
239
394
  var imageBufferCache = new SourceAssetCache();
@@ -274,7 +429,7 @@ async function rasterizeSvgAsset(filePath, squareSize) {
274
429
  });
275
430
  const pngBuffer = resvg.render().asPng();
276
431
  const image = await loadImage(pngBuffer);
277
- const canvas = createCanvas(squareSize, squareSize);
432
+ const canvas = createCanvas2(squareSize, squareSize);
278
433
  const context = canvas.getContext("2d");
279
434
  context.drawImage(image, 0, 0, squareSize, squareSize);
280
435
  return canvas;
@@ -286,7 +441,7 @@ async function rasterizePngAsset(filePath, squareSize) {
286
441
  try {
287
442
  const pngSource = await readBinaryAsset(filePath);
288
443
  const image = await loadImage(pngSource);
289
- const canvas = createCanvas(squareSize, squareSize);
444
+ const canvas = createCanvas2(squareSize, squareSize);
290
445
  const context = canvas.getContext("2d");
291
446
  context.drawImage(image, 0, 0, squareSize, squareSize);
292
447
  return canvas;
@@ -303,6 +458,13 @@ async function rasterizeThemeAsset(asset, squareSize) {
303
458
 
304
459
  // src/render/canvas-renderer.ts
305
460
  var pieceRasterCache = new RasterAssetCache();
461
+ var MIN_COORDINATE_FONT_SIZE = 8;
462
+ var MAX_FILE_LABEL_WIDTH_RATIO = 0.75;
463
+ var MAX_RANK_LABEL_WIDTH_RATIO = 0.7;
464
+ var MAX_LABEL_HEIGHT_RATIO = 0.7;
465
+ var INSIDE_COORDINATE_MAX_FONT_RATIO = 0.34;
466
+ var INSIDE_LIGHT_LABEL_COLOR = "rgba(255,255,255,0.6)";
467
+ var INSIDE_DARK_LABEL_COLOR = "rgba(0,0,0,0.45)";
306
468
  function isDarkSquare(square) {
307
469
  const fileIndex = square.charCodeAt(0) - 97;
308
470
  const rankNumber = Number(square[1]);
@@ -318,15 +480,118 @@ async function getPieceRaster(themeName, pieceKey, asset, squareSize) {
318
480
  pieceRasterCache.set(cacheKey, raster);
319
481
  return raster;
320
482
  }
483
+ function resolveInsideLabelColor(request, square) {
484
+ if (request.coordinates.color) {
485
+ return request.coordinates.color;
486
+ }
487
+ return isDarkSquare(square) ? INSIDE_LIGHT_LABEL_COLOR : INSIDE_DARK_LABEL_COLOR;
488
+ }
489
+ function resolveBorderCoordinateFontSize(context, geometry) {
490
+ const maxFontSize = Math.floor(
491
+ Math.min(geometry.squareSize * 0.6, geometry.borderSize * 0.65)
492
+ );
493
+ let fontSize = null;
494
+ for (let candidate = maxFontSize; candidate >= MIN_COORDINATE_FONT_SIZE; candidate -= 1) {
495
+ context.font = `${candidate}px sans-serif`;
496
+ const filesFit = geometry.borderFileLabels.every((label) => {
497
+ const metrics = context.measureText(label.text);
498
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
499
+ return metrics.width <= geometry.squareSize * MAX_FILE_LABEL_WIDTH_RATIO && textHeight <= geometry.borderSize * MAX_LABEL_HEIGHT_RATIO;
500
+ });
501
+ const ranksFit = geometry.borderRankLabels.every((label) => {
502
+ const metrics = context.measureText(label.text);
503
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
504
+ return metrics.width <= geometry.borderSize * MAX_RANK_LABEL_WIDTH_RATIO && textHeight <= geometry.squareSize * MAX_LABEL_HEIGHT_RATIO;
505
+ });
506
+ if (filesFit && ranksFit) {
507
+ fontSize = candidate;
508
+ break;
509
+ }
510
+ }
511
+ return fontSize;
512
+ }
513
+ function resolveInsideCoordinateFontSize(context, geometry) {
514
+ const maxFontSize = Math.floor(
515
+ geometry.squareSize * INSIDE_COORDINATE_MAX_FONT_RATIO
516
+ );
517
+ let fontSize = null;
518
+ for (let candidate = maxFontSize; candidate >= MIN_COORDINATE_FONT_SIZE; candidate -= 1) {
519
+ context.font = `${candidate}px sans-serif`;
520
+ const filesFit = geometry.insideFileLabels.every((label) => {
521
+ const metrics = context.measureText(label.text);
522
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
523
+ return metrics.width <= geometry.insideLabelMaxWidth && textHeight <= geometry.insideLabelMaxHeight;
524
+ });
525
+ const ranksFit = geometry.insideRankLabels.every((label) => {
526
+ const metrics = context.measureText(label.text);
527
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
528
+ return metrics.width <= geometry.insideLabelMaxWidth && textHeight <= geometry.insideLabelMaxHeight;
529
+ });
530
+ if (filesFit && ranksFit) {
531
+ fontSize = candidate;
532
+ break;
533
+ }
534
+ }
535
+ return fontSize;
536
+ }
537
+ function drawBorderCoordinates(context, request, geometry) {
538
+ if (geometry.borderSize === 0) {
539
+ return;
540
+ }
541
+ const fontSize = resolveBorderCoordinateFontSize(context, geometry);
542
+ if (fontSize === null) {
543
+ return;
544
+ }
545
+ context.fillStyle = request.coordinates.color ?? "#333";
546
+ context.font = `${fontSize}px sans-serif`;
547
+ context.textAlign = "center";
548
+ context.textBaseline = "middle";
549
+ for (const label of geometry.borderFileLabels) {
550
+ context.fillText(label.text, label.x, label.y);
551
+ }
552
+ for (const label of geometry.borderRankLabels) {
553
+ context.fillText(label.text, label.x, label.y);
554
+ }
555
+ }
556
+ function drawInsideCoordinates(context, request, geometry) {
557
+ const fontSize = resolveInsideCoordinateFontSize(context, geometry);
558
+ if (fontSize === null) {
559
+ return;
560
+ }
561
+ context.font = `${fontSize}px sans-serif`;
562
+ for (const label of geometry.insideFileLabels) {
563
+ context.fillStyle = resolveInsideLabelColor(request, label.square);
564
+ context.textAlign = label.textAlign;
565
+ context.textBaseline = label.textBaseline;
566
+ context.fillText(label.text, label.x, label.y);
567
+ }
568
+ for (const label of geometry.insideRankLabels) {
569
+ context.fillStyle = resolveInsideLabelColor(request, label.square);
570
+ context.textAlign = label.textAlign;
571
+ context.textBaseline = label.textBaseline;
572
+ context.fillText(label.text, label.x, label.y);
573
+ }
574
+ }
575
+ function drawCoordinates(context, request, geometry) {
576
+ if (!request.coordinates.enabled) {
577
+ return;
578
+ }
579
+ if (request.coordinates.position === "border") {
580
+ drawBorderCoordinates(context, request, geometry);
581
+ return;
582
+ }
583
+ drawInsideCoordinates(context, request, geometry);
584
+ }
321
585
  var CanvasPngRenderer = class {
322
586
  async render(request) {
323
587
  try {
324
588
  const geometry = createBoardGeometry({
325
589
  size: request.size,
326
590
  padding: request.padding,
591
+ borderSize: request.borderSize,
327
592
  flipped: request.flipped
328
593
  });
329
- const canvas = createCanvas2(geometry.imageWidth, geometry.imageHeight);
594
+ const canvas = createCanvas3(geometry.imageWidth, geometry.imageHeight);
330
595
  const context = canvas.getContext("2d");
331
596
  context.fillStyle = request.colors.lightSquare;
332
597
  context.fillRect(0, 0, geometry.imageWidth, geometry.imageHeight);
@@ -366,6 +631,7 @@ var CanvasPngRenderer = class {
366
631
  squareGeometry.size
367
632
  );
368
633
  }
634
+ drawCoordinates(context, request, geometry);
369
635
  return canvas.toBuffer("image/png");
370
636
  } catch (error) {
371
637
  if (error instanceof RenderError) {
@@ -528,11 +794,16 @@ function resolveTheme({ theme, style }) {
528
794
  // src/utils/normalization.ts
529
795
  var DEFAULT_SIZE = 480;
530
796
  var DEFAULT_PADDING = [0, 0, 0, 0];
797
+ var DEFAULT_BORDER_SIZE = 0;
531
798
  var DEFAULT_COLORS = {
532
799
  lightSquare: "#f0d9b5",
533
800
  darkSquare: "#b58863",
534
801
  highlight: "rgba(255, 206, 0, 0.45)"
535
802
  };
803
+ var DEFAULT_COORDINATES = {
804
+ enabled: false,
805
+ position: "inside"
806
+ };
536
807
  function normalizeColors(colors) {
537
808
  return {
538
809
  lightSquare: colors?.lightSquare ?? DEFAULT_COLORS.lightSquare,
@@ -540,17 +811,52 @@ function normalizeColors(colors) {
540
811
  highlight: colors?.highlight ?? DEFAULT_COLORS.highlight
541
812
  };
542
813
  }
814
+ function normalizeCoordinates(coordinates, borderSize) {
815
+ if (coordinates === void 0 || coordinates === false) {
816
+ return { ...DEFAULT_COORDINATES };
817
+ }
818
+ const defaultPosition = borderSize > 0 ? "border" : "inside";
819
+ if (coordinates === true) {
820
+ return {
821
+ enabled: true,
822
+ position: defaultPosition,
823
+ color: defaultPosition === "border" ? "#333" : void 0
824
+ };
825
+ }
826
+ if (coordinates === "border" || coordinates === "inside") {
827
+ return {
828
+ enabled: true,
829
+ position: coordinates,
830
+ color: coordinates === "border" ? "#333" : void 0
831
+ };
832
+ }
833
+ const position = coordinates.position ?? defaultPosition;
834
+ return {
835
+ enabled: coordinates.enabled ?? true,
836
+ position,
837
+ color: coordinates.color ?? (position === "border" ? "#333" : void 0)
838
+ };
839
+ }
543
840
  function normalizeRenderInputs(options) {
841
+ const size = validateSize(options.size ?? DEFAULT_SIZE);
842
+ const borderSize = validateBorderSize(
843
+ options.borderSize ?? DEFAULT_BORDER_SIZE,
844
+ size
845
+ );
846
+ validateBoardColors(options.colors);
847
+ validateCoordinatesOption(options.coordinates, borderSize);
544
848
  return {
545
- size: validateSize(options.size ?? DEFAULT_SIZE),
849
+ size,
546
850
  padding: normalizePadding(options.padding ?? DEFAULT_PADDING),
851
+ borderSize,
547
852
  flipped: options.flipped ?? false,
548
853
  theme: resolveTheme({
549
854
  theme: options.theme,
550
855
  style: options.style
551
856
  }),
552
857
  highlightSquares: normalizeHighlights(options.highlightSquares ?? []),
553
- colors: normalizeColors(options.colors)
858
+ colors: normalizeColors(options.colors),
859
+ coordinates: normalizeCoordinates(options.coordinates, borderSize)
554
860
  };
555
861
  }
556
862
 
@@ -599,8 +905,10 @@ var ChessImageGenerator = class {
599
905
  highlights: normalized.highlightSquares,
600
906
  size: normalized.size,
601
907
  padding: normalized.padding,
908
+ borderSize: normalized.borderSize,
602
909
  flipped: normalized.flipped,
603
- colors: normalized.colors
910
+ colors: normalized.colors,
911
+ coordinates: normalized.coordinates
604
912
  });
605
913
  }
606
914
  async toFile(filePath) {
@@ -640,8 +948,10 @@ async function renderChess(options) {
640
948
  highlights: normalized.highlightSquares,
641
949
  size: normalized.size,
642
950
  padding: normalized.padding,
951
+ borderSize: normalized.borderSize,
643
952
  flipped: normalized.flipped,
644
- colors: normalized.colors
953
+ colors: normalized.colors,
954
+ coordinates: normalized.coordinates
645
955
  });
646
956
  }
647
957
  export {