Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
13 changes: 10 additions & 3 deletions .github/workflows/danfse-visual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,22 @@ 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"
# Normalize PDF line wraps while still requiring the complete official disclosure.
pdftotext -layout "$pdf" - | tr -s '[:space:]' ' ' | 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 '...'
# A locally supplied TTF must be genuinely embedded (not silently
# replaced by Helvetica). The probe font is freely licensed.
pdffonts "${{ runner.temp }}/danfse/danfse-font-probe.pdf" | grep -Fq 'DejaVuSans'

- name: Preserve render evidence
if: always()
Expand Down
28 changes: 25 additions & 3 deletions docs/danfse-v2-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,41 @@ The stable contract covered by tests is:
- header municipality and UF only from `infNFSe/xLocEmi` and `emit/enderNac/UF`, suppressed for national taxation item 99; environment identification remains visible;
- provider, taker and intermediary identity fields;
- retained ISS labels from the DPS and municipal base/rate/assessed tax from the authorized `infNFSe/valores` (not the DPS `tribMun`);
- municipal special regime codes `0` (Nenhum), `9` (Outros) and missing values retain distinct presentation; the two optional municipal rows are omitted only if every field of that row is absent, and a present numeric zero is displayed;
- foreign taker NIF without inventing a Brazilian CPF/CNPJ;
- 60-character taxation-description content with an ellipsis suffix when truncated;
- IBS/CBS block rendered only when the authorized XML contains it;
- IBS/CBS values displayed from authorized totals without recalculation;
- legacy authorized XML without IBS/CBS remains renderable;
- homologation documents have a visible environment marker;
- cancelled and substituted documents may show the required 50pt diagonal status watermark when the consuming application supplies an **explicit, externally verified event state** through `DanfseConfig::fiscalState`; the original authorized XML alone does not establish that a subsequent cancel/substitution occurred and is not mutated;
- 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;
- the supplementary text follows NT 008's field order and pipe separators (including substitution, service references, construction, events and municipality information) when they exist in the authorized XML; estimated tax disclosure accepts explicitly supplied monetary **or** percentage values, preserving missing versus numeric zero;
- 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.

## Locally licensed typography

NT 008 requires **Arial** for labels and **Microsoft Sans Serif** for values.
CSS family names alone do not satisfy this: Dompdf otherwise falls back to
Helvetica. The library cannot redistribute those proprietary font binaries.

A caller holding suitable licenses can provide local TrueType files using
`DanfseConfig(fonts: new DanfseFonts($arialRegular, $arialBold, $microsoftSansSerif, $writableFontCacheDir))`.
Files are validated when the options are built, used only from local paths
within Dompdf's explicitly restricted chroot, and never loaded remotely or
serialized as font bytes in the HTML. Files must permit PDF embedding under
their own license. The last argument must be an existing writable font cache
directory reserved for this use; the renderer isolates its metrics from
Dompdf's bundled fonts to prevent affecting unrelated documents.

An A4 PDF should be inspected with `pdffonts receipt.pdf`; exact typography
is demonstrated only when the correct typefaces are present and embedded.
The default compatibility path may still fall back to Helvetica and must not
be claimed to prove complete NT 008 font compliance. CI exercises local-font
embedding with Dompdf's freely licensed DejaVuSans **only as a test surrogate**.

The project does not guarantee byte-identical PDF output. PDF metadata, font
metrics and QR SVG serialization can change when rendering dependencies are
updated. Reviews should therefore treat the named structural reference tests
Expand All @@ -48,5 +71,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.
1 change: 1 addition & 0 deletions src/Danfse/Config/DanfseConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public function __construct(
?string $logoPath = null,
public ?MunicipalityBranding $municipality = null,
public ?DanfseFiscalState $fiscalState = null,
public ?DanfseFonts $fonts = null,
) {
$this->logoDataUri = ($logoDataUri !== null || $logoPath !== null)
? LogoLoader::resolve($logoDataUri, $logoPath)
Expand Down
86 changes: 86 additions & 0 deletions src/Danfse/Config/DanfseFonts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

declare(strict_types=1);

namespace LibreCodeCoop\NfsePHP\Danfse\Config;

/**
* Local, caller-licensed font files used for strict NT008 typography.
*
* The NFS-e package does not distribute proprietary Arial or Microsoft Sans
* Serif. A caller must provide files that its own font license permits it to
* embed in PDFs. No remote URL is accepted or requested at render time.
*/
final readonly class DanfseFonts
{
public string $arialRegular;
public string $arialBold;
public string $microsoftSansSerif;
public string $fontCacheDirectory;

public function __construct(
string $arialRegular,
string $arialBold,
string $microsoftSansSerif,
string $fontCacheDirectory,
) {
$cache = realpath($fontCacheDirectory);
if ($cache === false || !is_dir($cache) || !is_writable($cache)) {
throw new \InvalidArgumentException('A writable, existing Dompdf font cache directory is required.');
}

$this->fontCacheDirectory = $cache;
$this->arialRegular = self::existingFont($arialRegular);
$this->arialBold = self::existingFont($arialBold);
$this->microsoftSansSerif = self::existingFont($microsoftSansSerif);
}

/**
* @return list<string>
*/
public function directories(): array
{
return array_values(array_unique([
dirname($this->arialRegular),
dirname($this->arialBold),
dirname($this->microsoftSansSerif),
]));
}

/**
* Output references files on disk, never inline font bytes or network URIs.
*/
public function fontFaceCss(): string
{
return implode("\n", [
'@font-face { font-family: "Arial"; font-style: normal; font-weight: 400; src: url("' .
self::fileUri($this->arialRegular) . '") format("truetype"); }',
'@font-face { font-family: "Arial"; font-style: normal; font-weight: 700; src: url("' .
self::fileUri($this->arialBold) . '") format("truetype"); }',
'@font-face { font-family: "Microsoft Sans Serif"; font-style: normal; font-weight: 400; src: url("' .
self::fileUri($this->microsoftSansSerif) . '") format("truetype"); }',
]);
}

private static function existingFont(string $path): string
{
$real = realpath($path);
if ($real === false || !is_file($real) || !is_readable($real)
|| preg_match('/\.ttf$/iD', $real) !== 1
) {
throw new \InvalidArgumentException('An existing, readable local TrueType font file is required.');
}

return $real;
}

private static function fileUri(string $path): string
{
// realpath() above guarantees an absolute local path; encode
// whitespace and quotes so this cannot inject arbitrary CSS.
return 'file://' . str_replace('%2F', '/', rawurlencode($path));
}
}
97 changes: 73 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,62 @@ 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');
if ($this->config->fonts !== null) {
// Permit only the caller-provided, validated font
// directories, not arbitrary filesystem access.
$options->setChroot(array_values(array_unique(array_merge(
$options->getChroot(),
$this->config->fonts->directories(),
))));
// Isolate dynamic font metrics from Dompdf's bundled
// cache. Without this, later default-font documents can
// silently inherit a different typeface and overflow A4.
$options->setFontDir($this->config->fonts->fontCacheDirectory);
$options->setFontCache($this->config->fonts->fontCacheDirectory);
}

$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
Loading