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,132 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import assert from 'assert'
4
+ import cvMdTo from '../src/cvMdTo.mjs'
5
+
6
+
7
+ //cvMdTo之html路徑(不需Microsoft Word); docx路徑於unit-cvMdToDocx以Word守門測試
8
+ describe('cvMdTo', function() {
9
+
10
+ let fdTmp = path.resolve('./tmp/zt_cvMdTo')
11
+ let svgB64 = fs.readFileSync('./test/cocktail.svg').toString('base64')
12
+ let toHtml = (r) => Buffer.from(r.html.base64, 'base64').toString('utf8')
13
+
14
+ before(function() {
15
+ fs.mkdirSync(fdTmp, { recursive: true })
16
+ })
17
+
18
+ after(function() {
19
+ fs.rmSync(fdTmp, { recursive: true, force: true })
20
+ })
21
+
22
+ it('out=html: 回傳物件形狀與檔名', async function() {
23
+ let r = await cvMdTo({ md: '# 標題A\n\n內文B', name: '報告R00.01', out: 'html' })
24
+ assert.strict.equal(r.name, '報告R00.01')
25
+ assert.strict.equal(r.out, 'html')
26
+ assert.strict.equal(r.nAssets, 0)
27
+ assert.strict.equal(typeof r.ms, 'number')
28
+ assert.strict.equal(r.docx, undefined)
29
+ assert.strict.equal(r.html.fileName, '報告R00.01.html')
30
+ assert.strict.equal(r.html.mime, 'text/html; charset=utf-8')
31
+ assert.strict.equal(r.html.size, Buffer.from(r.html.base64, 'base64').length)
32
+ let h = toHtml(r)
33
+ assert.strict.equal(h.includes('標題A'), true)
34
+ assert.strict.equal(h.includes('內文B'), true)
35
+ assert.strict.equal(h.includes('<title>報告R00.01</title>'), true) //title取自md檔名(=name)
36
+ })
37
+
38
+ it('name含路徑與非法字元時被清理', async function() {
39
+ let r = await cvMdTo({ md: '# t', name: '../a/b:c.md', out: 'html' })
40
+ assert.strict.equal(r.name, 'b_c')
41
+ assert.strict.equal(r.html.fileName, 'b_c.html')
42
+ })
43
+
44
+ it('name未給則為output', async function() {
45
+ let r = await cvMdTo({ md: '# t', out: 'html' })
46
+ assert.strict.equal(r.name, 'output')
47
+ })
48
+
49
+ it('mdBase64可替代md', async function() {
50
+ let r = await cvMdTo({ mdBase64: Buffer.from('# 由b64', 'utf8').toString('base64'), out: 'html' })
51
+ assert.strict.equal(toHtml(r).includes('由b64'), true)
52
+ })
53
+
54
+ it('md優先於mdBase64', async function() {
55
+ let r = await cvMdTo({ md: '# 主', mdBase64: Buffer.from('# 副', 'utf8').toString('base64'), out: 'html' })
56
+ let h = toHtml(r)
57
+ assert.strict.equal(h.includes('主'), true)
58
+ assert.strict.equal(h.includes('副'), false)
59
+ })
60
+
61
+ it('md為空(未給/空白/非字串)則reject', async function() {
62
+ let msg = 'md is empty: md (string) or mdBase64 is required'
63
+ await assert.rejects(cvMdTo({ out: 'html' }), (e) => e === msg)
64
+ await assert.rejects(cvMdTo({ md: ' \n', out: 'html' }), (e) => e === msg)
65
+ await assert.rejects(cvMdTo({ md: 123, out: 'html' }), (e) => e === msg)
66
+ await assert.rejects(cvMdTo(), (e) => e === msg)
67
+ })
68
+
69
+ it('資產: 以md內原字串為path, 同時支援markdown圖與<img>之URL編碼引用, 圖片內嵌為base64', async function() {
70
+ let r = await cvMdTo({
71
+ md: '# t\n\n![p](pics/圖.svg)\n\n<img src="pics/%E5%9C%96.svg" />',
72
+ out: 'html',
73
+ assets: [{ path: 'pics/%E5%9C%96.svg', base64: svgB64 }],
74
+ })
75
+ assert.strict.equal(r.nAssets, 1)
76
+ let h = toHtml(r)
77
+ assert.strict.equal((h.match(/src="data:image\/png;base64,/g) || []).length, 2)
78
+ assert.strict.equal(h.includes('pics/'), false) //相對路徵已全數替換
79
+ })
80
+
81
+ it('資產: assets非陣列視為無資產', async function() {
82
+ let r = await cvMdTo({ md: '# t', out: 'html', assets: 'x' })
83
+ assert.strict.equal(r.nAssets, 0)
84
+ })
85
+
86
+ it('資產缺漏(<img>引用): 英文可行動訊息且不含工作夾路徑', async function() {
87
+ await assert.rejects(cvMdTo({ md: '# t\n\n<img src="pics/no.png" />', out: 'html' }), (e) => {
88
+ assert.strict.equal(e, 'Failed to convert md to html: the referenced file (pics/no.png) does not exist, please make sure it is included in assets')
89
+ return true
90
+ })
91
+ })
92
+
93
+ it('資產穿越/絕對路徑/空base64: 以asset開頭之訊息reject', async function() {
94
+ await assert.rejects(cvMdTo({ md: '# t', out: 'html', assets: [{ path: '../x.png', base64: 'QUI=' }] }), (e) => e === 'asset.path is outside the working folder: ../x.png')
95
+ await assert.rejects(cvMdTo({ md: '# t', out: 'html', assets: [{ path: 'C:/x.png', base64: 'QUI=' }] }), (e) => e === 'asset.path must be a relative path: C:/x.png')
96
+ await assert.rejects(cvMdTo({ md: '# t', out: 'html', assets: [{ path: 'x.png' }] }), (e) => e === 'asset.base64 is empty: x.png')
97
+ })
98
+
99
+ it('optMd2html傳遞至w-md2html(imgWidthMax)', async function() {
100
+ let r = await cvMdTo({
101
+ md: '![p](pics/a.svg)',
102
+ out: 'html',
103
+ assets: [{ path: 'pics/a.svg', base64: svgB64 }],
104
+ optMd2html: { imgWidthMax: '123px' },
105
+ })
106
+ assert.strict.equal(/max-width:\s*123px/.test(toHtml(r)), true)
107
+ })
108
+
109
+ it('dirWork指定則作業夾建於其下; keepWork=true保留, false清除', async function() {
110
+ let dirWork = path.resolve(fdTmp, 'work')
111
+ await cvMdTo({ md: '# t', out: 'html', dirWork, keepWork: true })
112
+ let jobs = fs.readdirSync(dirWork).filter((v) => v.startsWith('job_'))
113
+ assert.strict.equal(jobs.length, 1)
114
+ assert.strict.equal(fs.existsSync(path.resolve(dirWork, jobs[0], 'output.md')), true)
115
+ assert.strict.equal(fs.existsSync(path.resolve(dirWork, jobs[0], 'output.html')), true)
116
+ await cvMdTo({ md: '# t', out: 'html', dirWork })
117
+ assert.strict.equal(fs.readdirSync(dirWork).filter((v) => v.startsWith('job_')).length, 1) //僅前一次保留者
118
+ })
119
+
120
+ it('keepWork非布林值視為false', async function() {
121
+ let dirWork = path.resolve(fdTmp, 'work2')
122
+ await cvMdTo({ md: '# t', out: 'html', dirWork, keepWork: 'true' })
123
+ assert.strict.equal(fs.readdirSync(dirWork).filter((v) => v.startsWith('job_')).length, 0)
124
+ })
125
+
126
+ it('失敗時作業夾仍被清除', async function() {
127
+ let dirWork = path.resolve(fdTmp, 'work3')
128
+ await assert.rejects(cvMdTo({ md: '# t\n\n<img src="no.png" />', out: 'html', dirWork }))
129
+ assert.strict.equal(fs.readdirSync(dirWork).filter((v) => v.startsWith('job_')).length, 0)
130
+ })
131
+
132
+ })
@@ -0,0 +1,185 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import zlib from 'zlib'
4
+ import { execFileSync } from 'child_process'
5
+ import assert from 'assert'
6
+ import cvMdToDocx from '../src/cvMdToDocx.mjs'
7
+ import cvMdTo from '../src/cvMdTo.mjs'
8
+ import { getFpExe } from '../src/utils.mjs'
9
+
10
+
11
+ //hasWord: 本機是否可調用 Microsoft Word(Windows + Word.Application COM 已註冊 + htmlToDocx.exe 存在)
12
+ //why: docx 階段須真 Word, 無 Word 之機器僅測輸入檢查, 實轉區塊整段略過(同 w-html2docx 之 isWindows 守門)
13
+ function hasWord() {
14
+ if (process.platform !== 'win32') {
15
+ return false
16
+ }
17
+ if (getFpExe() === '') {
18
+ return false
19
+ }
20
+ try {
21
+ execFileSync('reg', ['query', 'HKCR\\Word.Application'], { stdio: 'ignore', windowsHide: true })
22
+ return true
23
+ }
24
+ catch (err) {
25
+ return false
26
+ }
27
+ }
28
+
29
+
30
+ //readZipEntries: 讀取zip(docx)內各檔案, 回傳{name:Buffer}
31
+ //why: 以central directory解析, 不引入額外套件; docx為標準deflate/stored, 無zip64
32
+ function readZipEntries(buf) {
33
+ let iE = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
34
+ assert.strict.ok(iE >= 0, 'zip end of central directory not found')
35
+ let nEntries = buf.readUInt16LE(iE + 10)
36
+ let p = buf.readUInt32LE(iE + 16)
37
+ let kp = {}
38
+ for (let i = 0; i < nEntries; i++) {
39
+ assert.strict.equal(buf.readUInt32LE(p), 0x02014b50, 'central directory header signature')
40
+ let method = buf.readUInt16LE(p + 10)
41
+ let compSize = buf.readUInt32LE(p + 20)
42
+ let nameLen = buf.readUInt16LE(p + 28)
43
+ let extraLen = buf.readUInt16LE(p + 30)
44
+ let commentLen = buf.readUInt16LE(p + 32)
45
+ let offLocal = buf.readUInt32LE(p + 42)
46
+ let name = buf.toString('utf8', p + 46, p + 46 + nameLen)
47
+ let lNameLen = buf.readUInt16LE(offLocal + 26)
48
+ let lExtraLen = buf.readUInt16LE(offLocal + 28)
49
+ let start = offLocal + 30 + lNameLen + lExtraLen
50
+ let data = buf.subarray(start, start + compSize)
51
+ kp[name] = (method === 8) ? zlib.inflateRawSync(data) : Buffer.from(data)
52
+ p += 46 + nameLen + extraLen + commentLen
53
+ }
54
+ return kp
55
+ }
56
+
57
+
58
+ describe('cvMdToDocx 輸入檢查(不需Word)', function() {
59
+
60
+ it('fpInMd非有效字串', async function() {
61
+ await assert.rejects(cvMdToDocx('', './tmp/x.docx'), (e) => e === 'fpInMd must be a non-empty string')
62
+ await assert.rejects(cvMdToDocx(null, './tmp/x.docx'), (e) => e === 'fpInMd must be a non-empty string')
63
+ })
64
+
65
+ it('fpInMd不存在', async function() {
66
+ await assert.rejects(cvMdToDocx('./test/nope.md', './tmp/x.docx'), (e) => /^fpInMd\[.+nope\.md\] does not exist$/.test(e))
67
+ })
68
+
69
+ it('fpOutDocx非有效字串', async function() {
70
+ await assert.rejects(cvMdToDocx('./test/report.md', ''), (e) => e === 'fpOutDocx must be a non-empty string')
71
+ })
72
+
73
+ it('fpInTemp不存在', async function() {
74
+ await assert.rejects(cvMdToDocx('./test/report.md', './tmp/x.docx', { fpInTemp: './test/nope.docx' }), (e) => /^fpInTemp\[.+nope\.docx\] does not exist$/.test(e))
75
+ })
76
+
77
+ })
78
+
79
+
80
+ describe('cvMdToDocx 實轉(需Windows+Microsoft Word)', function() {
81
+
82
+ //check
83
+ if (!hasWord()) {
84
+ console.log('[unit-cvMdToDocx] Microsoft Word not available on this machine, skip real conversion tests')
85
+ return
86
+ }
87
+
88
+ let fdTmp = path.resolve('./tmp/zt_cvMdToDocx')
89
+ let fpIn = './test/report.md'
90
+ let fpOut = path.resolve(fdTmp, 'report.docx')
91
+ let fpOutHtml = path.resolve(fdTmp, 'report.html')
92
+ let fpInTemp = './src/templates/temp_tpc.docx'
93
+ let rt = null
94
+ let zip = null
95
+ let xml = ''
96
+
97
+ before(async function() {
98
+ this.timeout(180000)
99
+ fs.mkdirSync(fdTmp, { recursive: true })
100
+ rt = await cvMdToDocx(fpIn, fpOut, {
101
+ fpInTemp,
102
+ fpOutHtml,
103
+ optMd2html: { imgWidthMax: '500px' },
104
+ optHtml2docx: { imgRatioWidthMax: 0.5 },
105
+ })
106
+ zip = readZipEntries(fs.readFileSync(fpOut))
107
+ xml = zip['word/document.xml'].toString('utf8')
108
+ })
109
+
110
+ after(function() {
111
+ fs.rmSync(fdTmp, { recursive: true, force: true })
112
+ })
113
+
114
+ it('回傳物件: fpOutDocx/sizeDocx/sizeHtml/ms/fpOutHtml與實體檔一致', function() {
115
+ assert.strict.equal(rt.fpOutDocx, fpOut)
116
+ assert.strict.equal(rt.sizeDocx, fs.statSync(fpOut).size)
117
+ assert.strict.equal(rt.sizeDocx > 0, true)
118
+ assert.strict.equal(rt.fpOutHtml, fpOutHtml)
119
+ assert.strict.equal(rt.sizeHtml, fs.statSync(fpOutHtml).size)
120
+ assert.strict.equal(typeof rt.ms, 'number')
121
+ })
122
+
123
+ it('docx為zip且含主文件', function() {
124
+ assert.strict.equal(fs.readFileSync(fpOut).subarray(0, 2).toString('latin1'), 'PK')
125
+ assert.strict.equal(typeof zip['word/document.xml'], 'object')
126
+ assert.strict.equal(typeof zip['[Content_Types].xml'], 'object')
127
+ })
128
+
129
+ it('主文件含來源md之標題與清單文字', function() {
130
+ assert.strict.equal(xml.includes('計畫報告'), true) //h1
131
+ assert.strict.equal(xml.includes('資料流程自動化'), true) //li
132
+ })
133
+
134
+ it('內文圖片已嵌入(word/media)', function() {
135
+ let medias = Object.keys(zip).filter((v) => v.startsWith('word/media/'))
136
+ assert.strict.equal(medias.length > 0, true)
137
+ })
138
+
139
+ it('模板settings之doNotAutoCompressPictures保留', function() {
140
+ let settings = zip['word/settings.xml'].toString('utf8')
141
+ assert.strict.equal(settings.includes('<w:doNotAutoCompressPictures'), true)
142
+ })
143
+
144
+ it('未指定fpOutHtml時不留中介檔, 回傳無fpOutHtml', async function() {
145
+ this.timeout(180000)
146
+ let fpOut2 = path.resolve(fdTmp, 'report2.docx')
147
+ let r = await cvMdToDocx(fpIn, fpOut2, { fpInTemp })
148
+ assert.strict.equal(r.fpOutHtml, undefined)
149
+ assert.strict.equal(fs.existsSync(fpOut2), true)
150
+ assert.strict.equal(fs.existsSync(fpOut2.replace(/\.docx$/, '.html')), false)
151
+ })
152
+
153
+ it('並發兩份轉檔皆成功(內部佇列序列化)', async function() {
154
+ this.timeout(300000)
155
+ let [ra, rb] = await Promise.all([
156
+ cvMdToDocx(fpIn, path.resolve(fdTmp, 'a.docx'), { fpInTemp }),
157
+ cvMdToDocx(fpIn, path.resolve(fdTmp, 'b.docx'), { fpInTemp }),
158
+ ])
159
+ assert.strict.equal(ra.sizeDocx > 0, true)
160
+ assert.strict.equal(rb.sizeDocx > 0, true)
161
+ })
162
+
163
+ it('cvMdTo out非法值(如大小寫不符)退回docx', async function() {
164
+ this.timeout(180000)
165
+ let r = await cvMdTo({ md: '# t', out: 'HTML', fpInTemp, dirWork: fdTmp })
166
+ assert.strict.equal(r.out, 'docx')
167
+ assert.strict.equal(r.html, undefined)
168
+ assert.strict.equal(r.docx.size > 0, true)
169
+ })
170
+
171
+ it('cvMdTo out=both: html與docx內容皆回傳且msDocx存在', async function() {
172
+ this.timeout(180000)
173
+ let r = await cvMdTo({ md: '# 內容轉檔\n\n段落', name: '內容', out: 'both', fpInTemp, dirWork: fdTmp })
174
+ assert.strict.equal(r.out, 'both')
175
+ assert.strict.equal(r.html.fileName, '內容.html')
176
+ assert.strict.equal(r.docx.fileName, '內容.docx')
177
+ assert.strict.equal(r.docx.mime, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
178
+ assert.strict.equal(r.docx.size, Buffer.from(r.docx.base64, 'base64').length)
179
+ assert.strict.equal(typeof r.msDocx, 'number')
180
+ let z = readZipEntries(Buffer.from(r.docx.base64, 'base64'))
181
+ assert.strict.equal(z['word/document.xml'].toString('utf8').includes('內容轉檔'), true)
182
+ assert.strict.equal(fs.readdirSync(fdTmp).filter((v) => v.startsWith('job_')).length, 0) //作業夾已清除
183
+ })
184
+
185
+ })
@@ -0,0 +1,370 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import assert from 'assert'
4
+ import {
5
+ mimeHtml,
6
+ mimeDocx,
7
+ toErrText,
8
+ retryBusy,
9
+ runExclusive,
10
+ getQueueSize,
11
+ getFpExe,
12
+ checkDocxReady,
13
+ toSafeName,
14
+ resolveAssetPaths,
15
+ writeAssets,
16
+ cleanWorkDir,
17
+ transErrAsset
18
+ } from '../src/utils.mjs'
19
+
20
+
21
+ let sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
22
+
23
+
24
+ describe('utils', function() {
25
+
26
+ let fdTmp = path.resolve('./tmp/zt_utils')
27
+
28
+ before(function() {
29
+ fs.mkdirSync(fdTmp, { recursive: true })
30
+ })
31
+
32
+ after(function() {
33
+ fs.rmSync(fdTmp, { recursive: true, force: true })
34
+ })
35
+
36
+ describe('mime', function() {
37
+
38
+ it('mimeHtml/mimeDocx', function() {
39
+ assert.strict.equal(mimeHtml, 'text/html; charset=utf-8')
40
+ assert.strict.equal(mimeDocx, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
41
+ })
42
+
43
+ })
44
+
45
+ describe('toErrText', function() {
46
+
47
+ it('字串原樣回傳', function() {
48
+ assert.strict.equal(toErrText('abc'), 'abc')
49
+ assert.strict.equal(toErrText(''), '')
50
+ })
51
+
52
+ it('Error取message', function() {
53
+ assert.strict.equal(toErrText(new Error('boom')), 'boom')
54
+ })
55
+
56
+ it('物件無message則JSON化', function() {
57
+ assert.strict.equal(toErrText({ code: 'E1' }), '{"code":"E1"}')
58
+ })
59
+
60
+ it('null/undefined轉為文字', function() {
61
+ assert.strict.equal(toErrText(null), 'null')
62
+ assert.strict.equal(toErrText(undefined), 'undefined')
63
+ })
64
+
65
+ })
66
+
67
+ describe('retryBusy', function() {
68
+
69
+ it('fun非函數則reject', async function() {
70
+ await assert.rejects(retryBusy('x'), (e) => e === 'fun must be a function')
71
+ })
72
+
73
+ it('EBUSY/EPERM重試至成功', async function() {
74
+ let n = 0
75
+ let r = await retryBusy(async () => {
76
+ n++
77
+ if (n <= 2) {
78
+ let e = new Error('locked')
79
+ e.code = (n === 1) ? 'EBUSY' : 'EPERM'
80
+ throw e
81
+ }
82
+ return 'ok'
83
+ }, { delayMs: 5 })
84
+ assert.strict.equal(r, 'ok')
85
+ assert.strict.equal(n, 3)
86
+ })
87
+
88
+ it('非busy錯誤不重試, 直接拋出', async function() {
89
+ let n = 0
90
+ await assert.rejects(retryBusy(async () => {
91
+ n++
92
+ throw new Error('other')
93
+ }, { delayMs: 5 }), /other/)
94
+ assert.strict.equal(n, 1)
95
+ })
96
+
97
+ it('字串reject(底層轉檔器慣例)不重試, 原樣拋出', async function() {
98
+ let n = 0
99
+ await assert.rejects(retryBusy(() => {
100
+ n++
101
+ return Promise.reject('fpIn[x] does not exist')
102
+ }, { delayMs: 5 }), (e) => e === 'fpIn[x] does not exist')
103
+ assert.strict.equal(n, 1)
104
+ })
105
+
106
+ it('tries耗盡則拋出最後錯誤', async function() {
107
+ let n = 0
108
+ await assert.rejects(retryBusy(async () => {
109
+ n++
110
+ let e = new Error('locked')
111
+ e.code = 'EBUSY'
112
+ throw e
113
+ }, { tries: 3, delayMs: 5 }), /locked/)
114
+ assert.strict.equal(n, 3)
115
+ })
116
+
117
+ it('tries/delayMs非法值採預設(tries=6)', async function() {
118
+ let n = 0
119
+ await assert.rejects(retryBusy(async () => {
120
+ n++
121
+ let e = new Error('locked')
122
+ e.code = 'EBUSY'
123
+ throw e
124
+ }, { tries: -1, delayMs: 'x' }), /locked/)
125
+ assert.strict.equal(n, 6)
126
+ })
127
+
128
+ })
129
+
130
+ describe('runExclusive/getQueueSize', function() {
131
+
132
+ it('fun非函數則reject', async function() {
133
+ await assert.rejects(runExclusive(null), (e) => e === 'fun must be a function')
134
+ })
135
+
136
+ it('依序執行, 佇列數隨進出增減', async function() {
137
+ let order = []
138
+ assert.strict.equal(getQueueSize(), 0)
139
+ let p1 = runExclusive(async () => {
140
+ await sleep(60)
141
+ order.push(1)
142
+ return 'a'
143
+ })
144
+ let p2 = runExclusive(async () => {
145
+ order.push(2)
146
+ return 'b'
147
+ })
148
+ assert.strict.equal(getQueueSize(), 2)
149
+ let rs = await Promise.all([p1, p2])
150
+ await sleep(5) //佇列減量於pm.then之後, 給予一個tick
151
+ assert.strict.deepEqual(rs, ['a', 'b'])
152
+ assert.strict.deepEqual(order, [1, 2]) //後進者須等前者完成
153
+ assert.strict.equal(getQueueSize(), 0)
154
+ })
155
+
156
+ it('前一件失敗不阻斷後續', async function() {
157
+ let p1 = runExclusive(() => Promise.reject('fail1'))
158
+ let p2 = runExclusive(async () => 'ok2')
159
+ await assert.rejects(p1, (e) => e === 'fail1')
160
+ assert.strict.equal(await p2, 'ok2')
161
+ await sleep(5)
162
+ assert.strict.equal(getQueueSize(), 0)
163
+ })
164
+
165
+ })
166
+
167
+ describe('getFpExe/checkDocxReady', function() {
168
+
169
+ it('getFpExe回傳字串, 存在時為實體檔', function() {
170
+ let fp = getFpExe()
171
+ assert.strict.equal(typeof fp, 'string')
172
+ if (fp !== '') {
173
+ assert.strict.equal(fs.existsSync(fp), true)
174
+ assert.strict.equal(/htmlToDocx\.exe$/.test(fp), true)
175
+ }
176
+ })
177
+
178
+ it('checkDocxReady欄位與一致性', function() {
179
+ let r = checkDocxReady()
180
+ assert.strict.equal(r.platform, process.platform)
181
+ assert.strict.equal(r.isWindows, process.platform === 'win32')
182
+ assert.strict.equal(r.cwd, path.resolve())
183
+ assert.strict.equal(r.exePath, getFpExe())
184
+ assert.strict.equal(r.exeFound, r.exePath !== '')
185
+ assert.strict.equal(r.ready, r.isWindows && r.exeFound)
186
+ })
187
+
188
+ })
189
+
190
+ describe('toSafeName', function() {
191
+
192
+ it('去除路徑成分與副檔名', function() {
193
+ assert.strict.equal(toSafeName('../report/報告R00.01.md'), '報告R00.01')
194
+ assert.strict.equal(toSafeName('a\\b\\c.docx'), 'c')
195
+ assert.strict.equal(toSafeName('x.HTML'), 'x')
196
+ })
197
+
198
+ it('非法字元替換為底線', function() {
199
+ //note: 冒號不置於第2字元, 否則 Windows 之 path.basename 視 'a:' 為磁碟機前綴(路徑成分)而剝除
200
+ assert.strict.equal(toSafeName('a*b?c"d<e>f|g:h'), 'a_b_c_d_e_f_g_h')
201
+ assert.strict.equal(toSafeName('a\tb\rc\nd'), 'a_b_c_d')
202
+ })
203
+
204
+ it('去除開頭點號, 空值回output', function() {
205
+ assert.strict.equal(toSafeName('..hidden'), 'hidden')
206
+ assert.strict.equal(toSafeName(''), 'output')
207
+ assert.strict.equal(toSafeName(' '), 'output')
208
+ assert.strict.equal(toSafeName(null), 'output')
209
+ assert.strict.equal(toSafeName(123), 'output')
210
+ assert.strict.equal(toSafeName('...'), 'output')
211
+ })
212
+
213
+ it('長度截至120', function() {
214
+ assert.strict.equal(toSafeName('x'.repeat(200)).length, 120)
215
+ })
216
+
217
+ })
218
+
219
+ describe('resolveAssetPaths', function() {
220
+
221
+ it('一般相對路徑回傳單一絕對路徑', function() {
222
+ let rs = resolveAssetPaths(fdTmp, 'pics/a.png')
223
+ assert.strict.deepEqual(rs, [path.resolve(fdTmp, 'pics/a.png')])
224
+ })
225
+
226
+ it('反斜線正規化', function() {
227
+ let rs = resolveAssetPaths(fdTmp, 'pics\\a.png')
228
+ assert.strict.deepEqual(rs, [path.resolve(fdTmp, 'pics/a.png')])
229
+ })
230
+
231
+ it('URL編碼路徑回傳原字串與解碼後兩者', function() {
232
+ let rs = resolveAssetPaths(fdTmp, 'pics/%E5%9C%96.png')
233
+ assert.strict.deepEqual(rs, [path.resolve(fdTmp, 'pics/%E5%9C%96.png'), path.resolve(fdTmp, 'pics/圖.png')])
234
+ })
235
+
236
+ it('非法percent序列視為未編碼', function() {
237
+ let rs = resolveAssetPaths(fdTmp, 'pics/100%.png')
238
+ assert.strict.deepEqual(rs, [path.resolve(fdTmp, 'pics/100%.png')])
239
+ })
240
+
241
+ it('空路徑/非字串拋錯', function() {
242
+ assert.throws(() => resolveAssetPaths(fdTmp, ''), /^Error: asset\.path is empty$/)
243
+ assert.throws(() => resolveAssetPaths(fdTmp, null), /^Error: asset\.path is empty$/)
244
+ })
245
+
246
+ it('絕對路徑(磁碟機/根/UNC)拋錯', function() {
247
+ assert.throws(() => resolveAssetPaths(fdTmp, 'C:/x.png'), /^Error: asset\.path must be a relative path: C:\/x\.png$/)
248
+ assert.throws(() => resolveAssetPaths(fdTmp, '/x.png'), /must be a relative path/)
249
+ assert.throws(() => resolveAssetPaths(fdTmp, '//srv/x.png'), /must be a relative path/)
250
+ })
251
+
252
+ it('路徑穿越(含編碼形式)拋錯', function() {
253
+ assert.throws(() => resolveAssetPaths(fdTmp, '../x.png'), /^Error: asset\.path is outside the working folder: \.\.\/x\.png$/)
254
+ assert.throws(() => resolveAssetPaths(fdTmp, 'a/../../x.png'), /is outside the working folder/)
255
+ assert.throws(() => resolveAssetPaths(fdTmp, '%2e%2e/x.png'), /is outside the working folder/)
256
+ })
257
+
258
+ it('同前綴兄弟資料夾不放行', function() {
259
+ let dirJob = path.resolve(fdTmp, 'job_1')
260
+ assert.throws(() => resolveAssetPaths(dirJob, '../job_1x/x.png'), /is outside the working folder/)
261
+ })
262
+
263
+ })
264
+
265
+ describe('writeAssets', function() {
266
+
267
+ it('非陣列回0', function() {
268
+ assert.strict.equal(writeAssets(fdTmp, null), 0)
269
+ assert.strict.equal(writeAssets(fdTmp, 'x'), 0)
270
+ })
271
+
272
+ it('寫入檔案(含子資料夾), 編碼路徵同時寫出兩檔, 容許data URI與contentBase64', function() {
273
+ let dirJob = path.resolve(fdTmp, 'job_w')
274
+ let n = writeAssets(dirJob, [
275
+ { path: 'pics/%E5%9C%96.png', base64: Buffer.from('AB').toString('base64') },
276
+ { path: 'b.txt', base64: `data:text/plain;base64,${Buffer.from('CD').toString('base64')}` },
277
+ { path: 'c.txt', contentBase64: Buffer.from('EF').toString('base64') },
278
+ ])
279
+ assert.strict.equal(n, 3)
280
+ assert.strict.equal(fs.readFileSync(path.resolve(dirJob, 'pics/%E5%9C%96.png'), 'utf8'), 'AB')
281
+ assert.strict.equal(fs.readFileSync(path.resolve(dirJob, 'pics/圖.png'), 'utf8'), 'AB')
282
+ assert.strict.equal(fs.readFileSync(path.resolve(dirJob, 'b.txt'), 'utf8'), 'CD')
283
+ assert.strict.equal(fs.readFileSync(path.resolve(dirJob, 'c.txt'), 'utf8'), 'EF')
284
+ })
285
+
286
+ it('base64為空拋錯', function() {
287
+ assert.throws(() => writeAssets(fdTmp, [{ path: 'x.png' }]), /^Error: asset\.base64 is empty: x\.png$/)
288
+ assert.throws(() => writeAssets(fdTmp, [{ path: 'x.png', base64: 123 }]), /asset\.base64 is empty/)
289
+ })
290
+
291
+ it('路徑穿越拋錯且不寫檔', function() {
292
+ assert.throws(() => writeAssets(fdTmp, [{ path: '../zt_evil.txt', base64: 'QUI=' }]), /is outside the working folder/)
293
+ assert.strict.equal(fs.existsSync(path.resolve(fdTmp, '../zt_evil.txt')), false)
294
+ })
295
+
296
+ })
297
+
298
+ describe('cleanWorkDir', function() {
299
+
300
+ it('非有效字串回0', function() {
301
+ assert.strict.equal(cleanWorkDir(''), 0)
302
+ assert.strict.equal(cleanWorkDir(null), 0)
303
+ })
304
+
305
+ it('資料夾不存在則建立並回0', function() {
306
+ let d = path.resolve(fdTmp, 'work_new')
307
+ assert.strict.equal(fs.existsSync(d), false)
308
+ assert.strict.equal(cleanWorkDir(d), 0)
309
+ assert.strict.equal(fs.existsSync(d), true)
310
+ })
311
+
312
+ it('僅刪逾時之job_夾, 非job_與未逾時者保留', function() {
313
+ let d = path.resolve(fdTmp, 'work_clean')
314
+ let dOld = path.resolve(d, 'job_old')
315
+ let dNew = path.resolve(d, 'job_new')
316
+ let dOther = path.resolve(d, 'other_old')
317
+ fs.mkdirSync(dOld, { recursive: true })
318
+ fs.mkdirSync(dNew, { recursive: true })
319
+ fs.mkdirSync(dOther, { recursive: true })
320
+ let tOld = (Date.now() - 2 * 3600 * 1000) / 1000
321
+ fs.utimesSync(dOld, tOld, tOld)
322
+ fs.utimesSync(dOther, tOld, tOld)
323
+ let n = cleanWorkDir(d, { msAge: 3600 * 1000 })
324
+ assert.strict.equal(n, 1)
325
+ assert.strict.equal(fs.existsSync(dOld), false)
326
+ assert.strict.equal(fs.existsSync(dNew), true)
327
+ assert.strict.equal(fs.existsSync(dOther), true)
328
+ })
329
+
330
+ it('msAge非法值採預設6小時', function() {
331
+ let d = path.resolve(fdTmp, 'work_def')
332
+ let d5h = path.resolve(d, 'job_5h')
333
+ let d7h = path.resolve(d, 'job_7h')
334
+ fs.mkdirSync(d5h, { recursive: true })
335
+ fs.mkdirSync(d7h, { recursive: true })
336
+ let t5 = (Date.now() - 5 * 3600 * 1000) / 1000
337
+ let t7 = (Date.now() - 7 * 3600 * 1000) / 1000
338
+ fs.utimesSync(d5h, t5, t5)
339
+ fs.utimesSync(d7h, t7, t7)
340
+ assert.strict.equal(cleanWorkDir(d, { msAge: 'x' }), 1)
341
+ assert.strict.equal(fs.existsSync(d5h), true)
342
+ assert.strict.equal(fs.existsSync(d7h), false)
343
+ })
344
+
345
+ })
346
+
347
+ describe('transErrAsset', function() {
348
+
349
+ it('資產缺漏訊息轉為可行動之英文提示並隱去工作夾路徑', function() {
350
+ let msg = 'fp[C:/tmp/w-md2docx/job_123_abc/pics/圖.png] does not exist'
351
+ assert.strict.equal(transErrAsset(msg), 'Failed to convert md to html: the referenced file (pics/圖.png) does not exist, please make sure it is included in assets')
352
+ })
353
+
354
+ it('反斜線路徑亦可解析', function() {
355
+ let msg = 'Failed to convert md to html: fp[C:\\tmp\\job_1_a\\pics\\a.png] does not exist'
356
+ assert.strict.equal(transErrAsset(msg), 'Failed to convert md to html: the referenced file (pics/a.png) does not exist, please make sure it is included in assets')
357
+ })
358
+
359
+ it('非資產缺漏訊息原樣回傳', function() {
360
+ assert.strict.equal(transErrAsset('other error'), 'other error')
361
+ assert.strict.equal(transErrAsset('fp[x] does not exist'), 'fp[x] does not exist') //無 job_ 片段
362
+ })
363
+
364
+ it('非字串轉為文字', function() {
365
+ assert.strict.equal(transErrAsset(new Error('e1')), 'e1')
366
+ })
367
+
368
+ })
369
+
370
+ })