From 22ebcd549babf0104b208c0c9607f0b8f38fc41e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:19:29 +0000 Subject: [PATCH 1/5] fix(danfse): port authorized XML display contracts onto current main (#118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/DanfseTemplate.php | 346 ++++++++++++++++--- src/Danfse/Enum/RegimeEspecialTributacao.php | 2 + src/Danfse/XmlToArray.php | 28 +- 3 files changed, 333 insertions(+), 43 deletions(-) diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index 171f609..f4df3a7 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -43,14 +43,25 @@ 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; // This is an explicitly supplied, externally verified state; it // cannot be inferred from an unchanged authorized NFS-e XML. $fiscalState = $config->fiscalState; + $fontFaces = $config->fonts?->fontFaceCss() ?? ''; $ambiente = Ambiente::fromValue((string) $data['ambiente']); $isHomologacao = $ambiente->isHomologacao(); $qrCode = $this->generateQrCode((string) $data['chave_acesso'], $ambiente); @@ -73,7 +84,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'); @@ -92,10 +103,28 @@ public function buildData(array $nfse): array $authorizedValues = $this->node($infNfse, 'valores'); $tribMun = $this->node($valores, 'trib', 'tribMun'); $tribFed = $this->node($valores, 'trib', 'tribFed'); - $totTrib = $this->node($valores, 'trib', 'totTrib', 'pTotTrib'); + $totTribGroup = $this->node($valores, 'trib', 'totTrib'); + $approxPercent = $this->node($totTribGroup, 'pTotTrib'); + $approxMoney = $this->node($totTribGroup, 'vTotTrib'); + $approxValues = $approxMoney !== [] + ? [ + $this->currencyOrDash($this->val($approxMoney, 'vTotTribFed')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribEst')), + $this->currencyOrDash($this->val($approxMoney, 'vTotTribMun')), + ] + : [ + $this->percentOrDash($this->val($approxPercent, 'pTotTribFed')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribEst')), + $this->percentOrDash($this->val($approxPercent, 'pTotTribMun')), + ]; + $infoComplementares = $this->node($serv, 'infoCompl'); $ibsCbs = $this->node($infNfse, 'IBSCBS'); $ibsCbsValores = $this->node($ibsCbs, 'valores'); $ibsCbsTotais = $this->node($ibsCbs, 'totCIBS'); + $ibsTotal = $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'); + $cbsTotal = $this->val($ibsCbsTotais, 'gCBS', 'vCBS'); + $incidenceCode = $this->val($ibsCbs, 'cLocalidadeIncid'); + $dpsDiscounts = $this->node($valores, 'vDescCondIncond'); // The authorized DPS is the primary source for the provider. The // authorized infNFSe/emit enriches omitted legacy address/name data. @@ -117,6 +146,39 @@ public function buildData(array $nfse): array $provider['simples_nacional'] = OptanteSimplesNacional::labelFor($this->val($regTrib, 'opSimpNac')); $provider['regime_sn'] = RegimeApuracaoTributariaSN::labelFor($this->val($regTrib, 'regApTribSN')); + // NT 008 note 5: each optional municipal row is suppressed only + // when all four of its source fields are absent. A literal '0' is + // supplied data and must not be treated as absent. + $regimeEspecial = $this->val($regTrib, 'regEspTrib'); + $tipoImunidade = $this->val($tribMun, 'tpImunidade'); + $tipoSuspensao = $this->val($tribMun, 'exigSusp', 'tpSusp'); + $numeroProcesso = $this->val($tribMun, 'exigSusp', 'nProcesso'); + + $tipoBeneficio = $this->val($authorizedValues, 'tpBM'); + $calculoBeneficio = $this->firstPresent( + $this->val($authorizedValues, 'vCalcBM'), + $this->val($tribMun, 'BM', 'vCalcBM'), + $this->val($tribMun, 'BM', 'vRedBCBM'), + ); + // The official display table chooses the explicit DPS vDR, or the + // calculated deduction and reimbursement amounts supplied in the + // authorized NFS-e. It does not re-derive these from service prices. + $explicitDeductions = $this->val($valores, 'vDedRed', 'vDR'); + $totalDeducoes = $explicitDeductions !== '' + ? $this->currencyOrDash($explicitDeductions) + : $this->sumCurrency( + $this->val($authorizedValues, 'vCalcDR'), + $this->val($ibsCbsValores, 'vCalcReeRepRes'), + ); + $descontoIncondicionado = $this->val($valores, 'vDescCondIncond', 'vDescIncond'); + + // NT008 v1.02, 2.4.5: these are display compositions of XML + // amounts, never a recalculation of a tax or retention liability. + $pisCofinsRetention = $this->val($tribFed, 'piscofins', 'tpRetPisCofins'); + $retainedSocial = $this->val($tribFed, 'vRetCSLL'); + $pisValue = $this->val($tribFed, 'piscofins', 'vPis'); + $cofinsValue = $this->val($tribFed, 'piscofins', 'vCofins'); + $id = $this->val($infNfse, 'Id'); $chaveAcesso = str_starts_with($id, 'NFS') ? substr($id, 3) : $id; @@ -185,7 +247,23 @@ public function buildData(array $nfse): array $this->val($infNfse, 'cLocIncid'), $this->val($tribMun, 'cPaisResult'), ), - 'regime_especial' => RegimeEspecialTributacao::labelFor($this->val($regTrib, 'regEspTrib')), + 'regime_especial' => RegimeEspecialTributacao::labelFor($regimeEspecial), + 'tipo_imunidade' => $this->immunityLabel($tipoImunidade), + 'suspensao_issqn' => $this->suspensionLabel($tipoSuspensao), + 'numero_processo' => $numeroProcesso !== '' ? $numeroProcesso : '-', + 'mostrar_linha_regime' => $this->hasAnyPresent($regimeEspecial, $tipoImunidade, $tipoSuspensao, $numeroProcesso), + 'beneficio_municipal' => $this->municipalBenefitLabel($tipoBeneficio), + 'calculo_bm' => $this->currencyOrDash($calculoBeneficio), + 'total_deducoes' => $totalDeducoes, + 'desconto_incondicionado' => $this->currencyOrDash($descontoIncondicionado), + 'mostrar_linha_beneficio' => $this->hasAnyPresent($tipoBeneficio, $calculoBeneficio, $totalDeducoes !== '-' ? $totalDeducoes : '', $descontoIncondicionado), + // Note 4 permits the concise no-ISS block when the XML + // expressly identifies the operation as not incident. + 'nao_sujeito_issqn' => in_array( + $this->val($tribMun, 'tribISSQN'), + ['2', '3', '4'], + true, + ), 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), 'bc_issqn' => $this->currencyOrDash($this->val($authorizedValues, 'vBC')), 'aliquota' => $this->percentOrDash($this->val($authorizedValues, 'pAliqAplic')), @@ -201,23 +279,36 @@ public function buildData(array $nfse): array ), 'irrf' => $this->currencyOrDash($this->val($tribFed, 'vRetIRRF')), 'cp' => $this->currencyOrDash($this->val($tribFed, 'vRetCP')), - 'contribuicoes_sociais' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), - // Alias de compatibilidade para consumidores de buildData(). - 'csll' => $this->currencyOrDash($this->val($tribFed, 'vRetCSLL')), - 'pis' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vPis')), - 'cofins' => $this->currencyOrDash($this->val($tribFed, 'piscofins', 'vCofins')), - 'descricao_retencao' => $this->pisCofinsRetentionLabel( - $this->val($tribFed, 'piscofins', 'tpRetPisCofins'), - ), + 'contribuicoes_sociais' => $pisCofinsRetention === '1' + ? $this->sumCurrency($retainedSocial, $pisValue, $cofinsValue) + : $this->currencyOrDash($retainedSocial), + // Keep legacy model keys unchanged for existing consumers. + 'csll' => $this->currencyOrDash($retainedSocial), + // When PIS/COFINS are retained, the NT008 prescribes zero + // for the own-assessment cells, not the retained amounts. + 'pis' => $pisCofinsRetention === '1' + ? $this->formatter->currency('0.00') + : $this->currencyOrDash($pisValue), + 'cofins' => $pisCofinsRetention === '1' + ? $this->formatter->currency('0.00') + : $this->currencyOrDash($cofinsValue), + 'descricao_retencao' => $this->pisCofinsRetentionLabel($pisCofinsRetention), ], 'totais' => [ - 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), - 'desconto_condicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescCond')), - 'desconto_incondicionado' => $this->currencyOrDash($this->val($tribMun, 'vDescIncond')), - 'issqn_retido' => ($this->val($authorizedValues, 'vISSQN') !== '' && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1') - ? $this->formatter->currency($this->val($authorizedValues, 'vISSQN')) - : '-', + 'valor_servico' => $this->formatter->currency($this->val($valores, 'vServPrest', 'vServ')), + 'desconto_condicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescCond')), + 'desconto_incondicionado' => $this->currencyOrDash($this->val($dpsDiscounts, 'vDescIncond')), + // This field is calculated by SEFIN and exists in the authorized + // NFS-e. Do not recalculate it from partially provided retentions. + 'total_retencoes' => $this->currencyOrDash($this->val($authorizedValues, 'vTotalRet')), + // Historical buildData() keys are retained for consumers and + // golden contracts. The DANFSe itself prints vTotalRet as the + // authoritative total rather than any partial DPS sum. + 'issqn_retido' => ( + $this->val($authorizedValues, 'vISSQN') !== '' + && ($this->val($tribMun, 'tpRetISSQN') ?: '1') !== '1' + ) ? $this->currencyOrDash($this->val($authorizedValues, 'vISSQN')) : '-', 'retencoes_federais' => $this->sumCurrency( $this->val($tribFed, 'vRetIRRF'), $this->val($tribFed, 'vRetCP'), @@ -227,13 +318,13 @@ public function buildData(array $nfse): array $this->val($tribFed, 'piscofins', 'vPis'), $this->val($tribFed, 'piscofins', 'vCofins'), ), - 'valor_liquido' => $this->formatter->currency($this->val($infNfse, 'valores', 'vLiq')), + 'valor_liquido' => $this->currencyOrDash($this->val($authorizedValues, 'vLiq')), ], 'totais_tributos' => [ - 'federais' => $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - 'estaduais' => $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - 'municipais' => $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + 'federais' => $approxValues[0], + 'estaduais' => $approxValues[1], + 'municipais' => $approxValues[2], ], 'ibs_cbs' => [ @@ -241,33 +332,60 @@ public function buildData(array $nfse): array $this->val($infDps, 'IBSCBS', 'valores', 'trib', 'gIBSCBS', 'CST'), $this->val($infDps, 'IBSCBS', 'valores', 'trib', 'gIBSCBS', 'cClassTrib'), ), - 'indicador_operacao' => $this->val($infDps, 'IBSCBS', 'cIndOp') ?: '-', + 'indicador_operacao' => $this->joinedOrDash( + $this->val($infDps, 'IBSCBS', 'cIndOp'), + $incidenceCode, + $this->val($ibsCbs, 'xLocalidadeIncid'), + $this->ufForIbge($incidenceCode), + ), + // Public buildData() key retained for consumers of the data + // model; HTML uses the complete official indicator field. 'localidade_incidencia' => $this->val($ibsCbs, 'xLocalidadeIncid') - ?: $this->val($ibsCbs, 'cLocalidadeIncid') + ?: $incidenceCode ?: '-', - 'exclusoes_reducoes' => $this->sumCurrency( - $this->val($infNfse, 'valores', 'vISSQN'), + // NT008 explicitly displays this sum of XML components. It + // does not authorize fresh IBS/CBS or ISS tax calculation. + 'exclusoes_reducoes' => $ibsCbs === [] ? '-' : $this->sumCurrency( + $this->val($dpsDiscounts, 'vDescIncond'), + $this->val($ibsCbsValores, 'vCalcReeRepRes'), + $this->val($authorizedValues, 'vISSQN'), $this->val($tribFed, 'piscofins', 'vPis'), $this->val($tribFed, 'piscofins', 'vCofins'), ), 'base_calculo' => $this->currencyOrDash($this->val($ibsCbsValores, 'vBC')), + 'reducao_aliquotas' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pRedAliqUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pRedAliqMun')), + $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pRedAliqCBS')), + ), + 'aliquota_ibs' => $this->joinedOrDash( + $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), + $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + ), 'aliquota_ibs_uf' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pIBSUF')), 'aliquota_ibs_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pIBSMun')), + 'aliquota_efetiva_municipal' => $this->percentOrDash($this->val($ibsCbsValores, 'mun', 'pAliqEfetMun')), + 'valor_ibs_municipal' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSMunTot', 'vIBSMun')), + 'aliquota_efetiva_estadual' => $this->percentOrDash($this->val($ibsCbsValores, 'uf', 'pAliqEfetUF')), + 'valor_ibs_estadual' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'gIBSUFTot', 'vIBSUF')), 'aliquota_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pCBS')), - 'total_ibs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gIBS', 'vIBSTot')), - 'total_cbs' => $this->currencyOrDash($this->val($ibsCbsTotais, 'gCBS', 'vCBS')), - 'total_ibs_cbs' => $ibsCbs === [] ? '-' : $this->sumCurrency( - $this->val($ibsCbsTotais, 'gIBS', 'vIBSTot'), - $this->val($ibsCbsTotais, 'gCBS', 'vCBS'), - ), + 'aliquota_efetiva_cbs' => $this->percentOrDash($this->val($ibsCbsValores, 'fed', 'pAliqEfetCBS')), + 'total_ibs' => $this->currencyOrDash($ibsTotal), + 'total_cbs' => $this->currencyOrDash($cbsTotal), + // An absent amount is not proof of zero. Two explicit amounts + // (including literal zero) are required for a displayed sum. + 'total_ibs_cbs' => $ibsTotal !== '' && $cbsTotal !== '' + ? $this->sumCurrency($ibsTotal, $cbsTotal) + : '-', 'valor_total_nfse' => $this->currencyOrDash($this->val($ibsCbsTotais, 'vTotNF')), ], 'informacoes_complementares' => $this->complementaryInformation( - $this->val($serv, 'infoCompl', 'xInfComp'), - $this->percentOrDash($this->val($totTrib, 'pTotTribFed')), - $this->percentOrDash($this->val($totTrib, 'pTotTribEst')), - $this->percentOrDash($this->val($totTrib, 'pTotTribMun')), + $this->authorizedComplementaryFields($infNfse, $infDps, $serv, $infoComplementares), + $approxValues[0], + $approxValues[1], + $approxValues[2], + $complementaryTextLimit, ), ]; } @@ -436,6 +554,23 @@ private function locationWithUfAndCountry(string $city, string $ibge, string $co . ($country !== '' ? $country : '-'); } + /** + * Derive the UF only from an explicitly authorized, recognized IBGE code. + */ + private function ufForIbge(string $code): string + { + if (preg_match('/^\\d{7}$/D', $code) !== 1) { + return '-'; + } + + $name = Municipios::lookup($code); + if ($name === $code || !str_contains($name, ' - ')) { + return '-'; + } + + return substr($name, (int) strrpos($name, ' - ') + 3); + } + private function formattedNbs(string $code): string { if (preg_match('/^\\d{9}$/D', $code) !== 1) { @@ -474,6 +609,68 @@ private function city(string $cMun): string return $cMun !== '' ? Municipios::lookup($cMun) : '-'; } + private function firstPresent(string ...$values): string + { + foreach ($values as $value) { + if ($value !== '') { + return $value; + } + } + + return ''; + } + + private function hasAnyPresent(string ...$values): bool + { + foreach ($values as $value) { + if ($value !== '') { + return true; + } + } + + return false; + } + + private function immunityLabel(string $value): string + { + $label = match ($value) { + // XSD 1.01 explicitly assigns 0 to a legacy, unspecified type. + // Display the actual fiscal meaning instead of confusing it with + // a missing XML tag, even though NT008's table lists 1–5. + '0' => 'Imunidade (tipo não informado)', + '1' => 'Patrimônio, renda ou serviços de entes públicos', + '2' => 'Entidades religiosas e templos de qualquer culto', + '3' => 'Partidos, sindicatos, educação e assistência social', + '4' => 'Livros, jornais, periódicos e o papel destinado à impressão', + '5' => 'Fonogramas e videofonogramas musicais brasileiros', + default => '', + }; + + return $label !== '' ? $this->formatter->limit($label, 37) : '-'; + } + + private function suspensionLabel(string $value): string + { + $label = match ($value) { + '1' => 'Exigibilidade Suspensa por Decisão Judicial', + '2' => 'Exigibilidade Suspensa por Processo Administrativo', + default => '', + }; + + return $label !== '' ? $this->formatter->limit($label, 37) : '-'; + } + + private function municipalBenefitLabel(string $value): string + { + return match ($value) { + '1' => 'Isenção', + '2' => 'Redução da BC em percentual', + '3' => 'Redução da BC em reais', + '4' => 'Alíquota Diferenciada', + default => '-', + }; + } + private function currencyOrDash(string $value): string { return $value !== '' ? $this->formatter->currency($value) : '-'; @@ -560,12 +757,83 @@ private function joinedOrDash(string ...$values): string return implode(' / ', array_map(static fn (string $value): string => $value !== '' ? $value : '-', $values)); } + /** + * NT008 table 2.4.5 prescribes a stable order and pipe separators. + * The values below exist exclusively in the authorized NFS-e XML. + * + * @param array $infNfse + * @param array $infDps + * @param array $service + * @param array $info + */ + private function authorizedComplementaryFields( + array $infNfse, + array $infDps, + array $service, + array $info, + ): string { + $parts = []; + $fieldSources = [ + ['Inf. Cont.:', $this->val($info, 'xInfComp')], + ['NFS-e Subst.:', $this->val($infDps, 'subst', 'chSubstda')], + ['Doc. Ref.:', $this->val($info, 'docRef')], + ['Cod. Obra:', $this->val($service, 'obra', 'cObra')], + ['Insc. Imob.:', $this->val($service, 'obra', 'inscImobFisc') + ?: $this->val($infDps, 'IBSCBS', 'imovel', 'inscImobFisc')], + ['Cod. Evt.:', $this->val($service, 'atvEvento', 'idAtvEvt')], + ['Doc. Tec.:', $this->val($info, 'idDocTec')], + ['Núm. Ped.:', $this->val($info, 'xPed')], + ['Item Ped.:', $this->listItemNumbers($this->node($info, 'gItemPed'))], + ['Inf. A. T. Mun.:', $this->val($infNfse, 'xOutInf')], + ]; + + foreach ($fieldSources as [$prefix, $content]) { + if ($content !== '') { + $parts[] = $prefix . ' ' . $content; + } + } + + return implode(' | ', $parts); + } + + /** + * Preserve all repeated xItemPed occurrences from XML without dropping + * the first items (up to 99 in the active XSD). + * + * @param array $group + */ + private function listItemNumbers(array $group): string + { + $items = $group['xItemPed'] ?? null; + if (is_scalar($items)) { + return trim((string) $items); + } + + if (!is_array($items)) { + return ''; + } + + $clean = []; + foreach ($items as $item) { + if (is_scalar($item) && trim((string) $item) !== '') { + $clean[] = trim((string) $item); + } + } + + return implode(', ', $clean); + } + private function complementaryInformation( string $existing, 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, @@ -573,7 +841,7 @@ private function complementaryInformation( $municipal, ); - return trim($existing) === '' ? $taxes : trim($existing) . "\n" . $taxes; + return $existing === '' ? $taxes : $existing . " | " . $taxes; } private function generateQrCode(string $chaveAcesso, Ambiente $ambiente): string diff --git a/src/Danfse/Enum/RegimeEspecialTributacao.php b/src/Danfse/Enum/RegimeEspecialTributacao.php index 7291b9b..f3bef0a 100644 --- a/src/Danfse/Enum/RegimeEspecialTributacao.php +++ b/src/Danfse/Enum/RegimeEspecialTributacao.php @@ -18,6 +18,7 @@ enum RegimeEspecialTributacao: int case NOTARIO_REGISTRADOR = 4; case PROFISSIONAL_AUTONOMO = 5; case SOCIEDADE_PROFISSIONAIS = 6; + case OUTROS = 9; public function label(): string { @@ -29,6 +30,7 @@ public function label(): string self::NOTARIO_REGISTRADOR => 'Notário ou Registrador', self::PROFISSIONAL_AUTONOMO => 'Profissional Autônomo', self::SOCIEDADE_PROFISSIONAIS => 'Sociedade de Profissionais', + self::OUTROS => 'Outros', }; } } diff --git a/src/Danfse/XmlToArray.php b/src/Danfse/XmlToArray.php index 02036aa..40eebba 100644 --- a/src/Danfse/XmlToArray.php +++ b/src/Danfse/XmlToArray.php @@ -55,14 +55,12 @@ private function nodeToArray(\SimpleXMLElement $node): array|string $nsChildren = $node->children(self::NFSE_NS); if ($nsChildren !== null && $nsChildren->count() > 0) { foreach ($nsChildren as $name => $child) { - $result[$name] = $this->nodeToArray($child); + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } else { // Fallback for XMLs that omit the namespace on children. foreach ($node->children() ?? [] as $name => $child) { - if (!isset($result[$name])) { - $result[$name] = $this->nodeToArray($child); - } + $this->appendNamedChild($result, $name, $this->nodeToArray($child)); } } @@ -73,4 +71,26 @@ private function nodeToArray(\SimpleXMLElement $node): array|string return $result; } + + /** + * Repeating an XML element is not a replacement: preserve each distinct + * child, but leave single-occurrence fields backward compatible. + * + * @param array $out + * @param array|string $value + */ + private function appendNamedChild(array &$out, string $name, array|string $value): void + { + if (!array_key_exists($name, $out)) { + $out[$name] = $value; + + return; + } + + if (!is_array($out[$name]) || !array_is_list($out[$name])) { + $out[$name] = [$out[$name]]; + } + + $out[$name][] = $value; + } } From 495acd142a0389a06826fdf9eb713d14af3eb53d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:19:56 +0000 Subject: [PATCH 2/5] feat(danfse): integrate one-page presentation and isolated local-font support (#118) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Danfse/Config/DanfseConfig.php | 1 + src/Danfse/Config/DanfseFonts.php | 86 +++++++++++++++ src/Danfse/DanfseGenerator.php | 97 ++++++++++++----- src/Danfse/HTMLTemplate.php | 164 ++++++++++++++--------------- 4 files changed, 241 insertions(+), 107 deletions(-) create mode 100644 src/Danfse/Config/DanfseFonts.php diff --git a/src/Danfse/Config/DanfseConfig.php b/src/Danfse/Config/DanfseConfig.php index ac67a6e..f1aa06e 100644 --- a/src/Danfse/Config/DanfseConfig.php +++ b/src/Danfse/Config/DanfseConfig.php @@ -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) diff --git a/src/Danfse/Config/DanfseFonts.php b/src/Danfse/Config/DanfseFonts.php new file mode 100644 index 0000000..1ee445b --- /dev/null +++ b/src/Danfse/Config/DanfseFonts.php @@ -0,0 +1,86 @@ +fontCacheDirectory = $cache; + $this->arialRegular = self::existingFont($arialRegular); + $this->arialBold = self::existingFont($arialBold); + $this->microsoftSansSerif = self::existingFont($microsoftSansSerif); + } + + /** + * @return list + */ + 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)); + } +} diff --git a/src/Danfse/DanfseGenerator.php b/src/Danfse/DanfseGenerator.php index 075f790..bd8ba8e 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,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, + ); } /** diff --git a/src/Danfse/HTMLTemplate.php b/src/Danfse/HTMLTemplate.php index a9ee6a7..95c0453 100644 --- a/src/Danfse/HTMLTemplate.php +++ b/src/Danfse/HTMLTemplate.php @@ -7,6 +7,7 @@ /** @var string $qrCode */ /** @var bool $isHomologacao */ /** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState $fiscalState */ +/** @var string $fontFaces */ /** @var ?\LibreCodeCoop\NfsePHP\Danfse\Config\MunicipalityBranding $municipality */ ?> @@ -15,35 +16,49 @@ DANFSe - <?= $data['numero_nfse'] ?> diff --git a/tests/Unit/Danfse/DanfseGeneratorTest.php b/tests/Unit/Danfse/DanfseGeneratorTest.php index d5f3939..8319a07 100644 --- a/tests/Unit/Danfse/DanfseGeneratorTest.php +++ b/tests/Unit/Danfse/DanfseGeneratorTest.php @@ -60,19 +60,27 @@ public function testLocalFontFilesCanBeRegisteredWithoutRemoteLoading(): void self::assertFileExists($bold); $cache = sys_get_temp_dir() . '/nfse-danfse-fonts-' . bin2hex(random_bytes(8)); - self::assertTrue(mkdir($cache, 0700)); + self::assertTrue(mkdir($cache, 0o700)); $fonts = new DanfseFonts($regular, $bold, $regular, $cache); self::assertCount(1, $fonts->directories()); $generator = new DanfseGenerator(new DanfseConfig(fonts: $fonts)); $html = $generator->generateHtml($this->xml); - self::assertStringContainsString('font-family: "Arial"', $html); - self::assertStringContainsString('font-family: "Microsoft Sans Serif"', $html); + self::assertStringContainsString('font-family: "' . $fonts->labelFamily() . '"', $html); + self::assertStringContainsString('font-family: "' . $fonts->valueFamily() . '"', $html); + self::assertStringNotContainsString('@font-face { font-family: "Arial"', $html); self::assertStringContainsString('src: url("file://', $html); self::assertStringNotContainsString('data:font/', $html); $pdf = $generator->generateFromXml($this->xml); self::assertStringStartsWith('%PDF-', $pdf); + + // Distinct font registrations must not change the default font + // lookup later in the SAME PHP process. + $default = new DanfseGenerator(); + self::assertStringStartsWith('%PDF-', $default->generateFromXml($this->xml)); + $homolog = str_replace('1', '2', $this->xml); + self::assertStringContainsString('HOMOLOGAÇÃO', $default->generateHtml($homolog)); } public function testGeneratedPdfSizeIsReasonable(): void diff --git a/tools/bin/render-danfse-visual-fixtures.php b/tools/bin/render-danfse-visual-fixtures.php index 3dde639..8f1dcd7 100644 --- a/tools/bin/render-danfse-visual-fixtures.php +++ b/tools/bin/render-danfse-visual-fixtures.php @@ -24,23 +24,6 @@ $pdf = $generator->generateFromXml($xml); file_put_contents($outputDir . '/danfse-reference.pdf', $pdf); -// Prove that external local TrueType fonts really embed, using only Dompdf's -// permissively licensed test font. This does not claim the actual NT008 -// proprietary font names are installed or licensed on the CI runner. -$fontRoot = dirname(__DIR__, 2) . '/vendor/dompdf/dompdf/lib/fonts'; -$fontCache = $outputDir . '/font-cache'; -if (!is_dir($fontCache) && !mkdir($fontCache, 0775, true) && !is_dir($fontCache)) { - throw new RuntimeException('Unable to create isolated font cache'); -} -$probeFonts = new DanfseFonts( - $fontRoot . '/DejaVuSans.ttf', - $fontRoot . '/DejaVuSans-Bold.ttf', - $fontRoot . '/DejaVuSans.ttf', - $fontCache, -); -$probePdf = (new DanfseGenerator(new DanfseConfig(fonts: $probeFonts)))->generateFromXml($xml); -file_put_contents($outputDir . '/danfse-font-probe.pdf', $probePdf); - // Stress-test description overflow/page breaking without using real taxpayer // data or changing the fiscal totals in the synthetic authorized fixture. $doc = new DOMDocument(); @@ -125,3 +108,20 @@ } file_put_contents($outputDir . '/danfse-' . $scenario . '.pdf', $casePdf); } + +// Prove that external local TrueType fonts really embed, using only Dompdf's +// permissively licensed test font. This does not claim the actual NT008 +// proprietary font names are installed or licensed on the CI runner. +$fontRoot = dirname(__DIR__, 2) . '/vendor/dompdf/dompdf/lib/fonts'; +$fontCache = $outputDir . '/font-cache'; +if (!is_dir($fontCache) && !mkdir($fontCache, 0775, true) && !is_dir($fontCache)) { + throw new RuntimeException('Unable to create isolated font cache'); +} +$probeFonts = new DanfseFonts( + $fontRoot . '/DejaVuSans.ttf', + $fontRoot . '/DejaVuSans-Bold.ttf', + $fontRoot . '/DejaVuSans.ttf', + $fontCache, +); +$probePdf = (new DanfseGenerator(new DanfseConfig(fonts: $probeFonts)))->generateFromXml($xml); +file_put_contents($outputDir . '/danfse-font-probe.pdf', $probePdf);