@design-core/core 50.1.0 → 51.2.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/cspell.json CHANGED
@@ -141,6 +141,8 @@
141
141
  "EXTMIN",
142
142
  "FILLETRAD",
143
143
  "FONTNAME",
144
+ "HATCHDOUBLE",
145
+ "HATCHSPACING",
144
146
  "groupcode",
145
147
  "groupcodes",
146
148
  "groupvalue",
@@ -193,6 +195,7 @@
193
195
  "RRGGBB",
194
196
  "rubberbandcolour",
195
197
  "SCALEFACTOR",
198
+ "SECONDCORNER",
196
199
  "selectedchild",
197
200
  "SELECTIONSET",
198
201
  "SEQEND",
@@ -275,6 +278,7 @@
275
278
  "INVALIDFILE",
276
279
  "INVALIDGROUPCODE",
277
280
  "INVALIDINTERSECTTYPE",
281
+ "FIRSTCORNER",
278
282
  "INVALIDNUMBER",
279
283
  "INVALIDPOINT",
280
284
  "INVALIDTYPE",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design-core/core",
3
- "version": "50.1.0",
3
+ "version": "51.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
@@ -19,7 +19,12 @@
19
19
  ".": "./core/core/core.js",
20
20
  "./designCore.js": "./core/designCore.js",
21
21
  "./property.js": "./core/properties/property.js",
22
- "./canvasRenderer.js": "./core/lib/renderers/canvasRenderer.js"
22
+ "./canvasRenderer.js": "./core/lib/renderers/canvasRenderer.js",
23
+ "./constants.js": "./core/lib/constants.js",
24
+ "./plotOptions.js": "./core/lib/plotOptions.js",
25
+ "./rendererBase.js": "./core/lib/renderers/rendererBase.js",
26
+ "./svgRenderer.js": "./core/lib/renderers/svgRenderer.js",
27
+ "./pdfRenderer.js": "./core/lib/renderers/pdfRenderer.js"
23
28
  },
24
29
  "directories": {
25
30
  "test": "test"
@@ -1,4 +1,5 @@
1
1
  import { Hatch } from '../../core/entities/hatch.js';
2
+ import { PatternLine } from '../../core/lib/patterns.js';
2
3
  import { Point } from '../../core/entities/point.js';
3
4
  import { DesignCore } from '../../core/designCore.js';
4
5
 
@@ -10,6 +11,8 @@ import { Arc } from '../../core/entities/arc.js';
10
11
  import { Text } from '../../core/entities/text.js';
11
12
 
12
13
  import { File, withMockInput } from '../test-helpers/test-helpers.js';
14
+ import { Logging } from '../../core/lib/logging.js';
15
+ import { jest } from '@jest/globals';
13
16
 
14
17
  import { Core } from '../../core/core/core.js';
15
18
 
@@ -1066,6 +1069,40 @@ test('Hatch.draw HONEY dashed family sets lineDashOffset and strokes once per se
1066
1069
  expect(ctx.getStrokeCalls()).toBe(expectedSegs);
1067
1070
  });
1068
1071
 
1072
+ // ── unknown pattern ───────────────────────────────────────────────────────────
1073
+
1074
+ describe('unknown hatch pattern', () => {
1075
+ test('logs a warning when pattern name is not recognised', () => {
1076
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1077
+ try {
1078
+ new Hatch({ patternName: 'NOTAPATTERN' });
1079
+ expect(warnSpy).toHaveBeenCalledWith('Hatch: pattern \'NOTAPATTERN\' not found');
1080
+ } finally {
1081
+ warnSpy.mockRestore();
1082
+ }
1083
+ });
1084
+
1085
+ test('retains the original pattern name when pattern is not recognised', () => {
1086
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1087
+ try {
1088
+ const h = new Hatch({ patternName: 'NOTAPATTERN' });
1089
+ expect(h.getProperty('patternName')).toBe('NOTAPATTERN');
1090
+ } finally {
1091
+ warnSpy.mockRestore();
1092
+ }
1093
+ });
1094
+
1095
+ test('does not log a warning for a known pattern', () => {
1096
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1097
+ try {
1098
+ new Hatch({ patternName: 'ANSI31' });
1099
+ expect(warnSpy).not.toHaveBeenCalled();
1100
+ } finally {
1101
+ warnSpy.mockRestore();
1102
+ }
1103
+ });
1104
+ });
1105
+
1069
1106
  // ── processSelection: uncloseable items ──────────────────────────────────────
