@mlightcad/cad-simple-viewer-cli 1.7.4 → 1.7.5

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.
@@ -1,236 +1,306 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Recursively scan a directory for .dwg / .dxf files, export each to a
4
- * multi-file ACEX zip + zoom-extents JPEG via export-html-multi-preview.scr,
5
- * then arrange outputs in the demo-drawings package layout:
6
- *
7
- * <outputDir>/<folder>/
8
- * drawing.acex.json
9
- * chunks/
10
- * preview.jpg
11
- * drawing.dwg | drawing.dxf
12
- *
13
- * Folder names match demo-drawings: the path relative to <inputDir> without
14
- * extension, with path separators turned into underscores. Same-stem
15
- * `.dwg`/`.dxf` pairs get a `_dwg` / `_dxf` suffix so they do not collide.
16
- *
17
- * Usage (from packages/cad-simple-viewer-cli after build):
18
- * node examples/batch-export-html-multi-preview.mjs <inputDir> <outputDir>
19
- */
20
- import { spawn, spawnSync } from 'node:child_process'
21
- import { existsSync } from 'node:fs'
22
- import {
23
- copyFile,
24
- mkdir,
25
- mkdtemp,
26
- readdir,
27
- rename,
28
- rm
29
- } from 'node:fs/promises'
30
- import os from 'node:os'
31
- import path from 'node:path'
32
- import { fileURLToPath } from 'node:url'
33
-
34
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
35
- const packageRoot = path.resolve(__dirname, '..')
36
- const cliJs = path.join(packageRoot, 'dist', 'cli.js')
37
- const scriptPath = path.join(__dirname, 'export-html-multi-preview.scr')
38
-
39
- const DRAWING_EXT = new Set(['.dwg', '.dxf'])
40
-
41
- /** True when `child` is `parent` or a path under it. */
42
- function isPathInside(parent, child) {
43
- const rel = path.relative(parent, child)
44
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
45
- }
46
-
47
- async function collectDrawings(dir, out = [], excludeDir) {
48
- if (excludeDir && isPathInside(excludeDir, dir)) {
49
- return out
50
- }
51
- const entries = await readdir(dir, { withFileTypes: true })
52
- for (const entry of entries) {
53
- const full = path.join(dir, entry.name)
54
- if (entry.isDirectory()) {
55
- await collectDrawings(full, out, excludeDir)
56
- } else if (DRAWING_EXT.has(path.extname(entry.name).toLowerCase())) {
57
- if (!(excludeDir && isPathInside(excludeDir, full))) {
58
- out.push(full)
59
- }
60
- }
61
- }
62
- return out
63
- }
64
-
65
- /**
66
- * demo-drawings-style folder name from a path relative to inputDir.
67
- * `used` tracks claimed names so same-stem DWG/DXF pairs stay unique.
68
- */
69
- function drawingFolderName(inputDir, drawingPath, used) {
70
- const rel = path.relative(inputDir, drawingPath)
71
- const ext = path.extname(rel).toLowerCase()
72
- const withoutExt = ext ? rel.slice(0, -ext.length) : rel
73
- const base = withoutExt
74
- .split(/[/\\]/)
75
- .filter(Boolean)
76
- .join('_')
77
- .replace(/[^\p{L}\p{N}._-]+/gu, '_')
78
- const extTag = ext.replace(/^\./, '') || 'bin'
79
-
80
- let name = base
81
- if (used.has(name)) {
82
- name = `${base}_${extTag}`
83
- }
84
- let n = 2
85
- while (used.has(name)) {
86
- name = `${base}_${extTag}_${n++}`
87
- }
88
- used.add(name)
89
- return name
90
- }
91
-
92
- function runCli(input, outputDir) {
93
- return new Promise((resolve, reject) => {
94
- const child = spawn(
95
- process.execPath,
96
- [
97
- cliJs,
98
- '-i',
99
- input,
100
- '-s',
101
- scriptPath,
102
- '-o',
103
- outputDir,
104
- '--mode',
105
- 'read'
106
- ],
107
- { stdio: 'inherit' }
108
- )
109
- child.on('error', reject)
110
- child.on('exit', code => {
111
- if (code === 0) resolve()
112
- else
113
- reject(
114
- new Error(
115
- `cad-simple-viewer-cli exited with code ${code} for ${input}`
116
- )
117
- )
118
- })
119
- })
120
- }
121
-
122
- function unzipWithTar(zipPath, destDir) {
123
- const result = spawnSync(
124
- 'tar',
125
- ['-xf', zipPath, '-C', destDir],
126
- { encoding: 'utf8' }
127
- )
128
- if (result.status !== 0) {
129
- throw new Error(
130
- `Failed to unzip ${zipPath}: ${result.stderr || result.stdout || 'tar error'}`
131
- )
132
- }
133
- }
134
-
135
- async function packageDrawing(workDir, targetDir, sourceDrawing) {
136
- const entries = await readdir(workDir)
137
- const zipName = entries.find(name => name.toLowerCase().endsWith('.zip'))
138
- const jpegName = entries.find(name => {
139
- const lower = name.toLowerCase()
140
- return lower.endsWith('.jpg') || lower.endsWith('.jpeg')
141
- })
142
- if (!zipName) {
143
- throw new Error(`No .zip produced in ${workDir}`)
144
- }
145
- if (!jpegName) {
146
- throw new Error(`No .jpg preview produced in ${workDir}`)
147
- }
148
-
149
- // Stage under the same parent as targetDir so rename stays on one volume.
150
- const stageDir = await mkdtemp(
151
- path.join(path.dirname(targetDir), '.cad-cli-pack-')
152
- )
153
- try {
154
- unzipWithTar(path.join(workDir, zipName), stageDir)
155
-
156
- await copyFile(
157
- path.join(workDir, jpegName),
158
- path.join(stageDir, 'preview.jpg')
159
- )
160
-
161
- const ext = path.extname(sourceDrawing).toLowerCase()
162
- const destName = ext === '.dxf' ? 'drawing.dxf' : 'drawing.dwg'
163
- await copyFile(sourceDrawing, path.join(stageDir, destName))
164
-
165
- await rm(targetDir, { recursive: true, force: true })
166
- await rename(stageDir, targetDir)
167
- } catch (error) {
168
- await rm(stageDir, { recursive: true, force: true })
169
- throw error
170
- }
171
- }
172
-
173
- async function main() {
174
- const inputDir = path.resolve(process.argv[2] ?? '')
175
- const outputDir = path.resolve(process.argv[3] ?? '')
176
-
177
- if (!process.argv[2] || !process.argv[3] || !existsSync(inputDir)) {
178
- console.error(
179
- 'Usage: node examples/batch-export-html-multi-preview.mjs <inputDir> <outputDir>'
180
- )
181
- process.exitCode = 1
182
- return
183
- }
184
- if (!existsSync(cliJs)) {
185
- console.error(
186
- 'CLI not built. Run: pnpm --filter @mlightcad/cad-simple-viewer-cli build'
187
- )
188
- process.exitCode = 1
189
- return
190
- }
191
- if (!existsSync(scriptPath)) {
192
- console.error(`Missing script: ${scriptPath}`)
193
- process.exitCode = 1
194
- return
195
- }
196
-
197
- await mkdir(outputDir, { recursive: true })
198
- // Skip anything already under outputDir so a nested out/ does not re-scan
199
- // packaged drawing.dwg / drawing.dxf copies on later runs.
200
- const drawings = await collectDrawings(inputDir, [], outputDir)
201
- if (!drawings.length) {
202
- console.error(`No .dwg/.dxf files found under ${inputDir}`)
203
- process.exitCode = 1
204
- return
205
- }
206
-
207
- console.log(
208
- `Found ${drawings.length} drawing(s). Output (demo-drawings layout): ${outputDir}`
209
- )
210
-
211
- const usedFolders = new Set()
212
- let failed = 0
213
-
214
- for (const drawing of drawings) {
215
- const folder = drawingFolderName(inputDir, drawing, usedFolders)
216
- const targetDir = path.join(outputDir, folder)
217
- console.log(`\n=== ${drawing} → ${folder}/ ===`)
218
-
219
- const workDir = await mkdtemp(path.join(os.tmpdir(), 'cad-cli-demo-'))
220
- try {
221
- await runCli(drawing, workDir)
222
- await packageDrawing(workDir, targetDir, drawing)
223
- console.log(`Packaged ${targetDir}`)
224
- } catch (error) {
225
- failed++
226
- console.error(error instanceof Error ? error.message : String(error))
227
- } finally {
228
- await rm(workDir, { recursive: true, force: true })
229
- }
230
- }
231
-
232
- console.log(`\nDone. success=${drawings.length - failed} failed=${failed}`)
233
- if (failed) process.exitCode = 1
234
- }
235
-
236
- await main()
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Recursively scan a directory for .dwg / .dxf files, export each to a
4
+ * multi-file ACEX zip + zoom-extents JPEG via export-html-multi-preview.scr,
5
+ * then arrange outputs in the demo-drawings package layout:
6
+ *
7
+ * <outputDir>/<folder>/
8
+ * drawing.acex.json
9
+ * chunks/
10
+ * preview.jpg
11
+ * drawing.dwg | drawing.dxf
12
+ *
13
+ * Folder names match demo-drawings: the path relative to <inputDir> without
14
+ * extension, with path separators turned into underscores. Same-stem
15
+ * `.dwg`/`.dxf` pairs get a `_dwg` / `_dxf` suffix so they do not collide.
16
+ *
17
+ * Reuses one Chromium session across all drawings (WASM / fonts stay warm).
18
+ *
19
+ * Usage (from packages/cad-simple-viewer-cli after build):
20
+ * node examples/batch-export-html-multi-preview.mjs <inputDir> <outputDir>
21
+ */
22
+ import { spawnSync } from 'node:child_process'
23
+ import { existsSync } from 'node:fs'
24
+ import {
25
+ copyFile,
26
+ cp,
27
+ mkdir,
28
+ mkdtemp,
29
+ readdir,
30
+ rename,
31
+ rm
32
+ } from 'node:fs/promises'
33
+ import os from 'node:os'
34
+ import path from 'node:path'
35
+ import { fileURLToPath, pathToFileURL } from 'node:url'
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
38
+ const packageRoot = path.resolve(__dirname, '..')
39
+ const runHeadlessJs = path.join(packageRoot, 'dist', 'runHeadless.js')
40
+ const scriptPath = path.join(__dirname, 'export-html-multi-preview.scr')
41
+
42
+ const DRAWING_EXT = new Set(['.dwg', '.dxf'])
43
+
44
+ /** True when `child` is `parent` or a path under it. */
45
+ function isPathInside(parent, child) {
46
+ const rel = path.relative(parent, child)
47
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
48
+ }
49
+
50
+ async function collectDrawings(dir, out = [], excludeDir) {
51
+ if (excludeDir && isPathInside(excludeDir, dir)) {
52
+ return out
53
+ }
54
+ const entries = await readdir(dir, { withFileTypes: true })
55
+ for (const entry of entries) {
56
+ const full = path.join(dir, entry.name)
57
+ if (entry.isDirectory()) {
58
+ await collectDrawings(full, out, excludeDir)
59
+ } else if (DRAWING_EXT.has(path.extname(entry.name).toLowerCase())) {
60
+ if (!(excludeDir && isPathInside(excludeDir, full))) {
61
+ out.push(full)
62
+ }
63
+ }
64
+ }
65
+ return out
66
+ }
67
+
68
+ /**
69
+ * demo-drawings-style folder name from a path relative to inputDir.
70
+ * `used` tracks claimed names so same-stem DWG/DXF pairs stay unique.
71
+ */
72
+ function drawingFolderName(inputDir, drawingPath, used) {
73
+ const rel = path.relative(inputDir, drawingPath)
74
+ const ext = path.extname(rel).toLowerCase()
75
+ const withoutExt = ext ? rel.slice(0, -ext.length) : rel
76
+ const base = withoutExt
77
+ .split(/[/\\]/)
78
+ .filter(Boolean)
79
+ .join('_')
80
+ .replace(/[^\p{L}\p{N}._-]+/gu, '_')
81
+ const extTag = ext.replace(/^\./, '') || 'bin'
82
+
83
+ let name = base
84
+ if (used.has(name)) {
85
+ name = `${base}_${extTag}`
86
+ }
87
+ let n = 2
88
+ while (used.has(name)) {
89
+ name = `${base}_${extTag}_${n++}`
90
+ }
91
+ used.add(name)
92
+ return name
93
+ }
94
+
95
+ function delay(ms) {
96
+ return new Promise(resolve => setTimeout(resolve, ms))
97
+ }
98
+
99
+ const REPLACE_DIR_RETRY = new Set([
100
+ 'EPERM',
101
+ 'EACCES',
102
+ 'EBUSY',
103
+ 'ENOTEMPTY',
104
+ 'EMFILE',
105
+ 'ENFILE'
106
+ ])
107
+
108
+ /**
109
+ * Move `src` onto `dest`. Windows often returns EPERM from rename while
110
+ * Defender or a file watcher still has a file inside a freshly unpacked
111
+ * directory open (sharing without delete). Retry, then copy into place.
112
+ */
113
+ async function replaceDir(src, dest) {
114
+ const waits = [0, 50, 100, 200, 400, 800, 1600]
115
+ let lastError
116
+ for (const wait of waits) {
117
+ if (wait) await delay(wait)
118
+ try {
119
+ await rm(dest, {
120
+ recursive: true,
121
+ force: true,
122
+ maxRetries: 5,
123
+ retryDelay: 100
124
+ })
125
+ await rename(src, dest)
126
+ return
127
+ } catch (error) {
128
+ lastError = error
129
+ const code = error && error.code
130
+ if (code === 'EXDEV') break
131
+ if (!REPLACE_DIR_RETRY.has(code)) throw error
132
+ }
133
+ }
134
+
135
+ let copyError
136
+ for (const wait of [0, 200, 500, 1000, 2000]) {
137
+ if (wait) await delay(wait)
138
+ try {
139
+ await rm(dest, {
140
+ recursive: true,
141
+ force: true,
142
+ maxRetries: 5,
143
+ retryDelay: 100
144
+ })
145
+ await cp(src, dest, { recursive: true })
146
+ copyError = undefined
147
+ break
148
+ } catch (error) {
149
+ copyError = error
150
+ const code = error && error.code
151
+ if (code !== 'EXDEV' && !REPLACE_DIR_RETRY.has(code)) break
152
+ }
153
+ }
154
+ if (copyError) {
155
+ await rm(dest, { recursive: true, force: true }).catch(() => {})
156
+ const renameMsg =
157
+ lastError instanceof Error ? lastError.message : String(lastError)
158
+ const copyMsg =
159
+ copyError instanceof Error ? copyError.message : String(copyError)
160
+ throw new Error(
161
+ `Failed to move ${src} to ${dest} (${renameMsg}); copy fallback failed: ${copyMsg}`
162
+ )
163
+ }
164
+ try {
165
+ await rm(src, {
166
+ recursive: true,
167
+ force: true,
168
+ maxRetries: 8,
169
+ retryDelay: 200
170
+ })
171
+ } catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error)
173
+ console.warn(`Could not remove temporary directory ${src}: ${message}`)
174
+ }
175
+ }
176
+
177
+ function unzipWithTar(zipPath, destDir) {
178
+ const result = spawnSync(
179
+ 'tar',
180
+ ['-xf', zipPath, '-C', destDir],
181
+ { encoding: 'utf8' }
182
+ )
183
+ if (result.status !== 0) {
184
+ throw new Error(
185
+ `Failed to unzip ${zipPath}: ${result.stderr || result.stdout || 'tar error'}`
186
+ )
187
+ }
188
+ }
189
+
190
+ async function packageDrawing(workDir, targetDir, sourceDrawing) {
191
+ const entries = await readdir(workDir)
192
+ const zipName = entries.find(name => name.toLowerCase().endsWith('.zip'))
193
+ const jpegName = entries.find(name => {
194
+ const lower = name.toLowerCase()
195
+ return lower.endsWith('.jpg') || lower.endsWith('.jpeg')
196
+ })
197
+ if (!zipName) {
198
+ throw new Error(`No .zip produced in ${workDir}`)
199
+ }
200
+ if (!jpegName) {
201
+ throw new Error(`No .jpg preview produced in ${workDir}`)
202
+ }
203
+
204
+ // Stage under the same parent as targetDir so rename stays on one volume.
205
+ const stageDir = await mkdtemp(
206
+ path.join(path.dirname(targetDir), '.cad-cli-pack-')
207
+ )
208
+ try {
209
+ unzipWithTar(path.join(workDir, zipName), stageDir)
210
+
211
+ await copyFile(
212
+ path.join(workDir, jpegName),
213
+ path.join(stageDir, 'preview.jpg')
214
+ )
215
+
216
+ const ext = path.extname(sourceDrawing).toLowerCase()
217
+ const destName = ext === '.dxf' ? 'drawing.dxf' : 'drawing.dwg'
218
+ await copyFile(sourceDrawing, path.join(stageDir, destName))
219
+
220
+ await replaceDir(stageDir, targetDir)
221
+ } catch (error) {
222
+ await rm(stageDir, {
223
+ recursive: true,
224
+ force: true,
225
+ maxRetries: 5,
226
+ retryDelay: 100
227
+ }).catch(() => {})
228
+ throw error
229
+ }
230
+ }
231
+
232
+ async function main() {
233
+ const inputDir = path.resolve(process.argv[2] ?? '')
234
+ const outputDir = path.resolve(process.argv[3] ?? '')
235
+
236
+ if (!process.argv[2] || !process.argv[3] || !existsSync(inputDir)) {
237
+ console.error(
238
+ 'Usage: node examples/batch-export-html-multi-preview.mjs <inputDir> <outputDir>'
239
+ )
240
+ process.exitCode = 1
241
+ return
242
+ }
243
+ if (!existsSync(runHeadlessJs)) {
244
+ console.error(
245
+ 'CLI not built. Run: pnpm --filter @mlightcad/cad-simple-viewer-cli build'
246
+ )
247
+ process.exitCode = 1
248
+ return
249
+ }
250
+ if (!existsSync(scriptPath)) {
251
+ console.error(`Missing script: ${scriptPath}`)
252
+ process.exitCode = 1
253
+ return
254
+ }
255
+
256
+ await mkdir(outputDir, { recursive: true })
257
+ // Skip anything already under outputDir so a nested out/ does not re-scan
258
+ // packaged drawing.dwg / drawing.dxf copies on later runs.
259
+ const drawings = await collectDrawings(inputDir, [], outputDir)
260
+ if (!drawings.length) {
261
+ console.error(`No .dwg/.dxf files found under ${inputDir}`)
262
+ process.exitCode = 1
263
+ return
264
+ }
265
+
266
+ console.log(
267
+ `Found ${drawings.length} drawing(s). Output (demo-drawings layout): ${outputDir}`
268
+ )
269
+
270
+ const { HeadlessCadSession } = await import(pathToFileURL(runHeadlessJs).href)
271
+ const session = await HeadlessCadSession.create()
272
+ const usedFolders = new Set()
273
+ let failed = 0
274
+
275
+ try {
276
+ for (const drawing of drawings) {
277
+ const folder = drawingFolderName(inputDir, drawing, usedFolders)
278
+ const targetDir = path.join(outputDir, folder)
279
+ console.log(`\n=== ${drawing} → ${folder}/ ===`)
280
+
281
+ const workDir = await mkdtemp(path.join(os.tmpdir(), 'cad-cli-demo-'))
282
+ try {
283
+ await session.run({
284
+ inputPath: drawing,
285
+ scriptPath,
286
+ outputDir: workDir,
287
+ mode: 'read'
288
+ })
289
+ await packageDrawing(workDir, targetDir, drawing)
290
+ console.log(`Packaged ${targetDir}`)
291
+ } catch (error) {
292
+ failed++
293
+ console.error(error instanceof Error ? error.message : String(error))
294
+ } finally {
295
+ await rm(workDir, { recursive: true, force: true })
296
+ }
297
+ }
298
+ } finally {
299
+ await session.close()
300
+ }
301
+
302
+ console.log(`\nDone. success=${drawings.length - failed} failed=${failed}`)
303
+ if (failed) process.exitCode = 1
304
+ }
305
+
306
+ await main()