gui-sparim commited on
Commit
4e531da
·
verified ·
1 Parent(s): e66459a

Mantém todos os exercícios nas tabelas com ajuste automático da fonte

Browse files

Remove 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 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.table import set_table_font_size
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
- set_table_font_size(add_simple_table(doc, rows, header_row=True, largura_colunas=[1.4, 3.6, 2.5]),7)
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 font_size is not None:
 
 
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, batch_size=4, show_base=False):
109
- """Keep references distinct, with a wider batch for short result labels."""
110
- for offset in range(0,len(rows),batch_size):
111
- batch = rows[offset:offset+batch_size]
112
- labels = [ref(row, show_base=show_base) for row in batch]
113
- duplicates = len(set(labels)) != len(labels)
114
- if duplicates: labels = [f"{label}\n{row['titulo_origem']} [{row['id']}]" for label,row in zip(labels,batch)]
115
- # Short comparative tables stay with their captions; longer ones may
116
- # continue with the repeated header.
117
- obj = table(doc, [['Parâmetro / resultado']+labels] + [[label]+[getter(row) for row in batch] for label,getter in metrics], [2.4]+[1.45]*len(batch), keep_together=len(metrics)<=12)
118
- for index, (label, _) in enumerate(metrics, 1):
119
- if notes and label in notes:
120
- add_footnote(doc, obj.rows[index].cells[0].paragraphs[0], notes[label])
121
- set_table_font_size(obj, 7)
 
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
- }, batch_size=5)
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 / composição CUB',lambda row:'CUB composto' if by_id[row['id']].get('cubCompostoAtivo') else str(by_id[row['id']].get('cubTipo') or 'Não informado')),
263
- ('CUB histórico (R$/m²)',lambda row:currency(by_id[row['id']].get('valorCub'))),
264
- ('Adequação do CUB (%)',lambda row:pct(by_id[row['id']].get('percentualCubCalculado',by_id[row['id']].get('percentualCub')))),
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
- ('Multiplicador 1 + BDI/100',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'),
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
- ('Fator remanescente Kd',lambda row:fmt(by_id[row['id']].get('kd'),3)),
271
- ('Valor da construção - VC (R$)',lambda row:currency(by_id[row['id']].get('vcalcConstSeq'))),
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 - VC (R$)' for r in t.rows)]
91
  self.assertEqual(len(calculations),2)
92
  for t in calculations:
93
- for label in ['CUB histórico (R$/m²)','BDI (%)','Depreciação K','Fator remanescente 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):
 
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), 2)
 
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), 7)
130
- self.assertEqual(len(notes), 7)
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'])