|
|
@@ -28,6 +28,14 @@ use Symfony\Component\Process\Process;
|
|
|
class ExamPdfExportService
|
|
|
{
|
|
|
private ?KatexRenderer $katexRenderer = null;
|
|
|
+ private const PDF_IMAGE_WIDTH_WIDE_PX = 250;
|
|
|
+ private const PDF_IMAGE_WIDTH_VERY_WIDE_PX = 330;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @var array<string, array{w:int,h:int}|null>
|
|
|
+ */
|
|
|
+ private array $pdfImageDimensionCache = [];
|
|
|
+ private ?bool $hasPdfImageMetricsTable = null;
|
|
|
|
|
|
public function __construct(
|
|
|
private readonly LearningAnalyticsService $learningAnalyticsService,
|
|
|
@@ -88,7 +96,7 @@ class ExamPdfExportService
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- $pdfBinary = $this->buildPdf($html);
|
|
|
+ $pdfBinary = $this->buildPdf($html, ! $includeAnswer && ! $useGradingView);
|
|
|
if ($pdfBinary === null || $pdfBinary === '') {
|
|
|
Log::error('renderAndStoreExamPdf: buildPdf 失败', [
|
|
|
'paper_id' => $paperId,
|
|
|
@@ -199,7 +207,7 @@ class ExamPdfExportService
|
|
|
|
|
|
// 步骤3:一次性生成PDF(只需20-25秒,比原来节省10-25秒)
|
|
|
Log::info('generateUnifiedPdf: 开始使用buildPdf直接生成PDF(不使用pdfunite)', ['paper_id' => $paperId]);
|
|
|
- $pdfBinary = $this->buildPdf($unifiedHtml);
|
|
|
+ $pdfBinary = $this->buildPdf($unifiedHtml, true, true);
|
|
|
if (! $pdfBinary) {
|
|
|
Log::error('ExamPdfExportService: 生成统一PDF失败', ['paper_id' => $paperId]);
|
|
|
|
|
|
@@ -1387,10 +1395,15 @@ class ExamPdfExportService
|
|
|
/**
|
|
|
* 构建PDF
|
|
|
*/
|
|
|
- private function buildPdf(string $html): ?string
|
|
|
+ private function buildPdf(string $html, bool $applyWideImageSizing = false, bool $scopeToExamPart = false): ?string
|
|
|
{
|
|
|
$tmpHtml = tempnam(sys_get_temp_dir(), 'exam_html_').'.html';
|
|
|
$utf8Html = $this->ensureUtf8Html($html);
|
|
|
+ if ($applyWideImageSizing) {
|
|
|
+ $utf8Html = $scopeToExamPart
|
|
|
+ ? $this->applyAdaptiveWideImageSizingToExamPart($utf8Html)
|
|
|
+ : $this->applyAdaptiveWideImageSizing($utf8Html);
|
|
|
+ }
|
|
|
$written = file_put_contents($tmpHtml, $utf8Html);
|
|
|
|
|
|
Log::debug('ExamPdfExportService: HTML文件已创建', [
|
|
|
@@ -1413,6 +1426,213 @@ class ExamPdfExportService
|
|
|
return $chromePdf;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 对扁长/超扁长图片做全局自适应放大,普通图片不处理。
|
|
|
+ */
|
|
|
+ private function applyAdaptiveWideImageSizing(string $html): string
|
|
|
+ {
|
|
|
+ return (string) preg_replace_callback(
|
|
|
+ '/<img\b[^>]*\bsrc=(["\'])([^"\']+)\1[^>]*>/i',
|
|
|
+ function (array $m): string {
|
|
|
+ $fullTag = $m[0] ?? '';
|
|
|
+ $src = $m[2] ?? '';
|
|
|
+ if ($fullTag === '' || $src === '' || str_starts_with($src, 'data:')) {
|
|
|
+ return $fullTag;
|
|
|
+ }
|
|
|
+ if (! $this->shouldApplyAdaptiveSizingToSrc($src)) {
|
|
|
+ return $fullTag;
|
|
|
+ }
|
|
|
+
|
|
|
+ $dim = $this->getPdfImageDimensions($src);
|
|
|
+ if (! $dim || ($dim['w'] ?? 0) <= 0 || ($dim['h'] ?? 0) <= 0) {
|
|
|
+ return $fullTag;
|
|
|
+ }
|
|
|
+
|
|
|
+ $ratio = $dim['w'] / max(1, $dim['h']);
|
|
|
+ if ($ratio < 2.8) {
|
|
|
+ return $fullTag;
|
|
|
+ }
|
|
|
+
|
|
|
+ $targetWidth = $ratio >= 3.5 ? self::PDF_IMAGE_WIDTH_VERY_WIDE_PX : self::PDF_IMAGE_WIDTH_WIDE_PX;
|
|
|
+ $targetWidth = min($targetWidth, $dim['w']);
|
|
|
+ $targetStyle = sprintf(
|
|
|
+ 'width:%dpx!important;max-width:%dpx!important;max-height:60mm!important;height:auto!important;object-fit:contain!important;',
|
|
|
+ $targetWidth,
|
|
|
+ $targetWidth
|
|
|
+ );
|
|
|
+
|
|
|
+ if (preg_match('/\sstyle=(["\'])(.*?)\1/i', $fullTag, $sm)) {
|
|
|
+ $originStyle = $sm[2] ?? '';
|
|
|
+ $originStyle = preg_replace('/\bmax-width\s*:[^;]+;?/i', '', $originStyle);
|
|
|
+ $originStyle = preg_replace('/\bmax-height\s*:[^;]+;?/i', '', $originStyle);
|
|
|
+ $originStyle = preg_replace('/\bwidth\s*:[^;]+;?/i', '', $originStyle);
|
|
|
+ $originStyle = preg_replace('/\bheight\s*:[^;]+;?/i', '', $originStyle);
|
|
|
+ $originStyle = preg_replace('/\bobject-fit\s*:[^;]+;?/i', '', $originStyle);
|
|
|
+ $newStyle = $targetStyle.trim((string) $originStyle);
|
|
|
+
|
|
|
+ return preg_replace(
|
|
|
+ '/\sstyle=(["\'])(.*?)\1/i',
|
|
|
+ ' style="'.$newStyle.'"',
|
|
|
+ $fullTag,
|
|
|
+ 1
|
|
|
+ ) ?? $fullTag;
|
|
|
+ }
|
|
|
+
|
|
|
+ return preg_replace('/<img\b/i', '<img style="'.$targetStyle.'"', $fullTag, 1) ?? $fullTag;
|
|
|
+ },
|
|
|
+ $html
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 仅在 unified HTML 的试卷容器中应用扁图策略,避免影响判卷/知识点讲解部分。
|
|
|
+ */
|
|
|
+ private function applyAdaptiveWideImageSizingToExamPart(string $html): string
|
|
|
+ {
|
|
|
+ $startMarker = '<!-- EXAM_PART_START -->';
|
|
|
+ $endMarker = '<!-- EXAM_PART_END -->';
|
|
|
+ $startPos = strpos($html, $startMarker);
|
|
|
+ $endPos = strpos($html, $endMarker);
|
|
|
+ if ($startPos === false || $endPos === false || $endPos <= $startPos) {
|
|
|
+ return $html;
|
|
|
+ }
|
|
|
+
|
|
|
+ $contentStart = $startPos + strlen($startMarker);
|
|
|
+ $examContent = substr($html, $contentStart, $endPos - $contentStart);
|
|
|
+ if ($examContent === false || $examContent === '') {
|
|
|
+ return $html;
|
|
|
+ }
|
|
|
+
|
|
|
+ $processedExamContent = $this->applyAdaptiveWideImageSizing($examContent);
|
|
|
+
|
|
|
+ return substr($html, 0, $contentStart).$processedExamContent.substr($html, $endPos);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @return array{w:int,h:int}|null
|
|
|
+ */
|
|
|
+ private function getPdfImageDimensions(string $src): ?array
|
|
|
+ {
|
|
|
+ if (array_key_exists($src, $this->pdfImageDimensionCache)) {
|
|
|
+ return $this->pdfImageDimensionCache[$src];
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ $persisted = $this->getPersistedPdfImageMetrics($src);
|
|
|
+ if ($persisted !== null) {
|
|
|
+ $this->pdfImageDimensionCache[$src] = $persisted;
|
|
|
+
|
|
|
+ return $persisted;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! str_starts_with($src, 'http://') && ! str_starts_with($src, 'https://')) {
|
|
|
+ $this->pdfImageDimensionCache[$src] = null;
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $size = @getimagesize($src);
|
|
|
+ if (is_array($size) && count($size) >= 2) {
|
|
|
+ $data = ['w' => (int) $size[0], 'h' => (int) $size[1]];
|
|
|
+ $this->persistPdfImageMetrics($src, $data);
|
|
|
+ $this->pdfImageDimensionCache[$src] = $data;
|
|
|
+
|
|
|
+ return $data;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->pdfImageDimensionCache[$src] = null;
|
|
|
+
|
|
|
+ return null;
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ Log::debug('ExamPdfExportService: 图片尺寸探测失败', ['src' => $src, 'error' => $e->getMessage()]);
|
|
|
+ $this->pdfImageDimensionCache[$src] = null;
|
|
|
+
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @return array{w:int,h:int}|null
|
|
|
+ */
|
|
|
+ private function getPersistedPdfImageMetrics(string $src): ?array
|
|
|
+ {
|
|
|
+ if (! $this->isPdfImageMetricsTableReady()) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $row = DB::table('pdf_image_metrics')
|
|
|
+ ->where('src', $src)
|
|
|
+ ->first(['width', 'height']);
|
|
|
+
|
|
|
+ if (! $row) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $w = (int) ($row->width ?? 0);
|
|
|
+ $h = (int) ($row->height ?? 0);
|
|
|
+ if ($w <= 0 || $h <= 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return ['w' => $w, 'h' => $h];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param array{w:int,h:int} $data
|
|
|
+ */
|
|
|
+ private function persistPdfImageMetrics(string $src, array $data): void
|
|
|
+ {
|
|
|
+ if (! $this->isPdfImageMetricsTableReady()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $w = (int) ($data['w'] ?? 0);
|
|
|
+ $h = (int) ($data['h'] ?? 0);
|
|
|
+ if ($w <= 0 || $h <= 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ DB::table('pdf_image_metrics')->upsert([
|
|
|
+ [
|
|
|
+ 'src' => $src,
|
|
|
+ 'width' => $w,
|
|
|
+ 'height' => $h,
|
|
|
+ 'ratio' => round($w / max(1, $h), 4),
|
|
|
+ 'updated_at' => now(),
|
|
|
+ 'created_at' => now(),
|
|
|
+ ],
|
|
|
+ ], ['src'], ['width', 'height', 'ratio', 'updated_at']);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function isPdfImageMetricsTableReady(): bool
|
|
|
+ {
|
|
|
+ if ($this->hasPdfImageMetricsTable !== null) {
|
|
|
+ return $this->hasPdfImageMetricsTable;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->hasPdfImageMetricsTable = Schema::hasTable('pdf_image_metrics');
|
|
|
+
|
|
|
+ return $this->hasPdfImageMetricsTable;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function shouldApplyAdaptiveSizingToSrc(string $src): bool
|
|
|
+ {
|
|
|
+ $parts = parse_url($src);
|
|
|
+ if (! is_array($parts)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ $host = strtolower((string) ($parts['host'] ?? ''));
|
|
|
+ $path = (string) ($parts['path'] ?? '');
|
|
|
+ if ($host !== 'file.chunsunqiuzhu.com') {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (! str_contains($path, '/data/')) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (bool) preg_match('/\.(png|jpe?g|webp)$/i', $path);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 使用Chrome渲染PDF
|
|
|
*/
|
|
|
@@ -2163,10 +2383,12 @@ class ExamPdfExportService
|
|
|
|
|
|
// 添加试卷部分
|
|
|
$bodyContent .= '
|
|
|
+ <!-- EXAM_PART_START -->
|
|
|
<!-- 试卷部分 - 连续显示 -->
|
|
|
<div class="exam-part">
|
|
|
'.$examBody.'
|
|
|
</div>
|
|
|
+ <!-- EXAM_PART_END -->
|
|
|
';
|
|
|
|
|
|
// 添加判卷部分
|
|
|
@@ -3234,6 +3456,16 @@ class ExamPdfExportService
|
|
|
private function normalizeAnswerFieldForPdf(object $question): object
|
|
|
{
|
|
|
$normalizedQuestion = clone $question;
|
|
|
+ // 以 paper_questions.question_text 为标准题干字段,兼容旧链路 content。
|
|
|
+ $questionText = trim((string) ($normalizedQuestion->question_text ?? ''));
|
|
|
+ if ($questionText === '') {
|
|
|
+ $questionText = trim((string) ($normalizedQuestion->content ?? ''));
|
|
|
+ }
|
|
|
+ if ($questionText !== '') {
|
|
|
+ $normalizedQuestion->question_text = $questionText;
|
|
|
+ $normalizedQuestion->content = $questionText;
|
|
|
+ }
|
|
|
+
|
|
|
$answerText = trim((string) ($normalizedQuestion->answer ?? ''));
|
|
|
if ($answerText !== '') {
|
|
|
return $normalizedQuestion;
|