Spaces:
Sleeping
Sleeping
Mantém todos os exercícios nas tabelas com ajuste automático da fonte
Browse filesRemove blocos fixos de quatro/cinco anos e encurta rótulos de construção. Mede os dados preenchidos, margens e notas para selecionar fonte uniforme a partir de 8 pt, em passos de 0,5 pt. Aplica aos comparativos, fundamentação e conclusões. Validado com 106 testes e renderização da cobaia de cinco exercícios e três construções.
- README.md +2 -0
- document/avaliacoes_importadas.py +2 -2
- document/evolutivo.py +30 -24
- document/formatters/table_fit.py +120 -0
- document/generator.py +3 -1
- tests/test_annual_tables.py +91 -0
- tests/test_evo_adjustments.py +2 -2
- tests/test_evolutivo_documento.py +4 -3
README.md
CHANGED
|
@@ -143,6 +143,8 @@ O arquivo é lido novamente no backend ao gerar o documento; os valores da prév
|
|
| 143 |
|
| 144 |
O laudo evolutivo apresenta no corpo as seções **3.3 Terreno**, **3.4 Construções** e **3.5 Composição do imóvel**, com parâmetros por exercício, variáveis e equação, áreas de modelo e aplicação, CUB histórico simples ou composto, adequação, BDI, Ross-Heidecke, residual, Kd, redutores e Fc. A seção 4 discrimina fundamentação dos componentes e global, com a precisão restrita ao terreno. A conclusão mostra o valor adotado e seu extenso; a composição conserva o resultado calculado.
|
| 145 |
|
|
|
|
|
|
|
| 146 |
Em **5. Avaliação**, a revisão recupera as justificativas públicas salvas e permite complementar VU, área, BDI por sequência, Fc, ajustes não aplicados e critério de adoção. É possível reutilizar deliberadamente as justificativas nos demais exercícios. Critérios de fundamentação não preenchidos continuam pendentes; observações internas não são publicadas. Conferências com os dados salvos apontam divergências sem corrigir os resultados.
|
| 147 |
|
| 148 |
Os anexos estatísticos EVO usam o modelo do catálogo (dataset da MESA no Hugging Face ou pasta local no desenvolvimento) somente quando coeficientes, covariância, transformações e amostra correspondem ao contexto preservado. Caso contrário, o documento registra a falta de correspondência e utiliza o contexto importado na narrativa. Resultados não são reestimados. As avaliações AME e o preenchimento manual mantêm seus fluxos próprios.
|
|
|
|
| 143 |
|
| 144 |
O laudo evolutivo apresenta no corpo as seções **3.3 Terreno**, **3.4 Construções** e **3.5 Composição do imóvel**, com parâmetros por exercício, variáveis e equação, áreas de modelo e aplicação, CUB histórico simples ou composto, adequação, BDI, Ross-Heidecke, residual, Kd, redutores e Fc. A seção 4 discrimina fundamentação dos componentes e global, com a precisão restrita ao terreno. A conclusão mostra o valor adotado e seu extenso; a composição conserva o resultado calculado.
|
| 145 |
|
| 146 |
+
Os quadros comparativos mantêm todos os exercícios na mesma tabela, sem dividir o quinto ano em outro quadro. Depois do preenchimento dos valores e marcadores de notas, a fonte parte de 8 pt e diminui em passos de 0,5 pt conforme a estimativa de largura e altura disponíveis. Os valores monetários e os anos são medidos para caberem sem quebra; cabeçalhos e valores usam o mesmo tamanho. A regra também se aplica aos quadros anuais de fundamentação e conclusão. As notas de rodapé permanecem em 7 pt.
|
| 147 |
+
|
| 148 |
Em **5. Avaliação**, a revisão recupera as justificativas públicas salvas e permite complementar VU, área, BDI por sequência, Fc, ajustes não aplicados e critério de adoção. É possível reutilizar deliberadamente as justificativas nos demais exercícios. Critérios de fundamentação não preenchidos continuam pendentes; observações internas não são publicadas. Conferências com os dados salvos apontam divergências sem corrigir os resultados.
|
| 149 |
|
| 150 |
Os anexos estatísticos EVO usam o modelo do catálogo (dataset da MESA no Hugging Face ou pasta local no desenvolvimento) somente quando coeficientes, covariância, transformações e amostra correspondem ao contexto preservado. Caso contrário, o documento registra a falta de correspondência e utiliza o contexto importado na narrativa. Resultados não são reestimados. As avaliações AME e o preenchimento manual mantêm seus fluxos próprios.
|
document/avaliacoes_importadas.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Document sections based on the adopted results saved in AME/EVO."""
|
| 2 |
from .formatters import add_body_text, add_simple_table, add_subsection_title
|
| 3 |
-
from .formatters.
|
| 4 |
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
| 5 |
|
| 6 |
|
|
@@ -26,7 +26,7 @@ def conclusion_table(doc, imported):
|
|
| 26 |
from .evolutivo import table
|
| 27 |
table(doc,rows,[1.3,3.4,1.6],keep_together=len(rows)<=9,font_size=7)
|
| 28 |
else:
|
| 29 |
-
|
| 30 |
from utils.formatters import numero_para_extenso
|
| 31 |
for item in imported['avaliacoes']:
|
| 32 |
extenso = numero_para_extenso(Decimal(item['valor_total']))
|
|
|
|
| 1 |
"""Document sections based on the adopted results saved in AME/EVO."""
|
| 2 |
from .formatters import add_body_text, add_simple_table, add_subsection_title
|
| 3 |
+
from .formatters.table_fit import fit_table_to_page
|
| 4 |
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
| 5 |
|
| 6 |
|
|
|
|
| 26 |
from .evolutivo import table
|
| 27 |
table(doc,rows,[1.3,3.4,1.6],keep_together=len(rows)<=9,font_size=7)
|
| 28 |
else:
|
| 29 |
+
fit_table_to_page(doc, add_simple_table(doc, rows, header_row=True, largura_colunas=[1.4, 3.6, 2.5]))
|
| 30 |
from utils.formatters import numero_para_extenso
|
| 31 |
for item in imported['avaliacoes']:
|
| 32 |
extenso = numero_para_extenso(Decimal(item['valor_total']))
|
document/evolutivo.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from collections import OrderedDict
|
| 3 |
from decimal import Decimal, ROUND_HALF_UP
|
| 4 |
import json
|
|
|
|
| 5 |
|
| 6 |
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 7 |
from docx.oxml import OxmlElement
|
|
@@ -15,6 +16,7 @@ from .evo_adjustments import REDUCERS, applied_reducers
|
|
| 15 |
from .fundamentacao import number, global_foundation
|
| 16 |
from .footnotes import add_footnote
|
| 17 |
from .formatters.table import set_table_font_size
|
|
|
|
| 18 |
from .anexos.metodologia import (_gerar_variaveis, _adicionar_pesquisa,
|
| 19 |
_adicionar_fundamentos_comparativos, _adicionar_tabela_equacao)
|
| 20 |
from models.imported_context import context_key, snapshot_model, matching_local
|
|
@@ -57,8 +59,9 @@ def complement(row, key, fallback='Não informado no arquivo; complemento técni
|
|
| 57 |
return (row.get('complementos') or {}).get(key) or fallback
|
| 58 |
|
| 59 |
|
| 60 |
-
def table(doc, rows, widths=None, compact=False, keep_together=False, font_size=None):
|
| 61 |
section = doc.sections[-1]
|
|
|
|
| 62 |
if doc.paragraphs:
|
| 63 |
previous = doc.paragraphs[-1]
|
| 64 |
if previous.text.startswith(('Construção ', 'Composição das áreas', 'IPTU ', 'Memória integral', 'Memória das etapas')):
|
|
@@ -71,7 +74,7 @@ def table(doc, rows, widths=None, compact=False, keep_together=False, font_size=
|
|
| 71 |
props = cell._tc.get_or_add_tcPr()
|
| 72 |
for no_wrap in list(props.findall(qn('w:noWrap'))): props.remove(no_wrap)
|
| 73 |
margins = OxmlElement('w:tcMar')
|
| 74 |
-
padding = 25 if compact else 65
|
| 75 |
for side, size in [('top',padding),('bottom',padding),('left',80),('right',80)]:
|
| 76 |
node = OxmlElement('w:'+side); node.set(qn('w:w'), str(size)); node.set(qn('w:type'),'dxa'); margins.append(node)
|
| 77 |
props.append(margins)
|
|
@@ -83,7 +86,9 @@ def table(doc, rows, widths=None, compact=False, keep_together=False, font_size=
|
|
| 83 |
for side in ['top','left','bottom','right','insideH','insideV']:
|
| 84 |
node = OxmlElement('w:'+side); node.set(qn('w:val'),'single'); node.set(qn('w:sz'),'4'); node.set(qn('w:color'),'D9D9D9'); borders.append(node)
|
| 85 |
obj._tbl.tblPr.append(borders)
|
| 86 |
-
if
|
|
|
|
|
|
|
| 87 |
set_table_font_size(obj, font_size)
|
| 88 |
return obj
|
| 89 |
|
|
@@ -105,20 +110,21 @@ def equation(doc, text):
|
|
| 105 |
para.paragraph_format.keep_together = True
|
| 106 |
|
| 107 |
|
| 108 |
-
def compared(doc, rows, metrics, notes=None,
|
| 109 |
-
"""Keep
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
def model_analysis(doc, model, tipo_y):
|
|
@@ -204,7 +210,7 @@ def gerar_corpo_evolutivo(doc, imported, num, caracteristicas_refs=None):
|
|
| 204 |
'Estimado Unitário': 'Estimativa do valor do terreno por metro quadrado (R$/m²), antes dos ajustes. Quando o modelo estima o valor total, corresponde à conversão desse valor pela área utilizada no modelo.',
|
| 205 |
'CA −15%': 'CA: campo de arbítrio. CA −15% e CA +15% são os limites inferior e superior, correspondentes a 85% e 115% da estimativa central. N��o representam descontos automaticamente aplicados. Os limites são expressos na unidade da variável dependente do modelo: R$/m² no modelo unitário e R$ no modelo total.',
|
| 206 |
'IC 80% Inf.': 'IC 80%: intervalo de confiança de 80% da estimativa do modelo. Inf. e Sup. indicam seus limites inferior e superior. Expressam a incerteza estatística e não se confundem com o campo de arbítrio. Os limites são expressos em R$/m² no modelo unitário e R$ no modelo total.',
|
| 207 |
-
}
|
| 208 |
add_body_text(doc,'A referência anual das avaliações é distinta do ano-base omitido na codificação das variáveis dicotômicas do modelo.')
|
| 209 |
heading(doc,num,'Ajustes aplicados e valor territorial adotado',4)
|
| 210 |
# Load after shared formatters are available to the narrative helper.
|
|
@@ -259,16 +265,16 @@ def gerar_corpo_evolutivo(doc, imported, num, caracteristicas_refs=None):
|
|
| 259 |
# annual inputs together; characteristics above are grouped by changes.
|
| 260 |
compared(doc,[row for row,c,i in entries],[
|
| 261 |
('Data do CUB',lambda row:cub_date(row)),
|
| 262 |
-
('Projeto
|
| 263 |
-
('CUB
|
| 264 |
-
('Adequação
|
| 265 |
('CUNIT (R$/m²)',lambda row:currency(by_id[row['id']].get('custoUnitario'))),
|
| 266 |
('BDI (%)',lambda row:pct(by_id[row['id']].get('bdi'))),
|
| 267 |
-
('
|
| 268 |
('Vida útil consumida (%)',lambda row:pct(by_id[row['id']].get('percentualVidaUtil'))),
|
| 269 |
('Depreciação K',lambda row:fmt(by_id[row['id']].get('depreciacao'),4)),
|
| 270 |
-
('
|
| 271 |
-
('Valor da construção
|
| 272 |
])
|
| 273 |
refs['composicao'] = heading(doc,num,'AVALIAÇÃO DO IMÓVEL OBJETO',2)
|
| 274 |
add_body_text(doc,'Na ausência de outros ajustes, a composição corresponde a VI = (VT + VC) × Fc, em que VT é o valor territorial e VC é o custo de reedição das construções. Havendo ajustes, a sequência efetivamente salva, apresentada abaixo, prevalece sobre essa expressão simplificada.').paragraph_format.keep_with_next = True
|
|
|
|
| 2 |
from collections import OrderedDict
|
| 3 |
from decimal import Decimal, ROUND_HALF_UP
|
| 4 |
import json
|
| 5 |
+
import re
|
| 6 |
|
| 7 |
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
| 8 |
from docx.oxml import OxmlElement
|
|
|
|
| 16 |
from .fundamentacao import number, global_foundation
|
| 17 |
from .footnotes import add_footnote
|
| 18 |
from .formatters.table import set_table_font_size
|
| 19 |
+
from .formatters.table_fit import fit_table_to_page
|
| 20 |
from .anexos.metodologia import (_gerar_variaveis, _adicionar_pesquisa,
|
| 21 |
_adicionar_fundamentos_comparativos, _adicionar_tabela_equacao)
|
| 22 |
from models.imported_context import context_key, snapshot_model, matching_local
|
|
|
|
| 59 |
return (row.get('complementos') or {}).get(key) or fallback
|
| 60 |
|
| 61 |
|
| 62 |
+
def table(doc, rows, widths=None, compact=False, keep_together=False, font_size=None, auto_fit=True):
|
| 63 |
section = doc.sections[-1]
|
| 64 |
+
annual = any(re.search(r'\bIPTU\s+\d{4}\b', str(value)) for row in rows for value in row)
|
| 65 |
if doc.paragraphs:
|
| 66 |
previous = doc.paragraphs[-1]
|
| 67 |
if previous.text.startswith(('Construção ', 'Composição das áreas', 'IPTU ', 'Memória integral', 'Memória das etapas')):
|
|
|
|
| 74 |
props = cell._tc.get_or_add_tcPr()
|
| 75 |
for no_wrap in list(props.findall(qn('w:noWrap'))): props.remove(no_wrap)
|
| 76 |
margins = OxmlElement('w:tcMar')
|
| 77 |
+
padding = 25 if compact or annual else 65
|
| 78 |
for side, size in [('top',padding),('bottom',padding),('left',80),('right',80)]:
|
| 79 |
node = OxmlElement('w:'+side); node.set(qn('w:w'), str(size)); node.set(qn('w:type'),'dxa'); margins.append(node)
|
| 80 |
props.append(margins)
|
|
|
|
| 86 |
for side in ['top','left','bottom','right','insideH','insideV']:
|
| 87 |
node = OxmlElement('w:'+side); node.set(qn('w:val'),'single'); node.set(qn('w:sz'),'4'); node.set(qn('w:color'),'D9D9D9'); borders.append(node)
|
| 88 |
obj._tbl.tblPr.append(borders)
|
| 89 |
+
if annual and auto_fit:
|
| 90 |
+
fit_table_to_page(doc, obj)
|
| 91 |
+
elif font_size is not None:
|
| 92 |
set_table_font_size(obj, font_size)
|
| 93 |
return obj
|
| 94 |
|
|
|
|
| 110 |
para.paragraph_format.keep_together = True
|
| 111 |
|
| 112 |
|
| 113 |
+
def compared(doc, rows, metrics, notes=None, show_base=False):
|
| 114 |
+
"""Keep all exercises in one table and fit after values and notes exist."""
|
| 115 |
+
if not rows:
|
| 116 |
+
return
|
| 117 |
+
labels = [ref(row, show_base=show_base) for row in rows]
|
| 118 |
+
if len(set(labels)) != len(labels):
|
| 119 |
+
labels = [f"{label}\n{row['titulo_origem']} [{row['id']}]" for label,row in zip(labels,rows)]
|
| 120 |
+
obj = table(doc, [['Parâmetro / valor']+labels] + [[label]+[getter(row) for row in rows] for label,getter in metrics],
|
| 121 |
+
[2.4]+[1.45]*len(rows), auto_fit=False)
|
| 122 |
+
used_notes = []
|
| 123 |
+
for index, (label, _) in enumerate(metrics, 1):
|
| 124 |
+
if notes and label in notes:
|
| 125 |
+
add_footnote(doc, obj.rows[index].cells[0].paragraphs[0], notes[label])
|
| 126 |
+
used_notes.append(notes[label])
|
| 127 |
+
fit_table_to_page(doc, obj, notes=used_notes)
|
| 128 |
|
| 129 |
|
| 130 |
def model_analysis(doc, model, tipo_y):
|
|
|
|
| 210 |
'Estimado Unitário': 'Estimativa do valor do terreno por metro quadrado (R$/m²), antes dos ajustes. Quando o modelo estima o valor total, corresponde à conversão desse valor pela área utilizada no modelo.',
|
| 211 |
'CA −15%': 'CA: campo de arbítrio. CA −15% e CA +15% são os limites inferior e superior, correspondentes a 85% e 115% da estimativa central. N��o representam descontos automaticamente aplicados. Os limites são expressos na unidade da variável dependente do modelo: R$/m² no modelo unitário e R$ no modelo total.',
|
| 212 |
'IC 80% Inf.': 'IC 80%: intervalo de confiança de 80% da estimativa do modelo. Inf. e Sup. indicam seus limites inferior e superior. Expressam a incerteza estatística e não se confundem com o campo de arbítrio. Os limites são expressos em R$/m² no modelo unitário e R$ no modelo total.',
|
| 213 |
+
})
|
| 214 |
add_body_text(doc,'A referência anual das avaliações é distinta do ano-base omitido na codificação das variáveis dicotômicas do modelo.')
|
| 215 |
heading(doc,num,'Ajustes aplicados e valor territorial adotado',4)
|
| 216 |
# Load after shared formatters are available to the narrative helper.
|
|
|
|
| 265 |
# annual inputs together; characteristics above are grouped by changes.
|
| 266 |
compared(doc,[row for row,c,i in entries],[
|
| 267 |
('Data do CUB',lambda row:cub_date(row)),
|
| 268 |
+
('Projeto CUB',lambda row:'Composto' if by_id[row['id']].get('cubCompostoAtivo') else str(by_id[row['id']].get('cubTipo') or 'Não informado').split(' (',1)[0]),
|
| 269 |
+
('CUB (R$/m²)',lambda row:currency(by_id[row['id']].get('valorCub'))),
|
| 270 |
+
('Adequação CUB (%)',lambda row:pct(by_id[row['id']].get('percentualCubCalculado',by_id[row['id']].get('percentualCub')))),
|
| 271 |
('CUNIT (R$/m²)',lambda row:currency(by_id[row['id']].get('custoUnitario'))),
|
| 272 |
('BDI (%)',lambda row:pct(by_id[row['id']].get('bdi'))),
|
| 273 |
+
('Fator BDI',lambda row:fmt(1+number(by_id[row['id']]['bdi'])/100,4) if number(by_id[row['id']].get('bdi')) is not None else 'Não informado'),
|
| 274 |
('Vida útil consumida (%)',lambda row:pct(by_id[row['id']].get('percentualVidaUtil'))),
|
| 275 |
('Depreciação K',lambda row:fmt(by_id[row['id']].get('depreciacao'),4)),
|
| 276 |
+
('Kd',lambda row:fmt(by_id[row['id']].get('kd'),3)),
|
| 277 |
+
('Valor da construção (R$)',lambda row:currency(by_id[row['id']].get('vcalcConstSeq'))),
|
| 278 |
])
|
| 279 |
refs['composicao'] = heading(doc,num,'AVALIAÇÃO DO IMÓVEL OBJETO',2)
|
| 280 |
add_body_text(doc,'Na ausência de outros ajustes, a composição corresponde a VI = (VT + VC) × Fc, em que VT é o valor territorial e VC é o custo de reedição das construções. Havendo ajustes, a sequência efetivamente salva, apresentada abaixo, prevalece sobre essa expressão simplificada.').paragraph_format.keep_with_next = True
|
document/formatters/table_fit.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fit annual tables after their values and footnote markers have been filled."""
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
from math import ceil
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
from PIL import ImageFont
|
| 8 |
+
from docx.oxml import OxmlElement
|
| 9 |
+
from docx.oxml.ns import qn
|
| 10 |
+
|
| 11 |
+
from .table import set_table_font_size
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@lru_cache(maxsize=2)
|
| 15 |
+
def _font(bold=False):
|
| 16 |
+
# The same bundled metrics are available locally and in Spaces. DejaVu Sans
|
| 17 |
+
# is slightly wider than Arial, leaving a margin for Word font substitution.
|
| 18 |
+
from matplotlib import get_data_path
|
| 19 |
+
name = 'DejaVuSans-Bold.ttf' if bold else 'DejaVuSans.ttf'
|
| 20 |
+
return ImageFont.truetype(str(Path(get_data_path()) / 'fonts' / 'ttf' / name), 1000)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@lru_cache(maxsize=8192)
|
| 24 |
+
def _width(text, bold=False):
|
| 25 |
+
return _font(bold).getlength(text) / 1000
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _lines(text, width, size, bold=False):
|
| 29 |
+
"""Estimate wrapping using glyph widths, preserving explicit line breaks."""
|
| 30 |
+
count = 0
|
| 31 |
+
for line in text.split('\n'):
|
| 32 |
+
used = 0
|
| 33 |
+
count += 1
|
| 34 |
+
for word in re.findall(r'\S+', line):
|
| 35 |
+
length = _width(word, bold) * size
|
| 36 |
+
gap = _width(' ', bold) * size if used else 0
|
| 37 |
+
if used and used + gap + length > width:
|
| 38 |
+
count += 1
|
| 39 |
+
used = 0
|
| 40 |
+
gap = 0
|
| 41 |
+
if length > width:
|
| 42 |
+
extra = max(0, ceil(length / width) - 1)
|
| 43 |
+
count += extra
|
| 44 |
+
used = length - extra * width
|
| 45 |
+
else:
|
| 46 |
+
used += gap + length
|
| 47 |
+
return count
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _margin(cell, side, default):
|
| 51 |
+
margins = cell._tc.get_or_add_tcPr().find(qn('w:tcMar'))
|
| 52 |
+
node = margins.find(qn('w:' + side)) if margins is not None else None
|
| 53 |
+
return int(node.get(qn('w:w'), default)) / 20 if node is not None else default / 20
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def fit_table_to_page(doc, table, notes=()):
|
| 57 |
+
"""Select the largest half-point size <= 8 that fits the populated table.
|
| 58 |
+
|
| 59 |
+
Currency, numbers and year labels must fit on one line. Other text can wrap.
|
| 60 |
+
Height includes cell padding, the caption allowance and 7 pt footnotes. This
|
| 61 |
+
is a conservative estimate: Word remains responsible for final pagination.
|
| 62 |
+
The years always remain columns of the same table, even for longer tables.
|
| 63 |
+
"""
|
| 64 |
+
section = doc.sections[-1]
|
| 65 |
+
page_width = (section.page_width - section.left_margin - section.right_margin) / 12700
|
| 66 |
+
page_height = (section.page_height - section.top_margin - section.bottom_margin) / 12700
|
| 67 |
+
note_height = sum(_lines(str(note), page_width, 7) * 8 + 4 for note in notes)
|
| 68 |
+
available_height = max(1, page_height - 30 - note_height)
|
| 69 |
+
rows = []
|
| 70 |
+
for row in table.rows:
|
| 71 |
+
row_props = row._tr.get_or_add_trPr()
|
| 72 |
+
if row_props.find(qn('w:cantSplit')) is None:
|
| 73 |
+
row_props.append(OxmlElement('w:cantSplit'))
|
| 74 |
+
cells = []
|
| 75 |
+
for cell in row.cells:
|
| 76 |
+
props = cell._tc.get_or_add_tcPr()
|
| 77 |
+
for node in list(props.findall(qn('w:noWrap'))):
|
| 78 |
+
props.remove(node)
|
| 79 |
+
width = max(1, cell.width / 12700 - _margin(cell, 'left', 80) - _margin(cell, 'right', 80) - 2)
|
| 80 |
+
paragraphs = []
|
| 81 |
+
for paragraph in cell.paragraphs:
|
| 82 |
+
marker_count = len(paragraph._p.findall('.//' + qn('w:footnoteReference')))
|
| 83 |
+
text = paragraph.text + '1' * marker_count
|
| 84 |
+
bold = any(run.bold for run in paragraph.runs)
|
| 85 |
+
paragraphs.append((text, bold, paragraph))
|
| 86 |
+
cells.append((width, _margin(cell, 'top', 65) + _margin(cell, 'bottom', 65), paragraphs))
|
| 87 |
+
rows.append(cells)
|
| 88 |
+
|
| 89 |
+
def fits(size):
|
| 90 |
+
height = 0
|
| 91 |
+
for cells in rows:
|
| 92 |
+
row_height = 0
|
| 93 |
+
for width, padding, paragraphs in cells:
|
| 94 |
+
cell_height = padding
|
| 95 |
+
for text, bold, paragraph in paragraphs:
|
| 96 |
+
# Do not split an amount, a date, a coefficient or IPTU year
|
| 97 |
+
# into fragments to make the width calculation succeed.
|
| 98 |
+
single_line = bool(re.fullmatch(r'(?:R\$\s*)?[−+\-]?\d[\d.,%/\s]*|IPTU\s+\d{4}', text.strip()))
|
| 99 |
+
if single_line and _width(text, bold) * size > width:
|
| 100 |
+
return False
|
| 101 |
+
cell_height += _lines(text, width, size, bold) * size * 1.15
|
| 102 |
+
cell_height += (paragraph.paragraph_format.space_before or 0) / 12700
|
| 103 |
+
cell_height += (paragraph.paragraph_format.space_after or 0) / 12700
|
| 104 |
+
row_height = max(row_height, cell_height)
|
| 105 |
+
height += row_height
|
| 106 |
+
return height <= available_height
|
| 107 |
+
|
| 108 |
+
# w:sz stores integer half-points: smaller steps would be rounded away.
|
| 109 |
+
size = 8
|
| 110 |
+
while size > 0.5 and not fits(size):
|
| 111 |
+
size -= 0.5
|
| 112 |
+
set_table_font_size(table, size)
|
| 113 |
+
keep_together = fits(size)
|
| 114 |
+
for row_index, row in enumerate(table.rows):
|
| 115 |
+
for cell in row.cells:
|
| 116 |
+
for paragraph in cell.paragraphs:
|
| 117 |
+
paragraph.paragraph_format.line_spacing = 1.05
|
| 118 |
+
paragraph.paragraph_format.keep_with_next = keep_together and row_index < len(table.rows) - 1
|
| 119 |
+
paragraph.paragraph_format.keep_together = True
|
| 120 |
+
return size
|
document/generator.py
CHANGED
|
@@ -31,6 +31,7 @@ from .formatters import (
|
|
| 31 |
iniciar_secao_paisagem,
|
| 32 |
iniciar_secao_retrato,
|
| 33 |
)
|
|
|
|
| 34 |
|
| 35 |
from config.settings import TEMPLATES_DIR, TEXTOS_DIR, OUTPUT_DIR
|
| 36 |
from models import get_model, list_available_models
|
|
@@ -354,12 +355,13 @@ class LaudoGenerator:
|
|
| 354 |
valores_extenso.append(f"{exercicio}: {valor_extenso}.")
|
| 355 |
|
| 356 |
if len(dados_tabela) > 1:
|
| 357 |
-
add_simple_table(
|
| 358 |
doc,
|
| 359 |
dados_tabela,
|
| 360 |
header_row=True,
|
| 361 |
largura_colunas=[1.6, 1.3, 4.6],
|
| 362 |
)
|
|
|
|
| 363 |
|
| 364 |
if valores_extenso:
|
| 365 |
p = criar_paragrafo_formatado(
|
|
|
|
| 31 |
iniciar_secao_paisagem,
|
| 32 |
iniciar_secao_retrato,
|
| 33 |
)
|
| 34 |
+
from .formatters.table_fit import fit_table_to_page
|
| 35 |
|
| 36 |
from config.settings import TEMPLATES_DIR, TEXTOS_DIR, OUTPUT_DIR
|
| 37 |
from models import get_model, list_available_models
|
|
|
|
| 355 |
valores_extenso.append(f"{exercicio}: {valor_extenso}.")
|
| 356 |
|
| 357 |
if len(dados_tabela) > 1:
|
| 358 |
+
tabela = add_simple_table(
|
| 359 |
doc,
|
| 360 |
dados_tabela,
|
| 361 |
header_row=True,
|
| 362 |
largura_colunas=[1.6, 1.3, 4.6],
|
| 363 |
)
|
| 364 |
+
fit_table_to_page(doc, tabela)
|
| 365 |
|
| 366 |
if valores_extenso:
|
| 367 |
p = criar_paragrafo_formatado(
|
tests/test_annual_tables.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import unittest
|
| 3 |
+
|
| 4 |
+
from docx import Document
|
| 5 |
+
from docx.oxml.ns import qn
|
| 6 |
+
from docx.shared import Cm
|
| 7 |
+
|
| 8 |
+
from document.avaliacoes_importadas import conclusion_table
|
| 9 |
+
from document.evolutivo import compared
|
| 10 |
+
from document.formatters.table_fit import _width
|
| 11 |
+
from document.generator import LaudoGenerator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AnnualTablesTests(unittest.TestCase):
|
| 15 |
+
def rows(self, count=5):
|
| 16 |
+
return [{'ano': str(2022 + i), 'ano_base': str(2021 + i)} for i in range(count)]
|
| 17 |
+
|
| 18 |
+
def test_five_exercises_remain_in_one_table_after_reopening(self):
|
| 19 |
+
doc = Document()
|
| 20 |
+
compared(doc, self.rows(), [('Fator BDI', lambda row: '1,2500')])
|
| 21 |
+
stream = io.BytesIO()
|
| 22 |
+
doc.save(stream)
|
| 23 |
+
stream.seek(0)
|
| 24 |
+
reopened = Document(stream)
|
| 25 |
+
self.assertEqual(len(reopened.tables), 1)
|
| 26 |
+
table = reopened.tables[0]
|
| 27 |
+
self.assertEqual([c.text for c in table.rows[0].cells][1:], [f'IPTU {year}' for year in range(2022, 2027)])
|
| 28 |
+
self.assertEqual({run.font.size.pt for row in table.rows for cell in row.cells for paragraph in cell.paragraphs for run in paragraph.runs}, {8})
|
| 29 |
+
self.assertTrue(all(p.paragraph_format.keep_with_next for cell in table.rows[0].cells for p in cell.paragraphs))
|
| 30 |
+
self.assertTrue(all(not p.paragraph_format.keep_with_next for cell in table.rows[-1].cells for p in cell.paragraphs))
|
| 31 |
+
|
| 32 |
+
def test_long_amounts_reduce_uniform_font_in_half_point_steps(self):
|
| 33 |
+
doc = Document()
|
| 34 |
+
amount = 'R$ 987.654.321.000,99'
|
| 35 |
+
compared(doc, self.rows(), [('Valor da construção (R$)', lambda row: amount)])
|
| 36 |
+
table = doc.tables[0]
|
| 37 |
+
sizes = {run.font.size.pt for row in table.rows for cell in row.cells for p in cell.paragraphs for run in p.runs}
|
| 38 |
+
self.assertEqual(len(sizes), 1)
|
| 39 |
+
size = sizes.pop()
|
| 40 |
+
self.assertLess(size, 8)
|
| 41 |
+
self.assertEqual(size * 2, int(size * 2))
|
| 42 |
+
cell = table.rows[1].cells[-1]
|
| 43 |
+
available = cell.width / 12700 - 10
|
| 44 |
+
self.assertLessEqual(_width(amount) * size, available)
|
| 45 |
+
self.assertGreater(_width(amount) * (size + 0.5), available)
|
| 46 |
+
self.assertEqual(cell.text, amount)
|
| 47 |
+
self.assertEqual(len(table.columns), 6)
|
| 48 |
+
self.assertTrue(all(row._tr.trPr.find(qn('w:cantSplit')) is not None for row in table.rows))
|
| 49 |
+
|
| 50 |
+
def test_six_exercises_and_duplicate_references_are_not_dropped(self):
|
| 51 |
+
rows = self.rows(6)
|
| 52 |
+
rows[1]['ano'] = rows[0]['ano']
|
| 53 |
+
for index, row in enumerate(rows):
|
| 54 |
+
row.update(titulo_origem=f'Avaliação {index + 1}', id=f'origem-{index}')
|
| 55 |
+
doc = Document()
|
| 56 |
+
compared(doc, rows, [('Valor', lambda row: 'R$ 1.234,56')])
|
| 57 |
+
self.assertEqual(len(doc.tables), 1)
|
| 58 |
+
labels = [c.text for c in doc.tables[0].rows[0].cells][1:]
|
| 59 |
+
self.assertEqual(len(labels), 6)
|
| 60 |
+
self.assertEqual(len(set(labels)), 6)
|
| 61 |
+
self.assertIn('Avaliação 1 [origem-0]', labels[0])
|
| 62 |
+
self.assertIn('Avaliação 2 [origem-1]', labels[1])
|
| 63 |
+
|
| 64 |
+
def test_height_is_also_considered_and_does_not_remove_years(self):
|
| 65 |
+
doc = Document()
|
| 66 |
+
doc.sections[0].page_height = Cm(15)
|
| 67 |
+
compared(doc, self.rows(), [(f'Parâmetro {i}', lambda row: '1,2500') for i in range(25)])
|
| 68 |
+
self.assertEqual(len(doc.tables), 1)
|
| 69 |
+
self.assertEqual(len(doc.tables[0].rows), 26)
|
| 70 |
+
self.assertEqual(len(doc.tables[0].columns), 6)
|
| 71 |
+
self.assertLess(doc.tables[0].cell(0, 0).paragraphs[0].runs[0].font.size.pt, 8)
|
| 72 |
+
|
| 73 |
+
def test_vertical_conclusions_also_start_at_eight_points(self):
|
| 74 |
+
for method in ('modelo', 'evolutivo', 'manual'):
|
| 75 |
+
with self.subTest(method=method):
|
| 76 |
+
doc = Document()
|
| 77 |
+
if method == 'manual':
|
| 78 |
+
LaudoGenerator()._gerar_tabela_valores_mercado(doc, [
|
| 79 |
+
{'ano': row['ano'], 'valor': '100.000,00'} for row in self.rows()])
|
| 80 |
+
else:
|
| 81 |
+
conclusion_table(doc, {'tipo': method, 'avaliacoes': [
|
| 82 |
+
{**row, 'id': f'row-{i}', 'objeto': 'Imóvel', 'titulo': f'Exercício {row["ano"]}', 'valor_total': '100000'}
|
| 83 |
+
for i, row in enumerate(self.rows())]})
|
| 84 |
+
self.assertEqual(len(doc.tables), 1)
|
| 85 |
+
self.assertEqual(len(doc.tables[0].rows), 6)
|
| 86 |
+
sizes = {run.font.size.pt for row in doc.tables[0].rows for cell in row.cells for p in cell.paragraphs for run in p.runs}
|
| 87 |
+
self.assertEqual(sizes, {8})
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == '__main__':
|
| 91 |
+
unittest.main()
|
tests/test_evo_adjustments.py
CHANGED
|
@@ -87,10 +87,10 @@ class AppliedAdjustmentsTests(unittest.TestCase):
|
|
| 87 |
doc=body(resolved(data))
|
| 88 |
weighted=[t for t in doc.tables if t.rows[0].cells[1].text=='Projeto padrão']
|
| 89 |
self.assertEqual(len(weighted),1);self.assertEqual(len(weighted[0].rows),3)
|
| 90 |
-
calculations=[t for t in doc.tables if any(r.cells[0].text=='Valor da construção
|
| 91 |
self.assertEqual(len(calculations),2)
|
| 92 |
for t in calculations:
|
| 93 |
-
for label in ['CUB
|
| 94 |
self.assertTrue(any(r.cells[0].text==label for r in t.rows))
|
| 95 |
|
| 96 |
def test_changes_in_characteristics_and_compositions_keep_applicability(self):
|
|
|
|
| 87 |
doc=body(resolved(data))
|
| 88 |
weighted=[t for t in doc.tables if t.rows[0].cells[1].text=='Projeto padrão']
|
| 89 |
self.assertEqual(len(weighted),1);self.assertEqual(len(weighted[0].rows),3)
|
| 90 |
+
calculations=[t for t in doc.tables if any(r.cells[0].text=='Valor da construção (R$)' for r in t.rows)]
|
| 91 |
self.assertEqual(len(calculations),2)
|
| 92 |
for t in calculations:
|
| 93 |
+
for label in ['Projeto CUB','CUB (R$/m²)','BDI (%)','Fator BDI','Depreciação K','Kd']:
|
| 94 |
self.assertTrue(any(r.cells[0].text==label for r in t.rows))
|
| 95 |
|
| 96 |
def test_changes_in_characteristics_and_compositions_keep_applicability(self):
|
tests/test_evolutivo_documento.py
CHANGED
|
@@ -113,7 +113,8 @@ class EvolutivoDocumentTests(unittest.TestCase):
|
|
| 113 |
doc = Document(); num = NumeradorSecoes(3); num.subsecao(); num.subsecao()
|
| 114 |
gerar_corpo_evolutivo(doc, imported, num)
|
| 115 |
tables = [t for t in doc.tables if any(r.cells[0].text == 'IC 80% Inf.' for r in t.rows)]
|
| 116 |
-
self.assertEqual(len(tables),
|
|
|
|
| 117 |
labels = [r.cells[0].text for r in tables[0].rows]
|
| 118 |
for label in ['Estimado Unitário', 'CA −15%', 'CA +15%', 'IC 80% Inf.', 'IC 80% Sup.']:
|
| 119 |
self.assertIn(label, labels)
|
|
@@ -126,8 +127,8 @@ class EvolutivoDocumentTests(unittest.TestCase):
|
|
| 126 |
body = etree.fromstring(archive.read('word/document.xml'))
|
| 127 |
refs = body.findall('.//'+qn('w:footnoteReference'))
|
| 128 |
notes = {n.get(qn('w:id')): ''.join(n.itertext()) for n in root if int(n.get(qn('w:id'))) > 0}
|
| 129 |
-
self.assertEqual(len(refs),
|
| 130 |
-
self.assertEqual(len(notes),
|
| 131 |
self.assertEqual({r.get(qn('w:id')) for r in refs}, set(notes))
|
| 132 |
self.assertIn('CA: campo de arbítrio', notes['2'])
|
| 133 |
self.assertIn('intervalo de confiança de 80%', notes['3'])
|
|
|
|
| 113 |
doc = Document(); num = NumeradorSecoes(3); num.subsecao(); num.subsecao()
|
| 114 |
gerar_corpo_evolutivo(doc, imported, num)
|
| 115 |
tables = [t for t in doc.tables if any(r.cells[0].text == 'IC 80% Inf.' for r in t.rows)]
|
| 116 |
+
self.assertEqual(len(tables), 1)
|
| 117 |
+
self.assertEqual(len(tables[0].columns), 7)
|
| 118 |
labels = [r.cells[0].text for r in tables[0].rows]
|
| 119 |
for label in ['Estimado Unitário', 'CA −15%', 'CA +15%', 'IC 80% Inf.', 'IC 80% Sup.']:
|
| 120 |
self.assertIn(label, labels)
|
|
|
|
| 127 |
body = etree.fromstring(archive.read('word/document.xml'))
|
| 128 |
refs = body.findall('.//'+qn('w:footnoteReference'))
|
| 129 |
notes = {n.get(qn('w:id')): ''.join(n.itertext()) for n in root if int(n.get(qn('w:id'))) > 0}
|
| 130 |
+
self.assertEqual(len(refs), 4)
|
| 131 |
+
self.assertEqual(len(notes), 4)
|
| 132 |
self.assertEqual({r.get(qn('w:id')) for r in refs}, set(notes))
|
| 133 |
self.assertIn('CA: campo de arbítrio', notes['2'])
|
| 134 |
self.assertIn('intervalo de confiança de 80%', notes['3'])
|