1070
1107
 
1071
1108
  test('Hatch.processSelection returns empty array when items cannot form a closed loop', () => {
@@ -1077,3 +1114,289 @@ test('Hatch.processSelection returns empty array when items cannot form a closed
1077
1114
  const h = new Hatch();
1078
1115
  expect(h.processSelection(disconnected)).toEqual([]);
1079
1116
  });
1117
+
1118
+ // ── patternLines: library (76=1 default) ─────────────────────────────────────
1119
+
1120
+ test('Hatch.patternLines is non-enumerable', () => {
1121
+ const h = new Hatch({ patternName: 'ANSI31' });
1122
+ const descriptor = Object.getOwnPropertyDescriptor(h, 'patternLines');
1123
+ expect(descriptor.enumerable).toBe(false);
1124
+ });
1125
+
1126
+ test('Hatch.patternLines populated from library for known predefined pattern', () => {
1127
+ const h = new Hatch({ patternName: 'ANSI31' });
1128
+ expect(h.patternLines.length).toBeGreaterThan(0);
1129
+ expect(h.patternLines[0]).toBeInstanceOf(PatternLine);
1130
+ });
1131
+
1132
+ test('Hatch.patternLines is empty for unknown pattern', () => {
1133
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1134
+ try {
1135
+ const h = new Hatch({ patternName: 'NOTAPATTERN' });
1136
+ expect(h.patternLines).toEqual([]);
1137
+ } finally {
1138
+ warnSpy.mockRestore();
1139
+ }
1140
+ });
1141
+
1142
+ test('Hatch.patternLines is empty for SOLID pattern', () => {
1143
+ const h = new Hatch({ patternName: 'SOLID' });
1144
+ expect(h.patternLines).toEqual([]);
1145
+ });
1146
+
1147
+ test('Hatch.patternLines rebuilt when patternName changes via setProperty', () => {
1148
+ const h = new Hatch({ patternName: 'ANSI31' });
1149
+ const originalLength = h.patternLines.length;
1150
+ expect(originalLength).toBeGreaterThan(0);
1151
+
1152
+ h.setProperty('patternName', 'HONEY');
1153
+ expect(h.patternLines.length).toBeGreaterThan(0);
1154
+ // HONEY has 3 families; ANSI31 has 1
1155
+ expect(h.patternLines.length).toBeGreaterThan(originalLength);
1156
+ });
1157
+
1158
+ test('Hatch.patternLines becomes empty when patternName set to unknown', () => {
1159
+ const warnSpy = jest.spyOn(Logging.instance, 'warn').mockImplementation(() => {});
1160
+ try {
1161
+ const h = new Hatch({ patternName: 'ANSI31' });
1162
+ h.setProperty('patternName', 'NOTAPATTERN');
1163
+ expect(h.patternLines).toEqual([]);
1164
+ } finally {
1165
+ warnSpy.mockRestore();
1166
+ }
1167
+ });
1168
+
1169
+ // ── patternLines: user-defined inline (76=0) ─────────────────────────────────
1170
+
1171
+ test('Hatch with 76=0 uses inline pattern lines, not library', () => {
1172
+ const data = {
1173
+ '76': 0,
1174
+ '41': 1,
1175
+ '52': 0,
1176
+ '78': 1,
1177
+ '53': 0,
1178
+ '45': 0,
1179
+ '46': 10, // dy delta (10 inches)
1180
+ '79': 0,
1181
+ };
1182
+ const h = new Hatch(data);
1183
+ expect(h.patternLines.length).toBe(1);
1184
+ expect(h.patternLines[0]).toBeInstanceOf(PatternLine);
1185
+ expect(h.patternLines[0].angle).toBe(0);
1186
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5); // 10 (drawing units)
1187
+ expect(h.patternLines[0].dashes).toEqual([]);
1188
+ });
1189
+
1190
+ test('Hatch with 76=0 correctly scales delta by dividing out the scale factor', () => {
1191
+ // scale=2: DXF stores delta pre-multiplied, so raw dy=20 → 20/2=10 (drawing units)
1192
+ const data = {
1193
+ '76': 0,
1194
+ '41': 2,
1195
+ '52': 0,
1196
+ '78': 1,
1197
+ '53': 0,
1198
+ '45': 0,
1199
+ '46': 20,
1200
+ '79': 0,
1201
+ };
1202
+ const h = new Hatch(data);
1203
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5);
1204
+ });
1205
+
1206
+ test('Hatch with 76=0 and 77=1 (double flag) adds a perpendicular family', () => {
1207
+ const data = {
1208
+ '76': 0,
1209
+ '77': 1,
1210
+ '41': 1,
1211
+ '52': 0,
1212
+ '78': 1,
1213
+ '53': 0,
1214
+ '45': 0,
1215
+ '46': 10,
1216
+ '79': 0,
1217
+ };
1218
+ const h = new Hatch(data);
1219
+ expect(h.patternLines.length).toBe(2);
1220
+ expect(h.patternLines[1].angle).toBe(90);
1221
+ expect(h.patternLines[1].yDelta).toBeCloseTo(h.patternLines[0].yDelta, 5);
1222
+ });
1223
+
1224
+ test('Hatch with 76=0 and dashes parses and converts to mm', () => {
1225
+ // [5, -5] → [5, -5] (drawing units)
1226
+ const data = {
1227
+ '76': 0,
1228
+ '41': 1,
1229
+ '52': 0,
1230
+ '78': 1,
1231
+ '53': 0,
1232
+ '45': 0,
1233
+ '46': 10,
1234
+ '79': 2,
1235
+ '49': [5, -5],
1236
+ };
1237
+ const h = new Hatch(data);
1238
+ expect(h.patternLines[0].dashes.length).toBe(2);
1239
+ expect(h.patternLines[0].dashes[0]).toBeCloseTo(5, 5);
1240
+ expect(h.patternLines[0].dashes[1]).toBeCloseTo(-5, 5);
1241
+ });
1242
+
1243
+ test('Hatch with 76=0 and multiple line families parses each correctly', () => {
1244
+ const data = {
1245
+ '76': 0,
1246
+ '41': 1,
1247
+ '52': 0,
1248
+ '78': 2,
1249
+ '53': [0, 90],
1250
+ '45': [0, 0],
1251
+ '46': [10, 10],
1252
+ '79': [0, 0],
1253
+ };
1254
+ const h = new Hatch(data);
1255
+ expect(h.patternLines.length).toBe(2);
1256
+ expect(h.patternLines[0].angle).toBe(0);
1257
+ expect(h.patternLines[1].angle).toBe(90);
1258
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5);
1259
+ expect(h.patternLines[1].yDelta).toBeCloseTo(10, 5);
1260
+ });
1261
+
1262
+ test('Hatch with 76=0 and no inline families synthesises from spacing (47)', () => {
1263
+ const data = { '76': 0, '41': 1, '52': 45, '47': 5 };
1264
+ const h = new Hatch(data);
1265
+ expect(h.patternLines.length).toBe(1);
1266
+ expect(h.patternLines[0].angle).toBe(0);
1267
+ expect(h.patternLines[0].yDelta).toBeCloseTo(5, 5);
1268
+ expect(h.patternLines[0].dashes).toEqual([]);
1269
+ });
1270
+
1271
+ test('Hatch with 76=0 explicit families: data[53] absolute angle is stored relative to entity angle', () => {
1272
+ // AutoCAD writes data[53]=45 as the absolute line angle when entity angle (52) is 45.
1273
+ // Subtracting entityAngle makes it 0 so buildPatternCache's addition yields the correct 45°.
1274
+ const data = { '76': 0, '41': 1, '52': 45, '78': 1, '53': 45, '45': 0, '46': 10, '79': 0 };
1275
+ const h = new Hatch(data);
1276
+ expect(h.patternLines[0].angle).toBeCloseTo(0, 5); // 45 - 45 = 0 (relative)
1277
+ });
1278
+
1279
+ test('Hatch with 76=0, no inline families, double flag (77=1) synthesises two families', () => {
1280
+ const data = { '76': 0, '77': 1, '41': 1, '52': 0, '47': 5 };
1281
+ const h = new Hatch(data);
1282
+ expect(h.patternLines.length).toBe(2);
1283
+ expect(h.patternLines[0].angle).toBe(0);
1284
+ expect(h.patternLines[1].angle).toBe(90);
1285
+ expect(h.patternLines[1].yDelta).toBeCloseTo(5, 5);
1286
+ });
1287
+
1288
+ // ── patternLines: custom embedded (76=2) ─────────────────────────────────────
1289
+
1290
+ test('Hatch with 76=2 uses inline pattern lines', () => {
1291
+ const data = {
1292
+ '76': 2,
1293
+ '41': 1,
1294
+ '52': 0,
1295
+ '78': 1,
1296
+ '53': 45,
1297
+ '45': 0,
1298
+ '46': 10,
1299
+ '79': 0,
1300
+ };
1301
+ const h = new Hatch(data);
1302
+ expect(h.patternLines.length).toBe(1);
1303
+ expect(h.patternLines[0].angle).toBe(45);
1304
+ expect(h.patternLines[0].yDelta).toBeCloseTo(10, 5);
1305
+ });
1306
+
1307
+ // ── dxf() output: 76 group code ──────────────────────────────────────────────
1308
+
1309
+ test('Hatch.dxf writes 76=1 for library patterns', () => {
1310
+ const h = new Hatch({ patternName: 'ANSI31', handle: '1' });
1311
+ h.setProperty('childEntities', makeSquare());
1312
+ const file = new File();
1313
+ h.dxf(file);
1314
+ const match = file.contents.match(/^76\n(\d+)/m);
1315
+ expect(match).not.toBeNull();
1316
+ expect(match[1]).toBe('1');
1317
+ });
1318
+
1319
+ test('Hatch.dxf writes 76=0 for user-defined patterns', () => {
1320
+ const data = {
1321
+ '76': 0,
1322
+ '41': 1,
1323
+ '52': 0,
1324
+ '78': 1,
1325
+ '53': 0,
1326
+ '45': 0,
1327
+ '46': 10,
1328
+ '79': 0,
1329
+ 'handle': '1',
1330
+ };
1331
+ const h = new Hatch(data);
1332
+ h.setProperty('childEntities', makeSquare());
1333
+ const file = new File();
1334
+ h.dxf(file);
1335
+ const match = file.contents.match(/^76\n(\d+)/m);
1336
+ expect(match).not.toBeNull();
1337
+ expect(match[1]).toBe('0');
1338
+ });
1339
+
1340
+ test('Hatch.dxf 76=0 double-flag round-trip writes correct family count', () => {
1341
+ const data = {
1342
+ '76': 0,
1343
+ '77': 1,
1344
+ '41': 1,
1345
+ '52': 0,
1346
+ '78': 1,
1347
+ '53': 0,
1348
+ '45': 0,
1349
+ '46': 10,
1350
+ '79': 0,
1351
+ 'handle': '1',
1352
+ };
1353
+ const h = new Hatch(data);
1354
+ h.setProperty('childEntities', makeSquare());
1355
+ const file = new File();
1356
+ h.dxf(file);
1357
+ const match = file.contents.match(/^78\n(\d+)/m);
1358
+ expect(match).not.toBeNull();
1359
+ expect(match[1]).toBe('2');
1360
+ });
1361
+
1362
+ // ── buildPatternCache with inline patterns ────────────────────────────────────
1363
+
1364
+ test('Hatch with 76=0 inline pattern renders segments inside boundary', () => {
1365
+ const data = {
1366
+ '76': 0,
1367
+ '41': 1,
1368
+ '52': 0,
1369
+ '78': 1,
1370
+ '53': 0,
1371
+ '45': 0,
1372
+ '46': 1, // 1 (drawing unit) spacing
1373
+ '79': 0,
1374
+ };
1375
+ const h = new Hatch(data);
1376
+ h.setProperty('childEntities', makeSquare());
1377
+ h.buildPatternCache();
1378
+
1379
+ expect(h.cachedPattern.length).toBe(1);
1380
+ expect(h.cachedPattern[0].segments.length).toBeGreaterThan(0);
1381
+ });
1382
+
1383
+ test('Hatch with 76=0 double flag renders two families of segments', () => {
1384
+ const data = {
1385
+ '76': 0,
1386
+ '77': 1,
1387
+ '41': 1,
1388
+ '52': 0,
1389
+ '78': 1,
1390
+ '53': 0,
1391
+ '45': 0,
1392
+ '46': 1,
1393
+ '79': 0,
1394
+ };
1395
+ const h = new Hatch(data);
1396
+ h.setProperty('childEntities', makeSquare());
1397
+ h.buildPatternCache();
1398
+
1399
+ expect(h.cachedPattern.length).toBe(2);
1400
+ expect(h.cachedPattern[0].segments.length).toBeGreaterThan(0);
1401
+ expect(h.cachedPattern[1].segments.length).toBeGreaterThan(0);
1402
+ });
@@ -0,0 +1,113 @@
1
+ import { Core } from '../../core/core/core.js';
2
+ import { Point } from '../../core/entities/point.js';
3
+ import { Constants } from '../../core/lib/constants.js';
4
+ import { PlotOptions } from '../../core/lib/plotOptions.js';
5
+ import { BoundingBox } from '../../core/lib/boundingBox.js';
6
+ import { SvgRenderer } from '../../core/lib/renderers/svgRenderer.js';
7
+ import { jest } from '@jest/globals';
8
+
9
+ // smallest -> largest; each rectangle fully contains all previous ones when aligned at the origin
10
+ const PAGE_NAMES_ASC = ['A4', 'A3', 'A2', 'A1', 'A0'];
11
+
12
+ // Portrait uses the raw (width < height) page size; Landscape swaps the two axes,
13
+ // matching the orientation toggle in the GTK plot dialog (plotWindow.js)
14
+ const ORIENTATIONS = {
15
+ Portrait: (size) => ({ pageWidth: size.width, pageHeight: size.height }),
16
+ Landscape: (size) => ({ pageWidth: size.height, pageHeight: size.width }),
17
+ };
18
+
19
+ /**
20
+ * Build a closed 4-corner loop, mirroring Rectangle.rectPoints()
21
+ * @param {Point} pt1
22
+ * @param {Point} pt2
23
+ * @return {Array}
24
+ */
25
+ function rectPoints(pt1, pt2) {
26
+ return [
27
+ new Point(pt1.x, pt1.y),
28
+ new Point(pt2.x, pt1.y),
29
+ new Point(pt2.x, pt2.y),
30
+ new Point(pt1.x, pt2.y),
31
+ new Point(pt1.x, pt1.y),
32
+ ];
33
+ }
34
+
35
+ describe.each(Object.keys(ORIENTATIONS))('%s orientation', (orientation) => {
36
+ const toPage = ORIENTATIONS[orientation];
37
+
38
+ test.each(PAGE_NAMES_ASC)('Extents matches %s page size exactly', (name) => {
39
+ const { pageWidth, pageHeight } = toPage(Constants.PageSizes[name]);
40
+ const core = new Core();
41
+ core.activate();
42
+ core.scene.addEntity('Polyline', { points: rectPoints(new Point(0, 0), new Point(pageWidth, pageHeight)) });
43
+
44
+ const area = core.scene.boundingBox();
45
+ expect(area.xLength).toBeCloseTo(pageWidth, 5);
46
+ expect(area.yLength).toBeCloseTo(pageHeight, 5);
47
+
48
+ const matrix = core.canvas.buildExportMatrix({
49
+ area, pageWidth, pageHeight, plotScale: 1, margin: 0,
50
+ });
51
+ expect(matrix.a).toBe(1);
52
+ expect(matrix.e).toBeCloseTo(0, 5);
53
+ expect(matrix.f).toBeCloseTo(0, 5);
54
+ });
55
+
56
+ test('Extents grows to match each page size as rectangles are added smallest to largest', () => {
57
+ const core = new Core();
58
+ core.activate();
59
+
60
+ for (const name of PAGE_NAMES_ASC) {
61
+ const { pageWidth, pageHeight } = toPage(Constants.PageSizes[name]);
62
+ core.scene.addEntity('Polyline', { points: rectPoints(new Point(0, 0), new Point(pageWidth, pageHeight)) });
63
+
64
+ const area = core.scene.boundingBox();
65
+ expect(area.xLength).toBeCloseTo(pageWidth, 5);
66
+ expect(area.yLength).toBeCloseTo(pageHeight, 5);
67
+ }
68
+ });
69
+
70
+ test('Window area isolates each nested rectangle regardless of surrounding sizes', () => {
71
+ const core = new Core();
72
+ core.activate();
73
+
74
+ const rects = {};
75
+ for (const name of PAGE_NAMES_ASC) {
76
+ const { pageWidth, pageHeight } = toPage(Constants.PageSizes[name]);
77
+ rects[name] = { p1: new Point(0, 0), p2: new Point(pageWidth, pageHeight) };
78
+ core.scene.addEntity('Polyline', { points: rectPoints(rects[name].p1, rects[name].p2) });
79
+ }
80
+
81
+ for (const name of PAGE_NAMES_ASC) {
82
+ const { pageWidth, pageHeight } = toPage(Constants.PageSizes[name]);
83
+ const { p1, p2 } = rects[name];
84
+
85
+ const windowArea = new BoundingBox(p1, p2);
86
+ expect(windowArea.xLength).toBeCloseTo(pageWidth, 5);
87
+ expect(windowArea.yLength).toBeCloseTo(pageHeight, 5);
88
+
89
+ // full exportTo path: WINDOW plotArea must isolate only this rectangle's bounds,
90
+ // not the full-scene extents (which would be A0 for every iteration)
91
+ const plotOptions = new PlotOptions(pageWidth, pageHeight);
92
+ plotOptions.setOption('plotArea', PlotOptions.Area.WINDOW);
93
+ plotOptions.setOption('windowArea', { point1: p1, point2: p2 });
94
+ plotOptions.setOption('plotScale', 1);
95
+ plotOptions.setOption('margin', 0);
96
+
97
+ const renderer = new SvgRenderer(pageWidth, pageHeight);
98
+ const setTransformSpy = jest.spyOn(renderer, 'setTransform');
99
+ expect(core.canvas.exportTo(renderer, plotOptions)).toBe(true);
100
+
101
+ // with plotScale 1 and margin 0, the matrix must map exactly this rectangle's
102
+ // own bounds onto the page - a full-scene (A0) fallback would produce a very
103
+ // different translation since every rect shares the same (0,0) origin
104
+ const matrix = setTransformSpy.mock.calls[0][0];
105
+ expect(matrix.a).toBe(1);
106
+ expect(matrix.d).toBe(1);
107
+ expect(matrix.e).toBeCloseTo(0, 5);
108
+ expect(matrix.f).toBeCloseTo(0, 5);
109
+
110
+ setTransformSpy.mockRestore();
111
+ }
112
+ });
113
+ });
@@ -4,6 +4,8 @@ import { Point } from '../../core/entities/point.js';
4
4
  import { DesignCore } from '../../core/designCore.js';
