@richardmcquiston01/unofficial-xcs-writer 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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.6.0] - 2026-09-21
9
+
10
+ ### Added
11
+
12
+ - **`.xs` generation**: `XCSGenerator.toXsBytes()` (`src/xs.ts`'s new `buildXsArchive`) exports a
13
+ built project as a `.xs` v2 archive instead of `.xcs` -- the same chainable builder
14
+ (`.addText()`/`.addPath()`/`.addBitmap()`/`.addLayer()`), same display objects, different
15
+ container. `project.json`/`profiles.json`/`devices/`/`canvases/` metadata is synthesized
16
+ fresh, always as a single canvas in a single `displays-0.json` chunk, with the cover image
17
+ written as a real `resources/project-cover.png` file instead of inline base64. Processing
18
+ profiles and device bindings are left empty (no `addProfile`-style API exists yet) -- a
19
+ generated `.xs` file's power/speed settings may need to be configured manually in xTool
20
+ Studio before cutting/engraving.
21
+
8
22
  ## [0.5.0] - 2026-09-20
9
23
 
10
24
  ### 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
 
@@ -97,6 +97,12 @@ const project = createXCS('P2S')
97
97
  .toBytes(); // Uint8Array, ready to write to a .xcs file
98
98
  ```
99
99
 
100
+ Call `.toXsBytes()` instead of `.toBytes()` to export the same project as a `.xs` (v2 workspace)
101
+ archive — same chainable builder, same display objects, different container. Processing
102
+ profiles/device bindings aren't populated (there's no `addProfile`-style API yet), so a
103
+ generated `.xs` file's power/speed may need to be set manually in xTool Studio before
104
+ cutting/engraving.
105
+
100
106
  ## API
101
107
 
102
108
  ### `assertXcsFormat(buffer: ArrayBuffer): void`
@@ -151,6 +157,10 @@ Same as `extractXcsTokens`, but scans every `canvases/<canvasId>/displays-<chunk
151
157
 
152
158
  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
159
 
160
+ ### `buildXsArchive(file: XCSFile): Uint8Array`
161
+
162
+ 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.
163
+
154
164
  ### `loadFont(buffer: ArrayBuffer)` / `loadDefaultFont()` / `layoutText(font, text, originX, originY)`
155
165
 
156
166
  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 +176,7 @@ Lower-level glyph-extraction primitives everything else in this library is built
166
176
  | `.addBitmap(pngBase64, x, y, widthMm, heightMm, originWidthPx, originHeightPx, options?)` | An embedded PNG, physically sized in mm. |
167
177
  | `.addLayer(color, name, order)` | A named, colored layer (one `#00befe` "Cyan" layer exists by default). |
168
178
 
169
- Then `.generate()` (the plain `XCSFile` object), `.toJSON()` (string), or `.toBytes()` (`Uint8Array`, matching `renderXcsFile`'s output type).
179
+ 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` — see the note in "Building a project from scratch" above about unset processing profiles).
170
180
 
171
181
  Text layout — build the `layout` option for `.addText()` with one of:
172
182
 
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,126 @@ 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 decodeDataUrl(dataUrl) {
366
+ const match = /^data:([^;]+);base64,(.*)$/.exec(dataUrl);
367
+ if (!match) return null;
368
+ const [, mimeType, base64] = match;
369
+ const binary = atob(base64);
370
+ const bytes = new Uint8Array(binary.length);
371
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
372
+ return { bytes, mimeType };
373
+ }
374
+ function coverResourceEntries(coverDataUrl) {
375
+ const cover = decodeDataUrl(coverDataUrl);
376
+ if (!cover) return {};
377
+ return {
378
+ "resources/project-cover.png": cover.bytes,
379
+ "resources/project-cover.png.meta.json": encodeJson({
380
+ ref: "resources/project-cover.png",
381
+ metadata: {
382
+ kind: "image",
383
+ source: { type: "workspace", value: "project-cover.png" },
384
+ mimeType: cover.mimeType
385
+ }
386
+ })
387
+ };
388
+ }
389
+ function buildXsArchive(file) {
390
+ const canvas = file.canvas[0];
391
+ if (!canvas) {
392
+ throw new Error("Cannot build a .xs archive from a project with no canvas.");
393
+ }
394
+ return (0, import_fflate.zipSync)({
395
+ ".format": new TextEncoder().encode("v2"),
396
+ "meta/persistence-meta.json": encodeJson({
397
+ schemaVersion: "2.0.0",
398
+ protocol: "xcs-workspace-v2"
399
+ }),
400
+ "project.json": encodeJson({
401
+ __v2__: true,
402
+ version: "2.0.0",
403
+ schemaMeta: { schemaVersion: "2.1.0", format: "directory" },
404
+ projectId: file.projectTraceID,
405
+ projectTraceID: file.projectTraceID,
406
+ projectName: "Untitled",
407
+ activeCanvasId: canvas.id,
408
+ activeDeviceId: file.device.id,
409
+ versionInfo: {
410
+ source: "web",
411
+ appVersion: "",
412
+ savedAt: file.modify,
413
+ ua: file.ua,
414
+ minRequiredVersion: file.minRequiredVersion,
415
+ appMinRequiredVersion: file.appMinRequiredVersion,
416
+ webMinRequiredVersion: file.webMinRequiredVersion
417
+ },
418
+ created: file.created,
419
+ modify: file.modify,
420
+ modules: { canvases: [canvas.id], devices: [file.device.id] },
421
+ cover: "resources/project-cover.png",
422
+ customProjectData: { projectTraceID: file.projectTraceID }
423
+ }),
424
+ "profiles.json": encodeJson({ profiles: {} }),
425
+ [`canvases/${canvas.id}.json`]: encodeJson({
426
+ id: canvas.id,
427
+ title: canvas.title,
428
+ hidden: false,
429
+ layerData: canvas.layerData,
430
+ groupData: canvas.groupData,
431
+ extendInfo: {
432
+ version: canvas.extendInfo.version,
433
+ minCanvasVersion: canvas.extendInfo.minCanvasVersion,
434
+ displayProcessConfigMap: {},
435
+ rulerPluginData: { rulerGuide: [] },
436
+ type: "2d"
437
+ },
438
+ chunkLayout: { displayCount: canvas.displays.length, chunkCount: 1, chunkIndexes: [0] }
439
+ }),
440
+ [`canvases/${canvas.id}/displays-0.json`]: encodeJson({
441
+ canvasId: canvas.id,
442
+ chunkIndex: 0,
443
+ displays: canvas.displays
444
+ }),
445
+ [`devices/device-${file.device.id}.json`]: encodeJson({
446
+ id: file.device.id,
447
+ deviceCode: file.device.id,
448
+ extId: file.extId,
449
+ extName: file.extName,
450
+ power: [file.device.power],
451
+ processing: {
452
+ [canvas.id]: {
453
+ id: canvas.id,
454
+ activeMode: "LASER_PLANE",
455
+ modes: {
456
+ LASER_PLANE: {
457
+ ignoredDisplayIds: [],
458
+ data: {
459
+ material: 0,
460
+ thickness: null,
461
+ perimeter: null,
462
+ diameter: null,
463
+ isProcessByLayer: false,
464
+ pathPlanning: "auto",
465
+ fillPlanning: "separate",
466
+ scanDirection: "topToBottom",
467
+ enableOddEvenKerf: true,
468
+ focalLen: null,
469
+ focalLength: null
470
+ },
471
+ profileRefs: [],
472
+ patches: {},
473
+ bindings: []
474
+ }
475
+ }
476
+ }
477
+ }
478
+ }),
479
+ ...coverResourceEntries(file.cover)
480
+ });
481
+ }
361
482
 
362
483
  // src/layout.ts
363
484
  var LAYOUT_MM_PER_EM = 25.4;
@@ -790,6 +911,16 @@ var XCSGenerator = class {
790
911
  toBytes() {
791
912
  return new TextEncoder().encode(this.toJSON());
792
913
  }
914
+ /**
915
+ * Export as a `.xs` (v2 workspace) ZIP archive instead of `.xcs` --
916
+ * see `buildXsArchive` in `src/xs.ts` for the container format and
917
+ * its limitations (no processing profiles/device bindings yet).
918
+ * Display objects themselves carry over unchanged; only the
919
+ * container differs from `toBytes()`.
920
+ */
921
+ toXsBytes() {
922
+ return buildXsArchive(this.generate());
923
+ }
793
924
  createCanvas() {
794
925
  return {
795
926
  id: this.canvasId,
@@ -1025,6 +1156,7 @@ function createXCS(deviceId = "P2S") {
1025
1156
  XCSGenerator,
1026
1157
  assertXcsFormat,
1027
1158
  assertXsFormat,
1159
+ buildXsArchive,
1028
1160
  createXCS,
1029
1161
  effectiveCurveDeg,
1030
1162
  extractXcsTokens,