@richardmcquiston01/unofficial-xcs-writer 0.4.1 → 0.5.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,23 @@ 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.5.0] - 2026-09-20
9
+
10
+ ### Added
11
+
12
+ - **`.xs` (v2 workspace) format support** (`src/xs.ts`): `assertXsFormat`, `extractXsTokens`,
13
+ and `renderXsFile` read and apply `{{token}}` substitution to xTool Studio v1.7+'s new `.xs`
14
+ format — a ZIP archive of chunked per-canvas display files, unlike `.xcs`'s single plain-JSON
15
+ document. `.xcs` is now legacy as of xTool Studio v1.7 (still readable, no longer savable from
16
+ the app). The `TEXT` display schema itself (`text`/`style`/`fontData`/`charJSONs`) is
17
+ unchanged from `.xcs`, so token extraction and glyph regeneration share their implementation
18
+ with `renderXcsFile`/`extractXcsTokens` via a new `src/substitution.ts` module; only the
19
+ container format differs. Archives are read/written with [fflate](https://github.com/101arrowz/fflate)
20
+ (new runtime dependency); every entry other than the modified displays chunks round-trips
21
+ byte-for-byte unchanged, including large `PATH` displays' deduplicated `vectors/<bucketType>/`
22
+ vector store. `.xs` *generation* (building a new project from scratch) isn't implemented yet
23
+ — see `CLAUDE.md`'s "Open Issues".
24
+
8
25
  ## [0.4.1] - 2026-09-19
9
26
 
10
27
  ### Changed
package/README.md CHANGED
@@ -1,15 +1,19 @@
1
1
  # unofficial-xcs-writer
2
2
 
3
- A framework-agnostic TypeScript library for reading, writing, building, and applying variable substitution to `.xcs` files produced by [xTool Creative Space](https://www.xtool.com/pages/software). Not affiliated with xTool.
3
+ A framework-agnostic TypeScript library for reading, writing, building, and applying variable substitution to `.xcs` and `.xs` files produced by [xTool Creative Space](https://www.xtool.com/pages/software). Not affiliated with xTool.
4
4
 
5
- ## What is an XCS file?
5
+ ## What is an XCS / XS file?
6
6
 
7
- An `.xcs` file is a plain UTF-8 JSON document exported by xTool Creative Space. It describes a laser project — canvas dimensions, design objects (shapes, text, images), layer assignments, and device parameters. This library lets you:
7
+ An `.xcs` file is a plain UTF-8 JSON document exported by xTool Creative Space. It describes a laser project — canvas dimensions, design objects (shapes, text, images), layer assignments, and device parameters.
8
8
 
9
- - **Read** an XCS file and inspect its contents
9
+ Starting with xTool Studio v1.7, new projects are saved as `.xs` instead — a ZIP archive of several JSON files (project metadata, per-canvas display chunks, processing profiles, device bindings, and binary resources as real files rather than inline base64). `.xcs` is now legacy: v1.7+ can still open `.xcs` files but can no longer save back to that format. See `src/xs.ts` for the full format writeup.
10
+
11
+ This library lets you, for both formats:
12
+
13
+ - **Read** a file and inspect its contents
10
14
  - **Extract** `{{token}}` template placeholders from text objects
11
15
  - **Render** a filled-in copy by substituting values for those placeholders
12
- - **Build** a new `.xcs` project from scratch — text (straight, multi-line, or curved), paths, and embedded images
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")
13
17
 
14
18
  ## Installation
15
19
 
@@ -46,6 +50,21 @@ const values = { FirstName: 'Jane', LastName: 'Smith' };
46
50
  const output: Uint8Array = renderXcsFile(buffer, variables, values);
47
51
  ```
48
52
 
53
+ ### Working with `.xs` files
54
+
55
+ Same shape, different functions — `.xs` is a ZIP archive, so there's no `readXsFile` equivalent to `readXcsFile` (there's no single JSON string to return), but token extraction and substitution work the same way:
56
+
57
+ ```ts
58
+ import { assertXsFormat, extractXsTokens, renderXsFile } from '@richardmcquiston01/unofficial-xcs-writer';
59
+
60
+ const buffer = await fetch('/templates/name-tag.xs').then(r => r.arrayBuffer());
61
+
62
+ assertXsFormat(buffer); // throws if invalid
63
+ const tokens = extractXsTokens(buffer); // e.g. ['FirstName', 'LastName']
64
+
65
+ const output: Uint8Array = renderXsFile(buffer, variables, values);
66
+ ```
67
+
49
68
  ### Building a project from scratch
50
69
 
51
70
  ```ts
@@ -118,6 +137,20 @@ interface XcsVariable {
118
137
  }
119
138
  ```
120
139
 
140
+ ### `.xs` (v2 workspace) format
141
+
142
+ ### `assertXsFormat(buffer: ArrayBuffer): void`
143
+
144
+ Validates that `buffer` is a ZIP archive with a `.format` entry equal to `"v2"` and a `project.json` entry. Throws a descriptive error otherwise.
145
+
146
+ ### `extractXsTokens(buffer: ArrayBuffer): string[]`
147
+
148
+ Same as `extractXcsTokens`, but scans every `canvases/<canvasId>/displays-<chunkIndex>.json` chunk across the archive instead of a single `canvas` array.
149
+
150
+ ### `renderXsFile(buffer, variables, values, fonts?): Uint8Array`
151
+
152
+ 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
+
121
154
  ### `loadFont(buffer: ArrayBuffer)` / `loadDefaultFont()` / `layoutText(font, text, originX, originY)`
122
155
 
123
156
  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.
@@ -175,7 +208,8 @@ It needs an `NPM_TOKEN` repository secret with publish rights.
175
208
 
176
209
  - All inputs and outputs use plain browser-compatible types (`ArrayBuffer`, `Uint8Array`, `string`) — no Node.js APIs, no DOM, no framework required. (`atob`/`crypto.randomUUID` are used for the bundled font and glyph IDs; both are available in browsers and Node ≥ 19.)
177
210
  - Only `TEXT` display objects are modified during substitution. Geometry, bitmaps, device configuration, and all other fields pass through unchanged.
178
- - This library targets the current xTool Creative Space JSON format. Older versions of the software may export a different structure.
211
+ - This library targets the current xTool Creative Space JSON/`.xs` formats. Older versions of the software may export a different structure.
212
+ - `.xs` archives are read and re-written with [fflate](https://github.com/101arrowz/fflate); large `PATH` displays' vector data may be deduplicated into a separate `vectors/<bucketType>/` store rather than inlined — this library never touches `PATH` displays, so that structure round-trips untouched.
179
213
  - Glyph outline extraction is powered by [opentype.js](https://github.com/opentypejs/opentype.js). The bundled fallback font is [Arimo](https://fonts.google.com/specimen/Arimo) v5.2.8 via [`@fontsource/arimo`](https://www.npmjs.com/package/@fontsource/arimo) (Apache License 2.0, see `third_party/arimo/LICENSE`).
180
214
 
181
215
  ## License
package/dist/index.cjs CHANGED
@@ -32,9 +32,11 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  XCSGenerator: () => XCSGenerator,
34
34
  assertXcsFormat: () => assertXcsFormat,
35
+ assertXsFormat: () => assertXsFormat,
35
36
  createXCS: () => createXCS,
36
37
  effectiveCurveDeg: () => effectiveCurveDeg,
37
38
  extractXcsTokens: () => extractXcsTokens,
39
+ extractXsTokens: () => extractXsTokens,
38
40
  fontSizePoints: () => fontSizePoints,
39
41
  layoutCurvedGlyphText: () => layoutCurvedGlyphText,
40
42
  layoutGlyphText: () => layoutGlyphText,
@@ -44,6 +46,7 @@ __export(index_exports, {
44
46
  loadFont: () => loadFont,
45
47
  readXcsFile: () => readXcsFile,
46
48
  renderXcsFile: () => renderXcsFile,
49
+ renderXsFile: () => renderXsFile,
47
50
  translateGlyphLayout: () => translateGlyphLayout
48
51
  });
49
52
  module.exports = __toCommonJS(index_exports);
@@ -228,8 +231,56 @@ function layoutText(font, text, originX, originY) {
228
231
  };
229
232
  }
230
233
 
231
- // src/xcs.ts
234
+ // src/substitution.ts
232
235
  var TOKEN_RE = /\{\{([^}]+)\}\}/g;
236
+ function collectTextTokens(displays, seen) {
237
+ for (const display of displays) {
238
+ if (display.type === "TEXT" && typeof display.text === "string") {
239
+ for (const match of display.text.matchAll(TOKEN_RE)) {
240
+ seen.add(match[1]);
241
+ }
242
+ }
243
+ }
244
+ }
245
+ function resolveFont(fontFamily, fonts, cache) {
246
+ const key = fontFamily ?? "";
247
+ const cached = cache.get(key);
248
+ if (cached) return cached;
249
+ const suppliedBuffer = fontFamily ? fonts?.[fontFamily] : void 0;
250
+ const font = suppliedBuffer ? loadFont(suppliedBuffer) : loadDefaultFont();
251
+ cache.set(key, font);
252
+ return font;
253
+ }
254
+ function substituteTextDisplays(displays, variables, values, fonts, fontCache) {
255
+ for (const display of displays) {
256
+ if (display.type !== "TEXT" || typeof display.text !== "string") continue;
257
+ const originalText = display.text;
258
+ let text = originalText;
259
+ for (const variable of variables) {
260
+ const replacement = values[variable.token] ?? variable.defaultValue ?? "";
261
+ text = text.replaceAll(`{{${variable.token}}}`, replacement);
262
+ }
263
+ display.text = text;
264
+ if (text === originalText) continue;
265
+ const style = display.style;
266
+ const font = resolveFont(style?.fontFamily, fonts, fontCache);
267
+ const originX = typeof display.x === "number" ? display.x : 0;
268
+ const originY = typeof display.y === "number" ? display.y : 0;
269
+ const layout = layoutText(font, text, originX, originY);
270
+ display.fontData = layout.fontData;
271
+ display.charJSONs = layout.charJSONs;
272
+ if (layout.x !== null && layout.y !== null) {
273
+ display.x = layout.x;
274
+ display.y = layout.y;
275
+ display.offsetX = layout.x;
276
+ display.offsetY = layout.y;
277
+ display.width = layout.width;
278
+ display.height = layout.height;
279
+ }
280
+ }
281
+ }
282
+
283
+ // src/xcs.ts
233
284
  function assertXcsFormat(buffer) {
234
285
  let project;
235
286
  try {
@@ -248,60 +299,66 @@ function extractXcsTokens(buffer) {
248
299
  const project = JSON.parse(readXcsFile(buffer));
249
300
  const seen = /* @__PURE__ */ new Set();
250
301
  for (const canvas of project.canvas) {
251
- for (const display of canvas.displays) {
252
- if (display.type === "TEXT" && typeof display.text === "string") {
253
- for (const match of display.text.matchAll(TOKEN_RE)) {
254
- seen.add(match[1]);
255
- }
256
- }
257
- }
302
+ collectTextTokens(canvas.displays, seen);
258
303
  }
259
304
  return Array.from(seen);
260
305
  }
261
- function resolveFont(fontFamily, fonts, cache) {
262
- const key = fontFamily ?? "";
263
- const cached = cache.get(key);
264
- if (cached) return cached;
265
- const suppliedBuffer = fontFamily ? fonts?.[fontFamily] : void 0;
266
- const font = suppliedBuffer ? loadFont(suppliedBuffer) : loadDefaultFont();
267
- cache.set(key, font);
268
- return font;
269
- }
270
306
  function renderXcsFile(buffer, variables, values, fonts) {
271
307
  const project = JSON.parse(readXcsFile(buffer));
272
308
  const fontCache = /* @__PURE__ */ new Map();
273
309
  for (const canvas of project.canvas) {
274
- for (const display of canvas.displays) {
275
- if (display.type === "TEXT" && typeof display.text === "string") {
276
- const originalText = display.text;
277
- let text = originalText;
278
- for (const variable of variables) {
279
- const replacement = values[variable.token] ?? variable.defaultValue ?? "";
280
- text = text.replaceAll(`{{${variable.token}}}`, replacement);
281
- }
282
- display.text = text;
283
- if (text === originalText) continue;
284
- const style = display.style;
285
- const font = resolveFont(style?.fontFamily, fonts, fontCache);
286
- const originX = typeof display.x === "number" ? display.x : 0;
287
- const originY = typeof display.y === "number" ? display.y : 0;
288
- const layout = layoutText(font, text, originX, originY);
289
- display.fontData = layout.fontData;
290
- display.charJSONs = layout.charJSONs;
291
- if (layout.x !== null && layout.y !== null) {
292
- display.x = layout.x;
293
- display.y = layout.y;
294
- display.offsetX = layout.x;
295
- display.offsetY = layout.y;
296
- display.width = layout.width;
297
- display.height = layout.height;
298
- }
299
- }
300
- }
310
+ substituteTextDisplays(canvas.displays, variables, values, fonts, fontCache);
301
311
  }
302
312
  return new TextEncoder().encode(JSON.stringify(project));
303
313
  }
304
314
 
315
+ // src/xs.ts
316
+ var import_fflate = require("fflate");
317
+ var DISPLAYS_CHUNK_RE = /^canvases\/[^/]+\/displays-\d+\.json$/;
318
+ function decodeJson(bytes) {
319
+ return JSON.parse(new TextDecoder().decode(bytes));
320
+ }
321
+ function assertXsFormat(buffer) {
322
+ let entries;
323
+ try {
324
+ entries = (0, import_fflate.unzipSync)(new Uint8Array(buffer));
325
+ } catch (err) {
326
+ throw new Error(`Not a valid .xs file: ${err.message}`);
327
+ }
328
+ const format = entries[".format"];
329
+ if (!format) {
330
+ throw new Error("Not a valid .xs file: missing .format entry.");
331
+ }
332
+ const formatVersion = new TextDecoder().decode(format).trim();
333
+ if (formatVersion !== "v2") {
334
+ throw new Error(`Unsupported .xs format version "${formatVersion}" (expected "v2").`);
335
+ }
336
+ if (!entries["project.json"]) {
337
+ throw new Error("Not a valid .xs file: missing project.json.");
338
+ }
339
+ }
340
+ function extractXsTokens(buffer) {
341
+ const entries = (0, import_fflate.unzipSync)(new Uint8Array(buffer));
342
+ const seen = /* @__PURE__ */ new Set();
343
+ for (const [path, data] of Object.entries(entries)) {
344
+ if (!DISPLAYS_CHUNK_RE.test(path)) continue;
345
+ const chunk = decodeJson(data);
346
+ collectTextTokens(chunk.displays, seen);
347
+ }
348
+ return Array.from(seen);
349
+ }
350
+ function renderXsFile(buffer, variables, values, fonts) {
351
+ const entries = (0, import_fflate.unzipSync)(new Uint8Array(buffer));
352
+ const fontCache = /* @__PURE__ */ new Map();
353
+ for (const [path, data] of Object.entries(entries)) {
354
+ if (!DISPLAYS_CHUNK_RE.test(path)) continue;
355
+ const chunk = decodeJson(data);
356
+ substituteTextDisplays(chunk.displays, variables, values, fonts, fontCache);
357
+ entries[path] = new TextEncoder().encode(JSON.stringify(chunk));
358
+ }
359
+ return (0, import_fflate.zipSync)(entries);
360
+ }
361
+
305
362
  // src/layout.ts
306
363
  var LAYOUT_MM_PER_EM = 25.4;
307
364
  function fontSizePoints(emSizeMm) {
@@ -967,9 +1024,11 @@ function createXCS(deviceId = "P2S") {
967
1024
  0 && (module.exports = {
968
1025
  XCSGenerator,
969
1026
  assertXcsFormat,
1027
+ assertXsFormat,
970
1028
  createXCS,
971
1029
  effectiveCurveDeg,
972
1030
  extractXcsTokens,
1031
+ extractXsTokens,
973
1032
  fontSizePoints,
974
1033
  layoutCurvedGlyphText,
975
1034
  layoutGlyphText,
@@ -979,6 +1038,7 @@ function createXCS(deviceId = "P2S") {
979
1038
  loadFont,
980
1039
  readXcsFile,
981
1040
  renderXcsFile,
1041
+ renderXsFile,
982
1042
  translateGlyphLayout
983
1043
  });
984
1044
  //# sourceMappingURL=index.cjs.map