Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/danfse-visual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 2 additions & 3 deletions docs/danfse-v2-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
84 changes: 60 additions & 24 deletions src/Danfse/DanfseGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
);
}

/**
Expand Down
24 changes: 20 additions & 4 deletions src/Danfse/DanfseTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,19 @@ public function __construct()
*
* @param array<string, mixed> $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']);
Expand All @@ -70,7 +80,7 @@ public function render(array $nfse, DanfseConfig $config): string
* @param array<string, mixed> $nfse
* @return array<string, mixed>
*/
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');
Expand Down Expand Up @@ -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,
),
];
}
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions tests/Unit/Danfse/DanfseGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'~<xDescServ>[^<]*</xDescServ>~',
'<xDescServ>' . $longDescription . '</xDescServ>',
$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(
'~<xInfComp>[^<]*</xInfComp>~',
'<xInfComp>' . $longNotes . '</xInfComp>',
$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('<tpAmb>1</tpAmb>', '<tpAmb>2</tpAmb>', $this->xml);
Expand Down
Loading