'use strict'; const superagent = require('superagent'); const qiniu = require('qiniu'); const mammoth = require('mammoth'); const cheerio = require('cheerio'); const path = require('path'); const { randomUUID } = require('crypto'); const { DOCX_IMPORT_MAX_FILE_SIZE, DOCX_IMPORT_RESPONSE_TIMEOUT, DOCX_IMPORT_DEADLINE_TIMEOUT, DOCX_ZIP_MAGIC, DOCX_CONTENT_TYPE_PATTERN, } = require('./constants'); const ensureHttpUrl = (value, label = 'sourceFileUrl') => { const raw = String(value || '').trim(); if (!raw) throw `missing param: ${label}`; let parsed = null; try { parsed = new URL(raw); } catch (error) { throw `invalid param: ${label}`; } if (!['http:', 'https:'].includes(parsed.protocol)) { throw `invalid param: ${label}`; } return parsed.toString(); }; const decodeURIComponentSafely = value => { try { return decodeURIComponent(value); } catch (error) { return value; } }; const parseDispositionFilename = (disposition = '') => { const utf8Match = disposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); if (utf8Match && utf8Match[1]) { return decodeURIComponentSafely(utf8Match[1].trim().replace(/^"|"$/g, '')); } const plainMatch = disposition.match(/filename\s*=\s*("?)([^";]+)\1/i); if (plainMatch && plainMatch[2]) return plainMatch[2].trim(); return ''; }; const getSourceFileName = (sourceFileUrl, contentDisposition = '') => { const fromDisposition = parseDispositionFilename(String(contentDisposition || '')); if (fromDisposition) return fromDisposition; try { const parsed = new URL(sourceFileUrl); const base = path.basename(parsed.pathname || ''); if (base) return decodeURIComponentSafely(base); } catch (error) { } return 'import.docx'; }; const toBuffer = data => { if (!data) return Buffer.alloc(0); if (Buffer.isBuffer(data)) return data; if (data instanceof ArrayBuffer) return Buffer.from(data); if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength); if (typeof data === 'string') return Buffer.from(data); return Buffer.alloc(0); }; const hasDocxZipMagic = buffer => { if (!Buffer.isBuffer(buffer) || buffer.length < DOCX_ZIP_MAGIC.length) return false; return DOCX_ZIP_MAGIC.every((byte, index) => buffer[index] === byte); }; const ensureDocxFile = ({ sourceFileUrl, contentType, fileBuffer }) => { const safeContentType = String(contentType || '').toLowerCase(); const hasDocxMime = DOCX_CONTENT_TYPE_PATTERN.test(safeContentType); const pathname = (() => { try { return new URL(sourceFileUrl).pathname || ''; } catch (error) { return ''; } })(); const hasDocxExt = pathname.toLowerCase().endsWith('.docx'); if (!hasDocxZipMagic(fileBuffer)) { throw 'source file is not a valid docx file'; } if (!hasDocxExt && !hasDocxMime) { throw 'source file must be a docx file'; } }; const downloadDocxFile = async sourceFileUrl => { const response = await superagent .get(sourceFileUrl) .redirects(3) .responseType('arraybuffer') .timeout({ response: DOCX_IMPORT_RESPONSE_TIMEOUT, deadline: DOCX_IMPORT_DEADLINE_TIMEOUT, }); const fileBuffer = toBuffer(response.body); if (!fileBuffer.length) throw 'source file is empty'; if (fileBuffer.length > DOCX_IMPORT_MAX_FILE_SIZE) { throw `source file too large, max ${Math.floor(DOCX_IMPORT_MAX_FILE_SIZE / 1024 / 1024)}MB`; } const contentType = String(response.header?.['content-type'] || '').trim(); const sourceFileName = getSourceFileName( sourceFileUrl, String(response.header?.['content-disposition'] || '') ); ensureDocxFile({ sourceFileUrl, contentType, fileBuffer, }); return { fileBuffer, contentType, sourceFileName, }; }; const ensureQiniuConfig = ctx => { const qiniuConfig = ctx.config?.qiniu || {}; const bucket = String(qiniuConfig.bkt || '').trim(); const accessKey = String(qiniuConfig.ak || '').trim(); const secretKey = String(qiniuConfig.sk || '').trim(); const domain = String(qiniuConfig.dmn || '').trim().replace(/\/+$/, ''); if (!bucket || !accessKey || !secretKey) { throw 'qiniu config is incomplete'; } return { bucket, accessKey, secretKey, domain, }; }; const createQiniuUploader = ctx => { const { bucket, accessKey, secretKey, domain } = ensureQiniuConfig(ctx); const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket }); const uploadToken = putPolicy.uploadToken(mac); const config = new qiniu.conf.Config(); config.zone = qiniu.zone.Zone_z0; const formUploader = new qiniu.form_up.FormUploader(config); return async (key, buffer, mimeType = 'application/octet-stream') => { const putExtra = new qiniu.form_up.PutExtra(); putExtra.mimeType = mimeType; const uploadBody = await new Promise((resolve, reject) => { formUploader.put(uploadToken, key, buffer, putExtra, (respErr, respBody, respInfo) => { if (respErr) { reject(respErr); return; } if (respInfo?.statusCode && respInfo.statusCode >= 300) { reject(new Error(`qiniu upload failed with status ${respInfo.statusCode}`)); return; } resolve(respBody || {}); }); }); const finalKey = String(uploadBody.key || key); return domain ? `${domain}/${finalKey}` : finalKey; }; }; const getImageExtensionByContentType = contentType => { const normalized = String(contentType || '').toLowerCase(); const map = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'image/bmp': 'bmp', 'image/tiff': 'tiff', 'image/svg+xml': 'svg', }; return map[normalized] || 'bin'; }; const buildDocxImageQiniuKey = contentType => { const extension = getImageExtensionByContentType(contentType); const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, ''); return `report/docx-import/images/${datePart}/${randomUUID()}.${extension}`; }; const convertDocxToHtml = async (ctx, fileBuffer) => { let uploadToQiniu = null; const result = await mammoth.convertToHtml( { buffer: fileBuffer }, { convertImage: mammoth.images.imgElement(async image => { const contentType = String(image.contentType || 'application/octet-stream'); const imageBuffer = toBuffer(await image.read()); if (!imageBuffer.length) throw new Error('empty image in docx'); if (!uploadToQiniu) { uploadToQiniu = createQiniuUploader(ctx); } const imageKey = buildDocxImageQiniuKey(contentType); const src = await uploadToQiniu(imageKey, imageBuffer, contentType); return { src }; }) } ); return String(result?.value || '').trim(); }; const parseDocxHtmlToChapters = html => { const $ = cheerio.load(`
${String(html || '')}
`, { decodeEntities: false }); const root = $('#docx-import-root'); const chapters = []; const stack = []; const pendingBeforeFirstHeading = []; root.contents().each((_, element) => { if (element.type === 'text' && !String(element.data || '').trim()) return; const serialized = String($.html(element) || '').trim(); if (!serialized) return; const tagName = String(element.name || element.tagName || '').toLowerCase(); const headingMatch = tagName.match(/^h([1-6])$/); if (!headingMatch) { if (!stack.length) { pendingBeforeFirstHeading.push(serialized); return; } stack[stack.length - 1].contentParts.push(serialized); return; } const level = Number(headingMatch[1]); const title = String($(element).text() || '').replace(/\s+/g, ' ').trim(); const node = { title: title || 'Untitled Chapter', level, contentParts: [], children: [], }; if (!stack.length && pendingBeforeFirstHeading.length) { node.contentParts.push(...pendingBeforeFirstHeading); pendingBeforeFirstHeading.length = 0; } while (stack.length && stack[stack.length - 1].level >= level) { stack.pop(); } if (stack.length) { stack[stack.length - 1].children.push(node); } else { chapters.push(node); } stack.push(node); }); if (!chapters.length) { throw 'docx 文档未识别到标题,请使用 heading 样式'; } const normalizeNode = node => ({ title: node.title, contentHtml: node.contentParts.join('\n').trim(), children: (node.children || []).map(normalizeNode), }); return chapters.map(normalizeNode); }; module.exports = { ensureHttpUrl, downloadDocxFile, convertDocxToHtml, parseDocxHtmlToChapters, };