修复合作合同 PDF 方框、文字重叠与签字区分页 (#290)

This commit is contained in:
limeng 2026-10-10 14:34:13 +08:00
parent 107c883eb2
commit 37d18612a6
3 changed files with 100 additions and 4 deletions

View File

@ -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) {

View File

@ -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; }

View File

@ -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();