@richardmcquiston01/unofficial-xcs-writer 0.5.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-09-21
9
+
10
+ ### Added
11
+
12
+ - **`.xs` processing-profile support**: `.addText()`/`.addPath()`/`.addBitmap()` accept a new
13
+ `processing: { processingType, values }` option (`src/builder.ts`), which `.toXsBytes()`
14
+ (`src/xs.ts`'s `buildXsArchive`, now taking an optional `XsProcessingBinding[]`) turns into a
15
+ real `profiles.json` entry plus a matching `LASER_PLANE`-mode binding in
16
+ `devices/device-<id>.json`. Displays given identical `processingType`+`values` are merged into
17
+ one shared profile/binding, matching how real xTool Studio exports dedupe identical settings.
18
+ `.toBytes()` (`.xcs` output) ignores this option -- `.xcs`'s own per-display processing format
19
+ is structurally different and much less understood, so it isn't implemented (see `CLAUDE.md`'s
20
+ Open Issues).
21
+
22
+ ## [0.6.0] - 2026-09-21
23
+
24
+ ### Added
25
+
26
+ - **`.xs` generation**: `XCSGenerator.toXsBytes()` (`src/xs.ts`'s new `buildXsArchive`) exports a
27
+ built project as a `.xs` v2 archive instead of `.xcs` -- the same chainable builder
28
+ (`.addText()`/`.addPath()`/`.addBitmap()`/`.addLayer()`), same display objects, different
29
+ container. `project.json`/`profiles.json`/`devices/`/`canvases/` metadata is synthesized
30
+ fresh, always as a single canvas in a single `displays-0.json` chunk, with the cover image
31
+ written as a real `resources/project-cover.png` file instead of inline base64. Processing
32
+ profiles and device bindings are left empty (no `addProfile`-style API exists yet) -- a
33
+ generated `.xs` file's power/speed settings may need to be configured manually in xTool
34
+ Studio before cutting/engraving.
35
+
8
36
  ## [0.5.0] - 2026-09-20
9
37
 
10
38
  ### Added
package/README.md CHANGED
@@ -13,7 +13,7 @@ This library lets you, for both formats:
13
13
  - **Read** a file and inspect its contents
14
14
  - **Extract** `{{token}}` template placeholders from text objects
15
15
  - **Render** a filled-in copy by substituting values for those placeholders
16
- - **Build** a new `.xcs` project from scratch — text (straight, multi-line, or curved), paths, and embedded images (`.xs` generation isn't supported yet — see `CLAUDE.md`'s "Open Issues")
16
+ - **Build** a new project from scratch — text (straight, multi-line, or curved), paths, and embedded images — and export it as either `.xcs` or `.xs`
17
17
 
18
18
  ## Installation
19
19
 
@@ -93,10 +93,26 @@ const project = createXCS('P2S')
93
93
  fontSize: 18,
94
94
  layout: layoutCurvedGlyphText(font, 'Arch Text', 6, 90, 60, 20, 'center'),
95
95
  })
96
- .addPath('M0 0L10 0L10 10L0 10Z', 0, 60, 10, 10)
96
+ .addPath('M0 0L10 0L10 10L0 10Z', 0, 60, 10, 10, {
97
+ // Only affects .toXsBytes() output -- see below.
98
+ processing: { processingType: 'VECTOR_CUTTING', values: { power: 80, speed: 10 } },
99
+ })
97
100
  .toBytes(); // Uint8Array, ready to write to a .xcs file
