Bläddra i källkod

Fix reading PDF text splits across pages

彭峰 2 veckor sedan
förälder
incheckning
b90e4eb822

+ 1 - 0
package-lock.json

@@ -17,6 +17,7 @@
         "@angular/platform-browser-dynamic": "^18.2.0",
         "@angular/router": "^18.2.0",
         "html2pdf.js": "^0.10.3",
+        "jspdf": "^3.0.4",
         "lucide-angular": "^0.468.0",
         "mustache": "^4.2.0",
         "rxjs": "~7.8.0",

+ 1 - 0
package.json

@@ -67,6 +67,7 @@
     "@angular/platform-browser-dynamic": "^18.2.0",
     "@angular/router": "^18.2.0",
     "html2pdf.js": "^0.10.3",
+    "jspdf": "^3.0.4",
     "lucide-angular": "^0.468.0",
     "mustache": "^4.2.0",
     "rxjs": "~7.8.0",

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.scss

@@ -5,6 +5,7 @@
 .pdf-document { width: min(794px, calc(100% - 24px)); margin: 18px auto; padding: 44px 52px 0; color: #202826; background: white; box-shadow: 0 5px 20px rgb(25 38 34 / 10%); }
 .reading-section h1 { margin: 0 0 12px; text-align: center; font-family: Georgia, serif; font-size: 28px; line-height: 1.3; letter-spacing: 0; }.document-meta { display: flex; flex-wrap: wrap; justify-content: center; margin-bottom: 24px; color: #68736f; font-size: 11px; gap: 12px; }.reading-content { font-family: Georgia, 'Times New Roman', serif; font-size: 15px; line-height: 1.85; }.reading-content ::ng-deep p { margin: 0 0 12px; }.reading-content ::ng-deep b { color: #1857b8; font-size: 1.2rem; font-weight: 750; }
 .question-section, .answer-section { margin-top: 0; padding-top: 1px; }.question-section h2, .answer-section h2 { padding-bottom: 8px; font-size: 20px; letter-spacing: 0; border-bottom: 2px solid var(--brand-strong); }.question-item, .answer-item { margin-top: 18px; break-inside: avoid; }.question-item h3, .answer-item h3 { margin: 0 0 10px; font-size: 14px; line-height: 1.45; letter-spacing: 0; }.question-item p { margin: 7px 0 7px 15px; font-size: 13px; }.answer-item > div { padding: 7px 9px; border-bottom: 1px solid #e6ebe9; }.answer-item p { margin: 0; font-size: 12px; }.answer-item small { display: block; margin-top: 4px; color: #68736f; line-height: 1.5; }.answer-item .correct-answer { background: #edf8f4; }.correct-answer strong { float: right; color: var(--brand-strong); font-size: 10px; }.answer-item footer { margin-top: 9px; padding: 9px; color: #4f5b58; background: #f4f7f6; font-size: 11px; line-height: 1.5; }
+.reading-content ::ng-deep p, .question-item h3, .question-item p, .answer-item h3, .answer-item > div, .answer-item footer { break-inside: avoid-page; page-break-inside: avoid; }
 .export-footer { position: fixed; z-index: 5; right: 0; bottom: 0; left: 0; display: flex; min-height: 66px; align-items: center; justify-content: center; padding: 10px 16px; background: white; border-top: 1px solid var(--line); }.export-footer button { display: flex; width: min(480px, 100%); align-items: center; justify-content: center; gap: 7px; }.pdf-state { display: flex; min-height: 420px; align-items: center; justify-content: center; color: var(--ink-soft); flex-direction: column; gap: 10px; }.spin { animation: spin .8s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }
 .wechat-backdrop { position: fixed; z-index: 30; display: grid; inset: 0; padding: 18px; background: rgb(14 25 22 / 62%); place-items: center; }.wechat-dialog { width: min(420px, 100%); padding: 22px; text-align: center; background: white; border-radius: var(--radius-md); box-shadow: var(--shadow-lg); }.wechat-dialog h2 { margin: 0 0 10px; font-size: 20px; letter-spacing: 0; }.wechat-dialog p { color: var(--ink-soft); line-height: 1.6; }.wechat-dialog small { display: block; margin: 14px 0; padding: 10px; background: var(--surface-muted); }.wechat-dialog button { width: 100%; }
 @media (max-width: 620px) { .pdf-document { width: 100%; margin: 0; padding: 28px 18px; box-shadow: none; }.reading-section h1 { font-size: 23px; }.pdf-toolbar strong { font-size: 13px; } }

+ 64 - 13
projects/xiaoshu-mobile/src/app/features/english-reading/reading-pdf.component.ts

@@ -100,9 +100,8 @@ export class ReadingPdfComponent implements OnInit {
       await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
       await document.fonts?.ready;
       const filename = `${article.title.replace(/[^a-zA-Z0-9一-龥]/g, '_')}_quiz.pdf`;
-      let hasBlankTrailingPage = false;
-      const { default: html2pdf } = await import('html2pdf.js');
-      const worker = html2pdf().set({
+      const [{ default: html2pdf }, { jsPDF }] = await Promise.all([import('html2pdf.js'), import('jspdf')]);
+      const canvas = await html2pdf().set({
         margin: [10, 10, 10, 10], filename,
         image: { type: 'jpeg', quality: 1 },
         html2canvas: { scale: 1.5, logging: false, useCORS: true, backgroundColor: '#ffffff' },
@@ -110,17 +109,36 @@ export class ReadingPdfComponent implements OnInit {
         pagebreak: {
           mode: ['css', 'legacy'],
           before: ['.question-section', '.answer-section'],
-          avoid: ['.question-item', '.answer-item'],
+          avoid: ['.reading-content p', '.question-item', '.question-item h3', '.question-item p', '.answer-item', '.answer-item h3', '.answer-item > div', '.answer-item footer'],
         },
-      }).from(element).toCanvas().get('canvas', (canvas) => {
-        const pageHeight = Math.floor(canvas.width * (277 / 190));
-        const trailingSlice = canvas.height % pageHeight;
-        hasBlankTrailingPage = trailingSlice > 0 && trailingSlice < 32;
-      }).toPdf();
-      await worker.get('pdf', (pdf) => {
-        const pageCount = pdf.internal.getNumberOfPages();
-        if (hasBlankTrailingPage && pageCount > 1) pdf.deletePage(pageCount);
-      }).save();
+      }).from(element).toCanvas().get('canvas');
+      const context = canvas.getContext('2d', { willReadFrequently: true });
+      if (!context) throw new Error('无法读取打印内容');
+      const pdf = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' });
+      const pageCanvas = document.createElement('canvas');
+      pageCanvas.width = canvas.width;
+      const pageContext = pageCanvas.getContext('2d');
+      if (!pageContext) throw new Error('无法创建 PDF 页面');
+      const pageHeight = Math.floor(canvas.width * (277 / 190));
+      let pageStart = 0;
+      let pageIndex = 0;
+      while (pageStart < canvas.height) {
+        if (pageIndex && canvas.height - pageStart < 120 && !this.hasVisibleContent(context, canvas.width, pageStart, canvas.height)) break;
+        const targetEnd = Math.min(pageStart + pageHeight, canvas.height);
+        let pageEnd = targetEnd === canvas.height ? targetEnd : this.safePageEnd(context, canvas.width, pageStart, targetEnd);
+        if (targetEnd < canvas.height && canvas.height - pageEnd < 120 && this.hasVisibleContent(context, canvas.width, pageEnd, canvas.height)) {
+          pageEnd = this.safePageEnd(context, canvas.width, pageStart, targetEnd - 140);
+        }
+        const sliceHeight = pageEnd - pageStart;
+        pageCanvas.height = sliceHeight;
+        pageContext.fillStyle = '#ffffff';
+        pageContext.fillRect(0, 0, canvas.width, sliceHeight);
+        pageContext.drawImage(canvas, 0, pageStart, canvas.width, sliceHeight, 0, 0, canvas.width, sliceHeight);
+        if (pageIndex++) pdf.addPage();
+        pdf.addImage(pageCanvas.toDataURL('image/jpeg', 1), 'JPEG', 10, 10, 190, sliceHeight * 190 / canvas.width);
+        pageStart = pageEnd;
+      }
+      await pdf.save(filename, { returnPromise: true });
     } catch (error) {
       const detail = error instanceof Error ? error.message : '';
       this.platform.toast(detail ? `PDF 导出失败:${detail}` : 'PDF 导出失败,请重试', 'error');
@@ -128,4 +146,37 @@ export class ReadingPdfComponent implements OnInit {
       this.exporting.set(false);
     }
   }
+
+  private safePageEnd(context: CanvasRenderingContext2D, width: number, pageStart: number, targetEnd: number): number {
+    // html2pdf normally cuts the canvas at a fixed height, including through text glyphs.
+    // Find the last gap between text lines near that height and cut there instead.
+    const scanStart = Math.max(pageStart + Math.floor((targetEnd - pageStart) * 0.6), targetEnd - 360);
+    const pixels = context.getImageData(0, scanStart, width, targetEnd - scanStart).data;
+    let gapStart = -1;
+    let safeEnd = targetEnd;
+    for (let row = 0; row < targetEnd - scanStart; row++) {
+      let hasText = false;
+      for (let column = 0; column < width; column++) {
+        const pixel = (row * width + column) * 4;
+        if (pixels[pixel] < 190 || pixels[pixel + 1] < 190 || pixels[pixel + 2] < 190) {
+          hasText = true;
+          break;
+        }
+      }
+      if (!hasText && gapStart < 0) gapStart = row;
+      if (hasText && gapStart >= 0) {
+        if (row - gapStart >= 4) safeEnd = scanStart + Math.floor((gapStart + row) / 2);
+        gapStart = -1;
+      }
+    }
+    return gapStart >= 0 && targetEnd - scanStart - gapStart >= 4 ? targetEnd : safeEnd;
+  }
+
+  private hasVisibleContent(context: CanvasRenderingContext2D, width: number, start: number, end: number): boolean {
+    const pixels = context.getImageData(0, start, width, end - start).data;
+    for (let pixel = 0; pixel < pixels.length; pixel += 4) {
+      if (pixels[pixel] < 190 || pixels[pixel + 1] < 190 || pixels[pixel + 2] < 190) return true;
+    }
+    return false;
+  }
 }

+ 1 - 0
projects/xiaoshu-mobile/src/types/html2pdf.d.ts

@@ -4,6 +4,7 @@ declare module 'html2pdf.js' {
     from(element: HTMLElement): Html2PdfWorker;
     toCanvas(): Html2PdfWorker;
     toPdf(): Html2PdfWorker;
+    get(key: 'canvas'): Promise<HTMLCanvasElement>;
     get(key: 'canvas', callback: (canvas: HTMLCanvasElement) => void): Html2PdfWorker;
     get(key: 'pdf', callback: (pdf: {
       internal: { getNumberOfPages(): number };