@primocaredentgroup/dental-digital-intake-shared 0.1.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 ADDED
@@ -0,0 +1,1568 @@
1
+ // src/externalReferences.ts
2
+ function mergeExternalReferences(base, patch) {
3
+ return { ...base ?? {}, ...patch };
4
+ }
5
+
6
+ // src/exocadParser.ts
7
+ var KNOWN_MANUFACTURERS = [
8
+ "Neodent",
9
+ "Straumann",
10
+ "Nobel Biocare",
11
+ "Nobel",
12
+ "Camlog",
13
+ "BioHorizons",
14
+ "Zimmer",
15
+ "Dentsply",
16
+ "Ankylos",
17
+ "Astra",
18
+ "Megagen",
19
+ "Bredent",
20
+ "Rhein",
21
+ "Sweden & Martina",
22
+ "Sweden"
23
+ ];
24
+ var CONNECTION_MAP = {
25
+ GM: "Grand Morse",
26
+ CM: "Cone Morse"
27
+ };
28
+ var DENTAL_CAD_KEYWORDS = [
29
+ "Neodent",
30
+ "Straumann",
31
+ "Nobel",
32
+ "Sweden",
33
+ "Camlog",
34
+ "BioHorizons",
35
+ "Zimmer",
36
+ "Dentsply",
37
+ "Ankylos",
38
+ "Astra",
39
+ "Megagen",
40
+ "Bredent",
41
+ "Rhein",
42
+ "analog",
43
+ "implant",
44
+ "scanbody",
45
+ "tibase",
46
+ "abutment",
47
+ "local-milling",
48
+ "local-printing",
49
+ "sdfa",
50
+ "library\\implant"
51
+ ];
52
+ function baseName(path) {
53
+ const normalized = path.replace(/\\/g, "/");
54
+ return normalized.split("/").pop() ?? normalized;
55
+ }
56
+ function extensionOf(name) {
57
+ const b = baseName(name);
58
+ const i = b.lastIndexOf(".");
59
+ return i <= 0 ? "" : b.slice(i + 1).toLowerCase();
60
+ }
61
+ function decodeText(data) {
62
+ let start = 0;
63
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
64
+ start = 3;
65
+ }
66
+ return new TextDecoder("utf-8", { fatal: false }).decode(data.subarray(start));
67
+ }
68
+ function stripTags(raw) {
69
+ return raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gi, "$1").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
70
+ }
71
+ function decodeEntities(raw) {
72
+ return raw.replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'");
73
+ }
74
+ function classifyExocadFile(path) {
75
+ const ext = extensionOf(path);
76
+ switch (ext) {
77
+ case "dentalproject":
78
+ return "dentalProject";
79
+ case "constructioninfo":
80
+ return "constructionInfo";
81
+ case "modelinfo":
82
+ return "modelInfo";
83
+ case "dentalcad":
84
+ return "dentalCAD";
85
+ case "stl":
86
+ return "stl";
87
+ default:
88
+ return "other";
89
+ }
90
+ }
91
+ function detectExocadFiles(entries) {
92
+ const classified = [];
93
+ const files = {
94
+ dentalProject: [],
95
+ constructionInfo: [],
96
+ modelInfo: [],
97
+ dentalCAD: [],
98
+ stl: []
99
+ };
100
+ for (const e of entries) {
101
+ const bn = baseName(e.path);
102
+ const ext = extensionOf(e.path);
103
+ const kind = classifyExocadFile(e.path);
104
+ classified.push({ path: e.path, baseName: bn, ext, kind, data: e.data });
105
+ if (kind === "dentalProject") files.dentalProject.push(bn);
106
+ else if (kind === "constructionInfo") files.constructionInfo.push(bn);
107
+ else if (kind === "modelInfo") files.modelInfo.push(bn);
108
+ else if (kind === "dentalCAD") files.dentalCAD.push(bn);
109
+ else if (kind === "stl") files.stl.push(bn);
110
+ }
111
+ return { classified, files };
112
+ }
113
+ function isExocadPackage(entries) {
114
+ return entries.some((e) => classifyExocadFile(e.path) !== "other" && classifyExocadFile(e.path) !== "stl") || entries.some((e) => classifyExocadFile(e.path) !== "other");
115
+ }
116
+ function firstTag(xml, tags) {
117
+ for (const tag of tags) {
118
+ const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i");
119
+ const m = xml.match(re);
120
+ if (m?.[1]) {
121
+ const val = stripTags(m[1]);
122
+ if (val) return val;
123
+ }
124
+ }
125
+ return void 0;
126
+ }
127
+ function valuesForKey(text, key) {
128
+ const out = [];
129
+ const tagRe = new RegExp(`<${key}\\b[^>]*>([\\s\\S]*?)<\\/${key}>`, "gi");
130
+ let m;
131
+ while ((m = tagRe.exec(text)) !== null) {
132
+ const val = decodeEntities(m[1]).trim();
133
+ if (val) out.push(val);
134
+ }
135
+ if (out.length === 0) {
136
+ const lineRe = new RegExp(`${key}\\s*[:=]\\s*([^\\r\\n]+)`, "gi");
137
+ while ((m = lineRe.exec(text)) !== null) {
138
+ const val = decodeEntities(m[1]).trim();
139
+ if (val) out.push(val);
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+ function parseBool(raw) {
145
+ if (raw === void 0) return void 0;
146
+ const v = raw.trim().toLowerCase();
147
+ if (v === "true" || v === "1" || v === "yes") return true;
148
+ if (v === "false" || v === "0" || v === "no") return false;
149
+ return void 0;
150
+ }
151
+ var TOOTH_BLOCK_RE = /<Tooth\b[^>]*>([\s\S]*?)<\/Tooth>/gi;
152
+ function parseDentalProject(text) {
153
+ const patientLast = firstTag(text, ["PatientName"]);
154
+ const patientFirst = firstTag(text, ["PatientFirstName"]);
155
+ const patientName = [patientFirst, patientLast].filter(Boolean).join(" ").trim() || null;
156
+ const caseInfo = {
157
+ patientName,
158
+ practiceName: firstTag(text, ["PracticeName"]) ?? null,
159
+ practiceId: firstTag(text, ["PracticeId"]) ?? null,
160
+ projectDate: firstTag(text, ["DateTime", "Date"]) ?? null,
161
+ exocadVersion: firstTag(text, ["DentalDBProductName"]) ?? null
162
+ };
163
+ const items = [];
164
+ for (const m of text.matchAll(TOOTH_BLOCK_RE)) {
165
+ const block = m[1] ?? "";
166
+ const tooth = firstTag(block, ["Number", "ToothNumber", "FDI"]);
167
+ if (!tooth) continue;
168
+ const type = firstTag(block, ["ReconstructionType"]);
169
+ const material = firstTag(block, ["MaterialName"]);
170
+ const materialCode = firstTag(block, ["Material"]);
171
+ const shade = firstTag(block, ["Shade", "Color", "ToothColor"]);
172
+ const preparationType = firstTag(block, ["PreparationType"]);
173
+ const implantType = firstTag(block, ["ImplantType"]);
174
+ const scanAbutmentScan = parseBool(firstTag(block, ["ScanAbutmentScan"]));
175
+ const mesialConnector = parseBool(firstTag(block, ["MesialConnector"]));
176
+ items.push({
177
+ tooth,
178
+ ...type ? { type } : {},
179
+ ...material ? { material } : {},
180
+ ...materialCode ? { materialCode } : {},
181
+ ...shade && shade !== "---" ? { shade } : {},
182
+ ...preparationType ? { preparationType } : {},
183
+ ...implantType ? { implantType } : {},
184
+ ...scanAbutmentScan !== void 0 ? { scanAbutmentScan } : {},
185
+ ...mesialConnector !== void 0 ? { mesialConnector } : {}
186
+ });
187
+ }
188
+ return { case: caseInfo, items };
189
+ }
190
+ var BASE_TYPE_MAP = {
191
+ mini: "Mini Abutment",
192
+ base: "Ti-Base",
193
+ tibase: "Ti-Base",
194
+ uni: "Universal Base",
195
+ pro: "Pro Abutment"
196
+ };
197
+ function tenths(digits) {
198
+ const n = Number(digits);
199
+ if (!Number.isFinite(n)) return digits;
200
+ return (n / 10).toFixed(1);
201
+ }
202
+ function inferGeometryCode(code) {
203
+ if (!code) return void 0;
204
+ const out = {};
205
+ const noBase = code.replace(/^base[_-]?/i, "");
206
+ const firstToken = noBase.split(/[_\-\s.]/)[0] ?? "";
207
+ const word = firstToken.replace(/\d+$/, "");
208
+ if (word && /[a-z]/i.test(word)) {
209
+ const mapped = BASE_TYPE_MAP[word.toLowerCase()];
210
+ out.baseType = {
211
+ value: mapped ?? word,
212
+ confidence: "inferred"
213
+ };
214
+ }
215
+ const d = code.match(/D(\d{2,3})\b/i);
216
+ if (d?.[1]) {
217
+ out.diameterOrPlatform = { value: tenths(d[1]), confidence: "inferred" };
218
+ }
219
+ const ah = code.match(/AH(\d{2,3})\b/i);
220
+ if (ah?.[1]) {
221
+ out.abutmentHeight = { value: tenths(ah[1]), confidence: "inferred" };
222
+ }
223
+ return Object.keys(out).length > 0 ? out : void 0;
224
+ }
225
+ function detectManufacturer(...sources) {
226
+ const hay = sources.join(" ").toLowerCase();
227
+ for (const m of KNOWN_MANUFACTURERS) {
228
+ if (hay.includes(m.toLowerCase())) return m;
229
+ }
230
+ return void 0;
231
+ }
232
+ function detectConnection(...sources) {
233
+ const text = sources.join(" ");
234
+ for (const [abbr, full] of Object.entries(CONNECTION_MAP)) {
235
+ const re = new RegExp(`\\b${abbr}\\b`);
236
+ if (re.test(text)) return full;
237
+ }
238
+ return void 0;
239
+ }
240
+ function parseDisplayInfo(raw) {
241
+ const segs = raw.split(/[\r\n:]+/).map((s) => s.trim()).filter(Boolean);
242
+ if (segs.length === 0) return {};
243
+ const result = {};
244
+ const seg0 = segs[0];
245
+ const dashSplit = seg0.split(/\s[-–]\s/);
246
+ if (dashSplit.length >= 2) {
247
+ result.manufacturer = dashSplit[0].replace(/[®™©]/g, "").trim();
248
+ let platformRaw = dashSplit.slice(1).join(" - ").trim();
249
+ const wf = platformRaw.match(/\(([^)]+)\)/);
250
+ if (wf?.[1]) result.workflow = wf[1].trim();
251
+ platformRaw = platformRaw.replace(/\([^)]*\)/g, "").trim();
252
+ if (platformRaw) result.platform = platformRaw;
253
+ } else {
254
+ const mfg = detectManufacturer(seg0);
255
+ if (mfg) result.manufacturer = mfg;
256
+ }
257
+ if (segs[1]) result.component = segs[1];
258
+ if (segs[2]) result.libraryName = segs[2];
259
+ return result;
260
+ }
261
+ function parseConstructionInfo(text, sourceFile) {
262
+ const geometryPaths = valuesForKey(text, "FilenameImplantGeometry");
263
+ const displayInfos = valuesForKey(text, "ImplantLibraryEntryDisplayInformation");
264
+ const scanAbutment = valuesForKey(text, "ScanAbutmentScan").some(
265
+ (v) => parseBool(v) === true
266
+ );
267
+ const isImplantCase = geometryPaths.length > 0 || scanAbutment || /FilenameImplantGeometry|ImplantLibrary\b/i.test(text);
268
+ const count = Math.max(geometryPaths.length, displayInfos.length);
269
+ const components = [];
270
+ for (let i = 0; i < count; i++) {
271
+ const geomPath = geometryPaths[i];
272
+ const display = displayInfos[i];
273
+ const component = {
274
+ sourceFiles: [sourceFile],
275
+ confidence: "high"
276
+ };
277
+ if (geomPath) {
278
+ component.libraryPath = geomPath;
279
+ const file = baseName(geomPath);
280
+ component.geometryFile = file;
281
+ const code = file.replace(/\.[^.]+$/, "");
282
+ component.geometryCode = code;
283
+ const parsed = inferGeometryCode(code);
284
+ if (parsed) component.parsedGeometry = parsed;
285
+ }
286
+ if (display) {
287
+ const info = parseDisplayInfo(display);
288
+ if (info.manufacturer) component.manufacturer = info.manufacturer;
289
+ if (info.platform) component.platform = info.platform;
290
+ if (info.component) component.component = info.component;
291
+ if (info.libraryName) component.libraryName = info.libraryName;
292
+ }
293
+ if (!component.manufacturer) {
294
+ const mfg = detectManufacturer(geomPath ?? "", display ?? "");
295
+ if (mfg) component.manufacturer = mfg;
296
+ }
297
+ const connection = detectConnection(
298
+ component.component ?? "",
299
+ display ?? "",
300
+ geomPath ?? ""
301
+ );
302
+ if (connection) component.connection = connection;
303
+ if (component.geometryFile || component.component || component.libraryName) {
304
+ components.push(component);
305
+ }
306
+ }
307
+ return { components, isImplantCase };
308
+ }
309
+ function parseModelInfo(text) {
310
+ const out = {};
311
+ const analogLib = valuesForKey(text, "AnalogLibrary")[0] ?? text.match(/([A-Za-z][\w&]*_Analogs?[\w-]*)/)?.[1] ?? text.match(/([A-Za-z][\w ]*Analog[\w. ]*)/)?.[1]?.trim();
312
+ if (analogLib) out.analogLibrary = analogLib;
313
+ const analogMfg = detectManufacturer(text);
314
+ if (analogMfg) out.analogManufacturer = analogMfg;
315
+ if (/local-printing/i.test(text)) out.modelWorkflow = "local-printing";
316
+ else if (/local-milling/i.test(text)) out.modelWorkflow = "local-milling";
317
+ else if (/printed/i.test(text)) out.modelWorkflow = "printed model";
318
+ return out;
319
+ }
320
+ function extractStringsFromDentalCAD(data, minLength = 4) {
321
+ const out = [];
322
+ let current = "";
323
+ for (let i = 0; i < data.length; i++) {
324
+ const c = data[i];
325
+ if (c >= 32 && c <= 126) {
326
+ current += String.fromCharCode(c);
327
+ } else {
328
+ if (current.length >= minLength) out.push(current);
329
+ current = "";
330
+ }
331
+ }
332
+ if (current.length >= minLength) out.push(current);
333
+ return out;
334
+ }
335
+ function findDentalCadKeywords(strings) {
336
+ const found = /* @__PURE__ */ new Set();
337
+ const joined = strings.join("\n");
338
+ for (const kw of DENTAL_CAD_KEYWORDS) {
339
+ const re = new RegExp(kw.replace(/[\\]/g, "\\\\"), "i");
340
+ if (re.test(joined)) found.add(kw);
341
+ }
342
+ return [...found];
343
+ }
344
+ function inferStlRole(fileName) {
345
+ const n = fileName.toLowerCase();
346
+ if (/modelbase|model_base|baseplate/.test(n)) return "modelbase";
347
+ if (/gingiva/.test(n)) return "gingiva";
348
+ if (/waxup|wax_up|wax-up/.test(n)) return "waxup";
349
+ if (/antagonist|opposing/.test(n)) return "antagonist";
350
+ if (/scanbody|scan_body|scan-body/.test(n)) return "scanbody";
351
+ if (/abutment/.test(n)) return "abutment";
352
+ if (/upper|maxilla|oberkiefer/.test(n)) return "upper";
353
+ if (/lower|mandible|unterkiefer/.test(n)) return "lower";
354
+ if (/scan|matrix/.test(n)) return "scan";
355
+ return "unknown";
356
+ }
357
+ function componentKey(c) {
358
+ return [c.geometryCode ?? "", c.component ?? "", c.libraryName ?? ""].join("|").toLowerCase();
359
+ }
360
+ function normalizeImplantComponents(components) {
361
+ const byKey = /* @__PURE__ */ new Map();
362
+ const order = [];
363
+ for (const c of components) {
364
+ const key = componentKey(c);
365
+ const existing = byKey.get(key);
366
+ if (!existing) {
367
+ byKey.set(key, { ...c, sourceFiles: [...new Set(c.sourceFiles)] });
368
+ order.push(key);
369
+ continue;
370
+ }
371
+ existing.sourceFiles = [
372
+ .../* @__PURE__ */ new Set([...existing.sourceFiles, ...c.sourceFiles])
373
+ ];
374
+ existing.manufacturer ??= c.manufacturer;
375
+ existing.platform ??= c.platform;
376
+ existing.connection ??= c.connection;
377
+ existing.component ??= c.component;
378
+ existing.libraryName ??= c.libraryName;
379
+ existing.libraryPath ??= c.libraryPath;
380
+ existing.geometryFile ??= c.geometryFile;
381
+ existing.geometryCode ??= c.geometryCode;
382
+ existing.parsedGeometry ??= c.parsedGeometry;
383
+ existing.tooth ??= c.tooth;
384
+ }
385
+ return order.map((k) => byKey.get(k));
386
+ }
387
+ function computeSpan(teeth) {
388
+ if (teeth.length < 2) return { span: null, isBridge: false };
389
+ const nums = teeth.map(Number).sort((a, b) => a - b);
390
+ const span = `${nums[0]}-${nums[nums.length - 1]}`;
391
+ return { span, isBridge: true };
392
+ }
393
+ var EMPTY_CASE = {
394
+ patientName: null,
395
+ practiceName: null,
396
+ practiceId: null,
397
+ projectDate: null,
398
+ exocadVersion: null
399
+ };
400
+ function buildExocadSummary(entries) {
401
+ const { classified, files } = detectExocadFiles(entries);
402
+ const hasExocadSidecar = classified.some(
403
+ (f) => f.kind === "dentalProject" || f.kind === "constructionInfo" || f.kind === "modelInfo" || f.kind === "dentalCAD"
404
+ );
405
+ if (!hasExocadSidecar) return void 0;
406
+ const warnings = [];
407
+ let caseInfo = { ...EMPTY_CASE };
408
+ let restorationItems = [];
409
+ const rawComponents = [];
410
+ let isImplantCase = false;
411
+ const model = {
412
+ hasModelBase: false,
413
+ hasGingiva: false,
414
+ hasWaxup: false,
415
+ analogLibrary: null,
416
+ analogManufacturer: null,
417
+ modelWorkflow: null
418
+ };
419
+ for (const f of classified) {
420
+ if (f.kind === "dentalProject") {
421
+ const parsed = parseDentalProject(decodeText(f.data));
422
+ caseInfo = { ...caseInfo, ...nonNull(parsed.case) };
423
+ if (parsed.items.length > 0) restorationItems = parsed.items;
424
+ } else if (f.kind === "constructionInfo") {
425
+ const parsed = parseConstructionInfo(decodeText(f.data), f.baseName);
426
+ rawComponents.push(...parsed.components);
427
+ if (parsed.isImplantCase) isImplantCase = true;
428
+ } else if (f.kind === "modelInfo") {
429
+ const m = parseModelInfo(decodeText(f.data));
430
+ if (m.analogLibrary) model.analogLibrary = m.analogLibrary;
431
+ if (m.analogManufacturer) model.analogManufacturer = m.analogManufacturer;
432
+ if (m.modelWorkflow) model.modelWorkflow = m.modelWorkflow;
433
+ } else if (f.kind === "dentalCAD") {
434
+ const strings = extractStringsFromDentalCAD(f.data);
435
+ const kws = findDentalCadKeywords(strings);
436
+ if (kws.length > 0) {
437
+ warnings.push(
438
+ `dentalCAD: keyword rilevate (medium confidence): ${kws.join(", ")}.`
439
+ );
440
+ }
441
+ if (rawComponents.length === 0) {
442
+ const mfg = detectManufacturer(strings.join(" "));
443
+ if (mfg) {
444
+ rawComponents.push({
445
+ manufacturer: mfg,
446
+ sourceFiles: [f.baseName],
447
+ confidence: "medium"
448
+ });
449
+ }
450
+ }
451
+ }
452
+ }
453
+ const stlEntries = files.stl.map((fileName) => ({
454
+ fileName,
455
+ role: inferStlRole(fileName)
456
+ }));
457
+ model.hasModelBase = stlEntries.some((s) => s.role === "modelbase");
458
+ model.hasGingiva = stlEntries.some((s) => s.role === "gingiva");
459
+ model.hasWaxup = stlEntries.some((s) => s.role === "waxup");
460
+ const teeth = [
461
+ ...new Set(restorationItems.map((i) => i.tooth))
462
+ ].sort((a, b) => Number(a) - Number(b));
463
+ const { span, isBridge } = computeSpan(teeth);
464
+ const implantComponents = normalizeImplantComponents(rawComponents);
465
+ if (implantComponents.length > 0) {
466
+ warnings.push("Commercial SKU not found in Exocad files");
467
+ if (implantComponents.some((c) => c.parsedGeometry)) {
468
+ warnings.push(
469
+ "Geometry code interpretation is inferred and requires manufacturer mapping"
470
+ );
471
+ }
472
+ }
473
+ if (!isImplantCase && restorationItems.some((i) => i.implantType && i.implantType !== "WithoutAbutment")) {
474
+ isImplantCase = true;
475
+ }
476
+ return {
477
+ case: caseInfo,
478
+ restoration: {
479
+ teeth,
480
+ items: restorationItems,
481
+ span,
482
+ isBridge
483
+ },
484
+ implantComponents,
485
+ model,
486
+ files,
487
+ isImplantCase,
488
+ warnings: [...new Set(warnings)]
489
+ };
490
+ }
491
+ function nonNull(obj) {
492
+ const out = {};
493
+ Object.keys(obj).forEach((k) => {
494
+ if (obj[k] !== null) out[k] = obj[k];
495
+ });
496
+ return out;
497
+ }
498
+
499
+ // src/threeShapeParser.ts
500
+ var MANUFACTURERS = [
501
+ { keys: ["NEODENT", "NEOGM", "NEO"], name: "Neodent" },
502
+ { keys: ["STRAUMANN"], name: "Straumann" },
503
+ { keys: ["NOBEL"], name: "Nobel Biocare" },
504
+ { keys: ["CAMLOG"], name: "Camlog" },
505
+ { keys: ["DENTSPLY"], name: "Dentsply" },
506
+ { keys: ["ASTRA"], name: "Astra" },
507
+ { keys: ["ZIMMER"], name: "Zimmer" },
508
+ { keys: ["MEGAGEN"], name: "Megagen" },
509
+ { keys: ["BREDENT"], name: "Bredent" }
510
+ ];
511
+ function baseName2(path) {
512
+ const normalized = path.replace(/\\/g, "/");
513
+ return normalized.split("/").pop() ?? normalized;
514
+ }
515
+ function extensionOf2(name) {
516
+ const b = baseName2(name);
517
+ const i = b.lastIndexOf(".");
518
+ return i <= 0 ? "" : b.slice(i + 1).toLowerCase();
519
+ }
520
+ function decodeText2(data) {
521
+ let start = 0;
522
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
523
+ start = 3;
524
+ }
525
+ return new TextDecoder("utf-8", { fatal: false }).decode(data.subarray(start));
526
+ }
527
+ function decodeEntities2(raw) {
528
+ return raw.replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'").trim();
529
+ }
530
+ function stripTags2(raw) {
531
+ return decodeEntities2(
532
+ raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gi, "$1").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ")
533
+ );
534
+ }
535
+ function extractReadableStringsFromBinary(data, minLength = 4) {
536
+ const out = [];
537
+ let current = "";
538
+ for (let i = 0; i < data.length; i++) {
539
+ const c = data[i];
540
+ if (c >= 32 && c <= 126) {
541
+ current += String.fromCharCode(c);
542
+ } else {
543
+ if (current.length >= minLength) out.push(current);
544
+ current = "";
545
+ }
546
+ }
547
+ if (current.length >= minLength) out.push(current);
548
+ return out;
549
+ }
550
+ function valuesForKey2(text, base) {
551
+ const out = [];
552
+ const seen = /* @__PURE__ */ new Set();
553
+ const push = (v) => {
554
+ const val = decodeEntities2(v).replace(/^["']|["']$/g, "").trim();
555
+ if (val && !seen.has(val)) {
556
+ seen.add(val);
557
+ out.push(val);
558
+ }
559
+ };
560
+ const tagRe = new RegExp(`<(${base}\\w*)\\b[^>]*>([\\s\\S]*?)</\\1>`, "gi");
561
+ let m;
562
+ while ((m = tagRe.exec(text)) !== null) push(stripTags2(m[2]));
563
+ const attrRe = new RegExp(`\\b${base}\\w*\\s*=\\s*"([^"]*)"`, "gi");
564
+ while ((m = attrRe.exec(text)) !== null) push(m[1]);
565
+ const kvRe = new RegExp(`\\b${base}\\w*\\s*[:=]\\s*([^\\r\\n<"]+)`, "gi");
566
+ while ((m = kvRe.exec(text)) !== null) push(m[1]);
567
+ return out;
568
+ }
569
+ function firstValue(text, bases) {
570
+ for (const b of bases) {
571
+ const v = valuesForKey2(text, b)[0];
572
+ if (v) return v;
573
+ }
574
+ return null;
575
+ }
576
+ var FDI_RE = /\b([1-4][1-8])\b/g;
577
+ function parseFdi(text) {
578
+ const set = /* @__PURE__ */ new Set();
579
+ for (const m of text.matchAll(FDI_RE)) {
580
+ const n = Number(m[1]);
581
+ if (n >= 11 && n <= 48) set.add(n);
582
+ }
583
+ return [...set].sort((a, b) => a - b);
584
+ }
585
+ function hasNonEmptyList(text, listName) {
586
+ const re = new RegExp(`<${listName}\\b[^>]*>([\\s\\S]*?)</${listName}>`, "i");
587
+ const m = text.match(re);
588
+ if (!m) return false;
589
+ const inner = m[1] ?? "";
590
+ return /<\w/.test(inner) || stripTags2(inner).length > 0;
591
+ }
592
+ function classifyThreeShapeFile(path) {
593
+ const lower = path.replace(/\\/g, "/").toLowerCase();
594
+ const bn = baseName2(lower);
595
+ const ext = extensionOf2(lower);
596
+ if (lower.includes("external models/")) return "externalModel";
597
+ if (ext === "3ml") return "threeMl";
598
+ if (bn === "materials.xml") return "materialsXml";
599
+ if (bn.includes("printableorderform")) return "printableOrderForm";
600
+ if (lower.includes("scans/")) return "scan";
601
+ if (ext === "dcm") return "cadDcm";
602
+ if (ext === "xml") return "mainXml";
603
+ if (ext === "html" || ext === "htm") return "printableOrderForm";
604
+ return "other";
605
+ }
606
+ function detectThreeShapeFiles(entries) {
607
+ const classified = [];
608
+ const files = {
609
+ mainXml: [],
610
+ materialsXml: [],
611
+ printableOrderForms: [],
612
+ cadDcm: [],
613
+ externalModels: [],
614
+ scans: [],
615
+ encrypted3ml: []
616
+ };
617
+ for (const e of entries) {
618
+ const kind = classifyThreeShapeFile(e.path);
619
+ const bn = baseName2(e.path);
620
+ classified.push({
621
+ path: e.path,
622
+ baseName: bn,
623
+ ext: extensionOf2(e.path),
624
+ kind,
625
+ data: e.data
626
+ });
627
+ switch (kind) {
628
+ case "mainXml":
629
+ files.mainXml.push(bn);
630
+ break;
631
+ case "materialsXml":
632
+ files.materialsXml.push(bn);
633
+ break;
634
+ case "printableOrderForm":
635
+ files.printableOrderForms.push(bn);
636
+ break;
637
+ case "cadDcm":
638
+ files.cadDcm.push(bn);
639
+ break;
640
+ case "externalModel":
641
+ files.externalModels.push(e.path);
642
+ break;
643
+ case "scan":
644
+ files.scans.push(bn);
645
+ break;
646
+ default:
647
+ break;
648
+ }
649
+ }
650
+ return { classified, files };
651
+ }
652
+ function isThreeShapePackage(entries) {
653
+ const { classified } = detectThreeShapeFiles(entries);
654
+ if (classified.some(
655
+ (f) => f.kind === "materialsXml" || f.kind === "printableOrderForm" || f.kind === "threeMl" || f.kind === "externalModel"
656
+ )) {
657
+ return true;
658
+ }
659
+ return classified.some(
660
+ (f) => f.kind === "mainXml" && /3shape|dentaldesigner|abutmentkit|toothelementtype|globalimplantid/i.test(
661
+ decodeText2(f.data)
662
+ )
663
+ );
664
+ }
665
+ function normalizeRestorationType(text) {
666
+ if (/AbutmentScrewRetainedCrown/i.test(text)) {
667
+ return {
668
+ type: "Screw Retained Crown",
669
+ isImplantCase: true,
670
+ isScrewRetained: true
671
+ };
672
+ }
673
+ if (/te?TemporaryVPrepCrown/i.test(text) || /TemporaryVPrepCrown/i.test(text)) {
674
+ return {
675
+ type: "Temporary Virtual Preparation Crown",
676
+ isImplantCase: false,
677
+ isScrewRetained: false
678
+ };
679
+ }
680
+ const raw = firstValue(text, [
681
+ "RestorationType",
682
+ "ToothElementType",
683
+ "ElementType"
684
+ ]);
685
+ return {
686
+ type: raw,
687
+ isImplantCase: /abutment|screwretained|screw retained/i.test(text),
688
+ isScrewRetained: /screwretained|screw retained/i.test(text)
689
+ };
690
+ }
691
+ function inferManufacturerFromIdsAndNames(value) {
692
+ const hay = value.toUpperCase();
693
+ let manufacturer = null;
694
+ for (const m of MANUFACTURERS) {
695
+ if (m.keys.some((k) => hay.includes(k))) {
696
+ manufacturer = m.name;
697
+ break;
698
+ }
699
+ }
700
+ let connection = null;
701
+ if (/\bGM\b|_GM_|_GM\b|NEOGM/.test(hay)) connection = "GM / Grand Morse";
702
+ else if (/\bCM\b|_CM_/.test(hay)) connection = "CM / Cone Morse";
703
+ return { manufacturer, connection };
704
+ }
705
+ function normalizeImplantComponent(fileName, sourcePath, confidence = "high") {
706
+ const n = fileName.toUpperCase();
707
+ let type = "unknown";
708
+ if (/SCAN ?BODY/.test(n)) type = "scanbody";
709
+ else if (/SOCKET/.test(n)) type = "analog_socket";
710
+ else if (/ANALOG/.test(n)) type = "analog";
711
+ else if (/\bLINK/.test(n)) type = "link/tibase_candidate";
712
+ else if (/\bTX\b|^TX|[-_]TX[-_]/.test(n))
713
+ type = "screw_or_transmucosal_component_candidate";
714
+ else if (/\bVM\b|^VM|NEO/.test(n)) type = "manufacturer_component_candidate";
715
+ return {
716
+ name: fileName.replace(/\.[^.]+$/, ""),
717
+ fileName,
718
+ type,
719
+ sourcePath,
720
+ confidence
721
+ };
722
+ }
723
+ function parseExternalModels(classified) {
724
+ const externalDcm = classified.filter(
725
+ (f) => f.kind === "externalModel" && f.ext === "dcm"
726
+ );
727
+ const components = [];
728
+ let abutmentKitId = null;
729
+ for (const f of externalDcm) {
730
+ const norm = f.path.replace(/\\/g, "/");
731
+ const idx = norm.toLowerCase().indexOf("external models/");
732
+ if (idx >= 0 && !abutmentKitId) {
733
+ const rest = norm.slice(idx + "external models/".length);
734
+ const folder = rest.split("/")[0];
735
+ if (folder && /abutmentkit|kit|\d{3,}/i.test(folder)) {
736
+ abutmentKitId = folder;
737
+ }
738
+ }
739
+ components.push(normalizeImplantComponent(f.baseName, f.path, "high"));
740
+ }
741
+ return { components, abutmentKitId };
742
+ }
743
+ function parseMaterialsXml(xml) {
744
+ const map = {};
745
+ const blockRe = /<Material\b[^>]*>([\s\S]*?)<\/Material>/gi;
746
+ let m;
747
+ let matched = false;
748
+ while ((m = blockRe.exec(xml)) !== null) {
749
+ const scope = m[0];
750
+ const id = valuesForKey2(scope, "MaterialID")[0];
751
+ if (!id) continue;
752
+ matched = true;
753
+ map[id] = {
754
+ ...valuesForKey2(scope, "Name")[0] ? { name: valuesForKey2(scope, "Name")[0] } : {},
755
+ ...valuesForKey2(scope, "MaterialFamily")[0] ? { family: valuesForKey2(scope, "MaterialFamily")[0] } : {},
756
+ ...valuesForKey2(scope, "ShaderMaterial")[0] ? { shader: valuesForKey2(scope, "ShaderMaterial")[0] } : {}
757
+ };
758
+ }
759
+ if (!matched) {
760
+ const id = valuesForKey2(xml, "MaterialID")[0];
761
+ if (id) {
762
+ map[id] = {
763
+ ...valuesForKey2(xml, "Name")[0] ? { name: valuesForKey2(xml, "Name")[0] } : {},
764
+ ...valuesForKey2(xml, "MaterialFamily")[0] ? { family: valuesForKey2(xml, "MaterialFamily")[0] } : {},
765
+ ...valuesForKey2(xml, "ShaderMaterial")[0] ? { shader: valuesForKey2(xml, "ShaderMaterial")[0] } : {}
766
+ };
767
+ }
768
+ }
769
+ return map;
770
+ }
771
+ function parseMainOrderXml(xml) {
772
+ const restType = normalizeRestorationType(xml);
773
+ const internalToothNumbers = valuesForKey2(xml, "ToothNumber").map((v) => Number(v.replace(/[^\d]/g, ""))).filter((n) => Number.isFinite(n) && n > 0);
774
+ const abutmentKitId = firstValue(xml, ["AbutmentKitID"]);
775
+ const connectionId = firstValue(xml, ["GlobalConnectionID"]);
776
+ const implantSystemId = firstValue(xml, ["GlobalImplantID"]);
777
+ const hasImplantLists = hasNonEmptyList(xml, "ImplantSystemList") || hasNonEmptyList(xml, "ImplantSystemPartList") || hasNonEmptyList(xml, "AbutmentKitList");
778
+ const isImplantCase = restType.isImplantCase || Boolean(abutmentKitId || connectionId || implantSystemId) || hasImplantLists;
779
+ return {
780
+ case: {
781
+ ...firstValue(xml, ["PatientName", "Patient"]) ? { patientName: firstValue(xml, ["PatientName", "Patient"]) } : {},
782
+ ...firstValue(xml, ["OrderId", "OrderNumber", "OrderID"]) ? { orderId: firstValue(xml, ["OrderId", "OrderNumber", "OrderID"]) } : {},
783
+ ...firstValue(xml, ["Customer", "Clinic"]) ? { customer: firstValue(xml, ["Customer", "Clinic"]) } : {},
784
+ ...firstValue(xml, ["Operator", "Technician", "User"]) ? { operator: firstValue(xml, ["Operator", "Technician", "User"]) } : {},
785
+ ...firstValue(xml, ["CreatedAt", "CreateDate", "OrderDate", "Created"]) ? {
786
+ createdAt: firstValue(xml, [
787
+ "CreatedAt",
788
+ "CreateDate",
789
+ "OrderDate",
790
+ "Created"
791
+ ])
792
+ } : {},
793
+ ...firstValue(xml, ["GroupFolder", "Folder"]) ? { groupFolder: firstValue(xml, ["GroupFolder", "Folder"]) } : {},
794
+ ...firstValue(xml, ["Software", "Application"]) ? { software: firstValue(xml, ["Software", "Application"]) } : {},
795
+ ...firstValue(xml, ["SoftwareVersion", "Version"]) ? { softwareVersion: firstValue(xml, ["SoftwareVersion", "Version"]) } : {},
796
+ ...firstValue(xml, ["ProcessStatus", "Status"]) ? { processStatus: firstValue(xml, ["ProcessStatus", "Status"]) } : {},
797
+ ...firstValue(xml, ["ScanSource", "Source"]) ? { scanSource: firstValue(xml, ["ScanSource", "Source"]) } : {}
798
+ },
799
+ restoration: {
800
+ internalToothNumbers,
801
+ ...restType.type ? { type: restType.type } : {},
802
+ ...firstValue(xml, ["RestorationName"]) ? { name: firstValue(xml, ["RestorationName"]) } : {},
803
+ ...firstValue(xml, ["ToothElementTypeID"]) ? { toothElementTypeID: firstValue(xml, ["ToothElementTypeID"]) } : {},
804
+ ...firstValue(xml, ["CacheToothTypeClass"]) ? { cacheToothTypeClass: firstValue(xml, ["CacheToothTypeClass"]) } : {},
805
+ ...firstValue(xml, ["MaterialID"]) ? { materialId: firstValue(xml, ["MaterialID"]) } : {},
806
+ ...firstValue(xml, ["Shade", "Color"]) ? { shade: firstValue(xml, ["Shade", "Color"]) } : {},
807
+ isImplantCase,
808
+ isScrewRetained: restType.isScrewRetained
809
+ },
810
+ implantIds: { abutmentKitId, connectionId, implantSystemId },
811
+ hasImplantLists
812
+ };
813
+ }
814
+ function htmlToLines(html) {
815
+ const norm = html.replace(/<\s*br\s*\/?>/gi, "\n").replace(/<\/(tr|p|div|li|h[1-6]|td|th)>/gi, "\n");
816
+ return norm.split(/\n+/).map((l) => stripTags2(l)).filter((l) => l.length > 0);
817
+ }
818
+ function parsePrintableOrderForm(html) {
819
+ const lines = htmlToLines(html);
820
+ const teeth = parseFdi(lines.join("\n"));
821
+ const kv = (label) => {
822
+ const re = new RegExp(`\\b${label}\\b\\s*[:#]\\s*(.+)$`, "i");
823
+ for (const line of lines) {
824
+ const m = line.match(re);
825
+ if (m?.[1]) return m[1].trim();
826
+ }
827
+ return null;
828
+ };
829
+ return {
830
+ teeth,
831
+ patientName: kv("Patient") ?? firstValue(html, ["PatientName"]),
832
+ orderId: kv("Order") ?? firstValue(html, ["OrderId", "OrderNumber"]),
833
+ customer: kv("Customer") ?? kv("Clinic"),
834
+ operator: kv("Operator") ?? kv("Technician"),
835
+ shade: kv("Shade") ?? kv("Color")
836
+ };
837
+ }
838
+ function parseCadDcm(data) {
839
+ const strings = extractReadableStringsFromBinary(data);
840
+ const joined = strings.join("\n");
841
+ const restType = normalizeRestorationType(joined);
842
+ const keywords = [];
843
+ for (const kw of [
844
+ "AbutmentScrewRetainedCrown",
845
+ "TemporaryVPrepCrown",
846
+ "ScanBody",
847
+ "Analog",
848
+ "TiBase",
849
+ "Socket",
850
+ "NEODENT",
851
+ "STRAUMANN",
852
+ "NOBEL"
853
+ ]) {
854
+ if (new RegExp(kw, "i").test(joined)) keywords.push(kw);
855
+ }
856
+ return { restorationType: restType.type, keywords };
857
+ }
858
+ function parseScans(classified) {
859
+ return classified.filter((f) => f.kind === "scan").map((f) => f.baseName);
860
+ }
861
+ var EMPTY_CASE2 = {
862
+ patientName: null,
863
+ orderId: null,
864
+ customer: null,
865
+ operator: null,
866
+ createdAt: null,
867
+ groupFolder: null,
868
+ software: "3Shape Dental Designer",
869
+ softwareVersion: null,
870
+ processStatus: null,
871
+ scanSource: null
872
+ };
873
+ function looksEncrypted(data) {
874
+ if (data.length >= 2 && data[0] === 80 && data[1] === 75) return true;
875
+ const sample = decodeText2(data.subarray(0, Math.min(512, data.length)));
876
+ return !/[<\w]/.test(sample);
877
+ }
878
+ function buildThreeShapeSummary(entries) {
879
+ if (!isThreeShapePackage(entries)) return void 0;
880
+ const { classified, files } = detectThreeShapeFiles(entries);
881
+ const warnings = [];
882
+ let caseInfo = { ...EMPTY_CASE2 };
883
+ let restoration = {
884
+ teeth: [],
885
+ internalToothNumbers: [],
886
+ name: null,
887
+ type: null,
888
+ toothElementTypeID: null,
889
+ cacheToothTypeClass: null,
890
+ materialId: null,
891
+ materialName: null,
892
+ materialFamily: null,
893
+ shaderMaterial: null,
894
+ shade: null,
895
+ isImplantCase: false,
896
+ isScrewRetained: false
897
+ };
898
+ const implant = {
899
+ isImplantCase: false,
900
+ manufacturer: null,
901
+ connection: null,
902
+ connectionId: null,
903
+ implantSystemId: null,
904
+ abutmentKitId: null,
905
+ components: []
906
+ };
907
+ for (const f of classified.filter((c) => c.kind === "mainXml")) {
908
+ const parsed = parseMainOrderXml(decodeText2(f.data));
909
+ caseInfo = { ...caseInfo, ...stripNull(parsed.case) };
910
+ restoration = mergeRestoration(restoration, parsed.restoration);
911
+ if (parsed.restoration.isImplantCase) restoration.isImplantCase = true;
912
+ if (parsed.restoration.isScrewRetained) restoration.isScrewRetained = true;
913
+ if (parsed.implantIds.abutmentKitId)
914
+ implant.abutmentKitId = parsed.implantIds.abutmentKitId;
915
+ if (parsed.implantIds.connectionId)
916
+ implant.connectionId = parsed.implantIds.connectionId;
917
+ if (parsed.implantIds.implantSystemId)
918
+ implant.implantSystemId = parsed.implantIds.implantSystemId;
919
+ }
920
+ let printableTeeth = [];
921
+ for (const f of classified.filter((c) => c.kind === "printableOrderForm")) {
922
+ const p = parsePrintableOrderForm(decodeText2(f.data));
923
+ if (p.teeth.length > 0) printableTeeth = p.teeth;
924
+ if (p.patientName && !caseInfo.patientName) caseInfo.patientName = p.patientName;
925
+ if (p.orderId && !caseInfo.orderId) caseInfo.orderId = p.orderId;
926
+ if (p.customer && !caseInfo.customer) caseInfo.customer = p.customer;
927
+ if (p.operator && !caseInfo.operator) caseInfo.operator = p.operator;
928
+ if (p.shade && !restoration.shade) restoration.shade = p.shade;
929
+ }
930
+ const materialMap = {};
931
+ for (const f of classified.filter((c) => c.kind === "materialsXml")) {
932
+ Object.assign(materialMap, parseMaterialsXml(decodeText2(f.data)));
933
+ }
934
+ if (restoration.materialId && materialMap[restoration.materialId]) {
935
+ const mat = materialMap[restoration.materialId];
936
+ restoration.materialName = mat.name ?? null;
937
+ restoration.materialFamily = mat.family ?? null;
938
+ restoration.shaderMaterial = mat.shader ?? null;
939
+ } else if (restoration.materialId) {
940
+ warnings.push(
941
+ `MaterialID ${restoration.materialId} non trovato in Materials.xml.`
942
+ );
943
+ }
944
+ const ext = parseExternalModels(classified);
945
+ if (ext.components.length > 0) implant.components = ext.components;
946
+ if (ext.abutmentKitId && !implant.abutmentKitId)
947
+ implant.abutmentKitId = ext.abutmentKitId;
948
+ if (!restoration.type) {
949
+ for (const f of classified.filter((c) => c.kind === "cadDcm")) {
950
+ const dcm = parseCadDcm(f.data);
951
+ if (dcm.restorationType) {
952
+ restoration.type = dcm.restorationType;
953
+ warnings.push(
954
+ `Tipo restauro dedotto da CAD/*.dcm (medium): ${dcm.restorationType}.`
955
+ );
956
+ break;
957
+ }
958
+ }
959
+ }
960
+ for (const f of classified.filter((c) => c.kind === "threeMl")) {
961
+ if (looksEncrypted(f.data)) {
962
+ files.encrypted3ml.push(f.baseName);
963
+ }
964
+ }
965
+ if (files.encrypted3ml.length > 0) {
966
+ warnings.push(
967
+ `File .3ml cifrati/non leggibili saltati: ${files.encrypted3ml.join(", ")}.`
968
+ );
969
+ }
970
+ if (printableTeeth.length > 0) {
971
+ restoration.teeth = printableTeeth;
972
+ } else {
973
+ restoration.teeth = restoration.internalToothNumbers.filter(
974
+ (n) => n >= 11 && n <= 48
975
+ );
976
+ }
977
+ if (printableTeeth.length > 0 && restoration.internalToothNumbers.length > 0 && restoration.internalToothNumbers.some((n) => !printableTeeth.includes(n))) {
978
+ warnings.push(
979
+ `ToothNumber interno (${restoration.internalToothNumbers.join(", ")}) diverso dal dente FDI dell'ordine (${printableTeeth.join(", ")}).`
980
+ );
981
+ }
982
+ const idText = [
983
+ implant.connectionId,
984
+ implant.implantSystemId,
985
+ implant.abutmentKitId,
986
+ ...implant.components.map((c) => c.fileName)
987
+ ].filter(Boolean).join(" ");
988
+ if (idText) {
989
+ const inf = inferManufacturerFromIdsAndNames(idText);
990
+ if (inf.manufacturer) implant.manufacturer = inf.manufacturer;
991
+ if (inf.connection) implant.connection = inf.connection;
992
+ }
993
+ const isImplantCase = restoration.isImplantCase || Boolean(
994
+ implant.abutmentKitId || implant.connectionId || implant.implantSystemId
995
+ ) || implant.components.length > 0;
996
+ implant.isImplantCase = isImplantCase;
997
+ restoration.isImplantCase = isImplantCase;
998
+ if (isImplantCase) {
999
+ if (!implant.manufacturer) {
1000
+ warnings.push("Caso implantare ma produttore non identificato.");
1001
+ }
1002
+ warnings.push("Commercial SKU not found in 3Shape files");
1003
+ if (implant.components.length > 0) {
1004
+ warnings.push(
1005
+ "Componenti classificati solo dal nome file (verifica manuale consigliata)."
1006
+ );
1007
+ }
1008
+ }
1009
+ return {
1010
+ source: "3Shape",
1011
+ case: caseInfo,
1012
+ restoration,
1013
+ implant,
1014
+ files,
1015
+ warnings: [...new Set(warnings)]
1016
+ };
1017
+ }
1018
+ function stripNull(obj) {
1019
+ const out = {};
1020
+ Object.keys(obj).forEach((k) => {
1021
+ const val = obj[k];
1022
+ if (val !== null && val !== void 0) out[k] = val;
1023
+ });
1024
+ return out;
1025
+ }
1026
+ function mergeRestoration(base, patch) {
1027
+ const merged = { ...base };
1028
+ Object.keys(patch).forEach((k) => {
1029
+ const val = patch[k];
1030
+ if (val === null || val === void 0) return;
1031
+ if (k === "internalToothNumbers" && Array.isArray(val)) {
1032
+ merged.internalToothNumbers = [
1033
+ .../* @__PURE__ */ new Set([...merged.internalToothNumbers, ...val])
1034
+ ];
1035
+ return;
1036
+ }
1037
+ if (k === "isImplantCase" || k === "isScrewRetained") return;
1038
+ merged[k] = val;
1039
+ });
1040
+ return merged;
1041
+ }
1042
+
1043
+ // src/cadProjectParser.ts
1044
+ var CAD_SIDECAR_RE = /\.(constructioninfo|modelinfo|dentalproject|dentalcad)(?:\.html)?$/i;
1045
+ var TOOTH_BLOCK_RE2 = /<(?:Tooth|Restoration|ConstructionElement|Element|RestorationElement)\b[^>]*>([\s\S]*?)<\/(?:Tooth|Restoration|ConstructionElement|Element|RestorationElement)>/gi;
1046
+ var FDI_TOOTH_RE = /\b([1-4][1-8])\b/g;
1047
+ function decodeCadText(data) {
1048
+ let start = 0;
1049
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
1050
+ start = 3;
1051
+ }
1052
+ return new TextDecoder("utf-8", { fatal: false }).decode(data.subarray(start));
1053
+ }
1054
+ function stripXmlText(raw) {
1055
+ return raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gi, "$1").replace(/<[^>]+>/g, "").trim();
1056
+ }
1057
+ function firstTagValue(xml, tags) {
1058
+ for (const tag of tags) {
1059
+ const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i");
1060
+ const m = xml.match(re);
1061
+ if (m?.[1]) {
1062
+ const val = stripXmlText(m[1]);
1063
+ if (val) return val;
1064
+ }
1065
+ }
1066
+ return void 0;
1067
+ }
1068
+ function allTagValues(xml, tags) {
1069
+ const out = [];
1070
+ for (const tag of tags) {
1071
+ const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "gi");
1072
+ let m;
1073
+ while ((m = re.exec(xml)) !== null) {
1074
+ const val = stripXmlText(m[1]);
1075
+ if (val) out.push(val);
1076
+ }
1077
+ }
1078
+ return out;
1079
+ }
1080
+ function parseBool2(raw) {
1081
+ if (raw === void 0) return void 0;
1082
+ const v = raw.trim().toLowerCase();
1083
+ if (v === "true" || v === "1" || v === "yes") return true;
1084
+ if (v === "false" || v === "0" || v === "no") return false;
1085
+ return void 0;
1086
+ }
1087
+ function parseFdiNumbers(text) {
1088
+ const nums = /* @__PURE__ */ new Set();
1089
+ for (const m of text.matchAll(FDI_TOOTH_RE)) {
1090
+ const n = Number(m[1]);
1091
+ if (n >= 11 && n <= 48) nums.add(n);
1092
+ }
1093
+ return [...nums].sort((a, b) => a - b);
1094
+ }
1095
+ function detectCadSource(fileName, text) {
1096
+ const lower = fileName.toLowerCase();
1097
+ if (lower.includes("constructioninfo") || lower.includes("dentalproject") || lower.includes("dentalcad") || /construction\s*info/i.test(text) || /<ConstructionInfo\b/i.test(text) || /<DentalProject\b/i.test(text)) {
1098
+ return "exocad";
1099
+ }
1100
+ return "unknown";
1101
+ }
1102
+ function parseRestorationBlocks(xml) {
1103
+ const entries = [];
1104
+ for (const m of xml.matchAll(TOOTH_BLOCK_RE2)) {
1105
+ const block = m[1] ?? "";
1106
+ const toothNumbers = parseFdiNumbers(
1107
+ firstTagValue(block, [
1108
+ "Number",
1109
+ "ToothNumber",
1110
+ "Tooth",
1111
+ "FDI",
1112
+ "ToothID"
1113
+ ]) ?? block
1114
+ );
1115
+ const material = firstTagValue(block, [
1116
+ "Material",
1117
+ "MaterialName",
1118
+ "MaterialDisplayName",
1119
+ "BlankMaterial"
1120
+ ]);
1121
+ const shade = firstTagValue(block, [
1122
+ "Shade",
1123
+ "ToothColor",
1124
+ "Color",
1125
+ "VitaShade"
1126
+ ]);
1127
+ const reconstructionType = firstTagValue(block, [
1128
+ "ReconstructionType",
1129
+ "Type",
1130
+ "WorkType",
1131
+ "RestorationType"
1132
+ ]);
1133
+ const meshFileName = firstTagValue(block, [
1134
+ "MeshFileName",
1135
+ "MeshFile",
1136
+ "FileName",
1137
+ "STLFileName",
1138
+ "ScanFileName"
1139
+ ]);
1140
+ const preparationType = firstTagValue(block, ["PreparationType"]);
1141
+ const implantType = firstTagValue(block, ["ImplantType"]);
1142
+ const scanAbutmentScan = parseBool2(firstTagValue(block, ["ScanAbutmentScan"]));
1143
+ const mesialConnector = parseBool2(firstTagValue(block, ["MesialConnector"]));
1144
+ if (toothNumbers.length === 0 && !material && !shade && !reconstructionType && !meshFileName && !preparationType && !implantType) {
1145
+ continue;
1146
+ }
1147
+ entries.push({
1148
+ toothNumbers,
1149
+ ...material ? { material } : {},
1150
+ ...shade ? { shade } : {},
1151
+ ...reconstructionType ? { reconstructionType } : {},
1152
+ ...meshFileName ? { meshFileName } : {},
1153
+ ...preparationType ? { preparationType } : {},
1154
+ ...implantType ? { implantType } : {},
1155
+ ...scanAbutmentScan !== void 0 ? { scanAbutmentScan } : {},
1156
+ ...mesialConnector !== void 0 ? { mesialConnector } : {}
1157
+ });
1158
+ }
1159
+ return entries;
1160
+ }
1161
+ function isCadSidecarFileName(fileName) {
1162
+ const base = fileName.replace(/\\/g, "/").split("/").pop() ?? fileName;
1163
+ return CAD_SIDECAR_RE.test(base) || /\.xml$/i.test(base);
1164
+ }
1165
+ function parseCadSidecarBytes(fileName, data) {
1166
+ if (!isCadSidecarFileName(fileName) && data.byteLength > 0) {
1167
+ const head = decodeCadText(data.subarray(0, Math.min(256, data.byteLength)));
1168
+ if (!head.includes("<") && !head.includes("<?xml")) {
1169
+ return null;
1170
+ }
1171
+ } else if (!isCadSidecarFileName(fileName)) {
1172
+ return null;
1173
+ }
1174
+ const text = decodeCadText(data).trim();
1175
+ if (!text || text.length < 8) {
1176
+ return null;
1177
+ }
1178
+ const parseWarnings = [];
1179
+ if (!text.includes("<")) {
1180
+ parseWarnings.push("Contenuto non XML: metadati CAD limitati.");
1181
+ }
1182
+ const source = detectCadSource(fileName, text);
1183
+ const projectName = firstTagValue(text, [
1184
+ "ProjectName",
1185
+ "Name",
1186
+ "CaseName",
1187
+ "OrderName"
1188
+ ]);
1189
+ const projectDirectory = firstTagValue(text, [
1190
+ "ProjectDirectory",
1191
+ "ProjectDir",
1192
+ "Directory"
1193
+ ]);
1194
+ const reconstructions = parseRestorationBlocks(text);
1195
+ const materialsFromTags = allTagValues(text, [
1196
+ "Material",
1197
+ "MaterialName",
1198
+ "MaterialDisplayName",
1199
+ "BlankMaterial"
1200
+ ]);
1201
+ const shadesFromTags = allTagValues(text, [
1202
+ "Shade",
1203
+ "ToothColor",
1204
+ "Color",
1205
+ "VitaShade"
1206
+ ]);
1207
+ if (reconstructions.length === 0 && (materialsFromTags.length || shadesFromTags.length)) {
1208
+ reconstructions.push({
1209
+ toothNumbers: parseFdiNumbers(text),
1210
+ ...materialsFromTags[0] ? { material: materialsFromTags[0] } : {},
1211
+ ...shadesFromTags[0] ? { shade: shadesFromTags[0] } : {}
1212
+ });
1213
+ }
1214
+ const toothNumbers = [
1215
+ ...new Set(reconstructions.flatMap((r) => r.toothNumbers))
1216
+ ].sort((a, b) => a - b);
1217
+ const materials = [
1218
+ .../* @__PURE__ */ new Set([
1219
+ ...materialsFromTags,
1220
+ ...reconstructions.map((r) => r.material).filter(Boolean)
1221
+ ])
1222
+ ];
1223
+ const shades = [
1224
+ .../* @__PURE__ */ new Set([
1225
+ ...shadesFromTags,
1226
+ ...reconstructions.map((r) => r.shade).filter(Boolean)
1227
+ ])
1228
+ ];
1229
+ if (!projectName && toothNumbers.length === 0 && materials.length === 0 && reconstructions.length === 0) {
1230
+ return null;
1231
+ }
1232
+ return {
1233
+ source,
1234
+ sourceFile: fileName,
1235
+ sourceFiles: [fileName],
1236
+ ...projectName ? { projectName } : {},
1237
+ ...projectDirectory ? { projectDirectory } : {},
1238
+ materials,
1239
+ shades,
1240
+ toothNumbers,
1241
+ reconstructions,
1242
+ parseWarnings
1243
+ };
1244
+ }
1245
+ function toSourceFileMetadata(part) {
1246
+ return {
1247
+ sourceFile: part.sourceFile,
1248
+ source: part.source,
1249
+ ...part.projectName ? { projectName: part.projectName } : {},
1250
+ ...part.projectDirectory ? { projectDirectory: part.projectDirectory } : {},
1251
+ materials: part.materials,
1252
+ shades: part.shades,
1253
+ toothNumbers: part.toothNumbers,
1254
+ reconstructions: part.reconstructions,
1255
+ parseWarnings: part.parseWarnings
1256
+ };
1257
+ }
1258
+ function mergeCadProjectParseResults(parts) {
1259
+ if (parts.length === 0) return void 0;
1260
+ const sourceFiles = [...new Set(parts.flatMap((p) => p.sourceFiles))];
1261
+ const bySourceFile = parts.map(toSourceFileMetadata);
1262
+ const source = parts.some((p) => p.source === "exocad") ? "exocad" : "unknown";
1263
+ const projectName = parts.find((p) => p.projectName)?.projectName;
1264
+ const projectDirectory = parts.find((p) => p.projectDirectory)?.projectDirectory;
1265
+ const reconstructions = [];
1266
+ for (const p of parts) {
1267
+ reconstructions.push(...p.reconstructions);
1268
+ }
1269
+ const toothNumbers = [
1270
+ ...new Set(reconstructions.flatMap((r) => r.toothNumbers))
1271
+ ].sort((a, b) => a - b);
1272
+ const materials = [
1273
+ .../* @__PURE__ */ new Set([
1274
+ ...parts.flatMap((p) => p.materials),
1275
+ ...reconstructions.map((r) => r.material).filter(Boolean)
1276
+ ])
1277
+ ];
1278
+ const shades = [
1279
+ .../* @__PURE__ */ new Set([
1280
+ ...parts.flatMap((p) => p.shades),
1281
+ ...reconstructions.map((r) => r.shade).filter(Boolean)
1282
+ ])
1283
+ ];
1284
+ const parseWarnings = [...new Set(parts.flatMap((p) => p.parseWarnings))];
1285
+ return {
1286
+ source,
1287
+ sourceFiles,
1288
+ bySourceFile,
1289
+ ...projectName ? { projectName } : {},
1290
+ ...projectDirectory ? { projectDirectory } : {},
1291
+ materials,
1292
+ shades,
1293
+ toothNumbers,
1294
+ reconstructions,
1295
+ parseWarnings
1296
+ };
1297
+ }
1298
+ function collectCadMetadataFromZipEntries(entries) {
1299
+ const parts = [];
1300
+ for (const e of entries) {
1301
+ const baseName3 = e.safePath.split("/").pop() ?? e.safePath;
1302
+ const parsed = parseCadSidecarBytes(baseName3, e.data);
1303
+ if (parsed) parts.push(parsed);
1304
+ }
1305
+ const merged = mergeCadProjectParseResults(parts);
1306
+ const fileEntries = entries.map((e) => ({ path: e.safePath, data: e.data }));
1307
+ const exocad = buildExocadSummary(fileEntries);
1308
+ const threeShape = buildThreeShapeSummary(fileEntries);
1309
+ if (!merged) {
1310
+ if (!exocad && !threeShape) return void 0;
1311
+ const base = {
1312
+ source: exocad ? "exocad" : "unknown",
1313
+ sourceFiles: exocad ? [
1314
+ ...exocad.files.dentalProject,
1315
+ ...exocad.files.constructionInfo,
1316
+ ...exocad.files.modelInfo,
1317
+ ...exocad.files.dentalCAD
1318
+ ] : [
1319
+ ...threeShape?.files.mainXml ?? [],
1320
+ ...threeShape?.files.materialsXml ?? [],
1321
+ ...threeShape?.files.printableOrderForms ?? []
1322
+ ],
1323
+ materials: [],
1324
+ shades: [],
1325
+ toothNumbers: exocad ? exocad.restoration.teeth.map(Number) : threeShape?.restoration.teeth ?? [],
1326
+ reconstructions: [],
1327
+ parseWarnings: [],
1328
+ ...exocad ? { exocad } : {},
1329
+ ...threeShape ? { threeShape } : {}
1330
+ };
1331
+ return base;
1332
+ }
1333
+ if (exocad) {
1334
+ merged.exocad = exocad;
1335
+ if (exocad.implantComponents.length > 0 || exocad.isImplantCase) {
1336
+ merged.source = "exocad";
1337
+ }
1338
+ }
1339
+ if (threeShape) {
1340
+ merged.threeShape = threeShape;
1341
+ }
1342
+ return merged;
1343
+ }
1344
+
1345
+ // src/zipExtractor.ts
1346
+ import { unzipSync } from "fflate";
1347
+ var MAX_ZIP_ENTRIES = 200;
1348
+ var MAX_FILE_BYTES = 250 * 1024 * 1024;
1349
+ var MAX_TOTAL_EXTRACTED_BYTES = 1024 * 1024 * 1024;
1350
+ var MAX_ZIP_PACKAGE_BYTES = 40 * 1024 * 1024;
1351
+ function zipPackageTooLargeMessage(sizeBytes) {
1352
+ const mb = (sizeBytes / (1024 * 1024)).toFixed(1);
1353
+ const limit = (MAX_ZIP_PACKAGE_BYTES / (1024 * 1024)).toFixed(0);
1354
+ return `ZIP troppo grande (${mb} MB). Limite estrazione su Convex: ${limit} MB (runtime action ~64 MB). Usa l\u2019estrazione nel browser nell\u2019app o carica i file singolarmente.`;
1355
+ }
1356
+ var WHITELIST = /* @__PURE__ */ new Set([
1357
+ "stl",
1358
+ "ply",
1359
+ "obj",
1360
+ "dcm",
1361
+ "xml",
1362
+ "json",
1363
+ "png",
1364
+ "jpg",
1365
+ "jpeg",
1366
+ "pdf",
1367
+ "txt",
1368
+ /** Metadati progetto CAD (es. Exocad / pacchetti dental CAD export) */
1369
+ "constructioninfo",
1370
+ "modelinfo",
1371
+ "dentalproject",
1372
+ "dentalcad",
1373
+ "html",
1374
+ /** 3Shape Dental System / Dental Designer (spesso ZIP cifrati, lettura best-effort) */
1375
+ "3ml"
1376
+ ]);
1377
+ var BLOCKED = /* @__PURE__ */ new Set([
1378
+ "exe",
1379
+ "dmg",
1380
+ "app",
1381
+ "sh",
1382
+ "bat",
1383
+ "cmd",
1384
+ "js",
1385
+ "msi",
1386
+ "jar",
1387
+ "ps1"
1388
+ ]);
1389
+ function sanitizeZipEntryPath(entryPath) {
1390
+ const normalized = entryPath.replace(/\\/g, "/").replace(/^\uFEFF/, "");
1391
+ if (/^[a-zA-Z]:/.test(normalized)) return null;
1392
+ if (normalized.startsWith("/") || normalized.startsWith("\\")) return null;
1393
+ const parts = normalized.split("/").filter((p) => p.length > 0);
1394
+ const built = [];
1395
+ for (const p of parts) {
1396
+ if (p === "..") return null;
1397
+ if (p === ".") continue;
1398
+ built.push(p);
1399
+ }
1400
+ if (built.length === 0) return null;
1401
+ return built.join("/");
1402
+ }
1403
+ function extensionFromSafePath(safePath) {
1404
+ const base = safePath.split("/").pop() ?? safePath;
1405
+ const i = base.lastIndexOf(".");
1406
+ if (i <= 0) return "";
1407
+ return base.slice(i + 1).toLowerCase();
1408
+ }
1409
+ function expandThreeShape3mlEntries(entries) {
1410
+ const out = [];
1411
+ const warnings = [];
1412
+ for (const entry of entries) {
1413
+ out.push(entry);
1414
+ if (entry.extension !== "3ml") continue;
1415
+ let inner;
1416
+ try {
1417
+ inner = unzipSync(entry.data);
1418
+ } catch {
1419
+ warnings.push(
1420
+ `.3ml non leggibile (probabilmente cifrato/proprietario): ${entry.safePath}`
1421
+ );
1422
+ continue;
1423
+ }
1424
+ let found = 0;
1425
+ let skippedFormat = 0;
1426
+ for (const rawPath of Object.keys(inner)) {
1427
+ if (out.length >= MAX_ZIP_ENTRIES) {
1428
+ warnings.push(
1429
+ `Limite di ${MAX_ZIP_ENTRIES} file raggiunto: contenuto residuo di ${entry.safePath} ignorato.`
1430
+ );
1431
+ break;
1432
+ }
1433
+ const data = inner[rawPath];
1434
+ if (!data || data.byteLength === 0) continue;
1435
+ const safe = sanitizeZipEntryPath(rawPath);
1436
+ if (safe === null) continue;
1437
+ const ext = extensionFromSafePath(safe);
1438
+ if (!ext || BLOCKED.has(ext) || ext === "3ml") {
1439
+ skippedFormat++;
1440
+ continue;
1441
+ }
1442
+ if (!WHITELIST.has(ext)) {
1443
+ skippedFormat++;
1444
+ continue;
1445
+ }
1446
+ if (data.byteLength > MAX_FILE_BYTES) continue;
1447
+ out.push({ safePath: `${entry.safePath}/${safe}`, extension: ext, data });
1448
+ found++;
1449
+ }
1450
+ if (found > 0) {
1451
+ warnings.push(`.3ml espanso (${found} file estratti): ${entry.safePath}`);
1452
+ } else {
1453
+ warnings.push(
1454
+ `.3ml aperto ma senza file utili estraibili${skippedFormat > 0 ? ` (${skippedFormat} interni non in whitelist)` : ""}: ${entry.safePath}`
1455
+ );
1456
+ }
1457
+ }
1458
+ return { entries: out, warnings };
1459
+ }
1460
+ function extractZipSafely(buffer) {
1461
+ const warnings = [];
1462
+ let unzipped;
1463
+ try {
1464
+ unzipped = unzipSync(buffer);
1465
+ } catch {
1466
+ return { ok: false, fatal: "ZIP non valido o corrotto.", warnings };
1467
+ }
1468
+ const nonemptyKeys = Object.keys(unzipped).filter((k) => {
1469
+ const chunk = unzipped[k];
1470
+ return chunk !== void 0 && chunk.byteLength > 0;
1471
+ });
1472
+ if (nonemptyKeys.length > MAX_ZIP_ENTRIES) {
1473
+ return {
1474
+ ok: false,
1475
+ fatal: `Troppi file nel ZIP (limite ${MAX_ZIP_ENTRIES}).`,
1476
+ warnings
1477
+ };
1478
+ }
1479
+ let totalSize = 0;
1480
+ const entries = [];
1481
+ for (const rawPath of nonemptyKeys) {
1482
+ const data = unzipped[rawPath];
1483
+ const safe = sanitizeZipEntryPath(rawPath);
1484
+ if (safe === null) {
1485
+ warnings.push(`Percorso ZIP non sicuro ignorato: ${rawPath}`);
1486
+ continue;
1487
+ }
1488
+ if (data.byteLength > MAX_FILE_BYTES) {
1489
+ return {
1490
+ ok: false,
1491
+ fatal: `File troppo grande nello ZIP (limite 250 MB): ${safe}`,
1492
+ warnings
1493
+ };
1494
+ }
1495
+ totalSize += data.byteLength;
1496
+ if (totalSize > MAX_TOTAL_EXTRACTED_BYTES) {
1497
+ return {
1498
+ ok: false,
1499
+ fatal: "Volume totale estratto oltre il limite di 1 GB.",
1500
+ warnings
1501
+ };
1502
+ }
1503
+ const ext = extensionFromSafePath(safe);
1504
+ if (!ext) {
1505
+ warnings.push(`Estensione assente, ignorato: ${safe}`);
1506
+ continue;
1507
+ }
1508
+ if (BLOCKED.has(ext)) {
1509
+ warnings.push(`File bloccato (tipo rischioso .${ext}): ${safe}`);
1510
+ continue;
1511
+ }
1512
+ if (!WHITELIST.has(ext)) {
1513
+ warnings.push(`File fuori whitelist ignorato: ${safe}`);
1514
+ continue;
1515
+ }
1516
+ entries.push({ safePath: safe, extension: ext, data });
1517
+ }
1518
+ if (entries.length === 0) {
1519
+ return {
1520
+ ok: false,
1521
+ fatal: "Nessun file operativo estratto dal ZIP (solo voci ignorate, vuote o non consent).",
1522
+ warnings
1523
+ };
1524
+ }
1525
+ return { ok: true, entries, warnings };
1526
+ }
1527
+ export {
1528
+ MAX_FILE_BYTES,
1529
+ MAX_TOTAL_EXTRACTED_BYTES,
1530
+ MAX_ZIP_ENTRIES,
1531
+ MAX_ZIP_PACKAGE_BYTES,
1532
+ buildExocadSummary,
1533
+ buildThreeShapeSummary,
1534
+ classifyExocadFile,
1535
+ classifyThreeShapeFile,
1536
+ collectCadMetadataFromZipEntries,
1537
+ detectExocadFiles,
1538
+ detectThreeShapeFiles,
1539
+ expandThreeShape3mlEntries,
1540
+ extractReadableStringsFromBinary,
1541
+ extractStringsFromDentalCAD,
1542
+ extractZipSafely,
1543
+ findDentalCadKeywords,
1544
+ inferGeometryCode,
1545
+ inferManufacturerFromIdsAndNames,
1546
+ inferStlRole,
1547
+ isCadSidecarFileName,
1548
+ isExocadPackage,
1549
+ isThreeShapePackage,
1550
+ mergeCadProjectParseResults,
1551
+ mergeExternalReferences,
1552
+ normalizeImplantComponent,
1553
+ normalizeImplantComponents,
1554
+ normalizeRestorationType,
1555
+ parseCadDcm,
1556
+ parseCadSidecarBytes,
1557
+ parseConstructionInfo,
1558
+ parseDentalProject,
1559
+ parseExternalModels,
1560
+ parseMainOrderXml,
1561
+ parseMaterialsXml,
1562
+ parseModelInfo,
1563
+ parsePrintableOrderForm,
1564
+ parseScans,
1565
+ sanitizeZipEntryPath,
1566
+ zipPackageTooLargeMessage
1567
+ };
1568
+ //# sourceMappingURL=index.js.map