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,444 @@
1
+ import fs from 'fs'
2
+ import os from 'os'
3
+ import path from 'path'
4
+ import { fileURLToPath } from 'url'
5
+ import { execFile } from 'child_process'
6
+ import get from 'lodash-es/get.js'
7
+ import isstr from 'wsemi/src/isstr.mjs'
8
+ import isestr from 'wsemi/src/isestr.mjs'
9
+ import isbol from 'wsemi/src/isbol.mjs'
10
+ import isobj from 'wsemi/src/isobj.mjs'
11
+ import isp0int from 'wsemi/src/isp0int.mjs'
12
+ import ispnum from 'wsemi/src/ispnum.mjs'
13
+ import cint from 'wsemi/src/cint.mjs'
14
+ import cdbl from 'wsemi/src/cdbl.mjs'
15
+ import fsIsFile from 'wsemi/src/fsIsFile.mjs'
16
+ import fsIsFolder from 'wsemi/src/fsIsFolder.mjs'
17
+ import Hapi from '@hapi/hapi'
18
+ import cvMdTo from './cvMdTo.mjs'
19
+ import { toErrText, checkDocxReady, getQueueSize, cleanWorkDir } from './utils.mjs'
20
+
21
+
22
+ //fdSelf (場景B: 內建模板隨模組所在資料夾, 不依啟動時之相對路徑漂移)
23
+ let fdSelf = path.dirname(fileURLToPath(import.meta.url))
24
+
25
+
26
+ //getWinwordCount: 取當前 WINWORD 行程數(診斷用)
27
+ //why: 殘留之 WINWORD 行程會鎖檔並使後續轉檔失敗, 健檢時一併呈現供判斷
28
+ function getWinwordCount() {
29
+ return new Promise((resolve) => {
30
+ if (process.platform !== 'win32') {
31
+ resolve(-1)
32
+ return
33
+ }
34
+ execFile('tasklist', ['/FI', 'IMAGENAME eq WINWORD.EXE', '/NH'], { windowsHide: true, timeout: 8000 }, (err, stdout) => {
35
+ if (err) {
36
+ resolve(-1)
37
+ return
38
+ }
39
+ let n = (String(stdout).match(/WINWORD\.EXE/gi) || []).length
40
+ resolve(n)
41
+ })
42
+ })
43
+ }
44
+
45
+
46
+ //toContentDisposition: 組附檔標頭(中文檔名以 RFC5987 之 filename* 提供)
47
+ function toContentDisposition(fileName) {
48
+ let ascii = fileName.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '')
49
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
50
+ }
51
+
52
+
53
+ //isInputError: 判斷是否為輸入面錯誤(回 400), 其餘視為轉檔或環境錯誤(回 500)
54
+ //note: 前綴須與 cvMdTo/utils 之錯誤訊息一致
55
+ function isInputError(msg) {
56
+ return /^(md is empty|asset|templateName)/.test(msg)
57
+ }
58
+
59
+
60
+ /**
61
+ * 建立並啟動Markdown轉Html/Docx之API服務
62
+ *
63
+ * 以hapi提供三個端點:GET /api/health(健康檢查與環境狀態)、GET /api/selftest(實跑一次最小轉檔驗證本機Word可用)、POST /api/convert(轉檔主端點,JSON參數同cvMdTo,另可用templateName指定服務端模板或templateBase64夾帶模板;query帶download=1時單一格式直接回傳二進位檔)。
64
+ *
65
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
66
+ * @param {Integer} [opt.port=22000] 輸入服務埠號整數,給0則由系統自動配置(實際埠號見回傳之server.info.port),預設22000
67
+ * @param {String} [opt.host='0.0.0.0'] 輸入監聽位址字串,預設'0.0.0.0'
68
+ * @param {String} [opt.token=''] 輸入權杖字串,非空時所有/api/*須帶x-api-token標頭,預設''
69
+ * @param {Number} [opt.maxMb=256] 輸入請求大小上限數字,單位MB,預設256
70
+ * @param {String} [opt.dirTemplates=''] 輸入docx模板資料夾位置字串,未給則用本套件src/templates,預設''
71
+ * @param {String} [opt.dirWork=''] 輸入工作資料夾位置字串,未給則用系統暫存夾下之w-md2docx,預設''
72
+ * @param {String} [opt.templateDef='temp_tpc.docx'] 輸入預設模板檔名字串,預設'temp_tpc.docx'
73
+ * @param {Boolean} [opt.autoStart=true] 輸入是否立即啟動服務布林值,預設true
74
+ * @returns {Promise} 回傳Promise,resolve回傳{server,settings,getState,stop},其中server為hapi實例,getState為取環境狀態之async函數,stop為停止服務之async函數,reject回傳錯誤訊息
75
+ * @example
76
+ *
77
+ * import ApiServer from 'w-md2docx/src/ApiServer.mjs'
78
+ *
79
+ * let { server, settings } = await ApiServer({
80
+ * port: 22000,
81
+ * token: '',
82
+ * })
83
+ * console.log(`listening on ${server.info.uri}`, settings)
84
+ *
85
+ */
86
+ async function ApiServer(opt = {}) {
87
+
88
+ //port (0 為由系統自動配置, 供測試等場景避免撞埠; 實際埠號由 server.info.port 取得)
89
+ let port = get(opt, 'port', null)
90
+ if (!isp0int(port)) {
91
+ port = 22000
92
+ }
93
+ port = cint(port)
94
+
95
+ //host
96
+ let host = get(opt, 'host', '')
97
+ if (!isestr(host)) {
98
+ host = '0.0.0.0'
99
+ }
100
+ host = host.trim()
101
+
102
+ //token
103
+ let token = get(opt, 'token', '')
104
+ if (!isstr(token)) {
105
+ token = ''
106
+ }
107
+ token = token.trim()
108
+
109
+ //maxMb
110
+ let maxMb = get(opt, 'maxMb', null)
111
+ if (!ispnum(maxMb)) {
112
+ maxMb = 256
113
+ }
114
+ maxMb = cdbl(maxMb)
115
+ let maxBytes = Math.round(maxMb * 1024 * 1024)
116
+
117
+ //dirTemplates
118
+ let dirTemplates = get(opt, 'dirTemplates', '')
119
+ if (!isestr(dirTemplates)) {
120
+ dirTemplates = path.resolve(fdSelf, 'templates')
121
+ }
122
+ dirTemplates = path.resolve(dirTemplates)
123
+
124
+ //dirWork
125
+ let dirWork = get(opt, 'dirWork', '')
126
+ if (!isestr(dirWork)) {
127
+ dirWork = path.join(os.tmpdir(), 'w-md2docx')
128
+ }
129
+ dirWork = path.resolve(dirWork)
130
+
131
+ //templateDef
132
+ let templateDef = get(opt, 'templateDef', '')
133
+ if (!isestr(templateDef)) {
134
+ templateDef = 'temp_tpc.docx'
135
+ }
136
+ templateDef = templateDef.trim()
137
+
138
+ //autoStart
139
+ let autoStart = get(opt, 'autoStart', true)
140
+ if (!isbol(autoStart)) {
141
+ autoStart = true
142
+ }
143
+
144
+ //listTemplates: 列出可用之 docx 模板檔名
145
+ let listTemplates = () => {
146
+ if (!fsIsFolder(dirTemplates)) {
147
+ return []
148
+ }
149
+ return fs.readdirSync(dirTemplates).filter((v) => /\.docx$/i.test(v))
150
+ }
151
+
152
+ //getFpTemplateDef: 取預設模板位置(不存在則回空字串)
153
+ let getFpTemplateDef = () => {
154
+ let fp = path.resolve(dirTemplates, path.basename(templateDef))
155
+ return fsIsFile(fp) ? fp : ''
156
+ }
157
+
158
+ //pickTemplate: 決定本次使用之模板
159
+ //順序: 請求端夾帶(base64) > 請求指定之服務端模板名 > 服務端預設模板 > 空(交由底層自行決定)
160
+ //回傳 { fpInTemp, from, fpTmpDel } ; fpTmpDel 為須於用畢刪除之暫存模板檔
161
+ let pickTemplate = (inp) => {
162
+
163
+ //請求端夾帶
164
+ let b64 = get(inp, 'templateBase64', '')
165
+ if (!isestr(b64)) {
166
+ b64 = get(inp, 'template.base64', '')
167
+ }
168
+ if (isestr(b64)) {
169
+ b64 = b64.replace(/^data:[^;]+;base64,/, '')
170
+ fs.mkdirSync(dirWork, { recursive: true })
171
+ let fp = path.resolve(dirWork, `tmpl_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.docx`)
172
+ fs.writeFileSync(fp, Buffer.from(b64, 'base64'))
173
+ return { fpInTemp: fp, from: 'request', fpTmpDel: fp }
174
+ }
175
+
176
+ //指定服務端模板名
177
+ let fn = get(inp, 'templateName', '')
178
+ if (isestr(fn)) {
179
+ fn = fn.trim()
180
+ }
181
+ else {
182
+ fn = ''
183
+ }
184
+ if (fn !== '') {
185
+ let fp = path.resolve(dirTemplates, path.basename(fn)) //basename 化, 限定取用 templates 內之檔案
186
+ if (!fsIsFile(fp)) {
187
+ throw new Error(`templateName[${fn}] does not exist (available templates: ${listTemplates().join(', ') || 'none'})`)
188
+ }
189
+ return { fpInTemp: fp, from: 'server', fpTmpDel: '' }
190
+ }
191
+
192
+ //服務端預設模板
193
+ let fpDef = getFpTemplateDef()
194
+ if (fpDef !== '') {
195
+ return { fpInTemp: fpDef, from: 'default', fpTmpDel: '' }
196
+ }
197
+
198
+ return { fpInTemp: '', from: 'none', fpTmpDel: '' }
199
+ }
200
+
201
+ //getState: 取服務環境狀態
202
+ let getState = async () => {
203
+ let rd = checkDocxReady()
204
+ let nWinword = await getWinwordCount()
205
+ return {
206
+ platform: rd.platform,
207
+ node: process.version,
208
+ cwd: rd.cwd,
209
+ exeFound: rd.exeFound,
210
+ exePath: rd.exePath,
211
+ docxReady: rd.ready, //僅代表環境具備條件, 實際 Word 可用性須以 /api/selftest 驗證
212
+ templateDefault: getFpTemplateDef(),
213
+ templates: listTemplates(),
214
+ dirTemplates,
215
+ dirWork,
216
+ queue: getQueueSize(),
217
+ winwordProcesses: nWinword,
218
+ maxBytes,
219
+ tokenRequired: token !== '',
220
+ }
221
+ }
222
+
223
+ //convertCore: 依請求內容轉檔(模板由服務端決定後交由核心處理)
224
+ let convertCore = async (inp) => {
225
+ let rTemp = pickTemplate(inp)
226
+ try {
227
+ let keepTmp = get(inp, 'keepTmp', false)
228
+ let keepWork = get(inp, 'keepWork', false)
229
+ let r = await cvMdTo({
230
+ md: get(inp, 'md', ''),
231
+ mdBase64: get(inp, 'mdBase64', ''),
232
+ name: get(inp, 'name', ''),
233
+ out: get(inp, 'out', ''),
234
+ assets: get(inp, 'assets', []),
235
+ dirWork,
236
+ fpInTemp: rTemp.fpInTemp,
237
+ optMd2html: get(inp, 'optMd2html', {}),
238
+ optHtml2docx: get(inp, 'optHtml2docx', {}),
239
+ keepWork: (keepTmp === true || keepTmp === 'true' || keepWork === true || keepWork === 'true'),
240
+ })
241
+ return {
242
+ success: true,
243
+ ...r,
244
+ template: rTemp.from,
245
+ }
246
+ }
247
+ finally {
248
+ //刪除本次夾帶之暫存模板
249
+ if (rTemp.fpTmpDel !== '' && fsIsFile(rTemp.fpTmpDel)) {
250
+ try {
251
+ fs.unlinkSync(rTemp.fpTmpDel)
252
+ }
253
+ catch (err) {
254
+ //殘檔被鎖, 留待下次清理
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ //清除逾時殘留之作業資料夾
261
+ let nClean = cleanWorkDir(dirWork)
262
+
263
+ //server
264
+ let server = Hapi.server({
265
+ port,
266
+ host,
267
+ routes: {
268
+ cors: true, //內網工具服務, 便於自瀏覽器或他機測試
269
+ payload: {
270
+ maxBytes,
271
+ },
272
+ },
273
+ })
274
+
275
+ //token 驗證(未設定時不啟用)
276
+ server.ext('onRequest', (req, h) => {
277
+ if (token === '') {
278
+ return h.continue
279
+ }
280
+ if (!req.path.startsWith('/api/')) {
281
+ return h.continue
282
+ }
283
+ let t = get(req, 'headers.x-api-token', '')
284
+ if (t !== token) {
285
+ return h.response({ success: false, error: 'unauthorized: a valid x-api-token header is required' }).code(401).takeover()
286
+ }
287
+ return h.continue
288
+ })
289
+
290
+ //apis
291
+ server.route([
292
+
293
+ {
294
+ //服務說明(供瀏覽器直接開啟確認服務存活)
295
+ method: 'GET',
296
+ path: '/',
297
+ handler: () => {
298
+ return {
299
+ service: 'w-md2docx',
300
+ description: 'API service for converting Markdown to HTML/DOCX',
301
+ endpoints: {
302
+ 'GET /api/health': 'health check and environment state',
303
+ 'GET /api/selftest': 'run a minimal conversion to verify that Microsoft Word is available',
304
+ 'POST /api/convert': 'main conversion endpoint (JSON)',
305
+ },
306
+ }
307
+ },
308
+ },
309
+
310
+ {
311
+ method: 'GET',
312
+ path: '/api/health',
313
+ handler: async () => {
314
+ let st = await getState()
315
+ return {
316
+ success: true,
317
+ ...st,
318
+ }
319
+ },
320
+ },
321
+
322
+ {
323
+ //實跑一次最小 md -> docx, 驗證本機 Word 確實可用(健檢僅檢查靜態條件, 無法確認 Word 能否被調用)
324
+ method: 'GET',
325
+ path: '/api/selftest',
326
+ handler: async (req, h) => {
327
+ let msStart = Date.now()
328
+ try {
329
+ let r = await convertCore({
330
+ md: '# selftest\n\nThis file is generated by the self test of the conversion service.\n',
331
+ name: 'selftest',
332
+ out: 'both',
333
+ })
334
+ return {
335
+ success: true,
336
+ ms: Date.now() - msStart,
337
+ htmlSize: get(r, 'html.size', 0),
338
+ docxSize: get(r, 'docx.size', 0),
339
+ template: r.template,
340
+ }
341
+ }
342
+ catch (err) {
343
+ let st = await getState()
344
+ return h.response({
345
+ success: false,
346
+ error: toErrText(err),
347
+ ms: Date.now() - msStart,
348
+ env: st,
349
+ }).code(500)
350
+ }
351
+ },
352
+ },
353
+
354
+ {
355
+ method: 'POST',
356
+ path: '/api/convert',
357
+ options: {
358
+ payload: {
359
+ maxBytes,
360
+ parse: true,
361
+ allow: 'application/json',
362
+ timeout: false, //允許大檔慢速上傳
363
+ },
364
+ timeout: {
365
+ socket: false, //轉檔耗時較長, 不以 socket 逾時中斷
366
+ },
367
+ },
368
+ handler: async (req, h) => {
369
+
370
+ let inp = req.payload
371
+ if (!isobj(inp)) {
372
+ return h.response({ success: false, error: 'parameters must be provided as a JSON object' }).code(400)
373
+ }
374
+
375
+ //download: 單一格式時可直接回傳二進位檔(便於 curl -o 與瀏覽器下載)
376
+ //why: 不合法組合(out='both')於轉檔前即擋下, 避免先跑完耗時之 Word 轉檔才回 400
377
+ let qd = get(req, 'query.download', '')
378
+ let download = (qd === '1' || qd === 'true')
379
+ if (download && get(inp, 'out', '') === 'both') {
380
+ return h.response({ success: false, error: 'download only supports out of html or docx (single format)' }).code(400)
381
+ }
382
+
383
+ try {
384
+
385
+ let r = await convertCore(inp)
386
+
387
+ if (download) {
388
+ let one = null
389
+ if (r.out === 'docx' && isobj(r.docx)) {
390
+ one = r.docx
391
+ }
392
+ else if (r.out === 'html' && isobj(r.html)) {
393
+ one = r.html
394
+ }
395
+ if (one === null) {
396
+ return h.response({ success: false, error: 'download only supports out of html or docx (single format)' }).code(400)
397
+ }
398
+ return h.response(Buffer.from(one.base64, 'base64'))
399
+ .type(one.mime)
400
+ .header('content-disposition', toContentDisposition(one.fileName))
401
+ }
402
+
403
+ return r
404
+
405
+ }
406
+ catch (err) {
407
+ let msg = toErrText(err)
408
+ let code = isInputError(msg) ? 400 : 500
409
+ return h.response({ success: false, error: msg }).code(code)
410
+ }
411
+
412
+ },
413
+ },
414
+
415
+ ])
416
+
417
+ //autoStart
418
+ if (autoStart) {
419
+ await server.start()
420
+ }
421
+
422
+ //定期清除逾時殘留之作業資料夾
423
+ let idInterval = setInterval(() => {
424
+ cleanWorkDir(dirWork)
425
+ }, 3600 * 1000)
426
+ idInterval.unref()
427
+
428
+ //stop
429
+ let stop = async () => {
430
+ clearInterval(idInterval)
431
+ await server.stop()
432
+ }
433
+
434
+ return {
435
+ server,
436
+ getState,
437
+ stop,
438
+ settings: { port, host, token, maxMb, maxBytes, dirTemplates, dirWork, templateDef, nClean },
439
+ }
440
+
441
+ }
442
+
443
+
444
+ export default ApiServer
@@ -0,0 +1,29 @@
1
+ import cvMdToDocx from './cvMdToDocx.mjs'
2
+ import cvMdTo from './cvMdTo.mjs'
3
+ import ApiServer from './ApiServer.mjs'
4
+ import ApiClient from './ApiClient.mjs'
5
+
6
+
7
+ /**
8
+ * Markdown轉Docx工具集
9
+ *
10
+ * 匯整四個入口:cvMdToDocx(md檔轉docx檔)、cvMdTo(md內容轉html/docx之base64)、ApiServer(hapi轉檔服務)、ApiClient(呼叫轉檔服務之用戶端)。
11
+ *
12
+ * @example
13
+ *
14
+ * import WMd2docx from 'w-md2docx/src/WMd2docx.mjs'
15
+ *
16
+ * let r = await WMd2docx.cvMdToDocx('./test/report.md', './test/report.docx')
17
+ * console.log(r)
18
+ * // => { fpOutDocx: '…', sizeDocx: 39856, sizeHtml: 12345, ms: 8342 }
19
+ *
20
+ */
21
+ let WMd2docx = {
22
+ cvMdToDocx,
23
+ cvMdTo,
24
+ ApiServer,
25
+ ApiClient,
26
+ }
27
+
28
+
29
+ export default WMd2docx
package/src/cvMdTo.mjs ADDED
@@ -0,0 +1,216 @@
1
+ import fs from 'fs'
2
+ import os from 'os'
3
+ import path from 'path'
4
+ import get from 'lodash-es/get.js'
5
+ import isestr from 'wsemi/src/isestr.mjs'
6
+ import isbol from 'wsemi/src/isbol.mjs'
7
+ import isobj from 'wsemi/src/isobj.mjs'
8
+ import isarr from 'wsemi/src/isarr.mjs'
9
+ import fsIsFile from 'wsemi/src/fsIsFile.mjs'
10
+ import WMd2html from 'w-md2html/src/WMd2html.mjs'
11
+ import cvMdToDocx from './cvMdToDocx.mjs'
12
+ import { mimeHtml, mimeDocx, toErrText, retryBusy, toSafeName, writeAssets, transErrAsset } from './utils.mjs'
13
+
14
+
15
+ /**
16
+ * Markdown內容轉Html與Docx內容
17
+ *
18
+ * 與cvMdToDocx之差異:本函數接收Markdown「內容」與其引用之資產(圖片等),於獨立工作資料夾內還原後轉檔,完成後回傳產物之base64內容並清除工作資料夾。適用於服務端等無實體來源檔之情境;有實體md檔者請直接用cvMdToDocx。
19
+ *
20
+ * @param {Object} [opt={}] 輸入設定物件,預設{}
21
+ * @param {String} [opt.md=''] 輸入Markdown內容字串,與mdBase64二擇一
22
+ * @param {String} [opt.mdBase64=''] 輸入Markdown內容之base64字串,與md二擇一
23
+ * @param {String} [opt.name='output'] 輸入輸出檔名主體字串(不含副檔名),將自動移除路徑成分與非法字元,預設'output'
24
+ * @param {String} [opt.out='docx'] 輸入產出類型字串,可為'html'、'docx'、'both',預設'docx'
25
+ * @param {Array} [opt.assets=[]] 輸入md內引用之相對路徑資產陣列,格式[{path,base64}],預設[]
26
+ * @param {String} [opt.dirWork=''] 輸入工作資料夾根位置字串,未給則用系統暫存夾下之w-md2docx,預設''
27
+ * @param {String} [opt.fpInTemp=''] 輸入Docx模板檔位置字串,未給則由w-html2docx使用其內建模板,預設''
28
+ * @param {Object} [opt.optMd2html={}] 輸入傳予w-md2html之設定物件,預設{}
29
+ * @param {Object} [opt.optHtml2docx={}] 輸入傳予w-html2docx之設定物件,預設{}
30
+ * @param {Boolean} [opt.keepWork=false] 輸入是否保留工作資料夾供除錯布林值,預設false
31
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件{name,out,ms,nAssets,[msDocx],[html],[docx]},其中html與docx為{fileName,mime,size,base64},reject回傳錯誤訊息
32
+ * @example
33
+ *
34
+ * import cvMdTo from 'w-md2docx/src/cvMdTo.mjs'
35
+ *
36
+ * let r = await cvMdTo({
37
+ * md: '# 標題\n\n<img src="pics/圖.png" />',
38
+ * name: '報告R00.01',
39
+ * out: 'docx',
40
+ * assets: [{ path: 'pics/圖.png', base64: '…' }],
41
+ * })
42
+ * console.log(r)
43
+ * // => { name: '報告R00.01', out: 'docx', ms: 8342, nAssets: 1, msDocx: 8300, docx: { fileName: '報告R00.01.docx', mime: '…', size: 39856, base64: '…' } }
44
+ *
45
+ */
46
+ async function cvMdTo(opt = {}) {
47
+
48
+ let msStart = Date.now()
49
+
50
+ //out
51
+ let out = get(opt, 'out', '')
52
+ if (out !== 'html' && out !== 'docx' && out !== 'both') {
53
+ out = 'docx'
54
+ }
55
+ let needHtml = (out === 'html' || out === 'both')
56
+ let needDocx = (out === 'docx' || out === 'both')
57
+
58
+ //md
59
+ let md = ''
60
+ let mdIn = get(opt, 'md', '')
61
+ let mdBase64 = get(opt, 'mdBase64', '')
62
+ if (isestr(mdIn)) {
63
+ md = mdIn
64
+ }
65
+ else if (isestr(mdBase64)) {
66
+ md = Buffer.from(mdBase64, 'base64').toString('utf8')
67
+ }
68
+ if (md.trim() === '') {
69
+ return Promise.reject('md is empty: md (string) or mdBase64 is required')
70
+ }
71
+
72
+ //name
73
+ let name = toSafeName(get(opt, 'name', ''))
74
+
75
+ //assets
76
+ let assets = get(opt, 'assets', [])
77
+ if (!isarr(assets)) {
78
+ assets = []
79
+ }
80
+
81
+ //dirWork (未指定則用系統暫存夾)
82
+ let dirWork = get(opt, 'dirWork', '')
83
+ if (!isestr(dirWork)) {
84
+ dirWork = path.join(os.tmpdir(), 'w-md2docx')
85
+ }
86
+ dirWork = path.resolve(dirWork)
87
+
88
+ //fpInTemp (存在性由 cvMdToDocx 檢查)
89
+ let fpInTemp = get(opt, 'fpInTemp', '')
90
+ if (!isestr(fpInTemp)) {
91
+ fpInTemp = ''
92
+ }
93
+
94
+ //optMd2html
95
+ let optMd2html = get(opt, 'optMd2html', {})
96
+ if (!isobj(optMd2html)) {
97
+ optMd2html = {}
98
+ }
99
+
100
+ //optHtml2docx
101
+ let optHtml2docx = get(opt, 'optHtml2docx', {})
102
+ if (!isobj(optHtml2docx)) {
103
+ optHtml2docx = {}
104
+ }
105
+
106
+ //keepWork (除錯用, 保留工作資料夾)
107
+ let keepWork = get(opt, 'keepWork', false)
108
+ if (!isbol(keepWork)) {
109
+ keepWork = false
110
+ }
111
+
112
+ //dirJob (每次作業獨立資料夾, 使同名檔案與資產互不干擾)
113
+ let idJob = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
114
+ let dirJob = path.resolve(dirWork, `job_${idJob}`)
115
+ fs.mkdirSync(dirJob, { recursive: true })
116
+
117
+ let rt = {
118
+ name,
119
+ out,
120
+ ms: 0,
121
+ nAssets: 0,
122
+ }
123
+
124
+ try {
125
+
126
+ //寫入 md (資產以 md 所在資料夾為基準解析, 故兩者同置於 dirJob)
127
+ let fpMd = path.resolve(dirJob, `${name}.md`)
128
+ fs.writeFileSync(fpMd, md, 'utf8')
129
+
130
+ //寫入資產
131
+ try {
132
+ rt.nAssets = writeAssets(dirJob, assets)
133
+ }
134
+ catch (err) {
135
+ return Promise.reject(toErrText(err))
136
+ }
137
+
138
+ let fpHtml = path.resolve(dirJob, `${name}.html`)
139
+
140
+ if (needDocx) {
141
+
142
+ //md -> html -> docx (中介 html 指定產於作業夾, 供 out 含 html 時一併回傳)
143
+ let fpDocx = path.resolve(dirJob, `${name}.docx`)
144
+ let errDocx = null
145
+ let r = await cvMdToDocx(fpMd, fpDocx, {
146
+ fpInTemp,
147
+ fpOutHtml: fpHtml,
148
+ optMd2html,
149
+ optHtml2docx,
150
+ })
151
+ .catch((err) => {
152
+ errDocx = transErrAsset(toErrText(err))
153
+ })
154
+ if (errDocx !== null) {
155
+ return Promise.reject(errDocx)
156
+ }
157
+
158
+ let buf = fs.readFileSync(fpDocx)
159
+ rt.docx = {
160
+ fileName: `${name}.docx`,
161
+ mime: mimeDocx,
162
+ size: buf.length,
163
+ base64: buf.toString('base64'),
164
+ }
165
+ rt.msDocx = get(r, 'ms', 0)
166
+
167
+ }
168
+ else {
169
+
170
+ //僅需 html, 不啟動 Word
171
+ let errHtml = null
172
+ await retryBusy(() => WMd2html(fpMd, fpHtml, optMd2html))
173
+ .catch((err) => {
174
+ errHtml = toErrText(err)
175
+ })
176
+ if (errHtml !== null) {
177
+ return Promise.reject(transErrAsset(`Failed to convert md to html: ${errHtml}`))
178
+ }
179
+ if (!fsIsFile(fpHtml) || fs.statSync(fpHtml).size === 0) {
180
+ return Promise.reject('Failed to convert md to html: html was not generated or is empty')
181
+ }
182
+
183
+ }
184
+
185
+ if (needHtml) {
186
+ let buf = fs.readFileSync(fpHtml)
187
+ rt.html = {
188
+ fileName: `${name}.html`,
189
+ mime: mimeHtml,
190
+ size: buf.length,
191
+ base64: buf.toString('base64'),
192
+ }
193
+ }
194
+
195
+ rt.ms = Date.now() - msStart
196
+ return rt
197
+
198
+ }
199
+ finally {
200
+
201
+ //清除作業資料夾(除錯模式保留)
202
+ if (!keepWork) {
203
+ try {
204
+ fs.rmSync(dirJob, { recursive: true, force: true })
205
+ }
206
+ catch (err) {
207
+ //殘檔被鎖, 留待下次清理
208
+ }
209
+ }
210
+
211
+ }
212
+
213
+ }
214
+
215
+
216
+ export default cvMdTo