灵活用工项目

convert.js 7.7KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. /**
  2. * One-off: convert 企业端操作流程说明.md -> .docx
  3. */
  4. const fs = require('fs')
  5. const path = require('path')
  6. const {
  7. Document,
  8. Packer,
  9. Paragraph,
  10. TextRun,
  11. HeadingLevel,
  12. Table,
  13. TableRow,
  14. TableCell,
  15. WidthType,
  16. BorderStyle,
  17. ImageRun,
  18. AlignmentType,
  19. ShadingType
  20. } = require('docx')
  21. const DOC_DIR = path.resolve(__dirname, '..')
  22. const MD_PATH = path.join(DOC_DIR, '企业端操作流程说明.md')
  23. const OUT_PATH = path.join(DOC_DIR, '企业端操作流程说明.docx')
  24. function parseInline(text) {
  25. const runs = []
  26. const re = /(\*\*[^*]+\*\*|`[^`]+`)/g
  27. let last = 0
  28. let m
  29. while ((m = re.exec(text)) !== null) {
  30. if (m.index > last) {
  31. runs.push(new TextRun({ text: text.slice(last, m.index), size: 21 }))
  32. }
  33. const token = m[0]
  34. if (token.startsWith('**')) {
  35. runs.push(new TextRun({ text: token.slice(2, -2), bold: true, size: 21 }))
  36. } else {
  37. runs.push(new TextRun({ text: token.slice(1, -1), font: 'Consolas', size: 18 }))
  38. }
  39. last = m.index + token.length
  40. }
  41. if (last < text.length) {
  42. runs.push(new TextRun({ text: text.slice(last), size: 21 }))
  43. }
  44. if (!runs.length) runs.push(new TextRun({ text: text || '', size: 21 }))
  45. return runs
  46. }
  47. function para(text, opts = {}) {
  48. return new Paragraph({
  49. spacing: { after: 120, line: 360 },
  50. ...opts,
  51. children: typeof text === 'string' ? parseInline(text) : text
  52. })
  53. }
  54. function heading(text, level) {
  55. const map = {
  56. 1: HeadingLevel.HEADING_1,
  57. 2: HeadingLevel.HEADING_2,
  58. 3: HeadingLevel.HEADING_3,
  59. 4: HeadingLevel.HEADING_4
  60. }
  61. return new Paragraph({
  62. heading: map[level] || HeadingLevel.HEADING_2,
  63. spacing: { before: 240, after: 160 },
  64. children: [new TextRun({ text, bold: true, size: level === 1 ? 32 : level === 2 ? 28 : 24 })]
  65. })
  66. }
  67. function quotePara(text) {
  68. return new Paragraph({
  69. spacing: { after: 100 },
  70. indent: { left: 240 },
  71. border: {
  72. left: { style: BorderStyle.SINGLE, size: 18, color: '93C5FD', space: 8 }
  73. },
  74. children: parseInline(text.replace(/^>\s?/, ''))
  75. })
  76. }
  77. function codePara(line) {
  78. return new Paragraph({
  79. spacing: { after: 40 },
  80. shading: { type: ShadingType.CLEAR, fill: 'F3F4F6' },
  81. children: [new TextRun({ text: line || ' ', font: 'Consolas', size: 18 })]
  82. })
  83. }
  84. function cell(text, opts = {}) {
  85. const isHeader = !!opts.header
  86. return new TableCell({
  87. width: { size: opts.width || 3000, type: WidthType.DXA },
  88. shading: isHeader ? { type: ShadingType.CLEAR, fill: 'DBEAFE' } : undefined,
  89. borders: {
  90. top: { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' },
  91. bottom: { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' },
  92. left: { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' },
  93. right: { style: BorderStyle.SINGLE, size: 4, color: 'CBD5E1' }
  94. },
  95. children: [
  96. new Paragraph({
  97. spacing: { after: 40 },
  98. children: parseInline(String(text || '').trim()).map((r) => {
  99. if (isHeader) {
  100. return new TextRun({ text: r.text || '', bold: true, size: 20 })
  101. }
  102. return r
  103. })
  104. })
  105. ]
  106. })
  107. }
  108. function parseTable(lines) {
  109. const rows = lines
  110. .filter((l) => !/^\|?\s*-+/.test(l.replace(/\|/g, '').trim() === '' ? 'x' : l) && !/^\|[\s\-:|]+\|$/.test(l))
  111. .filter((l) => l.includes('|'))
  112. .map((l) =>
  113. l
  114. .replace(/^\|/, '')
  115. .replace(/\|$/, '')
  116. .split('|')
  117. .map((c) => c.trim())
  118. )
  119. .filter((cols) => !cols.every((c) => /^:?-+:?$/.test(c)))
  120. if (!rows.length) return null
  121. const colCount = Math.max(...rows.map((r) => r.length))
  122. const width = Math.floor(9000 / colCount)
  123. return new Table({
  124. width: { size: 9000, type: WidthType.DXA },
  125. rows: rows.map((cols, idx) => {
  126. while (cols.length < colCount) cols.push('')
  127. return new TableRow({
  128. children: cols.map((c) => cell(c, { header: idx === 0, width }))
  129. })
  130. })
  131. })
  132. }
  133. async function main() {
  134. const md = fs.readFileSync(MD_PATH, 'utf8')
  135. const lines = md.replace(/\r\n/g, '\n').split('\n')
  136. const children = []
  137. let i = 0
  138. while (i < lines.length) {
  139. const line = lines[i]
  140. const trimmed = line.trim()
  141. if (!trimmed) {
  142. i++
  143. continue
  144. }
  145. if (trimmed === '---') {
  146. children.push(
  147. new Paragraph({
  148. spacing: { before: 120, after: 120 },
  149. border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB', space: 1 } },
  150. children: []
  151. })
  152. )
  153. i++
  154. continue
  155. }
  156. if (trimmed.startsWith('```')) {
  157. i++
  158. while (i < lines.length && !lines[i].trim().startsWith('```')) {
  159. children.push(codePara(lines[i]))
  160. i++
  161. }
  162. i++ // closing ```
  163. children.push(new Paragraph({ spacing: { after: 120 }, children: [] }))
  164. continue
  165. }
  166. if (trimmed.startsWith('|')) {
  167. const tableLines = []
  168. while (i < lines.length && lines[i].trim().startsWith('|')) {
  169. tableLines.push(lines[i].trim())
  170. i++
  171. }
  172. const table = parseTable(tableLines)
  173. if (table) {
  174. children.push(table)
  175. children.push(new Paragraph({ spacing: { after: 160 }, children: [] }))
  176. }
  177. continue
  178. }
  179. const img = trimmed.match(/^!\[([^\]]*)\]\(([^)]+)\)/)
  180. if (img) {
  181. const imgPath = path.resolve(DOC_DIR, img[2])
  182. if (fs.existsSync(imgPath)) {
  183. const buf = fs.readFileSync(imgPath)
  184. children.push(
  185. new Paragraph({
  186. alignment: AlignmentType.CENTER,
  187. spacing: { before: 160, after: 160 },
  188. children: [
  189. new ImageRun({
  190. data: buf,
  191. transformation: { width: 420, height: 560 },
  192. type: 'png'
  193. })
  194. ]
  195. })
  196. )
  197. children.push(
  198. new Paragraph({
  199. alignment: AlignmentType.CENTER,
  200. spacing: { after: 200 },
  201. children: [new TextRun({ text: img[1] || '流程图', italics: true, size: 18, color: '6B7280' })]
  202. })
  203. )
  204. } else {
  205. children.push(para(`[图片缺失] ${img[1]} (${img[2]})`))
  206. }
  207. i++
  208. continue
  209. }
  210. if (trimmed.startsWith('# ')) {
  211. children.push(heading(trimmed.slice(2), 1))
  212. i++
  213. continue
  214. }
  215. if (trimmed.startsWith('## ')) {
  216. children.push(heading(trimmed.slice(3), 2))
  217. i++
  218. continue
  219. }
  220. if (trimmed.startsWith('### ')) {
  221. children.push(heading(trimmed.slice(4), 3))
  222. i++
  223. continue
  224. }
  225. if (trimmed.startsWith('#### ')) {
  226. children.push(heading(trimmed.slice(5), 4))
  227. i++
  228. continue
  229. }
  230. if (trimmed.startsWith('>')) {
  231. while (i < lines.length && lines[i].trim().startsWith('>')) {
  232. children.push(quotePara(lines[i].trim()))
  233. i++
  234. }
  235. continue
  236. }
  237. if (/^[-*]\s+/.test(trimmed)) {
  238. children.push(
  239. new Paragraph({
  240. spacing: { after: 80 },
  241. indent: { left: 360 },
  242. children: [
  243. new TextRun({ text: '• ', size: 21 }),
  244. ...parseInline(trimmed.replace(/^[-*]\s+/, ''))
  245. ]
  246. })
  247. )
  248. i++
  249. continue
  250. }
  251. if (/^\d+\.\s+/.test(trimmed)) {
  252. const num = trimmed.match(/^(\d+)\.\s+/)[1]
  253. children.push(
  254. new Paragraph({
  255. spacing: { after: 80 },
  256. indent: { left: 240 },
  257. children: [
  258. new TextRun({ text: `${num}. `, size: 21 }),
  259. ...parseInline(trimmed.replace(/^\d+\.\s+/, ''))
  260. ]
  261. })
  262. )
  263. i++
  264. continue
  265. }
  266. if (trimmed.startsWith('*') && trimmed.endsWith('*') && !trimmed.startsWith('**')) {
  267. children.push(
  268. new Paragraph({
  269. spacing: { after: 100 },
  270. children: [new TextRun({ text: trimmed.replace(/^\*|\*$/g, ''), italics: true, size: 18, color: '6B7280' })]
  271. })
  272. )
  273. i++
  274. continue
  275. }
  276. children.push(para(trimmed))
  277. i++
  278. }
  279. const doc = new Document({
  280. styles: {
  281. default: {
  282. document: {
  283. styles: [
  284. {
  285. id: 'Normal',
  286. run: { font: 'Microsoft YaHei', size: 21 }
  287. }
  288. ]
  289. }
  290. }
  291. },
  292. sections: [
  293. {
  294. properties: {
  295. page: {
  296. margin: { top: 720, right: 720, bottom: 720, left: 720 }
  297. }
  298. },
  299. children
  300. }
  301. ]
  302. })
  303. const buffer = await Packer.toBuffer(doc)
  304. fs.writeFileSync(OUT_PATH, buffer)
  305. console.log('OK ->', OUT_PATH)
  306. console.log('bytes', buffer.length)
  307. }
  308. main().catch((e) => {
  309. console.error(e)
  310. process.exit(1)
  311. })