diff --git a/brazilfiscalreport/dacte/dacte.py b/brazilfiscalreport/dacte/dacte.py
index 9edd90ee..6ed31f44 100644
--- a/brazilfiscalreport/dacte/dacte.py
+++ b/brazilfiscalreport/dacte/dacte.py
@@ -5,6 +5,7 @@
import warnings
import xml.etree.ElementTree as ET
from io import BytesIO
+from typing import Optional
from xml.etree.ElementTree import Element
from barcode.codex import Code128
@@ -43,6 +44,14 @@ def extract_text(node: Element, tag: str) -> str:
return get_tag_text(node, URL, tag)
+def to_float(value: Optional[str]) -> float:
+ """Converte valor numérico cru do XML, tolerando ausência/formato inválido."""
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return 0.0
+
+
class Dacte(xFPDF):
def __init__(self, xml, config: DacteConfig = None):
super().__init__(unit="mm", format="A4")
@@ -1347,9 +1356,19 @@ def _draw_service_fee_value(self):
self.cell(w=col_width / 2, h=4, text=titles[1], align="L")
# Distribuir os componentes em 3 colunas com 3 linhas cada
- col1 = self.comp_list[:3] # Primeiros 3 componentes
- col2 = self.comp_list[3:6] # Próximos 3 componentes
- col3 = self.comp_list[6:9] # Últimos 3 componentes
+ comp_list = self.comp_list
+ # O bloco comporta no máximo 9 linhas; do 10º componente em diante o valor
+ # era simplesmente descartado, sem aviso — e a soma dos componentes
+ # exibidos deixava de bater com o vTPrest impresso ao lado. Agrega o
+ # excedente numa linha "DEMAIS" para preservar a soma. O rótulo não é
+ # "OUTROS" porque xNome é texto livre e um componente real pode se
+ # chamar assim.
+ if len(comp_list) > 9:
+ demais = sum(to_float(valor) for _, valor in comp_list[8:])
+ comp_list = comp_list[:8] + [("DEMAIS", f"{demais:.2f}")]
+ col1 = comp_list[:3] # Primeiros 3 componentes
+ col2 = comp_list[3:6] # Próximos 3 componentes
+ col3 = comp_list[6:9] # Últimos 3 componentes
# Altura inicial para começo dos dados
data_y = section_start_y + 6
diff --git a/tests/test_dacte_components.py b/tests/test_dacte_components.py
new file mode 100644
index 00000000..a69c2199
--- /dev/null
+++ b/tests/test_dacte_components.py
@@ -0,0 +1,105 @@
+"""Componentes do valor da prestação além do 9º não podem sumir do DACTE.
+
+O quadro "COMPONENTES DO VALOR DA PRESTAÇÃO DO SERVIÇO" é um retângulo fixo de
+18 mm que comporta 3 colunas × 3 linhas. O XSD do CT-e define `Comp` como
+`maxOccurs="unbounded"`, então um CT-e válido pode declarar mais de 9 — e o
+excedente era descartado sem aviso, deixando a soma dos componentes exibidos
+diferente do vTPrest impresso na coluna ao lado.
+"""
+
+import re
+import zlib
+
+import pytest
+
+from brazilfiscalreport.dacte import Dacte
+from brazilfiscalreport.dacte.dacte import to_float
+
+
+def textos_posicionados(pdf_bytes):
+ """[(y, x, texto)] de cada trecho desenhado, lendo o content stream do PDF."""
+ achados = []
+ for bruto in re.findall(rb"stream\r?\n(.*?)endstream", pdf_bytes, re.S):
+ try:
+ fluxo = zlib.decompress(bruto.strip(b"\r\n")).decode("latin-1")
+ except zlib.error:
+ continue
+ for td_x, td_y, texto in re.findall(
+ r"([\d.-]+)\s+([\d.-]+)\s+Td\s*\((.*?)\)\s*Tj", fluxo
+ ):
+ achados.append((round(float(td_y), 2), round(float(td_x), 2), texto))
+ return achados
+
+
+@pytest.fixture
+def xml_dacte(load_xml):
+ return load_xml("dacte/dacte_test_1.xml")
+
+
+def xml_com_componentes(xml, quantidade):
+ """Substitui os componentes do XML por `quantidade` deles, de 10 em 10."""
+ comps = "".join(
+ f"COMP {i}{i * 10}.00"
+ for i in range(1, quantidade + 1)
+ )
+ return re.sub(r".*", comps, xml, count=1, flags=re.S)
+
+
+def test_componentes_alem_do_nono_aparecem_agregados(xml_dacte):
+ """O bloco comporta 9 linhas; o excedente vira "DEMAIS" em vez de sumir."""
+ xml = xml_com_componentes(xml_dacte, 12)
+ trechos = textos_posicionados(bytes(Dacte(xml=xml).output()))
+ texto = " ".join(t[2] for t in trechos)
+
+ assert "DEMAIS" in texto, "o excedente de componentes sumiu do PDF"
+
+ # Os 8 primeiros saem individualmente; do 9º ao 12º somam 90+100+110+120.
+ assert "COMP 8" in texto
+ assert "COMP 9" not in texto
+ assert "420.00" in texto, "a linha agregada não preserva a soma do excedente"
+
+
+def test_nove_componentes_saem_sem_agregacao(xml_dacte):
+ """Exatamente 9 cabem no quadro: nada é agregado."""
+ xml = xml_com_componentes(xml_dacte, 9)
+ texto = " ".join(t[2] for t in textos_posicionados(bytes(Dacte(xml=xml).output())))
+
+ assert "DEMAIS" not in texto
+ for i in range(1, 10):
+ assert f"COMP {i}" in texto
+
+
+def test_soma_dos_componentes_exibidos_bate_com_o_total(xml_dacte):
+ """A soma do que está impresso tem de fechar, com ou sem agregação."""
+ for quantidade in (6, 9, 12, 20):
+ xml = xml_com_componentes(xml_dacte, quantidade)
+ trechos = textos_posicionados(bytes(Dacte(xml=xml).output()))
+ rotulos = {t[2] for t in trechos}
+
+ exibidos = [t for t in trechos if t[2].startswith("COMP ") or t[2] == "DEMAIS"]
+ assert len(exibidos) == min(quantidade, 9), (
+ f"{quantidade} componentes deveriam render "
+ f"{min(quantidade, 9)} linhas, mas saíram {len(exibidos)}"
+ )
+
+ # As 3 colunas partilham a baseline, então cada linha do quadro é somada
+ # uma vez só — daí o set de baselines em vez de iterar sobre os nomes.
+ baselines = {y for y, _, _ in exibidos}
+ total_exibido = sum(
+ to_float(v)
+ for y, _, v in trechos
+ if y in baselines and re.fullmatch(r"\d+\.\d{2}", v)
+ )
+
+ esperado = sum(i * 10 for i in range(1, quantidade + 1))
+ assert total_exibido == esperado, (
+ f"com {quantidade} componentes o PDF soma {total_exibido}, "
+ f"mas o XML declara {esperado} (rótulos: {sorted(rotulos)[:5]})"
+ )
+
+
+def test_to_float_tolera_valor_ausente_ou_invalido():
+ assert to_float("12.34") == 12.34
+ assert to_float("") == 0.0
+ assert to_float(None) == 0.0
+ assert to_float("R$ 10") == 0.0