5
5
  import { Logging } from '../../core/lib/logging.js';
6
6
  import { jest } from '@jest/globals';
7
+ import { Polyline } from '../../core/entities/polyline.js';
8
+ import { Text } from '../../core/entities/text.js';
7
9
 
8
10
  // Initialize core (sets DesignCore.Core)
9
11
  new Core();
@@ -67,4 +69,75 @@ describe('Clipboard', () => {
67
69
  expect(fresh.Entities).toHaveLength(0);
68
70
  errorSpy.mockRestore();
69
71
  });
72
+
73
+ describe('stringify/parse flag survival', () => {
74
+ test('closed Polyline retains closed flag after stringify/parse roundtrip', () => {
75
+ // Create a closed polyline (flags bit 1 = closed)
76
+ const poly = new Polyline({
77
+ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)],
78
+ flags: 1,
79
+ });
80
+ expect(poly.getProperty('closed')).toBe(true);
81
+
82
+ DesignCore.Clipboard.Entities = [poly];
83
+ const json = DesignCore.Clipboard.stringify();
84
+
85
+ const fresh = new Clipboard();
86
+ fresh.parse(json);
87
+
88
+ expect(fresh.Entities).toHaveLength(1);
89
+ expect(fresh.Entities[0].getProperty('closed')).toBe(true);
90
+ });
91
+
92
+ test('closed Polyline retains correct point count after stringify/parse roundtrip', () => {
93
+ // A closed polyline stores 4 points (not 5 — the closing duplicate is removed)
94
+ const poly = new Polyline({
95
+ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10), new Point(0, 10)],
96
+ flags: 1,
97
+ });
98
+ expect(poly.points).toHaveLength(4);
99
+
100
+ DesignCore.Clipboard.Entities = [poly];
101
+ const json = DesignCore.Clipboard.stringify();
102
+
103
+ const fresh = new Clipboard();
104
+ fresh.parse(json);
105
+
106
+ expect(fresh.Entities[0].points).toHaveLength(4);
107
+ });
108
+
109
+ test('backwards (mirrored) Text retains backwards flag after stringify/parse roundtrip', () => {
110
+ // Text flags bit 2 = backwards
111
+ const text = new Text({
112
+ points: [new Point(0, 0), new Point(10, 0)],
113
+ textValue: 'hello',
114
+ flags: 2,
115
+ });
116
+ expect(text.getBackwards()).toBe(true);
117
+
118
+ DesignCore.Clipboard.Entities = [text];
119
+ const json = DesignCore.Clipboard.stringify();
120
+
121
+ const fresh = new Clipboard();
122
+ fresh.parse(json);
123
+
124
+ expect(fresh.Entities).toHaveLength(1);
125
+ expect(fresh.Entities[0].getBackwards()).toBe(true);
126
+ });
127
+
128
+ test('non-flagged entities are unaffected', () => {
129
+ const poly = new Polyline({
130
+ points: [new Point(0, 0), new Point(10, 0), new Point(10, 10)],
131
+ });
132
+ expect(poly.getProperty('closed')).toBe(false);
133
+
134
+ DesignCore.Clipboard.Entities = [poly];
135
+ const json = DesignCore.Clipboard.stringify();
136
+
137
+ const fresh = new Clipboard();
138
+ fresh.parse(json);
139
+
140
+ expect(fresh.Entities[0].getProperty('closed')).toBe(false);
141
+ });
142
+ });
70
143
  });
