|
|
@@ -366,6 +366,7 @@ export async function DeleteYJBDCArticleList(ctx) {
|
|
366
|
366
|
}
|
|
367
|
367
|
|
|
368
|
368
|
|
|
|
369
|
+
|
|
369
|
370
|
export async function GeneratePDF(ctx) {
|
|
370
|
371
|
const params = ctx.request.body;
|
|
371
|
372
|
if (!params || !params.Content) {
|
|
|
@@ -373,32 +374,32 @@ export async function GeneratePDF(ctx) {
|
|
373
|
374
|
ctx.body = { error: 'Invalid request body: Content is required' };
|
|
374
|
375
|
return;
|
|
375
|
376
|
}
|
|
376
|
|
- //文章类型
|
|
377
|
|
- const ARTICLE_STYLE={
|
|
378
|
|
- "成长":"Personal Growth",
|
|
379
|
|
- "童话":"Fairy Tales",
|
|
380
|
|
- "家庭亲子":"Family Stories",
|
|
381
|
|
- "人生励志":"Inspirational",
|
|
382
|
|
- "科幻":"Science Fiction",
|
|
383
|
|
- "奇幻":"Fantasy",
|
|
384
|
|
- "校园生活":"School Life",
|
|
385
|
|
- "节日文化":"Cultural Stories",
|
|
386
|
|
- "旅行":"Travel Stories",
|
|
387
|
|
- "科普":"Popular Science",
|
|
388
|
|
- "动物":"Animal Stories",
|
|
389
|
|
- "环保":"Environmental Stories",
|
|
|
377
|
+
|
|
|
378
|
+ // 文章类型映射
|
|
|
379
|
+ const ARTICLE_STYLE = {
|
|
|
380
|
+ "成长": "Personal Growth",
|
|
|
381
|
+ "童话": "Fairy Tales",
|
|
|
382
|
+ "家庭亲子": "Family Stories",
|
|
|
383
|
+ "人生励志": "Inspirational",
|
|
|
384
|
+ "科幻": "Science Fiction",
|
|
|
385
|
+ "奇幻": "Fantasy",
|
|
|
386
|
+ "校园生活": "School Life",
|
|
|
387
|
+ "节日文化": "Cultural Stories",
|
|
|
388
|
+ "旅行": "Travel Stories",
|
|
|
389
|
+ "科普": "Popular Science",
|
|
|
390
|
+ "动物": "Animal Stories",
|
|
|
391
|
+ "环保": "Environmental Stories",
|
|
390
|
392
|
};
|
|
391
|
393
|
|
|
392
|
|
- //等级难度
|
|
393
|
|
- const LEVEL=[
|
|
|
394
|
+ // 等级难度
|
|
|
395
|
+ const LEVEL = [
|
|
394
|
396
|
"Primary school vocabulary size",
|
|
395
|
397
|
"Junior high school vocabulary size",
|
|
396
|
398
|
"High school vocabulary size",
|
|
397
|
399
|
"College vocabulary size"
|
|
398
|
|
- ]
|
|
|
400
|
+ ];
|
|
399
|
401
|
|
|
400
|
402
|
const content = params.Content;
|
|
401
|
|
- //console.log("Generating PDF with content:", JSON.stringify(content).substring(0, 200) + "...");
|
|
402
|
403
|
|
|
403
|
404
|
try {
|
|
404
|
405
|
// 创建新的 PDF 文档 - 使用A4尺寸
|
|
|
@@ -416,9 +417,8 @@ export async function GeneratePDF(ctx) {
|
|
416
|
417
|
// 注册中文字体
|
|
417
|
418
|
doc.registerFont('ChineseFont', './public/fonts/方正黑体简体.TTF');
|
|
418
|
419
|
|
|
419
|
|
- // 定义字体选择函数,根据内容是否包含中文选择合适的字体
|
|
|
420
|
+ // 定义字体选择函数
|
|
420
|
421
|
const selectFont = (text, defaultFont = 'Helvetica') => {
|
|
421
|
|
- // 检查文本是否包含中文字符
|
|
422
|
422
|
if (/[\u4E00-\u9FFF]/.test(text)) {
|
|
423
|
423
|
return 'ChineseFont';
|
|
424
|
424
|
}
|
|
|
@@ -429,302 +429,261 @@ export async function GeneratePDF(ctx) {
|
|
429
|
429
|
const chunks = [];
|
|
430
|
430
|
doc.on('data', (chunk) => chunks.push(chunk));
|
|
431
|
431
|
|
|
432
|
|
- // 像素到点(pt)的转换函数 - A4纸张像素尺寸2100×2970,PDFKit点尺寸595.28×841.89
|
|
|
432
|
+ // 像素到点(pt)的转换函数
|
|
433
|
433
|
const pixelToPt = (pixel) => pixel * (595.28 / 2100);
|
|
434
|
434
|
|
|
435
|
|
- // 获取文章内容和单词列表
|
|
|
435
|
+ // 获取文章内容
|
|
436
|
436
|
let articleText = "";
|
|
437
|
437
|
if (content.ArticleEnglish) {
|
|
438
|
|
- if (Array.isArray(content.ArticleEnglish)) {
|
|
439
|
|
- articleText = content.ArticleEnglish.join(" ");
|
|
440
|
|
- } else {
|
|
441
|
|
- articleText = content.ArticleEnglish;
|
|
442
|
|
- }
|
|
|
438
|
+ articleText = Array.isArray(content.ArticleEnglish) ?
|
|
|
439
|
+ content.ArticleEnglish.join(" ") : content.ArticleEnglish;
|
|
443
|
440
|
} else {
|
|
444
|
441
|
articleText = "No content available";
|
|
445
|
442
|
}
|
|
446
|
|
-
|
|
447
|
|
- // 清理文章文本中的HTML标签
|
|
448
|
443
|
articleText = articleText.replace(/<[^>]*>/g, '');
|
|
449
|
444
|
|
|
450
|
|
- // 获取单词列表(如果存在)
|
|
|
445
|
+ // 获取单词列表
|
|
451
|
446
|
let words = [];
|
|
452
|
447
|
if (content.Words) {
|
|
453
|
|
- try {
|
|
454
|
|
- if (typeof content.Words === 'string') {
|
|
455
|
|
- // 尝试解析JSON字符串
|
|
456
|
|
- words = content.Words.split(",");
|
|
457
|
|
- } else if (Array.isArray(content.Words)) {
|
|
458
|
|
- // 已经是数组
|
|
459
|
|
- words = content.Words;
|
|
460
|
|
- }
|
|
461
|
|
- } catch (e) {
|
|
462
|
|
- console.error("Error parsing words:", e);
|
|
463
|
|
- // 如果解析失败,尝试直接使用
|
|
464
|
|
- words = typeof content.Words === 'string' ? content.Words.split(',') : [];
|
|
465
|
|
- }
|
|
|
448
|
+ words = typeof content.Words === 'string' ?
|
|
|
449
|
+ content.Words.split(",") :
|
|
|
450
|
+ (Array.isArray(content.Words) ? content.Words : []);
|
|
466
|
451
|
}
|
|
467
|
|
- //console.log("Words to display:", words);
|
|
468
|
452
|
|
|
469
|
|
- // 获取问题列表(如果存在)
|
|
|
453
|
+ // 获取问题列表
|
|
470
|
454
|
let questions = [];
|
|
471
|
455
|
if (content.Question && Array.isArray(content.Question)) {
|
|
472
|
456
|
questions = content.Question;
|
|
473
|
457
|
}
|
|
474
|
|
- //console.log("Questions to display:", questions.length);
|
|
475
|
|
-
|
|
476
|
|
- // 1. 在top:90,left:120像素处写"Story",60像素大小,Semibold粗细
|
|
477
|
|
- doc.font('Helvetica-Bold') // 使用Helvetica-Bold作为Semibold替代
|
|
478
|
|
- .fontSize(pixelToPt(60))
|
|
479
|
|
- .text("Story", pixelToPt(120), pixelToPt(90), {
|
|
480
|
|
- width: pixelToPt(200),
|
|
481
|
|
- align: 'left'
|
|
482
|
|
- });
|
|
483
|
458
|
|
|
484
|
|
- // 2. 在top:250,left:120像素处写英文文章,48像素大小,宽1240像素,高自适应,Regular粗细
|
|
485
|
|
- doc.font('Helvetica')
|
|
486
|
|
- .fontSize(pixelToPt(48))
|
|
487
|
|
- .text(articleText, pixelToPt(120), pixelToPt(250), {
|
|
488
|
|
- width: pixelToPt(1240),
|
|
489
|
|
- align: 'left',
|
|
490
|
|
- lineGap: pixelToPt(50) // 行间距
|
|
491
|
|
- });
|
|
|
459
|
+ doc.image('./public/images/PDF.png', 0, 0, {
|
|
|
460
|
+ width: pixelToPt(2100),
|
|
|
461
|
+ height: pixelToPt(2970)
|
|
|
462
|
+ });
|
|
492
|
463
|
|
|
493
|
|
- // 记录文章结束位置的y坐标
|
|
494
|
|
- const articleEndY = doc.y;
|
|
495
|
|
- //console.log("Article end Y position:", articleEndY);
|
|
|
464
|
+ // 1. 标题 - 文章类型
|
|
|
465
|
+ doc.font(selectFont(ARTICLE_STYLE[content.ArticleStyle] || "Story"))
|
|
|
466
|
+ .fontSize(pixelToPt(48))
|
|
|
467
|
+ .text(ARTICLE_STYLE[content.ArticleStyle] || "Story",
|
|
|
468
|
+ pixelToPt(120), pixelToPt(110));
|
|
496
|
469
|
|
|
497
|
|
- // 3. 在top:90,left:1443像素处写"备注",48像素大小,Regular粗细
|
|
498
|
|
- // 使用中文字体显示中文
|
|
|
470
|
+ // 2. 副标题 - 难度级别
|
|
|
471
|
+ doc.font('Helvetica')
|
|
|
472
|
+ .fontSize(pixelToPt(30))
|
|
|
473
|
+ .text(LEVEL[content.Level] || "",
|
|
|
474
|
+ pixelToPt(120), pixelToPt(170));
|
|
|
475
|
+
|
|
|
476
|
+ // 3. 时间
|
|
|
477
|
+ const currentDate = moment().format("YYYY年MM月DD日 HH:mm");
|
|
|
478
|
+ if (params.CreateTime)
|
|
|
479
|
+ currentDate=moment(params.CreateTime).format("YYYY年MM月DD日 HH:mm");
|
|
499
|
480
|
doc.font('ChineseFont')
|
|
500
|
|
- .fontSize(pixelToPt(48))
|
|
501
|
|
- .text("备注", pixelToPt(1443), pixelToPt(90), {
|
|
502
|
|
- width: pixelToPt(537),
|
|
503
|
|
- align: 'left'
|
|
504
|
|
- });
|
|
|
481
|
+ .fontSize(pixelToPt(30))
|
|
|
482
|
+ .text(currentDate,
|
|
|
483
|
+ pixelToPt(120), pixelToPt(212));
|
|
|
484
|
+
|
|
505
|
485
|
|
|
506
|
|
- // 4. 在top:210,left:1443像素处画一条黑线,537像素宽,10像素高
|
|
507
|
|
- doc.rect(pixelToPt(1443), pixelToPt(210), pixelToPt(537), pixelToPt(10))
|
|
|
486
|
+ // 4. 黑线
|
|
|
487
|
+ doc.rect(pixelToPt(120), pixelToPt(289), pixelToPt(537), pixelToPt(10))
|
|
508
|
488
|
.fill('black');
|
|
509
|
489
|
|
|
510
|
|
- // 5. 在top:250,left:1443像素,宽537像素,高900像素的空间内竖排显示1-10个单词,每行一个,右对齐
|
|
511
|
|
- doc.font('Helvetica'); // 使用常规体(Regular)
|
|
512
|
|
-
|
|
513
|
|
- // 计算单词显示区域
|
|
514
|
|
- const wordAreaTop = pixelToPt(250);
|
|
515
|
|
- const wordAreaLeft = pixelToPt(1443);
|
|
516
|
|
- const wordAreaWidth = pixelToPt(537);
|
|
517
|
|
- const wordAreaHeight = pixelToPt(900);
|
|
518
|
|
-
|
|
519
|
|
- // 确保words是数组
|
|
520
|
|
- const wordsArray = Array.isArray(words) ? words :
|
|
521
|
|
- (typeof words === 'string' ? words.split(',') : []);
|
|
522
|
|
-
|
|
523
|
|
- // 显示单词(最多10个)
|
|
524
|
|
- const maxWords = Math.min(wordsArray.length, 10);
|
|
525
|
|
-
|
|
526
|
|
- // 根据单词数量动态计算每个单词的高度空间
|
|
527
|
|
- const wordHeight = pixelToPt(80); // 固定高度,确保足够的空间显示
|
|
528
|
|
-
|
|
529
|
|
- // 首先确定所有单词中需要的最小字体大小
|
|
530
|
|
- let minFontSize = pixelToPt(48); // 默认字体大小
|
|
531
|
|
- for (let i = 0; i < maxWords; i++) {
|
|
532
|
|
- const word = wordsArray[i];
|
|
533
|
|
- if (word) {
|
|
534
|
|
- const wordLength = word.toString().length;
|
|
535
|
|
- // 根据词组长度确定需要的字体大小
|
|
536
|
|
- if (wordLength > 15) {
|
|
537
|
|
- minFontSize = Math.min(minFontSize, pixelToPt(36)); // 长词组使用较小字体
|
|
538
|
|
- } else if (wordLength > 10) {
|
|
539
|
|
- minFontSize = Math.min(minFontSize, pixelToPt(42)); // 中等长度词组使用中等字体
|
|
540
|
|
- }
|
|
541
|
|
- }
|
|
542
|
|
- }
|
|
543
|
|
-
|
|
544
|
|
- // 使用确定的字体大小显示所有单词
|
|
545
|
|
- doc.fontSize(minFontSize);
|
|
546
|
|
- console.log(`Using font size ${minFontSize} for all words`);
|
|
|
490
|
+ // 5. 单词列表
|
|
|
491
|
+ doc.font('Helvetica')
|
|
|
492
|
+ .fontSize(pixelToPt(36));
|
|
547
|
493
|
|
|
548
|
|
- for (let i = 0; i < maxWords; i++) {
|
|
549
|
|
- const word = wordsArray[i];
|
|
550
|
|
- if (word) { // 确保单词存在
|
|
551
|
|
- const yPosition = wordAreaTop + (i * wordHeight);
|
|
552
|
|
-
|
|
553
|
|
- // 确保不超出指定区域
|
|
554
|
|
- if (yPosition + wordHeight <= wordAreaTop + wordAreaHeight) {
|
|
555
|
|
- // 添加调试信息
|
|
556
|
|
- console.log(`Drawing word: "${word}" at position: ${yPosition}`);
|
|
557
|
|
-
|
|
558
|
|
- doc.text(word.toString(), wordAreaLeft, yPosition, {
|
|
559
|
|
- width: wordAreaWidth,
|
|
560
|
|
- align: 'right', // 右对齐
|
|
561
|
|
- lineBreak: true // 允许自动换行,处理特别长的词组
|
|
562
|
|
- });
|
|
563
|
|
- }
|
|
564
|
|
- }
|
|
565
|
|
- }
|
|
|
494
|
+ let wordY = pixelToPt(364);
|
|
|
495
|
+ words.slice(0, 10).forEach(word => {
|
|
|
496
|
+ doc.text(word, pixelToPt(122), wordY, {
|
|
|
497
|
+ width: pixelToPt(535),
|
|
|
498
|
+ align: 'left'
|
|
|
499
|
+ });
|
|
|
500
|
+ wordY += pixelToPt(70);
|
|
|
501
|
+ });
|
|
|
502
|
+
|
|
|
503
|
+ // 6. 文章内容
|
|
|
504
|
+ doc.font('Helvetica')
|
|
|
505
|
+ .fontSize(pixelToPt(48))
|
|
|
506
|
+ .text(articleText, pixelToPt(740), pixelToPt(105), {
|
|
|
507
|
+ width: pixelToPt(1240),
|
|
|
508
|
+ lineGap: pixelToPt(40.5)
|
|
|
509
|
+ });
|
|
566
|
510
|
|
|
567
|
|
- // 6. 在文章显示全部完成的下方100像素,left是120位置,画一个黑线,1860像素宽,10像素高
|
|
568
|
|
- const lineY = Math.max(articleEndY, wordAreaTop + wordAreaHeight) + pixelToPt(100);
|
|
569
|
|
- doc.rect(pixelToPt(120), lineY, pixelToPt(1860), pixelToPt(10))
|
|
|
511
|
+ // 7. 黑线
|
|
|
512
|
+ const articleEndY = doc.y;
|
|
|
513
|
+ doc.rect(pixelToPt(120), articleEndY + pixelToPt(41),
|
|
|
514
|
+ pixelToPt(1860), pixelToPt(10))
|
|
570
|
515
|
.fill('black');
|
|
571
|
516
|
|
|
572
|
|
- // 7-12. 添加问题和答案部分
|
|
|
517
|
+ // 8-13. 问题和答案
|
|
573
|
518
|
if (questions.length > 0) {
|
|
574
|
|
- // 问题1和答案 - left:120像素位置
|
|
575
|
|
- if (questions.length >= 1) {
|
|
576
|
|
- // 问题标题 - 检查是否包含中文并使用适当的字体
|
|
577
|
|
- const q1Text = `1. ${questions[0].QuestionEnglish || "Question 1"}`;
|
|
578
|
|
- doc.font(/[\u4E00-\u9FFF]/.test(q1Text) ? 'ChineseFont' : 'Helvetica-Bold')
|
|
579
|
|
- .fontSize(pixelToPt(36))
|
|
580
|
|
- .text(q1Text, pixelToPt(120), lineY + pixelToPt(100), {
|
|
581
|
|
- width: pixelToPt(540),
|
|
582
|
|
- align: 'left'
|
|
583
|
|
- });
|
|
584
|
|
-
|
|
585
|
|
- // 问题选项 - 使用常规字体(Regular)
|
|
586
|
|
- doc.font('Helvetica') // 明确设置为常规字体,不使用粗体
|
|
587
|
|
- .fontSize(pixelToPt(36));
|
|
588
|
|
-
|
|
589
|
|
- const options = questions[0].OptionsEnglish || [];
|
|
590
|
|
- let optionY = doc.y + pixelToPt(20);
|
|
591
|
|
-
|
|
592
|
|
- for (let i = 0; i < options.length; i++) {
|
|
593
|
|
- doc.text(options[i] || `Option ${i+1}`, pixelToPt(120), optionY, {
|
|
594
|
|
- width: pixelToPt(540),
|
|
595
|
|
- align: 'left'
|
|
|
519
|
+ // 定义问题的X坐标位置
|
|
|
520
|
+ const questionXPositions = [
|
|
|
521
|
+ pixelToPt(120), // 问题1
|
|
|
522
|
+ pixelToPt(120), // 问题2
|
|
|
523
|
+ pixelToPt(740), // 问题3
|
|
|
524
|
+ pixelToPt(740), // 问题4
|
|
|
525
|
+ pixelToPt(1360) // 问题5
|
|
|
526
|
+ ];
|
|
|
527
|
+
|
|
|
528
|
+ // 存储每列最后一个选项的Y坐标位置
|
|
|
529
|
+ let lastOptionYPositions = {
|
|
|
530
|
+ column1: 0, // 用于跟踪第一列(问题1)的最后位置
|
|
|
531
|
+ column2: 0, // 用于跟踪第二列(问题3)的最后位置
|
|
|
532
|
+ };
|
|
|
533
|
+
|
|
|
534
|
+ // 首先渲染问题1、3、5(第一行问题)
|
|
|
535
|
+ const firstRowQuestions = [0, 2, 4]; // 问题1、3、5的索引
|
|
|
536
|
+
|
|
|
537
|
+ for (const i of firstRowQuestions) {
|
|
|
538
|
+ if (i < questions.length && questions[i]) {
|
|
|
539
|
+ const currentX = questionXPositions[i];
|
|
|
540
|
+ const currentY = articleEndY + pixelToPt(130); // 所有第一行问题的起始Y坐标相同
|
|
|
541
|
+
|
|
|
542
|
+ // 渲染问题文本
|
|
|
543
|
+ doc.font('Helvetica-Bold')
|
|
|
544
|
+ .fontSize(pixelToPt(36))
|
|
|
545
|
+ .text(`${i+1}. ${questions[i].QuestionEnglish || `Question ${i+1}`}`,
|
|
|
546
|
+ currentX, currentY, {
|
|
|
547
|
+ width: pixelToPt(540),
|
|
|
548
|
+ align: 'left'
|
|
|
549
|
+ });
|
|
|
550
|
+
|
|
|
551
|
+ // 获取问题文本渲染后的Y坐标位置
|
|
|
552
|
+ const questionEndY = doc.y;
|
|
|
553
|
+
|
|
|
554
|
+ // 选项起始位置 = 问题结束位置 + 20像素间距
|
|
|
555
|
+ let optionY = questionEndY + pixelToPt(28);
|
|
|
556
|
+
|
|
|
557
|
+ // 渲染选项
|
|
|
558
|
+ const options = questions[i].OptionsEnglish || [];
|
|
|
559
|
+ options.forEach((opt) => {
|
|
|
560
|
+ doc.font('Helvetica')
|
|
|
561
|
+ .fontSize(pixelToPt(36))
|
|
|
562
|
+ .text(`${opt}`, currentX, optionY);
|
|
|
563
|
+
|
|
|
564
|
+ // 更新选项Y坐标,为下一个选项做准备
|
|
|
565
|
+ optionY = doc.y + pixelToPt(8);
|
|
596
|
566
|
});
|
|
597
|
|
- optionY = doc.y + pixelToPt(10);
|
|
|
567
|
+
|
|
|
568
|
+ // 保存该列最后一个选项的Y坐标
|
|
|
569
|
+ if (i === 0) {
|
|
|
570
|
+ lastOptionYPositions.column1 = doc.y;
|
|
|
571
|
+ } else if (i === 2) {
|
|
|
572
|
+ lastOptionYPositions.column2 = doc.y;
|
|
|
573
|
+ }
|
|
598
|
574
|
}
|
|
599
|
575
|
}
|
|
600
|
576
|
|
|
601
|
|
- // 问题2和答案 - left:120像素位置
|
|
602
|
|
- if (questions.length >= 2) {
|
|
603
|
|
- // 获取问题1结束的Y坐标
|
|
604
|
|
- const q1EndY = doc.y + pixelToPt(60);
|
|
|
577
|
+ // 然后渲染问题2和4(第二行问题)
|
|
|
578
|
+ if (questions.length > 1 && questions[1]) {
|
|
|
579
|
+ // 问题2位于问题1的选项下方60像素处
|
|
|
580
|
+ const question2Y = lastOptionYPositions.column1 + pixelToPt(75);
|
|
605
|
581
|
|
|
606
|
|
- // 问题标题
|
|
607
|
582
|
doc.font('Helvetica-Bold')
|
|
608
|
583
|
.fontSize(pixelToPt(36))
|
|
609
|
|
- .text(`2. ${questions[1].QuestionEnglish || "Question 2"}`, pixelToPt(120), q1EndY, {
|
|
|
584
|
+ .text(`2. ${questions[1].QuestionEnglish || "Question 2"}`,
|
|
|
585
|
+ questionXPositions[1], question2Y, {
|
|
610
|
586
|
width: pixelToPt(540),
|
|
611
|
587
|
align: 'left'
|
|
612
|
588
|
});
|
|
613
|
589
|
|
|
614
|
|
- // 问题选项
|
|
615
|
|
- doc.font('Helvetica')
|
|
616
|
|
- .fontSize(pixelToPt(36));
|
|
|
590
|
+ // 获取问题文本渲染后的Y坐标位置
|
|
|
591
|
+ const questionEndY = doc.y;
|
|
617
|
592
|
|
|
618
|
|
- const options = questions[1].OptionsEnglish || [];
|
|
619
|
|
- let optionY = doc.y + pixelToPt(20);
|
|
|
593
|
+ // 选项起始位置 = 问题结束位置 + 20像素间距
|
|
|
594
|
+ let optionY = questionEndY + pixelToPt(28);
|
|
620
|
595
|
|
|
621
|
|
- for (let i = 0; i < options.length; i++) {
|
|
622
|
|
- doc.text(options[i] || `Option ${i+1}`, pixelToPt(120), optionY, {
|
|
623
|
|
- width: pixelToPt(540),
|
|
624
|
|
- align: 'left'
|
|
625
|
|
- });
|
|
626
|
|
- optionY = doc.y + pixelToPt(10);
|
|
627
|
|
- }
|
|
|
596
|
+ // 渲染选项
|
|
|
597
|
+ const options = questions[1].OptionsEnglish || [];
|
|
|
598
|
+ options.forEach((opt) => {
|
|
|
599
|
+ doc.font('Helvetica')
|
|
|
600
|
+ .fontSize(pixelToPt(36))
|
|
|
601
|
+ .text(`${opt}`, questionXPositions[1], optionY);
|
|
|
602
|
+
|
|
|
603
|
+ // 更新选项Y坐标,为下一个选项做准备
|
|
|
604
|
+ optionY = doc.y + pixelToPt(8);
|
|
|
605
|
+ });
|
|
628
|
606
|
}
|
|
629
|
607
|
|
|
630
|
|
- // 问题3和问题4 - left:740像素位置
|
|
631
|
|
- if (questions.length >= 3) {
|
|
632
|
|
- // 问题3标题
|
|
|
608
|
+ if (questions.length > 3 && questions[3]) {
|
|
|
609
|
+ // 问题4位于问题3的选项下方60像素处
|
|
|
610
|
+ const question4Y = lastOptionYPositions.column2 + pixelToPt(75);
|
|
|
611
|
+
|
|
633
|
612
|
doc.font('Helvetica-Bold')
|
|
634
|
613
|
.fontSize(pixelToPt(36))
|
|
635
|
|
- .text(`3. ${questions[2].QuestionEnglish || "Question 3"}`, pixelToPt(740), lineY + pixelToPt(100), {
|
|
|
614
|
+ .text(`4. ${questions[3].QuestionEnglish || "Question 4"}`,
|
|
|
615
|
+ questionXPositions[3], question4Y, {
|
|
636
|
616
|
width: pixelToPt(540),
|
|
637
|
617
|
align: 'left'
|
|
638
|
618
|
});
|
|
639
|
619
|
|
|
640
|
|
- // 问题3选项
|
|
641
|
|
- doc.font('Helvetica')
|
|
642
|
|
- .fontSize(pixelToPt(36));
|
|
|
620
|
+ // 获取问题文本渲染后的Y坐标位置
|
|
|
621
|
+ const questionEndY = doc.y;
|
|
643
|
622
|
|
|
644
|
|
- const options3 = questions[2].OptionsEnglish || [];
|
|
645
|
|
- let option3Y = doc.y + pixelToPt(20);
|
|
646
|
|
-
|
|
647
|
|
- for (let i = 0; i < options3.length; i++) {
|
|
648
|
|
- doc.text(options3[i] || `Option ${i+1}`, pixelToPt(740), option3Y, {
|
|
649
|
|
- width: pixelToPt(540),
|
|
650
|
|
- align: 'left'
|
|
651
|
|
- });
|
|
652
|
|
- option3Y = doc.y + pixelToPt(10);
|
|
653
|
|
- }
|
|
|
623
|
+ // 选项起始位置 = 问题结束位置 + 20像素间距
|
|
|
624
|
+ let optionY = questionEndY + pixelToPt(28);
|
|
654
|
625
|
|
|
655
|
|
- // 问题4 - 如果存在
|
|
656
|
|
- if (questions.length >= 4) {
|
|
657
|
|
- // 获取问题3结束的Y坐标
|
|
658
|
|
- const q3EndY = doc.y + pixelToPt(60);
|
|
659
|
|
-
|
|
660
|
|
- // 问题4标题
|
|
661
|
|
- doc.font('Helvetica-Bold')
|
|
662
|
|
- .fontSize(pixelToPt(36))
|
|
663
|
|
- .text(`4. ${questions[3].QuestionEnglish || "Question 4"}`, pixelToPt(740), q3EndY, {
|
|
664
|
|
- width: pixelToPt(540),
|
|
665
|
|
- align: 'left'
|
|
666
|
|
- });
|
|
667
|
|
-
|
|
668
|
|
- // 问题4选项
|
|
|
626
|
+ // 渲染选项
|
|
|
627
|
+ const options = questions[3].OptionsEnglish || [];
|
|
|
628
|
+ options.forEach((opt) => {
|
|
669
|
629
|
doc.font('Helvetica')
|
|
670
|
|
- .fontSize(pixelToPt(36));
|
|
671
|
|
-
|
|
672
|
|
- const options4 = questions[3].OptionsEnglish || [];
|
|
673
|
|
- let option4Y = doc.y + pixelToPt(20);
|
|
|
630
|
+ .fontSize(pixelToPt(36))
|
|
|
631
|
+ .text(`${opt}`, questionXPositions[3], optionY);
|
|
674
|
632
|
|
|
675
|
|
- for (let i = 0; i < options4.length; i++) {
|
|
676
|
|
- doc.text(options4[i] || `Option ${i+1}`, pixelToPt(740), option4Y, {
|
|
677
|
|
- width: pixelToPt(540),
|
|
678
|
|
- align: 'left'
|
|
679
|
|
- });
|
|
680
|
|
- option4Y = doc.y + pixelToPt(10);
|
|
681
|
|
- }
|
|
682
|
|
- }
|
|
|
633
|
+ // 更新选项Y坐标,为下一个选项做准备
|
|
|
634
|
+ optionY = doc.y + pixelToPt(8);
|
|
|
635
|
+ });
|
|
683
|
636
|
}
|
|
684
|
637
|
|
|
685
|
|
- // 问题5 - left:1360像素位置
|
|
686
|
|
- if (questions.length >= 5) {
|
|
687
|
|
- // 问题5标题
|
|
|
638
|
+ // 问题5保持原位置不变
|
|
|
639
|
+ if (questions.length > 4 && questions[4]) {
|
|
|
640
|
+ const currentX = questionXPositions[4];
|
|
|
641
|
+ const currentY = articleEndY + pixelToPt(130);
|
|
|
642
|
+
|
|
688
|
643
|
doc.font('Helvetica-Bold')
|
|
689
|
644
|
.fontSize(pixelToPt(36))
|
|
690
|
|
- .text(`5. ${questions[4].QuestionEnglish || "Question 5"}`, pixelToPt(1360), lineY + pixelToPt(100), {
|
|
|
645
|
+ .text(`5. ${questions[4].QuestionEnglish || "Question 5"}`,
|
|
|
646
|
+ currentX, currentY, {
|
|
691
|
647
|
width: pixelToPt(540),
|
|
692
|
648
|
align: 'left'
|
|
693
|
649
|
});
|
|
694
|
650
|
|
|
695
|
|
- // 问题5选项
|
|
696
|
|
- doc.font('Helvetica')
|
|
697
|
|
- .fontSize(pixelToPt(36));
|
|
|
651
|
+ const questionEndY = doc.y;
|
|
698
|
652
|
|
|
699
|
|
- const options5 = questions[4].OptionsEnglish || [];
|
|
700
|
|
- let option5Y = doc.y + pixelToPt(20);
|
|
|
653
|
+ let optionY = questionEndY + pixelToPt(28);
|
|
701
|
654
|
|
|
702
|
|
- for (let i = 0; i < options5.length; i++) {
|
|
703
|
|
- doc.text(options5[i] || `Option ${i+1}`, pixelToPt(1360), option5Y, {
|
|
704
|
|
- width: pixelToPt(540),
|
|
705
|
|
- align: 'left'
|
|
706
|
|
- });
|
|
707
|
|
- option5Y = doc.y + pixelToPt(10);
|
|
708
|
|
- }
|
|
|
655
|
+ // 渲染选项
|
|
|
656
|
+ const options = questions[4].OptionsEnglish || [];
|
|
|
657
|
+ options.forEach((opt) => {
|
|
|
658
|
+ doc.font('Helvetica')
|
|
|
659
|
+ .fontSize(pixelToPt(36))
|
|
|
660
|
+ .text(`${opt}`, currentX, optionY);
|
|
|
661
|
+
|
|
|
662
|
+ // 更新选项Y坐标,为下一个选项做准备
|
|
|
663
|
+ optionY = doc.y + pixelToPt(8);
|
|
|
664
|
+ });
|
|
709
|
665
|
}
|
|
710
|
666
|
}
|
|
711
|
667
|
|
|
712
|
|
- // 13. 下方距离底边330像素,left:120像素处,画一条虚线,1860像素宽,20像素高,颜色为#D2D2D2
|
|
713
|
|
- const dashLineY = doc.page.height - pixelToPt(330);
|
|
|
668
|
+ // 14. 虚线
|
|
|
669
|
+ const lastContentY = doc.page.height - pixelToPt(190);
|
|
|
670
|
+ // doc.rect(pixelToPt(120), lastContentY,
|
|
|
671
|
+ // pixelToPt(1630), pixelToPt(10))
|
|
|
672
|
+ // .fill('#D2D2D2');
|
|
714
|
673
|
doc.strokeColor('#D2D2D2')
|
|
715
|
|
- .dash(pixelToPt(20), { space: pixelToPt(20) }) // 设置虚线样式:宽20像素,间隔20像素
|
|
|
674
|
+ .dash(pixelToPt(20), { space: pixelToPt(28) }) // 设置虚线样式:宽20像素,间隔20像素
|
|
716
|
675
|
.lineWidth(pixelToPt(10)) // 线高10像素
|
|
717
|
|
- .moveTo(pixelToPt(120), dashLineY)
|
|
718
|
|
- .lineTo(pixelToPt(120) + pixelToPt(1860), dashLineY)
|
|
|
676
|
+ .moveTo(pixelToPt(120), lastContentY)
|
|
|
677
|
+ .lineTo(pixelToPt(120) + pixelToPt(1630), lastContentY)
|
|
719
|
678
|
.stroke()
|
|
720
|
679
|
.undash(); // 重置虚线样式
|
|
721
|
680
|
|
|
722
|
|
- // 14-18. 显示问题答案
|
|
|
681
|
+ // 15-19. 答案和底部信息
|
|
723
|
682
|
doc.font('Helvetica-Bold') // 使用Helvetica-Bold作为Semibold替代
|
|
724
|
|
- .fontSize(pixelToPt(45))
|
|
|
683
|
+ .fontSize(pixelToPt(36))
|
|
725
|
684
|
.fillColor('black'); // 重置颜色为黑色
|
|
726
|
685
|
|
|
727
|
|
- const answersY = doc.page.height - pixelToPt(177);
|
|
|
686
|
+ const answersY = doc.page.height - pixelToPt(144);
|
|
728
|
687
|
|
|
729
|
688
|
// 获取问题答案
|
|
730
|
689
|
const answers = [];
|
|
|
@@ -736,42 +695,19 @@ export async function GeneratePDF(ctx) {
|
|
736
|
695
|
}
|
|
737
|
696
|
|
|
738
|
697
|
// 显示答案(如果存在)
|
|
739
|
|
- const answerPositions = [120, 277, 443, 611, 778];
|
|
|
698
|
+ const answerPositions = [120, 262, 411, 561, 711];
|
|
740
|
699
|
for (let i = 0; i < Math.min(answers.length, 5); i++) {
|
|
741
|
|
- doc.text(answers[i], pixelToPt(answerPositions[i]), answersY, {
|
|
|
700
|
+ doc.text(answers[i], pixelToPt(answerPositions[i]), answersY+2, {
|
|
742
|
701
|
width: pixelToPt(100),
|
|
743
|
702
|
align: 'left'
|
|
744
|
703
|
});
|
|
745
|
704
|
}
|
|
746
|
|
-
|
|
747
|
|
- // 20. 显示"秒过·语境背单词"
|
|
748
|
|
- const appNameY = doc.page.height - pixelToPt(223);
|
|
749
|
|
- doc.font('ChineseFont') // 使用中文字体显示中文
|
|
750
|
|
- .fontSize(pixelToPt(48))
|
|
751
|
|
- .fillColor('black')
|
|
752
|
|
- .text("秒过·语境背单词", pixelToPt(1320), appNameY, {
|
|
753
|
|
- width: pixelToPt(400),
|
|
754
|
|
- align: 'right'
|
|
755
|
|
- });
|
|
756
|
|
-
|
|
757
|
|
- // 21. 显示"微信小程序"
|
|
758
|
|
- const appTypeY = doc.page.height - pixelToPt(163);
|
|
759
|
|
- doc.font('ChineseFont') // 使用中文字体显示中文
|
|
760
|
|
- .fontSize(pixelToPt(28))
|
|
761
|
|
- .text("微信小程序", pixelToPt(1320), appTypeY, {
|
|
762
|
|
- width: pixelToPt(400),
|
|
763
|
|
- align: 'right'
|
|
764
|
|
- });
|
|
765
|
705
|
|
|
766
|
|
- // 19. 显示当前时间
|
|
767
|
|
- const currentTime = moment().format('YYYY年MM月DD日 HH:mm');
|
|
768
|
|
- const timeY = doc.page.height - pixelToPt(118);
|
|
769
|
|
- // 计算正确的位置:总宽度2100像素 - 右边距380像素 = 右边缘位置1720像素
|
|
770
|
|
- // 为了使用text()方法的右对齐,设置起始位置为1720-380=1340像素
|
|
771
|
|
- doc.font('ChineseFont') // 使用中文字体显示包含中文的日期
|
|
772
|
|
- .fontSize(pixelToPt(32))
|
|
773
|
|
- .text(currentTime, pixelToPt(1340), timeY, {
|
|
774
|
|
- width: pixelToPt(380),
|
|
|
706
|
+ // 20-21. 应用名称
|
|
|
707
|
+ doc.font('ChineseFont')
|
|
|
708
|
+ .fontSize(pixelToPt(36))
|
|
|
709
|
+ .text("语境背单词(微信小程序)", pixelToPt(1338), answersY,{
|
|
|
710
|
+ width: pixelToPt(440),
|
|
775
|
711
|
align: 'right'
|
|
776
|
712
|
});
|
|
777
|
713
|
|
|
|
@@ -782,7 +718,7 @@ export async function GeneratePDF(ctx) {
|
|
782
|
718
|
const qrCodeWidth = pixelToPt(200);
|
|
783
|
719
|
const qrCodeHeight = pixelToPt(200);
|
|
784
|
720
|
const qrCodeX = doc.page.width - pixelToPt(120) - qrCodeWidth; // 右边距离转换为左边距离
|
|
785
|
|
- const qrCodeY = doc.page.height - pixelToPt(60) - qrCodeHeight; // 底部距离转换为顶部距离
|
|
|
721
|
+ const qrCodeY = doc.page.height - pixelToPt(100) - qrCodeHeight; // 底部距离转换为顶部距离
|
|
786
|
722
|
|
|
787
|
723
|
// 添加二维码图片
|
|
788
|
724
|
doc.image('./public/images/acode/YJBDC_QRCode.png', qrCodeX, qrCodeY, {
|
|
|
@@ -794,44 +730,26 @@ export async function GeneratePDF(ctx) {
|
|
794
|
730
|
console.error("Error adding QR Code image:", imgError);
|
|
795
|
731
|
}
|
|
796
|
732
|
|
|
797
|
|
- //console.log("PDF generation completed, finalizing document...");
|
|
798
|
733
|
|
|
799
|
|
- // 使用 Promise 等待 PDF 生成完成
|
|
800
|
|
- await new Promise((resolve, reject) => {
|
|
801
|
|
- // 监听 end 事件,表示 PDF 生成完成
|
|
802
|
|
- doc.on('end', () => {
|
|
803
|
|
- try {
|
|
804
|
|
- // 将所有数据块合并为一个 Buffer
|
|
805
|
|
- const pdfBuffer = Buffer.concat(chunks);
|
|
806
|
|
- console.log("PDF buffer size:", pdfBuffer.length);
|
|
807
|
|
-
|
|
808
|
|
- // 设置响应头
|
|
809
|
|
- ctx.set('Content-Type', 'application/pdf');
|
|
810
|
|
- ctx.set('Content-Disposition', 'attachment; filename=reading.pdf');
|
|
811
|
|
-
|
|
812
|
|
- // 设置响应体为 PDF buffer
|
|
813
|
|
- ctx.body = pdfBuffer;
|
|
814
|
|
- resolve();
|
|
815
|
|
- } catch (err) {
|
|
816
|
|
- console.error("Error in PDF end handler:", err);
|
|
817
|
|
- reject(err);
|
|
818
|
|
- }
|
|
819
|
|
- });
|
|
|
734
|
+ // 结束PDF生成
|
|
|
735
|
+ doc.end();
|
|
820
|
736
|
|
|
821
|
|
- // 监听错误事件
|
|
822
|
|
- doc.on('error', (err) => {
|
|
823
|
|
- console.error("PDF generation error:", err);
|
|
824
|
|
- reject(err);
|
|
|
737
|
+ // 等待PDF生成完成
|
|
|
738
|
+ const pdfBuffer = await new Promise((resolve) => {
|
|
|
739
|
+ doc.on('end', () => {
|
|
|
740
|
+ resolve(Buffer.concat(chunks));
|
|
825
|
741
|
});
|
|
826
|
|
-
|
|
827
|
|
- // 结束文档生成
|
|
828
|
|
- doc.end();
|
|
829
|
742
|
});
|
|
830
|
743
|
|
|
|
744
|
+ // 设置响应头
|
|
|
745
|
+ ctx.set('Content-Type', 'application/pdf');
|
|
|
746
|
+ ctx.set('Content-Disposition', 'attachment; filename=article.pdf');
|
|
|
747
|
+ ctx.body = pdfBuffer;
|
|
|
748
|
+
|
|
831
|
749
|
} catch (error) {
|
|
832
|
|
- console.error('Error generating PDF:', error);
|
|
|
750
|
+ console.error("Error generating PDF:", error);
|
|
833
|
751
|
ctx.status = 500;
|
|
834
|
|
- ctx.body = { error: error.message, stack: error.stack };
|
|
|
752
|
+ ctx.body = { error: "Failed to generate PDF" };
|
|
835
|
753
|
}
|
|
836
|
754
|
}
|
|
837
|
755
|
|