98
101
  ```
99
102
 
103
+ Call `.toXsBytes()` instead of `.toBytes()` to export the same project as a `.xs` (v2 workspace)
104
+ archive — same chainable builder, same display objects, different container.
105
+
106
+ Pass `processing: { processingType, values }` to `.addText()`/`.addPath()`/`.addBitmap()`'s
107
+ options to set that display's power/speed/etc. in the exported `.xs` file's `profiles.json` +
108
+ device bindings — `.xcs` output (`.toBytes()`) ignores this option; its own per-display
109
+ processing format is different and not supported yet. Displays given identical settings share
110
+ one profile/binding automatically. `processingType`/`values`' exact shape is xTool Studio's own
111
+ and undocumented beyond real exports — see `xs_samples/*.xs`'s `profiles.json` for examples
112
+ (`VECTOR_ENGRAVING`, `VECTOR_CUTTING`, `FILL_VECTOR_ENGRAVING`). Displays with no `processing`
113
+ option behave as before: a generated `.xs` file's power/speed may need to be set manually in
114
+ xTool Studio before cutting/engraving.
115
+
100
116
  ## API
101
117
 
102
118
  ### `assertXcsFormat(buffer: ArrayBuffer): void`
@@ -151,6 +167,10 @@ Same as `extractXcsTokens`, but scans every `canvases/<canvasId>/displays-<chunk
151
167
 
152
168
  Same substitution and glyph-regeneration behavior as `renderXcsFile` (see above — including the same `fonts` parameter and curved-text limitation), applied to every displays chunk. Returns a re-zipped `.xs` archive; every entry other than the modified displays chunks is passed through byte-for-byte unchanged.
153
169
 
170
+ ### `buildXsArchive(file: XCSFile): Uint8Array`
171
+
172
+ Packages an already-`generate()`d project (see Building, below) into a `.xs` v2 ZIP archive. You won't normally call this directly — use `XCSGenerator.toXsBytes()` instead, which calls it for you.
173
+
154
174
  ### `loadFont(buffer: ArrayBuffer)` / `loadDefaultFont()` / `layoutText(font, text, originX, originY)`
155
175
 
156
176
  Lower-level glyph-extraction primitives everything else in this library is built on, exported for direct use. See `src/glyphs.ts` for the exact xTool JSON conventions these were reverse-engineered against.
@@ -166,7 +186,9 @@ Lower-level glyph-extraction primitives everything else in this library is built
166
186
  | `.addBitmap(pngBase64, x, y, widthMm, heightMm, originWidthPx, originHeightPx, options?)` | An embedded PNG, physically sized in mm. |
167
187
  | `.addLayer(color, name, order)` | A named, colored layer (one `#00befe` "Cyan" layer exists by default). |
168
188
 
169
- Then `.generate()` (the plain `XCSFile` object), `.toJSON()` (string), or `.toBytes()` (`Uint8Array`, matching `renderXcsFile`'s output type).
189
+ `.addText()`/`.addPath()`/`.addBitmap()` all also accept a `processing: { processingType, values }` option — see "Building a project from scratch" above; it only affects `.toXsBytes()` output.
190
+
191
+ Then `.generate()` (the plain `XCSFile` object), `.toJSON()` (string), `.toBytes()` (`Uint8Array`, matching `renderXcsFile`'s output type, as `.xcs`), or `.toXsBytes()` (`Uint8Array`, matching `renderXsFile`'s output type, as `.xs`).
170
192
 
171
193
  Text layout — build the `layout` option for `.addText()` with one of:
172
194
 
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  XCSGenerator: () => XCSGenerator,
34
34
  assertXcsFormat: () => assertXcsFormat,
35
35
  assertXsFormat: () => assertXsFormat,
36
+ buildXsArchive: () => buildXsArchive,
36
37
  createXCS: () => createXCS,
37
38
  effectiveCurveDeg: () => effectiveCurveDeg,
38
39
  extractXcsTokens: () => extractXcsTokens,
@@ -358,6 +359,160 @@ function renderXsFile(buffer, variables, values, fonts) {
358
359
  }
359
360
  return (0, import_fflate.zipSync)(entries);
360
361
  }
362
+ function encodeJson(value) {
363
+ return new TextEncoder().encode(JSON.stringify(value));
364
+ }
365
+ function generateXsId(prefix) {
366
+ const uuid = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
367
+ const r = Math.random() * 16 | 0;
368
+ const v = c === "x" ? r : r & 3 | 8;
369
+ return v.toString(16);
370
+ });
371
+ return `${prefix}:${uuid}`;
372
+ }
373
+ function buildProcessing(canvasId, bindings) {
374
+ const profiles = {};
375
+ const profileRefs = [];
376
+ const deviceBindings = [];
377
+ for (const binding of bindings) {
378
+ const profileId = generateXsId("profile");
379
+ profiles[profileId] = {
380
+ id: profileId,
381
+ processingType: binding.processingType,
382
+ values: binding.values
383
+ };
384
+ profileRefs.push(profileId);
385
+ deviceBindings.push({
386
+ bindingId: generateXsId("binding"),
387
+ canvasId,
388
+ mode: "LASER_PLANE",
389
+ baseProfileId: profileId,
390
+ displayIds: binding.displayIds
391
+ });
392
+ }
393
+ return { profiles, profileRefs, deviceBindings };
394
+ }
395
+ function decodeDataUrl(dataUrl) {
396
+ const match = /^data:([^;]+);base64,(.*)$/.exec(dataUrl);
397
+ if (!match) return null;
398
+ const [, mimeType, base64] = match;
399
+ const binary = atob(base64);
400
+ const bytes = new Uint8Array(binary.length);
401
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
402
+ return { bytes, mimeType };
403
+ }
404
+ function coverResourceEntries(coverDataUrl) {
405
+ const cover = decodeDataUrl(coverDataUrl);
406
+ if (!cover) return {};
407
+ return {
408
+ "resources/project-cover.png": cover.bytes,
409
+ "resources/project-cover.png.meta.json": encodeJson({
410
+ ref: "resources/project-cover.png",
411
+ metadata: {
412
+ kind: "image",
413
+ source: { type: "workspace", value: "project-cover.png" },
414
+ mimeType: cover.mimeType
415
+ }
416
+ })
417
+ };
418
+ }
419
+ function buildXsArchive(file, processingBindings = []) {
420
+ const canvas = file.canvas[0];
421
+ if (!canvas) {
422
+ throw new Error("Cannot build a .xs archive from a project with no canvas.");
423
+ }
424
+ const { profiles, profileRefs, deviceBindings } = buildProcessing(
425
+ canvas.id,
426
+ processingBindings
427
+ );
428
+ return (0, import_fflate.zipSync)({
429
+ ".format": new TextEncoder().encode("v2"),
430
+ "meta/persistence-meta.json": encodeJson({
431
+ schemaVersion: "2.0.0",
432
+ protocol: "xcs-workspace-v2"
433
+ }),
434
+ "project.json": encodeJson({
435
+ __v2__: true,
436
+ version: "2.0.0",
437
+ schemaMeta: { schemaVersion: "2.1.0", format: "directory" },
438
+ projectId: file.projectTraceID,
439
+ projectTraceID: file.projectTraceID,
440
+ projectName: "Untitled",
441
+ activeCanvasId: canvas.id,
442
+ activeDeviceId: file.device.id,
443
+ versionInfo: {
444
+ source: "web",
445
+ appVersion: "",
446
+ savedAt: file.modify,
447
+ ua: file.ua,
448
+ minRequiredVersion: file.minRequiredVersion,
449
+ appMinRequiredVersion: file.appMinRequiredVersion,
450
+ webMinRequiredVersion: file.webMinRequiredVersion
451
+ },
452
+ created: file.created,
453
+ modify: file.modify,
454
+ modules: { canvases: [canvas.id], devices: [file.device.id] },
455
+ cover: "resources/project-cover.png",
456
+ customProjectData: { projectTraceID: file.projectTraceID }
457
+ }),
458
+ "profiles.json": encodeJson({ profiles }),
459
+ [`canvases/${canvas.id}.json`]: encodeJson({
460
+ id: canvas.id,
461
+ title: canvas.title,
462
+ hidden: false,
463
+ layerData: canvas.layerData,
464
+ groupData: canvas.groupData,
465
+ extendInfo: {
466
+ version: canvas.extendInfo.version,
467
+ minCanvasVersion: canvas.extendInfo.minCanvasVersion,
468
+ displayProcessConfigMap: {},
469
+ rulerPluginData: { rulerGuide: [] },
470
+ type: "2d"
471
+ },
472
+ chunkLayout: { displayCount: canvas.displays.length, chunkCount: 1, chunkIndexes: [0] }
473
+ }),
474
+ [`canvases/${canvas.id}/displays-0.json`]: encodeJson({
475
+ canvasId: canvas.id,
476
+ chunkIndex: 0,
477
+ displays: canvas.displays
478
+ }),
479
+ [`devices/device-${file.device.id}.json`]: encodeJson({
480
+ id: file.device.id,
481
+ deviceCode: file.device.id,
482
+ extId: file.extId,
483
+ extName: file.extName,
484
+ power: [file.device.power],
485
+ processing: {
486
+ [canvas.id]: {
487
+ id: canvas.id,
488
+ activeMode: "LASER_PLANE",
489
+ modes: {
490
+ LASER_PLANE: {
491
+ ignoredDisplayIds: [],
492
+ data: {
493
+ material: 0,
494
+ thickness: null,
495
+ perimeter: null,
496
+ diameter: null,
497
+ isProcessByLayer: false,
498
+ pathPlanning: "auto",
499
+ fillPlanning: "separate",
500
+ scanDirection: "topToBottom",
501
+ enableOddEvenKerf: true,
502
+ focalLen: null,
503
+ focalLength: null
504
+ },
505
+ profileRefs,
506
+ patches: {},
507
+ bindings: deviceBindings
508
+ }
509
+ }
510
+ }
511
+ }
512
+ }),
513
+ ...coverResourceEntries(file.cover)
514
+ });
515
+ }
361
516
 
362
517
  // src/layout.ts
363
518
  var LAYOUT_MM_PER_EM = 25.4;
@@ -618,6 +773,8 @@ var XCSGenerator = class {
618
773
  displays = [];
619
774
  layers = /* @__PURE__ */ new Map();