@@ -130,7 +130,7 @@ test('setOption accepts PlotOptions.Area.DISPLAY for plotArea', () => {
130
130
 
131
131
  test('setOption throws for an unknown plotArea value', () => {
132
132
  const po = new PlotOptions(595, 842);
133
- expect(() => po.setOption('plotArea', 'Window')).toThrow('plotArea');
133
+ expect(() => po.setOption('plotArea', 'Unknown')).toThrow('plotArea');
134
134
  });
135
135
 
136
136
  // ─── setOption — style ────────────────────────────────────────────────────────
@@ -152,6 +152,63 @@ test('setOption throws when style is not a function', () => {
152
152
  expect(() => po.setOption('style', 'monochrome')).toThrow('style');
153
153
  });
154
154
 
155
+ // ─── setOption — margin ───────────────────────────────────────────────────────
156
+
157
+ test('setOption accepts a valid margin', () => {
158
+ const po = new PlotOptions(595, 842);
159
+ po.setOption('margin', 40);
160
+ expect(po.margin).toBe(40);
161
+ });
162
+
163
+ test('setOption accepts zero margin', () => {
164
+ const po = new PlotOptions(595, 842);
165
+ po.setOption('margin', 0);
166
+ expect(po.margin).toBe(0);
167
+ });
168
+
169
+ test('setOption throws for negative margin', () => {
170
+ const po = new PlotOptions(595, 842);
171
+ expect(() => po.setOption('margin', -1)).toThrow('margin');
172
+ });
173
+
174
+ test('setOption throws for non-numeric margin', () => {
175
+ const po = new PlotOptions(595, 842);
176
+ expect(() => po.setOption('margin', '40')).toThrow('margin');
177
+ });
178
+
179
+ test('setOption throws for NaN margin', () => {
180
+ const po = new PlotOptions(595, 842);
181
+ expect(() => po.setOption('margin', NaN)).toThrow('margin');
182
+ });
183
+
184
+ test('setOption throws for Infinity margin', () => {
185
+ const po = new PlotOptions(595, 842);
186
+ expect(() => po.setOption('margin', Infinity)).toThrow('margin');
187
+ });
188
+
189
+ test('setOption throws when margin leaves zero usable width', () => {
190
+ const po = new PlotOptions(595, 842);
191
+ // 297.5 * 2 === pageWidth -> usable width would be exactly 0
192
+ expect(() => po.setOption('margin', 297.5)).toThrow('usable page area');
193
+ });
194
+
195
+ test('setOption throws when margin leaves zero usable height', () => {
196
+ const po = new PlotOptions(842, 595);
197
+ expect(() => po.setOption('margin', 297.5)).toThrow('usable page area');
198
+ });
199
+
200
+ test('setOption throws when margin exceeds half the narrower page dimension only', () => {
201
+ const po = new PlotOptions(595, 842);
202
+ // exceeds half of pageWidth (297.5) but not half of pageHeight (421)
203
+ expect(() => po.setOption('margin', 300)).toThrow('usable page area');
204
+ });
205
+
206
+ test('setOption accepts a margin just under half the narrower page dimension', () => {
207
+ const po = new PlotOptions(595, 842);
208
+ po.setOption('margin', 297);
209
+ expect(po.margin).toBe(297);
210
+ });
211
+
155
212
  // ─── setOption — fileType ─────────────────────────────────────────────────────
156
213
 
157
214
  test('setOption accepts pdf for fileType', () => {
@@ -162,3 +162,27 @@ test('Test flags.removeValue', () => {
162
162
  // 2 shouldn't exist again
163
163
  expect(flags.hasFlag(2)).toBe(false);
164
164
  });
165
+
166
+ test('Test flags.toJSON returns the numeric flag value', () => {
167
+ const flags = new Flags();
168
+ expect(flags.toJSON()).toBe(0);
169
+
170
+ flags.setFlagValue(7);
171
+ expect(flags.toJSON()).toBe(7);
172
+ });
173
+
174
+ test('Test flags.toJSON round-trips through JSON.stringify/parse', () => {
175
+ const flags = new Flags();
176
+ flags.setFlagValue(1);
177
+
178
+ // JSON.stringify should produce the plain number, not a nested object
179
+ const json = JSON.stringify({ flags });
180
+ expect(json).toBe('{"flags":1}');
181
+
182
+ // The parsed value is a plain number — Flags constructor accepts it via setFlagValue
183
+ const parsed = JSON.parse(json);
184
+ const restored = new Flags();
185
+ restored.setFlagValue(parsed.flags);
186
+ expect(restored.getFlagValue()).toBe(1);
187
+ expect(restored.hasFlag(1)).toBe(true);
188
+ });