w-md2docx 1.0.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.
Files changed (69) hide show
  1. package/.editorconfig +9 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc.js +55 -0
  4. package/.jsdoc +25 -0
  5. package/LICENSE +21 -0
  6. package/README.md +83 -0
  7. package/SECURITY.md +5 -0
  8. package/babel.config.js +16 -0
  9. package/dist/w-md2docx.umd.js +7 -0
  10. package/dist/w-md2docx.umd.js.map +1 -0
  11. package/docs/ApiClient.mjs.html +539 -0
  12. package/docs/ApiServer.mjs.html +516 -0
  13. package/docs/WMd2docx.mjs.html +101 -0
  14. package/docs/cvMdTo.mjs.html +288 -0
  15. package/docs/cvMdToDocx.mjs.html +243 -0
  16. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  17. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  18. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  19. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  20. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  21. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  22. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  23. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  24. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  25. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
  26. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  27. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  28. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  29. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  30. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
  31. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  32. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  33. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  34. package/docs/global.html +5453 -0
  35. package/docs/index.html +84 -0
  36. package/docs/scripts/collapse.js +39 -0
  37. package/docs/scripts/commonNav.js +28 -0
  38. package/docs/scripts/linenumber.js +25 -0
  39. package/docs/scripts/nav.js +12 -0
  40. package/docs/scripts/polyfill.js +4 -0
  41. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  42. package/docs/scripts/prettify/lang-css.js +2 -0
  43. package/docs/scripts/prettify/prettify.js +28 -0
  44. package/docs/scripts/search.js +99 -0
  45. package/docs/styles/jsdoc.css +776 -0
  46. package/docs/styles/prettify.css +80 -0
  47. package/docs/utils.mjs.html +520 -0
  48. package/g.mjs +34 -0
  49. package/package.json +32 -0
  50. package/script.txt +18 -0
  51. package/src/ApiClient.mjs +467 -0
  52. package/src/ApiServer.mjs +444 -0
  53. package/src/WMd2docx.mjs +29 -0
  54. package/src/cvMdTo.mjs +216 -0
  55. package/src/cvMdToDocx.mjs +171 -0
  56. package/src/templates/temp_tpc.docx +0 -0
  57. package/src/utils.mjs +448 -0
  58. package/test/api-ApiClient.test.mjs +221 -0
  59. package/test/api-ApiServer.test.mjs +191 -0
  60. package/test/cocktail.svg +3 -0
  61. package/test/report.md +257 -0
  62. package/test/unit-cvMdTo.test.mjs +132 -0
  63. package/test/unit-cvMdToDocx.test.mjs +185 -0
  64. package/test/unit-utils.test.mjs +370 -0
  65. package/toolg/addVersion.mjs +4 -0
  66. package/toolg/cleanFolder.mjs +5 -0
  67. package/toolg/gDistRollup.mjs +56 -0
  68. package/toolg/gDocsExams.mjs +51 -0
  69. package/toolg/modifyReadme.mjs +4 -0