620
775
  options;
776
+ /** Keyed by `JSON.stringify([processingType, values])` to merge displays sharing identical settings into one profile. */
777
+ processingBindings = /* @__PURE__ */ new Map();
621
778
  constructor(options = {}) {
622
779
  this.options = {
623
780
  deviceId: options.deviceId || "P2S",
@@ -639,6 +796,7 @@ var XCSGenerator = class {
639
796
  addText(text, x, y, options = {}) {
640
797
  const textObj = this.createTextObject(text, x, y, options);
641
798
  this.displays.push(textObj);
799
+ this.registerProcessing(textObj.id, options.processing);
642
800
  return this;
643
801
  }
644
802
  /**
@@ -647,6 +805,7 @@ var XCSGenerator = class {
647
805
  addPath(pathData, x, y, width, height, options = {}) {
648
806
  const pathObj = this.createPathObject(pathData, x, y, width, height, options);
649
807
  this.displays.push(pathObj);
808
+ this.registerProcessing(pathObj.id, options.processing);
650
809
  return this;
651
810
  }
652
811
  /**
@@ -730,6 +889,7 @@ var XCSGenerator = class {
730
889
  opacity: 1
731
890
  };
732
891
  this.displays.push(bitmap);
892
+ this.registerProcessing(bitmap.id, options.processing);
733
893
  return this;
734
894
  }
735
895
  /**
@@ -790,6 +950,31 @@ var XCSGenerator = class {
790
950
  toBytes() {
791
951
  return new TextEncoder().encode(this.toJSON());
792
952
  }
953
+ /**
954
+ * Export as a `.xs` (v2 workspace) ZIP archive instead of `.xcs` --
955
+ * see `buildXsArchive` in `src/xs.ts` for the container format and
956
+ * its limitations (no processing profiles/device bindings yet).
957
+ * Display objects themselves carry over unchanged; only the
958
+ * container differs from `toBytes()`.
959
+ */
960
+ toXsBytes() {
961
+ return buildXsArchive(this.generate(), Array.from(this.processingBindings.values()));
962
+ }
963
+ /** Merges a display into an existing profile when another display already shares its exact `processing` settings, else starts a new one. */
964
+ registerProcessing(displayId, processing) {
965
+ if (!processing) return;
966
+ const key = JSON.stringify([processing.processingType, processing.values]);
967
+ const existing = this.processingBindings.get(key);
968
+ if (existing) {
969
+ existing.displayIds.push(displayId);
970
+ return;
971
+ }
972
+ this.processingBindings.set(key, {
973
+ processingType: processing.processingType,
974
+ values: processing.values,
975
+ displayIds: [displayId]
976
+ });
977
+ }
793
978
  createCanvas() {
794
979
  return {
795
980
  id: this.canvasId,
@@ -1025,6 +1210,7 @@ function createXCS(deviceId = "P2S") {
1025
1210
  XCSGenerator,
1026
1211
  assertXcsFormat,
1027
1212
  assertXsFormat,
1213
+ buildXsArchive,
1028
1214
  createXCS,
1029
1215
  effectiveCurveDeg,
1030
1216
  extractXcsTokens,