diff --git a/app/Support/CourierAgreementPdf.php b/app/Support/CourierAgreementPdf.php index 8629db0d..40dbe002 100644 --- a/app/Support/CourierAgreementPdf.php +++ b/app/Support/CourierAgreementPdf.php @@ -3,6 +3,9 @@ namespace App\Support; use App\Models\Business\CourierAgreementSignature; +use DOMDocument; +use DOMXPath; +use Dompdf\FrameReflower\Text; use Illuminate\Support\Carbon; use RuntimeException; @@ -29,7 +32,16 @@ final class CourierAgreementPdf $pdf = app('dompdf.wrapper'); $pdf->setPaper('a4', 'portrait'); $pdf->loadHTML($html, 'UTF-8'); - $bytes = $pdf->output(); + $this->applyLatinFont($pdf->getDomPDF()->getDom()); + // Dompdf otherwise treats uninterrupted Chinese text as one word, + // moving a whole clause to the next line after its Latin number. + $wordBreakPattern = Text::$_wordbreak_pattern; + Text::$_wordbreak_pattern = '/([^\S\xA0\x{202F}\x{2007}\n]+|\R|\-+|\xAD+|(?<=[\p{Han}\x{3000}-\x{303F}\x{FF00}-\x{FFEF}])(?![,。、;:?!)】》]))/u'; + try { + $bytes = $pdf->output(); + } finally { + Text::$_wordbreak_pattern = $wordBreakPattern; + } if (! is_string($bytes) || ! str_starts_with($bytes, '%PDF-')) { throw new RuntimeException('协议 PDF 生成失败。'); @@ -38,6 +50,38 @@ final class CourierAgreementPdf return $bytes; } + private function applyLatinFont(DOMDocument $document): void + { + // DroidSansFallback has no ASCII or typographic quote glyphs. Static + // clauses need the Latin font too, or glyph widths and layout diverge. + $nodes = (new DOMXPath($document))->query( + '//body//text()[not(ancestor::style or ancestor::script or ancestor::*[contains(concat(" ", normalize-space(@class), " "), " latin ")])]' + ); + + foreach ($nodes as $node) { + if (preg_match('/[\x21-\x7E\x{2018}-\x{201F}]/u', $node->nodeValue) !== 1) { + continue; + } + + $parts = preg_split('/([\x20-\x7E\x{2018}-\x{201F}]+)/u', $node->nodeValue, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + $fragment = $document->createDocumentFragment(); + + foreach ($parts as $part) { + $text = $document->createTextNode($part); + if (preg_match('/^[\x20-\x7E\x{2018}-\x{201F}]+$/u', $part) === 1) { + $span = $document->createElement('span'); + $span->setAttribute('class', 'latin'); + $span->appendChild($text); + $fragment->appendChild($span); + } else { + $fragment->appendChild($text); + } + } + + $node->parentNode->replaceChild($fragment, $node); + } + } + private function title(CourierAgreementSignature $signature): string { return match ($signature->document_type) { diff --git a/resources/views/business/pdf/courier-agreement.blade.php b/resources/views/business/pdf/courier-agreement.blade.php index 2e454688..30118e45 100644 --- a/resources/views/business/pdf/courier-agreement.blade.php +++ b/resources/views/business/pdf/courier-agreement.blade.php @@ -8,13 +8,13 @@ body { font-family: agreement-fallback, sans-serif; font-size: 13px; font-weight: 400; line-height: 1.8; color: #171717; } .latin { font-family: "DejaVu Sans", sans-serif; } h1 { text-align: center; font-size: 20px; margin: 0 0 24px; } - h2 { font-size: 14px; margin: 18px 0 8px; } - h4 { font-size: 14px; margin: 18px 0 8px; } + h2 { font-size: 14px; margin: 18px 0 8px; page-break-after: avoid; } + h4 { font-size: 14px; margin: 18px 0 8px; page-break-after: avoid; } p { margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-all; } .meta { color: #595959; font-size: 11px; text-align: right; margin-bottom: 20px; } .account { margin: 14px 0 20px 28px; } .account div { margin: 6px 0; } - .sign { margin-top: 36px; text-align: right; } + .sign { margin-top: 36px; text-align: right; page-break-inside: avoid; } .sign p { margin: 8px 0; } .list { margin-top: 0; } .page { page-break-after: always; } diff --git a/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php index 49474608..f91b66bf 100644 --- a/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php +++ b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php @@ -8,6 +8,9 @@ use App\Models\Business\CourierAgreementSignature; use App\Models\Business\CourierQuoteChange; use App\Models\Business\CourierQuoteSignature; use App\Models\Business\CourierQuoteVersion; +use App\Support\CourierAgreementPdf; +use DOMDocument; +use DOMXPath; use Illuminate\Http\Client\Request as ClientRequest; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; @@ -360,6 +363,55 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase }); } + public function test_cooperation_pdf_preserves_clauses_and_uses_a_supported_font_for_static_numbers(): void + { + $payload = $this->cooperationContractPayload('cooperation-font'); + $payload['signature_date'] = '2026-10-10'; + $signature = new CourierAgreementSignature([ + 'business_no' => 'AGR-FONT-REGRESSION', + 'template_code' => 'TPL-COOPERATION-CONTRACT-V1', + 'document_type' => 'cooperation-contract', + 'payload' => $payload, + ]); + $html = view('business.pdf.courier-agreement', [ + 'signature' => $signature, + 'type' => $signature->document_type, + 'title' => '服务协议', + 'payload' => $payload, + 'signatureDate' => '2026年10月10日', + 'fontPath' => str_replace('\\', '/', resource_path('fonts/DroidSansFallback.ttf')), + ])->render(); + $original = new DOMDocument(); + $original->loadHTML($html, LIBXML_NOWARNING | LIBXML_NOERROR); + $pdf = app('dompdf.wrapper'); + $document = null; + $wrapper = \Mockery::mock($pdf); + $wrapper->shouldReceive('output')->once()->andReturnUsing(function () use ($pdf, &$document): string { + $document = clone $pdf->getDomPDF()->getDom(); + + return $pdf->output(); + }); + $this->app->instance('dompdf.wrapper', $wrapper); + + $wordBreakPattern = \Dompdf\FrameReflower\Text::$_wordbreak_pattern; + $bytes = app(CourierAgreementPdf::class)->render($signature); + + $this->assertStringStartsWith('%PDF-', $bytes); + $this->assertSame($wordBreakPattern, \Dompdf\FrameReflower\Text::$_wordbreak_pattern); + $text = $document->getElementsByTagName('body')->item(0)->textContent; + $this->assertSame( + preg_replace('/\s+/u', '', $original->getElementsByTagName('body')->item(0)->textContent), + preg_replace('/\s+/u', '', $text), + ); + $this->assertStringContainsString('4.6.1、针对甲方客服录入', $text); + $this->assertStringContainsString('95%', $text); + $this->assertStringContainsString('5000元/次', $text); + $xpath = new DOMXPath($document); + foreach ($xpath->query('//body//text()[not(ancestor::*[contains(concat(" ", normalize-space(@class), " "), " latin ")])]') as $node) { + $this->assertDoesNotMatchRegularExpression('/[\x21-\x7E\x{2018}-\x{201F}]/u', $node->nodeValue); + } + } + public function test_cooperation_contract_uses_the_persons_real_name_instead_of_the_courier_name(): void { $this->fakeEsign();