diff --git a/.github/workflows/danfse-visual.yml b/.github/workflows/danfse-visual.yml index d4e479c..57dbacc 100644 --- a/.github/workflows/danfse-visual.yml +++ b/.github/workflows/danfse-visual.yml @@ -44,15 +44,18 @@ jobs: - name: Generate synthetic PDFs for visual review run: php tools/bin/render-danfse-visual-fixtures.php "${{ runner.temp }}/danfse" - - name: Verify printable receipt footer and long-description pagination + - name: Verify required fields and one-page A4 layout shell: bash run: | for pdf in "${{ runner.temp }}/danfse/"*.pdf; do pdftotext -layout "$pdf" - | grep -Fq "DATA CIENTIFICAÇÃO" pdftotext -layout "$pdf" - | grep -Fq "IDENTIFICAÇÃO E ASSINATURA" + pdftotext -layout "$pdf" - | grep -Fq "Totais aproximados dos Tributos" + pages=$(pdfinfo "$pdf" | awk '/^Pages:/ {print $2}') + test "$pages" -eq 1 + pdfinfo "$pdf" | grep -Fq 'Page size: 595.28 x 841.89 pts (A4)' done - pages=$(pdfinfo "${{ runner.temp }}/danfse/danfse-long-description.pdf" | awk '/^Pages:/ {print $2}') - test "$pages" -gt 1 + pdftotext -layout "${{ runner.temp }}/danfse/danfse-long-description.pdf" - | grep -Fq '...' - name: Preserve render evidence if: always() diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index bc2fbd1..6bc3098 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -20,7 +20,7 @@ The stable contract covered by tests is: - IBS/CBS values displayed from authorized totals without recalculation; - legacy authorized XML without IBS/CBS remains renderable; - homologation documents have a visible environment marker; -- local generation returns a valid PDF artifact; +- local generation returns a valid single-page A4 portrait PDF artifact; overflowing optional free text uses visible ellipses as permitted by NT 008 (item 2.1), without modifying the XML, the fiscal fields or mandatory tax disclosure. Layouts that still cannot fit fail explicitly instead of returning a nonconforming multi-page PDF; - line breaks in the authorized service description are preserved as escaped HTML line breaks; - optional contact complement and IBGE/CEP follow the authorized XML without inventing missing data; - the reference footer never fabricates an acknowledgement date or human signature. @@ -46,5 +46,4 @@ The image remains directly viewable on disk and is encoded in the temporary HTML All monetary values and document identity continue to come only from authorized NFS-e XML; the layout neither recalculates taxes nor invents absent fiscal data. Homologation warnings remain visible. No receipt date or signature -is invented. The footer reserves bottom page space, including when a lengthy -description flows onto additional pages. +is invented. The footer reserves bottom page space. Long free-text fields are progressively abbreviated with visible ellipses when needed to keep the DANFSe on one page; if mandatory content cannot fit, generation fails rather than producing additional pages. diff --git a/src/Danfse/DanfseGenerator.php b/src/Danfse/DanfseGenerator.php index 075f790..542456f 100644 --- a/src/Danfse/DanfseGenerator.php +++ b/src/Danfse/DanfseGenerator.php @@ -28,37 +28,32 @@ public function __construct( } /** - * Render the DANFSe PDF from the NFS-e XML and return its raw bytes. + * Render one A4 portrait page or fail explicitly. NT 008, item 2.2, + * forbids multiple-page DANFSe output. */ public function generateFromXml(string $xml): string { - $html = $this->generateHtml($xml); - - try { - $options = new Options(); - $options->set('isHtml5ParserEnabled', true); - $options->set('isRemoteEnabled', false); - $options->set('defaultFont', 'Helvetica'); - - $dompdf = new Dompdf($options); - $dompdf->loadHtml($html, 'UTF-8'); - $dompdf->setPaper('A4', 'portrait'); - $dompdf->render(); - - return (string) $dompdf->output(); - } catch (\Throwable $e) { - throw new ArtifactException( - 'Failed to render DANFSe PDF: ' . $e->getMessage(), - NfseErrorCode::ArtifactRetrievalFailed, - previous: $e, - ); - } + return $this->renderOnePage($xml)[1]; } /** - * Render the intermediate HTML (useful for inspection and testing). + * Return exactly the intermediate HTML used for the one-page PDF. */ public function generateHtml(string $xml): string + { + return $this->renderOnePage($xml)[0]; + } + + /** + * Progressive, visible ellipsis is allowed by NT 008, item 2.1. Ordinary + * invoices keep their full description. When a layout exceeds one page, + * trim only free-text descriptions, never keys, fields or fiscal totals. + * If even the minimum readable text does not fit, fail rather than + * silently returning a second page or hiding required fiscal fields. + * + * @return array{string, string} HTML and PDF bytes of the same rendering + */ + private function renderOnePage(string $xml): array { try { $data = (new XmlToArray())->convert($xml); @@ -71,8 +66,49 @@ public function generateHtml(string $xml): string } $this->assertAuthorizedNfse($data); + $template = new DanfseTemplate(); + + // [service-description limit, additional free-text limit]. + // These are adaptive budgets, NOT new fiscal-field constraints. + foreach ([ + [null, null], + [1300, 1900], + [850, 1100], + [600, 800], + [400, 500], + [250, 300], + [150, 150], + [60, 60], + ] as [$serviceLimit, $complementaryLimit]) { + $html = $template->render($data, $this->config, $serviceLimit, $complementaryLimit); + + try { + $options = new Options(); + $options->set('isHtml5ParserEnabled', true); + $options->set('isRemoteEnabled', false); + $options->set('defaultFont', 'Helvetica'); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + } catch (\Throwable $e) { + throw new ArtifactException( + 'Failed to render DANFSe PDF: ' . $e->getMessage(), + NfseErrorCode::ArtifactRetrievalFailed, + previous: $e, + ); + } + + if ($dompdf->getCanvas()->get_page_count() === 1) { + return [$html, (string) $dompdf->output()]; + } + } - return (new DanfseTemplate())->render($data, $this->config); + throw new ArtifactException( + 'DANFSe does not fit on one A4 page without suppressing mandatory information.', + NfseErrorCode::ArtifactRetrievalFailed, + ); } /** diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index d3f49bd..036bfce 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -43,9 +43,19 @@ public function __construct() * * @param array $nfse */ - public function render(array $nfse, DanfseConfig $config): string - { - $data = $this->buildData($nfse); + public function render( + array $nfse, + DanfseConfig $config, + ?int $serviceDescriptionLimit = null, + ?int $complementaryTextLimit = null, + ): string { + $data = $this->buildData($nfse, $complementaryTextLimit); + if ($serviceDescriptionLimit !== null) { + $data['servico']['descricao'] = $this->formatter->limit( + (string) $data['servico']['descricao'], + $serviceDescriptionLimit, + ); + } $logo = $config->logoDataUri; $municipality = $config->municipality; $ambiente = Ambiente::fromValue((string) $data['ambiente']); @@ -70,7 +80,7 @@ public function render(array $nfse, DanfseConfig $config): string * @param array $nfse * @return array */ - public function buildData(array $nfse): array + public function buildData(array $nfse, ?int $complementaryTextLimit = null): array { $infNfse = $this->node($nfse, 'infNFSe'); $infDps = $this->node($infNfse, 'DPS', 'infDPS'); @@ -246,6 +256,7 @@ public function buildData(array $nfse): array $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + $complementaryTextLimit, ), ]; } @@ -444,7 +455,12 @@ private function complementaryInformation( string $federal, string $state, string $municipal, + ?int $freeTextLimit = null, ): string { + if ($freeTextLimit !== null) { + $existing = $this->formatter->limit(trim($existing), $freeTextLimit); + } + $taxes = sprintf( 'Totais aproximados dos Tributos cfe. Lei n° 12.741/2012: Federais: %s; Estaduais: %s; Municipais: %s;', $federal, diff --git a/tests/Unit/Danfse/DanfseGeneratorTest.php b/tests/Unit/Danfse/DanfseGeneratorTest.php index 4c94229..ed3fe98 100644 --- a/tests/Unit/Danfse/DanfseGeneratorTest.php +++ b/tests/Unit/Danfse/DanfseGeneratorTest.php @@ -60,6 +60,41 @@ public function testGenerateWithMunicipalityConfig(): void self::assertStringStartsWith('%PDF-', $pdf); } + public function testLongSyntheticServiceKeepsExplicitEllipsisAndOnePageHtml(): void + { + $longDescription = str_repeat('DESCRICAO SINTETICA DE SERVICO E ETAPAS. ', 140); + $xml = preg_replace( + '~[^<]*~', + '' . $longDescription . '', + $this->xml, + ); + self::assertIsString($xml); + + $generator = new DanfseGenerator(); + $html = $generator->generateHtml($xml); + self::assertStringContainsString('DESCRICAO SINTETICA DE SERVICO', $html); + self::assertStringContainsString('...', $html); + self::assertStringContainsString('Totais aproximados dos Tributos', $html); + self::assertStringStartsWith('%PDF-', $generator->generateFromXml($xml)); + } + + public function testLongSyntheticComplementaryInformationPreservesTaxDisclosure(): void + { + $longNotes = str_repeat('OBSERVACAO SINTETICA DO SERVICO. ', 140); + $xml = preg_replace( + '~[^<]*~', + '' . $longNotes . '', + $this->xml, + ); + self::assertIsString($xml); + + $html = (new DanfseGenerator())->generateHtml($xml); + self::assertStringContainsString('OBSERVACAO SINTETICA DO SERVICO', $html); + self::assertStringContainsString('...', $html); + self::assertStringContainsString('Totais aproximados dos Tributos', $html); + self::assertStringContainsString('Federais: 4.50%', $html); + } + public function testGenerateHtmlContainsHomologacaoWatermark(): void { $xml = str_replace('1', '2', $this->xml);