import moment from 'moment'; import fs from 'fs'; import commonModel from '../../model/commonModel.js'; import config from '../../config/index.js'; import _ from 'lodash'; import axios from 'axios'; import { Encrypt, Decrypt } from '../../util/crypto/index.js'; import { stringUtils } from '../../util/stringClass.js'; import WXBizDataCrypt from '../../util/WXBizDataCrypt.js'; import { globalCache } from '../../util/GlobalCache.js'; import yjbdc from '../../model/yjbdc.js'; import aiController from './aiController.js'; import PDFDocument from 'pdfkit'; import tencentcloud from 'tencentcloud-sdk-nodejs-ocr'; const ONE_DAY_MAX_BUILD_COUNT=12;//一天最大生成数 const OcrClient = tencentcloud.ocr.v20181119.Client; // AI平台配置 const DEFAULT_AI_PROVIDER = 'volces1-5'; // 默认使用火山云AI export async function YJBDCLogin(ctx) { let param = ctx.request.body; if (param.param) { const paramStr = Decrypt(param.param, config.urlSecrets.aes_key, config.urlSecrets.aes_iv); //console.log("paramStr:"+paramStr); param = JSON.parse(paramStr); } const code = param.Code; //console.log("code:"+code); const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${config.wx.yjbdc_appid}&secret=${config.wx.yjbdc_appsecret}&js_code=${code}&grant_type=authorization_code`; let result = await axios.get(url) .then(res => { const json = res.data; //console.log("json:"+json); if (json && json.openid) { param.OpenID = json.openid; param.sessionKey = json.session_key; if (json.unionid) param.UnionID = json.unionid; return {errcode: 10000}; } else { return json; } }) .catch(err => { return {errcode: 101, errStr: err}; }); if (result.errcode == 10000) { delete param.Code; if (param.sessionKey && param.iv && param.encryptedData){ //console.log("param.sessionKey:"+param.sessionKey); const pc = new WXBizDataCrypt(config.wx.yjbdc_appid, param.sessionKey); const dataUnionID = pc.decryptData(param.encryptedData , param.iv); //console.log(dataUnionID); param.UnionID = dataUnionID.unionId; } delete param.sessionKey; delete param.iv; delete param.encryptedData; //todo //param.OpenID="o4UHq4gaNlHfdTWxgl3fTgC1mFsI"; let userList = await yjbdc.GetUsersInfo(param); if (userList.length > 0) { param.LastLoginTime = new Date(); const time1 = moment(userList[0].ProductServiceTime).format('YYYY-MM-DD HH:mm:ss'); const time3 = moment().format('YYYY-MM-DD HH:mm:ss'); if (time1 < time3) param.IsMember = 0; delete param.Introducer; delete param.UserSource; delete param.SourceID; //console.log(param.NickName); if (param.NickName == "陌生用户") { delete param.NickName; delete param.AvatarUrl; delete param.Language; delete param.Gender; delete param.City; delete param.Province; delete param.Country; } await yjbdc.UpdateUsers(param); userList = await yjbdc.GetUsersInfo(param); } else { param.NickName = "陌生用户"; param.AvatarUrl = "../images/userface_default.png"; param.CreateTime = new Date(); param.LastLoginTime = param.CreateTime; param.ProductServiceTime = param.CreateTime; const inseredID = await yjbdc.AddUsers(param); userList = await yjbdc.GetUsersInfo(param); } delete userList[0].OpenID; delete userList[0].UnionID; //产品支付是否显示 if (param.ProgramVersion) { let param2 = { ProgramID: 186, Version: param.ProgramVersion, }; let result3 = await commonModel.GetProductVersionList(param2); if (result3) { if ((param2.Version == result3[0].Version && result3[0].IsShowPay <= 0) || param2.Version > result3[0].Version) { userList[0].IsShow = result3[0].IsShowPay; } else { userList[0].IsShow = 1; } //针对iphone测试用户,永远是无支付状态 if (userList[0].Brand == 'iPhone' && userList[0].WXLanguage == 'en-US' && userList[0].UserSource == '1001' && userList[0].IsPay == 0) { userList[0].IsShow = 0; } //针对微信测试用户,永远是无支付状态 if ((userList[0].UserSource=='1001' && userList[0].System=="iOS 10.0.1") || (!userList[0].UserSource && (!userList[0].LastUserSource || userList[0].LastUserSource>10000)) || userList[0].NickName.indexOf("dgztest")>=0){ userList[0].IsShow=-1; } if (userList[0].IsMember===1) userList[0].IsShow=1; } } result = {errcode: 10000, result: userList[0]}; } ctx.body = result; } export async function OCRImageData(ctx) { const params = ctx.request.body; const clientConfig = { credential: { secretId: config.tencentcloud.secretId, secretKey: config.tencentcloud.secretKey, }, region: "ap-guangzhou", profile: { httpProfile: { endpoint: "ocr.tencentcloudapi.com", }, }, }; // 实例化要请求产品的client对象,clientProfile是可选的 const client = new OcrClient(clientConfig); const result = await client.GeneralBasicOCR(params); let param2={}; param2.UserID=ctx.query.UserID; param2.CreateTime=moment().format('YYYY-MM-DD HH:mm:ss'); param2.Params=JSON.stringify(params); param2.JSONString=JSON.stringify(result); await yjbdc.AddOCRInfo(param2); ctx.body = {"errcode": 10000, result}; } export async function GenerateArticle(ctx) { const url='GenerateArticle?UserID='+ctx.query.UserID; let result = globalCache.get(url); if (result === 0) { const params = ctx.request.body; const words = params.Words; let articleStyle = params.ArticleStyle; //文章类型 const ARTICLE_STYLE={ "成长":"指一个人从小慢慢长大,不仅身体变高变壮,还学会更多知识,懂得更多道理,变得越来越懂事、能干。", "童话":"充满神奇和想象的故事,里面可能有会说话的动物、美丽的公主、勇敢的王子,还有魔法和冒险,比如《白雪公主》、《小红帽》。", "家庭亲子":"指爸爸妈妈和孩子之间的相处,比如一起玩游戏、读书、聊天,让家庭充满爱和温暖。", "人生励志":"讲述一个人如何克服困难、努力奋斗,最终取得成功的故事,鼓励我们不要轻易放弃,比如《爱迪生发明电灯》。", "科幻":"科学幻想故事,里面有未来科技、外星人、太空旅行等,比如《海底两万里》、《流浪地球》、《时间的折皱》。", "奇幻":"充满魔法、神奇生物和不可思议事件的故事,比如《哈利·波特》。", "校园生活":"发生在学校里的故事,比如上课、考试、运动会、和同学一起玩耍,与友情、团结、如何学习、克服困难有关的事情,也可能有搞笑或感人的事情。", "节日文化":"不同节日的习俗和传统,比如春节贴春联、中秋节吃月饼、端午划龙舟、清明节扫墓、重阳节登高望远、元宵节猜灯谜,让我们了解不同文化。", "旅行":"去不同的地方游玩,看看美丽的风景,体验不一样的生活,比如爬山、逛动物园、参观博物馆。", "科普":"科学普及知识,用有趣的方式讲解自然、宇宙、动物、植物等科学现象,比如《十万个为什么》。", "动物":"生活在地球上的各种生物,有的会跑,有的会飞,有的会游泳,比如猫、狗、大象、企鹅,它们都有自己的特点和习性。", "环保":"指环境保护、保护大自然,让地球更干净、更健康。比如节约用水、减少垃圾、种树、不乱扔塑料袋,这样空气会更清新,动物也有更好的家园。每个人都可以从小事做起爱护地球。", }; //等级难度 const LEVEL=[ { Key:"小学", Content:"难度上是中国的小学六年级毕业的孩子可以阅读,生成的文章中除了用户提供的单词,其他全部使用人教版小学英语词汇表的单词,以及Sight words中220个单词,不要超出范围。" }, { Key:"初中", Content:"难度上是中国的初三毕业的孩子可以阅读,生成的文章中除了用户提供的单词,其他全部使用人教版初中英语词汇表的单词,以及Sight words中220个单词,不要超出范围。" }, { Key:"高中", Content:"难度上是中国的高三毕业的孩子可以阅读,生成的文章使用人教版高中英语词汇表的单词." }, { Key:"大学", Content:"难度上是中国的大学毕业生的孩子可以阅读,生成的文章使用大学生六级词汇表的单词." }, ] let content = "将"+words+"这些单词生成一篇英文文章。要求"+ "[难度];"+ "单词若是脏话,像'fuck'、'shit'等,可以忽略;"+ "文章类型是'"+articleStyle+"([类型])';"+ "要求提供丰富上下文线索、清晰的文章结构;"+ "文章单词数在200个左右,最多不能超过300个;"+ "文章按每句分成数组,且每句都有中文翻译;"+ "提供5道针对文章阅读理解的单项选择题,各有四个选项,并提供答案;"+ "单项选择题和选项也要有中文翻译;"+ "如果文章生成中用到了用户提交的单词中,有单词的变形形式,比如用到单词的原型、复数形式、过去式、过去分词、比较级、最高级、第三人称单数等,请罗列出文章中单词所用到的形式;"+ "内容格式为JSON,必须严格按照以下格式,不能有任何字段名错误或缺失:{"+ " \"ArticleEnglish\":[\"<英文文章句子1>\",\"<英文文章句子2>\"...],"+ " \"ArticleChinese\":[\"<中文翻译句子1>\",\"<中文翻译句子2>\"...],"+ " \"FormsOfWords\":[\"<单词1形式1>\",\"<单词1形式2>\",\"<单词2形式1>\",...],"+ " \"Question\":[{"+ " \"QuestionEnglish\":\"<英语题目1>\","+ " \"QuestionChinese\":\"<题目1中文翻译>\","+ " \"OptionsEnglish\":[\"A.<英语选项1>\",\"B.<英语选项2>\",\"C.<英语选项3>\",\"D.<英语选项4>\"],"+ " \"OptionsChinese\":[\"A.<选项1中文翻译>\",\"B.<选项2中文翻译>\",\"C.<选项3中文翻译>\",\"D.<选项4中文翻译>\"],"+ " \"Answer\":\"<答案>\""+ " }...]"+ "}"; if (!params.Level || params.Level=="" || params.Level==undefined || params.Level>="4"){ params.Level="0"; } content = content.replace("[难度]",LEVEL[Number(params.Level)].Content); if (articleStyle=="随机" || articleStyle=="任意") { // 获取ARTICLE_STYLE对象的所有键(文章类型) const articleTypes = Object.keys(ARTICLE_STYLE); // 随机选择一个文章类型 const randomIndex = stringUtils.Random(0, articleTypes.length-1); articleStyle = articleTypes[randomIndex]; } content = content.replace("[类型]",ARTICLE_STYLE[articleStyle]); //console.log("content:"+content); // 从请求参数中获取AI提供者,如果没有指定则使用默认值 let aiProvider = DEFAULT_AI_PROVIDER; if (params.Level>=2){ aiProvider="volces1-6"; } try { // 使用aiController生成文章 let result2 = await aiController.generateArticle(content, aiProvider); // 校验和修复JSON结构 result2 = aiController.validateAndFixJSON(result2); console.log("JSON结构已校验和修复"); let param2={}; param2.UserID=ctx.query.UserID; param2.CreateTime=moment().format('YYYY-MM-DD HH:mm:ss'); param2.Words=words; param2.Level=params.Level; param2.articleStyle=articleStyle; param2.BuildStr=content; try { // 尝试解析JSON以获取ArticleStart const jsonObj = JSON.parse(result2); param2.ArticleStart = jsonObj.ArticleEnglish && jsonObj.ArticleEnglish.length > 0 ? jsonObj.ArticleEnglish[0] : "No content available"; } catch (error) { param2.ArticleStart = "Error parsing JSON"; } // 去除JSON字符串中的所有换行符(\r\n, \n, \r) param2.JSONString=JSON.stringify(result2.replace(/[\r\n]+/g, '')); param2.Flag=0; await yjbdc.AddArticleInfo(param2); result = { errcode: 10000, result: result2 }; globalCache.set(url, result, config.BufferMemoryTime); console.log("缓存60秒"); } catch(err){ result = { errcode: 10000, result: "-1" }; } } ctx.body = result; } export async function GetYJBDCArticleList(ctx) { const param = { UserID: ctx.query.UserID || 0, ID:ctx.query.ID || 0, IsTodayCount: ctx.query.IsTodayCount || false, }; // 尝试从缓存获取 const url='GetYJBDCArticleList?UserID='+param.UserID+"&ID="+param.ID; let result = globalCache.get(url); if (result === 0) { result = await yjbdc.GetYJBDCArticleList(param); if (param.ID==0){ for(let i=0;i=today) count++; } let maxcount=ONE_DAY_MAX_BUILD_COUNT; if (param.UserID<4){ maxcount=100; } result={ TodayCount:count, MaxCount:maxcount, }; } ctx.body = {"errcode": 10000, result:result}; } export async function DeleteYJBDCArticleList(ctx) { const param = { UserID: ctx.query.UserID || 0, ID: ctx.query.ID || 0, }; if (param.ID>0){ const url='GetYJBDCArticleList?UserID='+param.UserID+"&ID=0"; globalCache.delete(url); await yjbdc.UpdateYJBDCArticle(param); } ctx.body = {"errcode": 10000}; } export async function GeneratePDF(ctx) { const params = ctx.request.body; if (!params || !params.Content) { ctx.status = 400; ctx.body = { error: 'Invalid request body: Content is required' }; return; } const content = params.Content; // 文章类型映射 const ARTICLE_STYLE = { "成长": "Personal Growth", "童话": "Fairy Tales", "家庭亲子": "Family Stories", "人生励志": "Inspirational", "科幻": "Science Fiction", "奇幻": "Fantasy", "校园生活": "School Life", "节日文化": "Cultural Stories", "旅行": "Travel Stories", "科普": "Popular Science", "动物": "Animal Stories", "环保": "Environmental Stories", }; // 等级难度 const LEVEL = [ "Primary school vocabulary size", "Junior high school vocabulary size", "High school vocabulary size", "College vocabulary size" ]; try { // 创建新的 PDF 文档 - 使用A4尺寸 const doc = new PDFDocument({ size: 'A4', // 使用标准A4尺寸 (595.28 x 841.89 points) margins: { top: 0, bottom: 0, left: 0, right: 0 }, autoFirstPage: true }); // 注册中文字体 doc.registerFont('ChineseFont', './public/fonts/方正黑体简体.TTF'); // 定义字体选择函数 const selectFont = (text, defaultFont = 'Helvetica') => { if (/[\u4E00-\u9FFF]/.test(text)) { return 'ChineseFont'; } return defaultFont; }; // 收集生成的 PDF 数据 const chunks = []; doc.on('data', (chunk) => chunks.push(chunk)); // 像素到点(pt)的转换函数 const pixelToPt = (pixel) => pixel * (595.28 / 2100); // 获取文章内容 let articleText = ""; if (content.ArticleEnglish) { articleText = Array.isArray(content.ArticleEnglish) ? content.ArticleEnglish.join(" ") : content.ArticleEnglish; } else { articleText = "No content available"; } articleText = articleText.replace(/<[^>]*>/g, ''); // 获取单词列表 let words = []; if (content.Words) { words = typeof content.Words === 'string' ? content.Words.split(",") : (Array.isArray(content.Words) ? content.Words : []); } // 获取问题列表 let questions = []; if (content.Question && Array.isArray(content.Question)) { questions = content.Question; } // doc.image('./public/images/PDF.png', 0, 0, { // width: pixelToPt(2100), // height: pixelToPt(2970) // }); // 1. 标题 - 文章类型 doc.font(selectFont(ARTICLE_STYLE[content.ArticleStyle] || "Story")) .fontSize(pixelToPt(48)) .text(ARTICLE_STYLE[content.ArticleStyle] || "Story", pixelToPt(120), pixelToPt(110)); // 2. 副标题 - 难度级别 doc.font('Helvetica') .fontSize(pixelToPt(30)) .text(LEVEL[content.Level] || "", pixelToPt(120), pixelToPt(170)); // 3. 时间 const currentDate = moment().format("YYYY年MM月DD日 HH:mm"); if (params.CreateTime) currentDate=moment(params.CreateTime).format("YYYY年MM月DD日 HH:mm"); doc.font('ChineseFont') .fontSize(pixelToPt(30)) .text(currentDate, pixelToPt(120), pixelToPt(212)); // 4. 黑线 doc.rect(pixelToPt(120), pixelToPt(289), pixelToPt(537), pixelToPt(10)) .fill('black'); // 5. 单词列表 doc.font('Helvetica') .fontSize(pixelToPt(36)); let wordY = pixelToPt(364); words.slice(0, 10).forEach(word => { doc.text(word, pixelToPt(122), wordY, { width: pixelToPt(535), align: 'left' }); wordY += pixelToPt(70); }); // 6. 文章内容 // 先计算文章内容的行数 doc.font('Helvetica'); // 使用48字号计算文本高度 const fontSize48 = pixelToPt(48); doc.fontSize(fontSize48); const textHeight48 = doc.heightOfString(articleText, { width: pixelToPt(1240), lineGap: pixelToPt(40.5) }); // 计算行数 (文本高度 / (字体大小 + 行间距)) const lineHeight = fontSize48 + pixelToPt(40.5); const lineCount = Math.ceil(textHeight48 / lineHeight); // 如果行数超过18行,则使用42字号 const fontSize = lineCount > 18 ? pixelToPt(42) : pixelToPt(48); // 渲染文章内容 doc.fontSize(fontSize) .text(articleText, pixelToPt(740), pixelToPt(105), { width: pixelToPt(1240), lineGap: pixelToPt(40.5) }); // 7. 黑线 const articleEndY = doc.y; doc.rect(pixelToPt(120), articleEndY + pixelToPt(41), pixelToPt(1860), pixelToPt(10)) .fill('black'); // 8-13. 问题和答案 if (questions.length > 0) { // 定义问题的X坐标位置 const questionXPositions = [ pixelToPt(120), // 问题1 pixelToPt(120), // 问题2 pixelToPt(740), // 问题3 pixelToPt(740), // 问题4 pixelToPt(1360) // 问题5 ]; // 存储每列最后一个选项的Y坐标位置 let lastOptionYPositions = { column1: 0, // 用于跟踪第一列(问题1)的最后位置 column2: 0, // 用于跟踪第二列(问题3)的最后位置 }; // 首先渲染问题1、3、5(第一行问题) const firstRowQuestions = [0, 2, 4]; // 问题1、3、5的索引 for (const i of firstRowQuestions) { if (i < questions.length && questions[i]) { const currentX = questionXPositions[i]; const currentY = articleEndY + pixelToPt(130); // 所有第一行问题的起始Y坐标相同 // 渲染问题文本 doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`${i+1}. ${questions[i].QuestionEnglish || `Question ${i+1}`}`, currentX, currentY, { width: pixelToPt(540), align: 'left' }); // 获取问题文本渲染后的Y坐标位置 const questionEndY = doc.y; // 选项起始位置 = 问题结束位置 + 20像素间距 let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[i].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, currentX, optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, currentX + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 // 获取当前位置,确保下一个选项在当前选项完全渲染后的位置 optionY = doc.y + pixelToPt(8); } }); // 保存该列最后一个选项的Y坐标 if (i === 0) { lastOptionYPositions.column1 = doc.y; } else if (i === 2) { lastOptionYPositions.column2 = doc.y; } } } // 然后渲染问题2和4(第二行问题) if (questions.length > 1 && questions[1]) { // 问题2位于问题1的选项下方60像素处 const question2Y = lastOptionYPositions.column1 + pixelToPt(75); doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`2. ${questions[1].QuestionEnglish || "Question 2"}`, questionXPositions[1], question2Y, { width: pixelToPt(540), align: 'left' }); // 获取问题文本渲染后的Y坐标位置 const questionEndY = doc.y; // 选项起始位置 = 问题结束位置 + 20像素间距 let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[1].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, questionXPositions[1], optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, questionXPositions[1] + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 optionY = doc.y + pixelToPt(8); } }); } if (questions.length > 3 && questions[3]) { // 问题4位于问题3的选项下方60像素处 const question4Y = lastOptionYPositions.column2 + pixelToPt(75); doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`4. ${questions[3].QuestionEnglish || "Question 4"}`, questionXPositions[3], question4Y, { width: pixelToPt(540), align: 'left' }); // 获取问题文本渲染后的Y坐标位置 const questionEndY = doc.y; // 选项起始位置 = 问题结束位置 + 20像素间距 let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[3].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, questionXPositions[3], optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, questionXPositions[3] + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 optionY = doc.y + pixelToPt(8); } }); } // 问题5保持原位置不变 if (questions.length > 4 && questions[4]) { const currentX = questionXPositions[4]; const currentY = articleEndY + pixelToPt(130); doc.font('Helvetica-Bold') .fontSize(pixelToPt(36)) .text(`5. ${questions[4].QuestionEnglish || "Question 5"}`, currentX, currentY, { width: pixelToPt(540), align: 'left' }); const questionEndY = doc.y; let optionY = questionEndY + pixelToPt(28); // 渲染选项 const options = questions[4].OptionsEnglish || []; const optionLabels = ['A', 'B', 'C', 'D']; // 设置选项标签宽度和选项总宽度 const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度 const totalWidth = pixelToPt(496); // 选项的总宽度 const contentWidth = totalWidth - labelWidth; // 选项内容的宽度 options.forEach((opt, index) => { if (index < optionLabels.length) { // 提取选项内容,移除可能存在的标签前缀 let optionContent = opt.replace(/^[A-D]\.\s*/, ''); // 渲染选项标签(A./B./C./D.) doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(`${optionLabels[index]}.`, currentX, optionY, { width: labelWidth, align: 'left' }); // 渲染选项内容,确保折行时与内容第一行对齐 doc.font('Helvetica') .fontSize(pixelToPt(36)) .text(optionContent, currentX + labelWidth, optionY, { width: contentWidth, align: 'left' }); // 更新选项Y坐标,为下一个选项做准备 optionY = doc.y + pixelToPt(8); } }); } } // 14. 虚线 const lastContentY = doc.page.height - pixelToPt(190); // doc.rect(pixelToPt(120), lastContentY, // pixelToPt(1630), pixelToPt(10)) // .fill('#D2D2D2'); doc.strokeColor('#D2D2D2') .dash(pixelToPt(20), { space: pixelToPt(28) }) // 设置虚线样式:宽20像素,间隔20像素 .lineWidth(pixelToPt(10)) // 线高10像素 .moveTo(pixelToPt(120), lastContentY) .lineTo(pixelToPt(120) + pixelToPt(1630), lastContentY) .stroke() .undash(); // 重置虚线样式 // 15-19. 答案和底部信息 doc.font('Helvetica-Bold') // 使用Helvetica-Bold作为Semibold替代 .fontSize(pixelToPt(36)) .fillColor('black'); // 重置颜色为黑色 const answersY = doc.page.height - pixelToPt(144); // 获取问题答案 const answers = []; if (questions.length > 0) { for (let i = 0; i < Math.min(questions.length, 5); i++) { const answer = questions[i].Answer || ""; answers.push(`${i+1}. ${answer}`); } } // 显示答案(如果存在) const answerPositions = [120, 262, 411, 561, 711]; for (let i = 0; i < Math.min(answers.length, 5); i++) { doc.text(answers[i], pixelToPt(answerPositions[i]), answersY+2, { width: pixelToPt(100), align: 'left' }); } // 20-21. 应用名称 doc.font('ChineseFont') .fontSize(pixelToPt(36)) .text("语境背单词(微信小程序)", pixelToPt(1338), answersY,{ width: pixelToPt(440), align: 'right' }); // 添加二维码图片 try { // 计算图片位置:距离右边120像素,距离底部100像素 // 由于PDFKit使用左上角坐标系,需要计算左上角坐标 const qrCodeWidth = pixelToPt(200); const qrCodeHeight = pixelToPt(200); const qrCodeX = doc.page.width - pixelToPt(120) - qrCodeWidth; // 右边距离转换为左边距离 const qrCodeY = doc.page.height - pixelToPt(100) - qrCodeHeight; // 底部距离转换为顶部距离 // 添加二维码图片 doc.image('./public/images/acode/YJBDC_QRCode.png', qrCodeX, qrCodeY, { width: qrCodeWidth, height: qrCodeHeight }); console.log("QR Code added successfully"); } catch (imgError) { console.error("Error adding QR Code image:", imgError); } // 结束PDF生成 doc.end(); // 等待PDF生成完成 const pdfBuffer = await new Promise((resolve) => { doc.on('end', () => { resolve(Buffer.concat(chunks)); }); }); // 设置响应头 ctx.set('Content-Type', 'application/pdf'); ctx.set('Content-Disposition', 'attachment; filename=article.pdf'); ctx.body = pdfBuffer; } catch (error) { console.error("Error generating PDF:", error); ctx.status = 500; ctx.body = { error: "Failed to generate PDF" }; } } export async function BuildYJBDCQRCode(ctx) { try { // 获取微信访问令牌 const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?appid=${config.wx.yjbdc_appid}&secret=${config.wx.yjbdc_appsecret}&grant_type=client_credential`; const tokenResponse = await axios.get(tokenUrl); const tokenData = tokenResponse.data; if (!tokenData || !tokenData.access_token) { ctx.status = 400; ctx.body = { errcode: 101, errStr: '获取微信访问令牌失败' }; return; } const accessToken = tokenData.access_token; // 生成小程序码 const qrCodeUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`; const path = './public/images/acode/'; const imageUrl = 'YJBDC_QRCode.png'; const fullPath = path + imageUrl; // 确保目录存在 if (!fs.existsSync(path)) { fs.mkdirSync(path, { recursive: true }); } const postData = { width: 280, scene: "SourceID=187" }; // 使用axios获取二维码并保存到文件 const qrCodeResponse = await axios({ method: 'POST', url: qrCodeUrl, data: postData, responseType: 'stream' }); // 创建写入流 const writer = fs.createWriteStream(fullPath); // 将响应数据写入文件 qrCodeResponse.data.pipe(writer); // 返回成功响应 ctx.body = { errcode: 10000, message: "二维码生成请求已发送" }; // 处理文件写入完成事件 writer.on('finish', () => { console.log("二维码生成成功:", fullPath); }); // 处理错误 writer.on('error', (err) => { console.error("二维码文件写入失败:", err); }); } catch (error) { console.error("生成二维码失败:", error); ctx.status = 500; ctx.body = { errcode: 500, errStr: '生成二维码失败', error: error.message }; } }