@@ -0,0 +1,221 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import assert from 'assert'
4
+ import ApiServer from '../src/ApiServer.mjs'
5
+ import ApiClient, { cvMdTo, health, scanAssetPaths, readAssets } from '../src/ApiClient.mjs'
6
+
7
+
8
+ describe('ApiClient', function() {
9
+
10
+ describe('scanAssetPaths(純函數)', function() {
11
+
12
+ it('涵蓋<img src>與![]() , 去重, 保留原字串', function() {
13
+ let md = [
14
+ '![a](pics/a.png)',
15
+ '<img src="pics/b.png" />',
16
+ `<IMG class='x' SRC='pics/c.png'>`,
17
+ '![a2](pics/a.png)',
18
+ '![e](<pics/e.png>)',
19
+ '![enc](pics/%E5%9C%96.png)',
20
+ ].join('\n')
21
+ assert.strict.deepEqual(scanAssetPaths(md), ['pics/b.png', 'pics/c.png', 'pics/a.png', 'pics/e.png', 'pics/%E5%9C%96.png'])
22
+ })
23
+
24
+ it('排除網路位址/data/mailto/錨點/絕對路徑', function() {
25
+ let md = [
26
+ '![1](https://x.com/1.png)',
27
+ '![2](//x.com/2.png)',
28
+ '![3](data:image/png;base64,AAAA)',
29
+ '[m](mailto:a@b.c)',
30
+ '![4](#top)',
31
+ '![5](C:/x/5.png)',
32
+ '![6](/abs/6.png)',
33
+ '![ok](rel/ok.png)',
34
+ ].join('\n')
35
+ assert.strict.deepEqual(scanAssetPaths(md), ['rel/ok.png'])
36
+ })
37
+
38
+ it('去除錨點與查詢字串', function() {
39
+ assert.strict.deepEqual(scanAssetPaths('![a](pics/a.png?v=1#x)'), ['pics/a.png'])
40
+ })
41
+
42
+ it('非字串回空陣列', function() {
43
+ assert.strict.deepEqual(scanAssetPaths(null), [])
44
+ assert.strict.deepEqual(scanAssetPaths(123), [])
45
+ })
46
+
47
+ })
48
+
49
+ describe('readAssets(讀本機檔)', function() {
50
+
51
+ let fdTmp = path.resolve('./tmp/zt_ApiClient_ra')
52
+
53
+ before(function() {
54
+ fs.mkdirSync(path.resolve(fdTmp, 'pics'), { recursive: true })
55
+ fs.writeFileSync(path.resolve(fdTmp, 'pics/圖.png'), 'IMG')
56
+ fs.writeFileSync(path.resolve(fdTmp, 'pics/a b.png'), 'IMG2')
57
+ })
58
+
59
+ after(function() {
60
+ fs.rmSync(fdTmp, { recursive: true, force: true })
61
+ })
62
+
63
+ it('URL編碼引用可對應解碼後實體檔, path保留原字串', function() {
64
+ let { assets, missing } = readAssets('![p](pics/%E5%9C%96.png)\n<img src="pics/a%20b.png">', fdTmp)
65
+ assert.strict.deepEqual(missing, [])
66
+ assert.strict.deepEqual(assets.map((v) => v.path), ['pics/a%20b.png', 'pics/%E5%9C%96.png'])
67
+ assert.strict.equal(Buffer.from(assets[1].base64, 'base64').toString('utf8'), 'IMG')
68
+ })
69
+
70
+ it('找不到者列入missing', function() {
71
+ let { assets, missing } = readAssets('![p](pics/圖.png)\n![n](pics/no.png)', fdTmp)
72
+ assert.strict.equal(assets.length, 1)
73
+ assert.strict.deepEqual(missing, ['pics/no.png'])
74
+ })
75
+
76
+ it('dirMd未給以cwd為基準', function() {
77
+ let { assets, missing } = readAssets('![c](test/cocktail.svg)')
78
+ assert.strict.equal(assets.length, 1)
79
+ assert.strict.deepEqual(missing, [])
80
+ })
81
+
82
+ })
83
+
84
+ describe('cvMdTo/health(對接ApiServer, html路徑不需Word)', function() {
85
+
86
+ let fdTmp = path.resolve('./tmp/zt_ApiClient')
87
+ let dirWork = path.resolve(fdTmp, 'work')
88
+ let token = 'tk-client'
89
+ let srv = null
90
+ let host = '127.0.0.1'
91
+ let port = 0
92
+ let url = ''
93
+ let fpOutHtml = path.resolve(fdTmp, 'out/report.html')
94
+
95
+ before(async function() {
96
+ fs.mkdirSync(fdTmp, { recursive: true })
97
+ srv = await ApiServer({ port: 0, host, token, dirWork })
98
+ port = srv.server.info.port
99
+ url = `http://${host}:${port}`
100
+ })
101
+
102
+ after(async function() {
103
+ if (srv) {
104
+ await srv.stop()
105
+ }
106
+ fs.rmSync(fdTmp, { recursive: true, force: true })
107
+ })
108
+
109
+ it('default export匯整四函數', function() {
110
+ assert.strict.deepEqual(Object.keys(ApiClient), ['cvMdTo', 'health', 'scanAssetPaths', 'readAssets'])
111
+ assert.strict.equal(ApiClient.cvMdTo, cvMdTo)
112
+ })
113
+
114
+ it('health', async function() {
115
+ let r = await health({ host, port, token })
116
+ assert.strict.equal(r.success, true)
117
+ assert.strict.equal(r.tokenRequired, true)
118
+ assert.strict.deepEqual(r.templates, ['temp_tpc.docx'])
119
+ })
120
+
121
+ it('host/port未給時取環境變數WMD2DOCX_HOST/WMD2DOCX_PORT', async function() {
122
+ process.env.WMD2DOCX_HOST = host
123
+ process.env.WMD2DOCX_PORT = String(port)
124
+ try {
125
+ let r = await health({ token })
126
+ assert.strict.equal(r.success, true)
127
+ }
128
+ finally {
129
+ delete process.env.WMD2DOCX_HOST
130
+ delete process.env.WMD2DOCX_PORT
131
+ }
132
+ })
133
+
134
+ it('host/port與環境變數皆未給時預設127.0.0.1:22000(與ApiServer預設埠一致)', async function() {
135
+ await assert.rejects(health({ token, timeoutMs: 3000 }), (e) => /^Unable to connect to the conversion service http:\/\/127\.0\.0\.1:22000: /.test(e) || /^Invalid response from the conversion service http:\/\/127\.0\.0\.1:22000/.test(e))
136
+ })
137
+
138
+ it('port非正整數視為未給', async function() {
139
+ await assert.rejects(health({ host, port: 'x', token, timeoutMs: 3000 }), (e) => /http:\/\/127\.0\.0\.1:22000/.test(e))
140
+ })
141
+
142
+ it('url給予時覆寫host/port', async function() {
143
+ let r = await health({ url, host: '10.255.255.1', port: 1, token })
144
+ assert.strict.equal(r.success, true)
145
+ })
146
+
147
+ it('health 連不上則reject', async function() {
148
+ await assert.rejects(health({ host: '127.0.0.1', port: 1, token, timeoutMs: 3000 }), (e) => /^Unable to connect to the conversion service http:\/\/127\.0\.0\.1:1: /.test(e))
149
+ })
150
+
151
+ it('cvMdTo fpOutHtml: 寫出檔案並回傳大小(自動夾帶md引用之圖)', async function() {
152
+ let r = await cvMdTo({ host, port, token, fpInMd: './test/report.md', fpOutHtml })
153
+ assert.strict.equal(r.nAssets, 1) //report.md 引用 ./cocktail.svg
154
+ assert.strict.equal(r.template, 'default')
155
+ assert.strict.equal(typeof r.ms, 'number')
156
+ assert.strict.equal(r.docx, undefined)
157
+ assert.strict.equal(r.html.fp, fpOutHtml)
158
+ assert.strict.equal(r.html.size, fs.statSync(fpOutHtml).size)
159
+ let h = fs.readFileSync(fpOutHtml, 'utf8')
160
+ assert.strict.equal(h.includes('計畫報告'), true)
161
+ assert.strict.equal(h.includes('<title>report</title>'), true) //name預設取md檔名
162
+ assert.strict.equal(h.includes('src="data:image/png;base64,'), true)
163
+ })
164
+
165
+ it('cvMdTo name/url尾斜線/環境變數token', async function() {
166
+ let fpOut2 = path.resolve(fdTmp, 'out/n.html')
167
+ process.env.WMD2DOCX_TOKEN = token
168
+ try {
169
+ let r = await cvMdTo({ url: `${url}///`, fpInMd: './test/report.md', fpOutHtml: fpOut2, name: '自訂名' })
170
+ assert.strict.equal(r.html.fp, fpOut2)
171
+ assert.strict.equal(fs.readFileSync(fpOut2, 'utf8').includes('<title>自訂名</title>'), true)
172
+ }
173
+ finally {
174
+ delete process.env.WMD2DOCX_TOKEN
175
+ }
176
+ })
177
+
178
+ it('cvMdTo 輸入檢查', async function() {
179
+ await assert.rejects(cvMdTo({ host, port, token, fpOutHtml }), (e) => e === 'fpInMd must be a non-empty string')
180
+ await assert.rejects(cvMdTo({ host, port, token, fpInMd: './test/nope.md', fpOutHtml }), (e) => /^fpInMd\[.+nope\.md\] does not exist$/.test(e))
181
+ await assert.rejects(cvMdTo({ host, port, token, fpInMd: './test/report.md', fpOutHtml, fpInTemp: './nope.docx' }), (e) => /^fpInTemp\[.+nope\.docx\] does not exist$/.test(e))
182
+ })
183
+
184
+ it('cvMdTo 資產缺漏預設reject, allowMissingAssets=true放行', async function() {
185
+ let fpMd = path.resolve(fdTmp, 'missing.md')
186
+ fs.writeFileSync(fpMd, '# m\n\n![x](nope.png)\n![y](nope2.png)\n', 'utf8')
187
+ await assert.rejects(cvMdTo({ host, port, token, fpInMd: fpMd, fpOutHtml }), (e) => e === '2 asset file(s) referenced in md do not exist: nope.png, nope2.png. Set allowMissingAssets:true to skip this check')
188
+ let r = await cvMdTo({ host, port, token, fpInMd: fpMd, fpOutHtml, allowMissingAssets: true })
189
+ assert.strict.equal(r.nAssets, 0)
190
+ assert.strict.equal(r.html.size > 0, true)
191
+ })
192
+
193
+ it('cvMdTo fpInTemp 以base64夾帶模板(template=request)', async function() {
194
+ let r = await cvMdTo({ host, port, token, fpInMd: './test/report.md', fpOutHtml, fpInTemp: './src/templates/temp_tpc.docx' })
195
+ assert.strict.equal(r.template, 'request')
196
+ })
197
+
198
+ it('cvMdTo templateName 指定服務端模板(template=server), 不存在則HTTP錯誤不重試', async function() {
199
+ let r = await cvMdTo({ host, port, token, fpInMd: './test/report.md', fpOutHtml, templateName: 'temp_tpc.docx' })
200
+ assert.strict.equal(r.template, 'server')
201
+ await assert.rejects(cvMdTo({ host, port, token, fpInMd: './test/report.md', fpOutHtml, templateName: 'nope.docx' }), (e) => e === 'Conversion failed: templateName[nope.docx] does not exist (available templates: temp_tpc.docx)')
202
+ })
203
+
204
+ it('cvMdTo token錯誤', async function() {
205
+ await assert.rejects(cvMdTo({ host, port, token: 'bad', fpInMd: './test/report.md', fpOutHtml }), (e) => e === 'Conversion failed: unauthorized: a valid x-api-token header is required')
206
+ })
207
+
208
+ it('cvMdTo 連不上: retries=0不重試', async function() {
209
+ let t0 = Date.now()
210
+ await assert.rejects(cvMdTo({ host: '127.0.0.1', port: 1, token, fpInMd: './test/report.md', fpOutHtml, retries: 0 }), (e) => /^Unable to connect to the conversion service http:\/\/127\.0\.0\.1:1 \(retried 0 times\): /.test(e))
211
+ assert.strict.equal(Date.now() - t0 < 3000, true) //無重試等待
212
+ })
213
+
214
+ it('cvMdTo 逾時: 不重試, 訊息含timeoutMs', async function() {
215
+ //以極短timeout使fetch逾時(轉檔需時>1ms)
216
+ await assert.rejects(cvMdTo({ host, port, token, fpInMd: './test/report.md', fpOutHtml, timeoutMs: 1 }), (e) => e === `Conversion timed out (no response within 1 ms): ${url}`)
217
+ })
218
+
219
+ })
220
+
221
+ })
@@ -0,0 +1,191 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import assert from 'assert'
4
+ import ApiServer from '../src/ApiServer.mjs'
5
+
6
+
7
+ //ApiServer: 直打HTTP端點(不經UI); docx相關以html路徵替代, 不需Word
8
+ describe('ApiServer', function() {
9
+
10
+ let fdTmp = path.resolve('./tmp/zt_ApiServer')
11
+ let dirWork = path.resolve(fdTmp, 'work')
12
+ let token = 'tk-test'
13
+ let svgB64 = fs.readFileSync('./test/cocktail.svg').toString('base64')
14
+ let srv = null
15
+ let url = ''
16
+
17
+ let post = (body, q = '', tk = token) => {
18
+ let headers = { 'content-type': 'application/json' }
19
+ if (tk !== '') {
20
+ headers['x-api-token'] = tk
21
+ }
22
+ return fetch(`${url}/api/convert${q}`, { method: 'POST', headers, body: JSON.stringify(body) })
23
+ }
24
+
25
+ before(async function() {
26
+ fs.mkdirSync(fdTmp, { recursive: true })
27
+ srv = await ApiServer({ port: 0, host: '127.0.0.1', token, dirWork }) //port 0 由系統配置, 避免並行測試撞埠
28
+ url = `http://127.0.0.1:${srv.server.info.port}`
29
+ })
30
+
31
+ after(async function() {
32
+ if (srv) {
33
+ await srv.stop()
34
+ }
35
+ fs.rmSync(fdTmp, { recursive: true, force: true })
36
+ })
37
+
38
+ it('回傳物件與settings', function() {
39
+ assert.strict.equal(typeof srv.server.info.port, 'number')
40
+ assert.strict.equal(typeof srv.getState, 'function')
41
+ assert.strict.equal(typeof srv.stop, 'function')
42
+ assert.strict.equal(srv.settings.host, '127.0.0.1')
43
+ assert.strict.equal(srv.settings.token, token)
44
+ assert.strict.equal(srv.settings.maxMb, 256)
45
+ assert.strict.equal(srv.settings.maxBytes, 256 * 1024 * 1024)
46
+ assert.strict.equal(srv.settings.dirWork, dirWork)
47
+ assert.strict.equal(srv.settings.templateDef, 'temp_tpc.docx')
48
+ assert.strict.equal(/src[\\/]templates$/.test(srv.settings.dirTemplates), true) //預設模板夾為套件 src/templates
49
+ assert.strict.equal(fs.existsSync(dirWork), true) //啟動時建立工作夾
50
+ })
51
+
52
+ it('GET / 不需token, 回服務說明', async function() {
53
+ let res = await fetch(`${url}/`)
54
+ assert.strict.equal(res.status, 200)
55
+ let r = await res.json()
56
+ assert.strict.equal(r.service, 'w-md2docx')
57
+ assert.strict.deepEqual(Object.keys(r.endpoints), ['GET /api/health', 'GET /api/selftest', 'POST /api/convert'])
58
+ })
59
+
60
+ it('/api/* 無token或錯token回401', async function() {
61
+ let res = await fetch(`${url}/api/health`)
62
+ assert.strict.equal(res.status, 401)
63
+ let r = await res.json()
64
+ assert.strict.deepEqual(r, { success: false, error: 'unauthorized: a valid x-api-token header is required' })
65
+ let res2 = await post({ md: '# t', out: 'html' }, '', 'bad')
66
+ assert.strict.equal(res2.status, 401)
67
+ })
68
+
69
+ it('GET /api/health 回環境狀態', async function() {
70
+ let res = await fetch(`${url}/api/health`, { headers: { 'x-api-token': token } })
71
+ assert.strict.equal(res.status, 200)
72
+ let r = await res.json()
73
+ assert.strict.equal(r.success, true)
74
+ assert.strict.equal(r.platform, process.platform)
75
+ assert.strict.equal(r.node, process.version)
76
+ assert.strict.equal(r.tokenRequired, true)
77
+ assert.strict.equal(r.dirWork, dirWork)
78
+ assert.strict.deepEqual(r.templates, ['temp_tpc.docx'])
79
+ assert.strict.equal(/temp_tpc\.docx$/.test(r.templateDefault), true)
80
+ assert.strict.equal(r.queue, 0)
81
+ assert.strict.equal(typeof r.winwordProcesses, 'number')
82
+ assert.strict.equal(r.maxBytes, 256 * 1024 * 1024)
83
+ assert.strict.equal(typeof r.docxReady, 'boolean')
84
+ let st = await srv.getState()
85
+ assert.strict.equal(st.docxReady, r.docxReady)
86
+ })
87
+
88
+ it('POST /api/convert out=html: 回傳結構與資產內嵌', async function() {
89
+ let res = await post({
90
+ md: '# 標題A\n\n![p](pics/圖.svg)',
91
+ name: '報告',
92
+ out: 'html',
93
+ assets: [{ path: 'pics/圖.svg', base64: svgB64 }],
94
+ })
95
+ assert.strict.equal(res.status, 200)
96
+ let r = await res.json()
97
+ assert.strict.equal(r.success, true)
98
+ assert.strict.equal(r.name, '報告')
99
+ assert.strict.equal(r.out, 'html')
100
+ assert.strict.equal(r.nAssets, 1)
101
+ assert.strict.equal(r.template, 'default') //服務端預設模板(temp_tpc.docx)存在
102
+ assert.strict.equal(typeof r.ms, 'number')
103
+ assert.strict.equal(r.docx, undefined)
104
+ assert.strict.equal(r.html.fileName, '報告.html')
105
+ assert.strict.equal(r.html.mime, 'text/html; charset=utf-8')
106
+ let h = Buffer.from(r.html.base64, 'base64').toString('utf8')
107
+ assert.strict.equal(h.includes('標題A'), true)
108
+ assert.strict.equal(h.includes('src="data:image/png;base64,'), true)
109
+ })
110
+
111
+ it('POST /api/convert mdBase64 與 templateName 指定服務端模板', async function() {
112
+ let res = await post({ mdBase64: Buffer.from('# b64', 'utf8').toString('base64'), out: 'html', templateName: 'temp_tpc.docx' })
113
+ assert.strict.equal(res.status, 200)
114
+ let r = await res.json()
115
+ assert.strict.equal(r.template, 'server')
116
+ })
117
+
118
+ it('POST /api/convert templateBase64 夾帶模板, 用畢刪除暫存模板', async function() {
119
+ let tplB64 = fs.readFileSync('./src/templates/temp_tpc.docx').toString('base64')
120
+ let res = await post({ md: '# t', out: 'html', templateBase64: tplB64 })
121
+ assert.strict.equal(res.status, 200)
122
+ let r = await res.json()
123
+ assert.strict.equal(r.template, 'request')
124
+ assert.strict.equal(fs.readdirSync(dirWork).filter((v) => v.startsWith('tmpl_')).length, 0)
125
+ })
126
+
127
+ it('POST /api/convert 非JSON物件回400', async function() {
128
+ let res = await fetch(`${url}/api/convert`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-token': token }, body: '"str"' })
129
+ assert.strict.equal(res.status, 400)
130
+ let r = await res.json()
131
+ assert.strict.deepEqual(r, { success: false, error: 'parameters must be provided as a JSON object' })
132
+ })
133
+
134
+ it('POST /api/convert 輸入錯誤回400(md為空/templateName不存在/資產穿越)', async function() {
135
+ let res1 = await post({ out: 'html' })
136
+ assert.strict.equal(res1.status, 400)
137
+ assert.strict.deepEqual(await res1.json(), { success: false, error: 'md is empty: md (string) or mdBase64 is required' })
138
+ let res2 = await post({ md: '# t', out: 'html', templateName: 'nope.docx' })
139
+ assert.strict.equal(res2.status, 400)
140
+ assert.strict.deepEqual(await res2.json(), { success: false, error: 'templateName[nope.docx] does not exist (available templates: temp_tpc.docx)' })
141
+ let res3 = await post({ md: '# t', out: 'html', assets: [{ path: '../x.png', base64: 'QUI=' }] })
142
+ assert.strict.equal(res3.status, 400)
143
+ assert.strict.deepEqual(await res3.json(), { success: false, error: 'asset.path is outside the working folder: ../x.png' })
144
+ })
145
+
146
+ it('POST /api/convert 轉檔錯誤(資產缺漏)回500', async function() {
147
+ let res = await post({ md: '# t\n\n<img src="no.png" />', out: 'html' })
148
+ assert.strict.equal(res.status, 500)
149
+ assert.strict.deepEqual(await res.json(), { success: false, error: 'Failed to convert md to html: the referenced file (no.png) does not exist, please make sure it is included in assets' })
150
+ })
151
+
152
+ it('POST /api/convert?download=1 單一格式直接回二進位, 中文檔名走filename*', async function() {
153
+ let res = await post({ md: '# hi', out: 'html', name: '報告' }, '?download=1')
154
+ assert.strict.equal(res.status, 200)
155
+ assert.strict.equal(res.headers.get('content-type').startsWith('text/html'), true)
156
+ assert.strict.equal(res.headers.get('content-disposition'), `attachment; filename="__.html"; filename*=UTF-8''%E5%A0%B1%E5%91%8A.html`)
157
+ let h = await res.text()
158
+ assert.strict.equal(h.includes('hi'), true)
159
+ })
160
+
161
+ it('POST /api/convert?download=true out=both 轉檔前即回400', async function() {
162
+ let res = await post({ md: '# hi', out: 'both' }, '?download=true')
163
+ assert.strict.equal(res.status, 400)
164
+ assert.strict.deepEqual(await res.json(), { success: false, error: 'download only supports out of html or docx (single format)' })
165
+ })
166
+
167
+ it('工作夾於請求後不殘留job_', async function() {
168
+ assert.strict.equal(fs.readdirSync(dirWork).filter((v) => v.startsWith('job_')).length, 0)
169
+ })
170
+
171
+ it('opt非法值採預設; autoStart=false不啟動', async function() {
172
+ let s2 = await ApiServer({ port: 'x', host: '', token: 5, maxMb: -1, templateDef: '', autoStart: false, dirWork: path.resolve(fdTmp, 'work2') })
173
+ assert.strict.equal(s2.settings.port, 22000)
174
+ assert.strict.equal(s2.settings.host, '0.0.0.0')
175
+ assert.strict.equal(s2.settings.token, '')
176
+ assert.strict.equal(s2.settings.maxMb, 256)
177
+ assert.strict.equal(s2.settings.templateDef, 'temp_tpc.docx')
178
+ assert.strict.equal(s2.server.info.started, 0) //未啟動
179
+ await s2.stop()
180
+ })
181
+
182
+ it('stop後不再回應', async function() {
183
+ let s3 = await ApiServer({ port: 0, host: '127.0.0.1', dirWork: path.resolve(fdTmp, 'work3') })
184
+ let u3 = `http://127.0.0.1:${s3.server.info.port}`
185
+ let res = await fetch(`${u3}/api/health`)
186
+ assert.strict.equal(res.status, 200) //無token時不需標頭
187
+ await s3.stop()
188
+ await assert.rejects(fetch(`${u3}/api/health`, { signal: AbortSignal.timeout(3000) }))
189
+ })
190
+
191
+ })
@@ -0,0 +1,3 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
3
+ <svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M948.6 703c-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2s-7.5 3-12.7 10.2c-5.1 7.2-12 17-25.7 17-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2s-7.5 3-12.7 10.2c-5.1 7.2-12 17-25.7 17s-20.7-9.8-25.7-17c-5.1-7.3-7.7-10.2-12.7-10.2-4.4 0-8-3.6-8-8s3.6-8 8-8c13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 5 0 7.5-3 12.7-10.2 5.1-7.2 12-17 25.7-17 13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 5 0 7.5-3 12.7-10.2 5.1-7.2 12-17 25.7-17s20.7 9.8 25.7 17c5.1 7.3 7.7 10.2 12.7 10.2 4.4 0 8 3.6 8 8s-3.6 8-8 8zM948.6 653c-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2s-7.5 3-12.7 10.2c-5.1 7.2-12 17-25.7 17-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2s-7.5 3-12.7 10.2c-5.1 7.2-12 17-25.7 17s-20.7-9.8-25.7-17c-5.1-7.3-7.7-10.2-12.7-10.2-4.4 0-8-3.6-8-8s3.6-8 8-8c13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2s7.5-3 12.7-10.2c5.1-7.2 12-17 25.7-17 13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 5 0 7.5-3 12.7-10.2 5.1-7.2 12-17 25.7-17s20.7 9.8 25.7 17c5.1 7.3 7.7 10.2 12.7 10.2 4.4 0 8 3.6 8 8s-3.6 8-8 8zM261.6 282.9c-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2s-7.5 3-12.7 10.2c-5.1 7.2-12 17-25.7 17-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2-5 0-7.5 3-12.7 10.2-5.1 7.2-12 17-25.7 17-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2-4.4 0-8-3.6-8-8s3.6-8 8-8c13.8 0 20.7 9.9 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 5 0 7.5-3 12.7-10.2 5.1-7.2 12-17 25.7-17 13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 5 0 7.5-3 12.7-10.2 5.1-7.2 12-17 25.7-17s20.7 9.8 25.7 17c5.1 7.3 7.7 10.2 12.7 10.2 4.4 0 8 3.6 8 8s-3.6 8-8 8zM261.6 233c-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2s-7.5 3-12.7 10.2c-5.1 7.2-12 17-25.7 17-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2-5 0-7.5 3-12.7 10.2-5.1 7.2-12 17-25.7 17-13.8 0-20.7-9.8-25.7-17-5.1-7.3-7.7-10.2-12.7-10.2-4.4 0-8-3.6-8-8s3.6-8 8-8c13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 5 0 7.5-3 12.7-10.2 5.1-7.2 12-17 25.7-17 13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 5 0 7.5-3 12.7-10.2 5.1-7.2 12-17 25.7-17 13.8 0 20.7 9.8 25.7 17 5.1 7.3 7.7 10.2 12.7 10.2 4.4 0 8 3.6 8 8s-3.6 8-8 8z" fill="#9A2D2F" /><path d="M348.7 103.3m-32 0a32 32 0 1 0 64 0 32 32 0 1 0-64 0Z" fill="#FFEB4D" /><path d="M348.7 143.3c-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40-18 40-40 40z m0-64c-13.2 0-24 10.8-24 24s10.8 24 24 24 24-10.8 24-24-10.8-24-24-24z" fill="#9A2D2F" /><path d="M554.1 408.8c58.3 0 105.5-47.2 105.5-105.5s-47.2-105.5-105.5-105.5S448.6 245 448.6 303.3s47.2 105.5 105.5 105.5z" fill="#C0FCD0" /><path d="M330.6 627.3l224-324h-448z" fill="#FFFFFF" /><path d="M500.6 381.3h-340l170 246z" fill="#FFACC2" /><path d="M330.6 613.2l54.5-78.8c-18.2 5.1-28.8 25.9-49.1 23.5-25.5-3-24-37.8-42.7-40.5-3.3-0.5-15.1-3.7-28 1.3l65.3 94.5z" fill="#FFFFFF" /><path d="M823.1 408.8c58.3 0 105.5-47.2 105.5-105.5s-47.2-105.5-105.5-105.5S717.6 245 717.6 303.3s47.2 105.5 105.5 105.5z" fill="#FFEB4D" /><path d="M599.6 627.3l224-324h-448z" fill="#FFFFFF" /><path d="M769.5 381.3h-340l170 246z" fill="#C0FCD0" /><path d="M599.6 613.2l54.5-78.8c-18.2 5.1-28.8 25.9-49.1 23.5-25.5-3-24-37.8-42.7-40.5-3.3-0.5-15.1-3.7-28 1.3l65.3 94.5z" fill="#FFFFFF" /><path d="M920.6 943.3H607.3V630.2l158.4-229.1c16.9 10 36.5 15.7 57.5 15.7 62.6 0 113.5-50.9 113.5-113.5s-50.9-113.5-113.5-113.5c-59.9 0-109.1 46.6-113.2 105.4h-42.6c-1.2-17.5-6.4-33.9-14.7-48.3-0.1-0.1-0.1-0.3-0.2-0.4-0.1-0.2-0.2-0.4-0.3-0.5-19.7-33.6-56.2-56.2-97.9-56.2s-78.2 22.6-97.9 56.2c-0.1 0.2-0.2 0.3-0.3 0.5-0.1 0.1-0.1 0.3-0.2 0.4-8.3 14.4-13.5 30.8-14.7 48.3h-65.3c-0.3 0-0.6 0-0.9 0.1H106.6c-3 0-5.7 1.7-7.1 4.3-1.4 2.6-1.2 5.8 0.5 8.3l222.6 321.9v313.5h-225c-4.4 0-8 3.6-8 8s3.6 8 8 8h823c4.4 0 8-3.6 8-8s-3.6-8-8-8z m-10.4-681c0.4-0.2 0.7-0.5 1-0.7 6 12.7 9.4 26.8 9.4 41.8s-3.4 29.1-9.4 41.8c-0.3-0.3-0.7-0.5-1-0.7l-71.1-41 71.1-41.2z m-6.9-14.4c-0.4 0.1-0.8 0.3-1.1 0.5l-71.1 41v-82.1c0-0.4 0-0.9-0.1-1.3 29.9 2.6 56.1 18.5 72.3 41.9z m-72.2 151.3v-82.1l71.1 41c0.4 0.2 0.7 0.4 1.1 0.5-16.2 23.4-42.4 39.4-72.3 41.8 0.1-0.3 0.1-0.7 0.1-1.2zM599.9 612.7h-0.6-0.1L466.9 421.3l-22.1-32h304.3c1.1 0.9 2.2 1.9 3.3 2.7L599.9 612.7z m174.8-224.8l40.4-58.4v69.7c0 0.4 0 0.9 0.1 1.3-14.6-1.2-28.3-5.6-40.5-12.6z m40.5-181.8c-0.1 0.4-0.1 0.8-0.1 1.3v82.1l-71.1-41c-0.4-0.2-0.7-0.4-1.1-0.5 16.2-23.5 42.4-39.4 72.3-41.9zM735 261.6c0.3 0.3 0.7 0.5 1 0.7l57.1 33h-67.2c1-12 4.2-23.4 9.1-33.7z m-92.7 0.1c4.9 10.3 8 21.7 9 33.6h-67.2l58.2-33.6z m-80.2-55.5c29.8 2.4 55.9 18.4 72.1 41.7l-72.1 41.6v-83.3z m-16 0v83.3L474 247.8c16.2-23.3 42.3-39.2 72.1-41.6zM466 261.7l58.2 33.6H457c0.9-12 4.1-23.3 9-33.6z m243.9 49.6H808.2L797 327.7l-31.5 45.6H433.7l-42.9-62h319.1z m-588 0h249.6l42.9 62H164.7l-42.8-62z m54 78h249.5l28.1 40.7 1.9 2.7-123.5 178.7c-0.4-0.1-0.8-0.1-1.3-0.1s-0.8 0-1.3 0.1L175.9 389.3z m162.7 240.5l126.5-183 126.1 182.5v314H338.6V629.8z" fill="#9A2D2F" /></svg>
package/test/report.md ADDED
@@ -0,0 +1,257 @@
1
+ <div pretitle style="font-size:14pt; text-align:center;">
2
+ 測試範例
3
+ </div>
4
+
5
+ # 計畫報告
6
+
7
+ ## 一、摘要
8
+
9
+ 本計畫旨在提升 **系統效能** 與 *資料處理品質*。
10
+ 專案期間共完成三大目標:
11
+
12
+ 1. 資料流程自動化
13
+ 2. 系統效能優化
14
+ 3. 使用者介面改善
15
+
16
+ > 「流程讓人專注,工具讓人加速。」—— 計畫紀錄語
17
+
18
+ ---
19
+
20
+ ## 二、背景與目的
21
+
22
+ ### 2.1 背景說明
23
+
24
+ 隨著資料量逐年增加,需要一套能:
25
+
26
+ - 自動化處理
27
+ - 智慧化調整
28
+ - 高效能儲存
29
+
30
+ 的整合方案。
31
+
32
+ ### 2.2 計畫目的(含核取方塊)
33
+
34
+ - [x] 建置自動化流程
35
+ - [x] 建立監控警示
36
+ - [ ] 完成第三方 API 串接(下一期目標)
37
+
38
+ ---
39
+
40
+ ## 三、工作項目與進度
41
+
42
+ ### 3.1 工作項目列表
43
+
44
+ 測試無序巢狀清單
45
+
46
+ - **資料前處理**
47
+ - 格式轉換
48
+ - 欄位標準化
49
+ - 雜訊清理
50
+
51
+ - **資料分析模組**
52
+ - 統計分析
53
+ - 時間序列模型
54
+ - ARIMA
55
+ - Prophet
56
+
57
+ - **視覺化平台**
58
+ - 儀表板建立
59
+ - 自訂圖層呈現
60
+
61
+ - **資料流程自動化**:各自獨立運行,且兼顧完整性與流程自動化之執行方式。
62
+
63
+ - **使用者介面改善**:通過多人協作與回饋等方式,協助使用者介面改善。
64
+
65
+ - **統計分析**
66
+
67
+ 須說明統計分析之數學模型,以及輸出入之資料格式。
68
+
69
+ - **時間序列模型**
70
+
71
+ 須說明所採用之時間序列模型,包含數學基礎、演算法與回測之統計分析結果。
72
+
73
+ 測試有序巢狀清單
74
+
75
+ 1. **資料前處理**
76
+ - 格式轉換
77
+ - 欄位標準化
78
+ - 雜訊清理
79
+
80
+ 1. **資料分析模組**
81
+ - 統計分析
82
+ - 時間序列模型
83
+ - ARIMA
84
+ - Prophet
85
+
86
+ 1. **視覺化平台**
87
+ - 儀表板建立
88
+ - 自訂圖層呈現
89
+
90
+ 1. **資料流程自動化**:各自獨立運行,且兼顧完整性與流程自動化之執行方式。
91
+
92
+ 1. **使用者介面改善**:通過多人協作與回饋等方式,協助使用者介面改善。
93
+
94
+ 1. **統計分析**
95
+
96
+ 須說明統計分析之數學模型,以及輸出入之資料格式。
97
+
98
+ 1. **時間序列模型**
99
+
100
+ 須說明所採用之時間序列模型,包含數學基礎、演算法與回測之統計分析結果。
101
+
102
+ ---
103
+
104
+ ## 四、成果內容
105
+
106
+ ### 4.1 表格測試
107
+
108
+ | 項目 | 完成度 | 備註 |
109
+ |------|--------|------|
110
+ | 自動化流程 | 100% | 已完成 |
111
+ | 系統效能優化 | 95% | 尚可持續改善 |
112
+ | 介面改善 | 80% | 預計下期延伸 |
113
+
114
+ ---
115
+
116
+ ### 4.1.1 表格字型大小標記(data-for="tab")
117
+
118
+ 以空 div 標記於表格前,data-style-th/data-style-td 分別設定該表 th/td 字型大小,處理後標記自動移除。
119
+
120
+ 大字級表(th 16pt、td 13pt):
121
+
122
+ <div data-for="tab" data-style-th="font-size:16pt;" data-style-td="font-size:13pt;"></div>
123
+
124
+ | 欄位 | 說明 |
125
+ |------|------|
126
+ | 甲 | 大字級示範 |
127
+ | 乙 | 大字級示範 |
128
+
129
+ 小字級表(僅設 td 8pt,th 維持預設):
130
+
131
+ <div data-for="tab" data-style-td="font-size:8pt;"></div>
132
+
133
+ | 代號 | 數量 |
134
+ |------|------|
135
+ | A | 1 |
136
+ | B | 2 |
137
+
138
+ ---
139
+
140
+ ### 4.1.2 逐欄樣式標記(data-style-th-{n}/data-style-td-{n})
141
+
142
+ n 為 0 起算之欄索引(4 欄則 n=0~3),套用至該欄各列之 th/td。可與全域 data-style-th/data-style-td 併用,逐欄設定後套故可覆寫全域。
143
+
144
+ 下表:全欄 td 10pt(全域);第 0 欄不換行(轉 docx 時文字不斷行);第 1 欄表頭置中;第 2 欄字級縮小覆寫全域。
145
+
146
+ <div data-for="tab" data-style-td="font-size:10pt;" data-style-td-0="white-space:nowrap;" data-style-th-1="text-align:center;" data-style-td-2="font-size:8pt;"></div>
147
+
148
+ | 項目代號 | 狀態 | 備註說明 |
149
+ |------|------|------|
150
+ | A-001-XYZ | 完成 | 第二欄字級較小 |
151
+ | B-002-ABC | 進行中 | 第二欄字級較小 |
152
+
153
+ ---
154
+
155
+ ### 4.2 程式碼區塊(JavaScript)
156
+
157
+ ```js
158
+ import dayjs from 'dayjs'
159
+
160
+ function calcPerformance(a, b) {
161
+ const diff = b - a
162
+ return {
163
+ start: a,
164
+ end: b,
165
+ improved: diff,
166
+ time: dayjs().format('YYYY-MM-DD HH:mm:ss')
167
+ }
168
+ }
169
+
170
+ console.log(calcPerformance(100, 260))
171
+ ```
172
+
173
+ ### 4.3 程式碼區塊(bash)
174
+
175
+ ```bash
176
+ #!/bin/bash
177
+ echo "Start processing..."
178
+ python analyze.py --input=data.json --output=report.csv
179
+ echo "Done."
180
+ ```
181
+
182
+ ---
183
+
184
+ ## 五、圖片、連結與註腳
185
+
186
+ ### 5.1 相關連結
187
+
188
+ - 官方網站:https://example.com
189
+ - GitHub Repo: [點我連結](https://github.com/)
190
+ - 參考文獻:見文末註腳[^ref1]
191
+
192
+ ### 5.2 圖片插入
193
+
194
+ ![測試圖片](./cocktail.svg)
195
+
196
+ ---
197
+
198
+ ## 六、結論與後續建議
199
+
200
+ ### 6.1 成果總結
201
+
202
+ 本計畫成功達成主要目標,並建立:
203
+
204
+ - 模組化程式架構
205
+ - 可擴充資料處理流程
206
+ - 具彈性的視覺化平台
207
+
208
+ ### 6.2 後續建議
209
+
210
+ 1. 增加第三方 API 整合
211
+ 2. 加強 AI 模型訓練與部署
212
+ 3. 系統導入自動化測試(CI/CD)
213
+
214
+ ---
215
+
216
+ ## 七、附錄
217
+
218
+ ### 7.1 引用區塊層級
219
+
220
+ > 第一層引用
221
+ >> 第二層引用
222
+ >>> 第三層引用
223
+
224
+ ---
225
+
226
+ ### 7.2 數學公式
227
+
228
+ 行內公式:$E = mc^2$
229
+
230
+ 區塊公式:
231
+
232
+ $$
233
+ y = ax^2 + bx + c
234
+ $$
235
+
236
+ ---
237
+
238
+ ### 7.3 刪除隱藏元素
239
+
240
+ <div style="margin:1rem 0;">
241
+ <div style="margin:1rem 0;">顯示文字</div>
242
+ <div style="margin:1rem 0; display:none;">不能顯示文字</div>
243
+ </div>
244
+
245
+ ---
246
+
247
+ ### 7.4 特殊元素
248
+
249
+ 前段文字
250
+ <div data-for="br"></div>
251
+ 後段文字
252
+
253
+ ---
254
+
255
+ ## 註腳
256
+
257
+ [^ref1]: 本註腳示範 Markdown footnote 語法。