Skip to content

Instantly share code, notes, and snippets.

@dinizime
Created February 6, 2026 16:29
Show Gist options
  • Select an option

  • Save dinizime/43227baa32727f7a1dbbe49c4069671a to your computer and use it in GitHub Desktop.

Select an option

Save dinizime/43227baa32727f7a1dbbe49c4069671a to your computer and use it in GitHub Desktop.
Cria poligonos ao redor das letras dos rótulos de forma precisa
"""
============================================================================
Label Bounds Extractor para QGIS – v4 CURVO + PRECISO
---------------------------------------------------------------------------
Itera cada polígono de uma camada de referência, renderiza off-screen,
e captura todos os rótulos. Extrai propriedades COMPLETAS do texto
(fonte, tamanho, bold, itálico, buffer, espaçamentos, capitalização)
para subdividir cada rótulo em retângulos por caractere com precisão.
★ v4: Suporte completo a rótulos CURVOS (Curved / PerimeterCurved)
- Detecta automaticamente o modo de posicionamento da camada
- Para labels curvos, caminha ao longo da geometria da linha/perímetro
- Cada caractere recebe um retângulo orientado pela tangente local
- Calibração automática de escala contra a geometria real do rótulo
- Suporte a offset perpendicular, multi-part, e inversão de direção
Saída:
• Camada "Limites_Rotulos" – um polígono por rótulo
• Camada "Limites_Caracteres" – um polígono por caractere
Uso: Console Python do QGIS (Ctrl+Alt+P) ou Editor de Scripts
============================================================================
"""
from qgis.core import (
QgsProject, QgsVectorLayer, QgsFeature, QgsField,
QgsGeometry, QgsPointXY, QgsRectangle, QgsWkbTypes,
QgsMapSettings, QgsMapRendererSequentialJob,
QgsFillSymbol, QgsSingleSymbolRenderer,
QgsCoordinateTransform, QgsUnitTypes,
QgsRenderContext, QgsTextFormat, QgsPalLayerSettings
)
from qgis.PyQt.QtCore import QVariant, QSize, Qt
from qgis.PyQt.QtGui import QFont, QFontMetricsF, QColor
import math
# ══════════════════════════════════════════════════════════════════════════
# ★ CONFIGURAÇÃO — AJUSTE AQUI ★
# ══════════════════════════════════════════════════════════════════════════
POLYGON_LAYER_NAME = "aux_moldura_a"
LABEL_LAYER_NAME = "Limites_Rotulos"
CHAR_LAYER_NAME = "Limites_Caracteres"
# None = escala do canvas atual | valor numérico = escala fixa
RENDER_SCALE = 25000
# Margem ao redor do bbox de cada polígono de referência
BBOX_MARGIN = 0.0
# Limite de pixels por lado (segurança contra estouro de memória).
MAX_PX_PER_SIDE = 16384
# Debug: imprime informações extras sobre labels curvos
DEBUG_CURVED = True
# ══════════════════════════════════════════════════════════════════════════
# Extração COMPLETA das propriedades de texto de uma camada
# ══════════════════════════════════════════════════════════════════════════
class LabelFontInfo:
"""Armazena todas as propriedades relevantes do texto de uma camada."""
def __init__(self, layer, mu_per_px, dpi):
self.valid = False
self.font = QFont("Sans Serif", 10)
self.letter_spacing = 0.0
self.word_spacing = 0.0
self.buffer_size_mu = 0.0
self.dpi = dpi
# Multi-linha
self.wrap_char = ''
self.auto_wrap_length = 0
self.multiline_align = 0 # 0=Left, 1=Center, 2=Right, 3=Follow
# ★ Posicionamento (novo v4)
self.placement = None
self.is_curved = False
self.offset_from_line_mu = 0.0
if layer is None or not layer.labelsEnabled():
return
labeling = layer.labeling()
if labeling is None:
return
try:
settings = labeling.settings()
except Exception:
return
text_format = settings.format()
# ── Placement (v4) ──
try:
self.placement = settings.placement
self.is_curved = self.placement in (
QgsPalLayerSettings.Curved,
QgsPalLayerSettings.PerimeterCurved,
)
except Exception:
self.placement = None
self.is_curved = False
# ── Offset da linha (v4) ──
try:
dist_val = settings.dist
dist_unit = settings.distUnits
if dist_unit == QgsUnitTypes.RenderMapUnits:
self.offset_from_line_mu = dist_val
else:
if dist_unit == QgsUnitTypes.RenderMillimeters:
dist_px = dist_val * dpi / 25.4
elif dist_unit == QgsUnitTypes.RenderPoints:
dist_px = dist_val * dpi / 72.0
elif dist_unit == QgsUnitTypes.RenderPixels:
dist_px = dist_val
else:
dist_px = dist_val * dpi / 25.4
self.offset_from_line_mu = dist_px * mu_per_px
except Exception:
self.offset_from_line_mu = 0.0
# ── Multi-linha ──
try:
self.wrap_char = settings.wrapChar if settings.wrapChar else ''
except Exception:
self.wrap_char = ''
try:
self.auto_wrap_length = settings.autoWrapLength if settings.autoWrapLength else 0
except Exception:
self.auto_wrap_length = 0
try:
self.multiline_align = settings.multilineAlign
except Exception:
self.multiline_align = 0
# ── Construir QFont completo ──
base_font = text_format.font()
font_size = text_format.size()
size_unit = text_format.sizeUnit()
if size_unit == QgsUnitTypes.RenderPoints:
font_size_px = font_size * dpi / 72.0
elif size_unit == QgsUnitTypes.RenderMillimeters:
font_size_px = font_size * dpi / 25.4
elif size_unit == QgsUnitTypes.RenderPixels:
font_size_px = font_size
elif size_unit == QgsUnitTypes.RenderMapUnits:
font_size_px = font_size / mu_per_px if mu_per_px > 0 else font_size
else:
font_size_px = font_size * dpi / 72.0
f = QFont(base_font.family())
f.setPixelSize(max(1, int(round(font_size_px))))
f.setBold(base_font.bold())
f.setItalic(base_font.italic())
f.setWeight(base_font.weight())
if hasattr(base_font, 'styleName') and base_font.styleName():
f.setStyleName(base_font.styleName())
stretch = base_font.stretch()
if stretch > 0:
f.setStretch(stretch)
f.setCapitalization(base_font.capitalization())
f.setStrikeOut(base_font.strikeOut())
f.setUnderline(base_font.underline())
letter_sp = base_font.letterSpacing()
word_sp = base_font.wordSpacing()
ls_type = base_font.letterSpacingType()
if ls_type == QFont.PercentageSpacing:
fm_temp = QFontMetricsF(f)
avg_w = fm_temp.averageCharWidth()
letter_sp_abs = avg_w * (letter_sp - 100.0) / 100.0
else:
letter_sp_abs = letter_sp
f.setLetterSpacing(QFont.AbsoluteSpacing, letter_sp_abs)
f.setWordSpacing(word_sp)
self.font = f
self.letter_spacing = letter_sp_abs
self.word_spacing = word_sp
# ── Buffer (halo) ──
buf = text_format.buffer()
if buf.enabled():
buf_size = buf.size()
buf_unit = buf.sizeUnit()
if buf_unit == QgsUnitTypes.RenderMillimeters:
buf_px = buf_size * dpi / 25.4
elif buf_unit == QgsUnitTypes.RenderPoints:
buf_px = buf_size * dpi / 72.0
elif buf_unit == QgsUnitTypes.RenderPixels:
buf_px = buf_size
elif buf_unit == QgsUnitTypes.RenderMapUnits:
buf_px = buf_size / mu_per_px if mu_per_px > 0 else buf_size
else:
buf_px = buf_size * dpi / 25.4
self.buffer_size_mu = buf_px * mu_per_px
else:
self.buffer_size_mu = 0.0
self.valid = True
# ── Log ──
align_names = ['Left', 'Center', 'Right', 'Follow']
align_str = align_names[min(self.multiline_align, 3)]
wrap_str = repr(self.wrap_char) if self.wrap_char else 'nenhum'
placement_names = {
0: 'AroundPoint', 1: 'OverPoint', 2: 'Line',
3: 'Curved', 4: 'Horizontal', 5: 'Free',
6: 'PerimeterCurved', 7: 'OutsidePolygons'
}
pl_str = placement_names.get(self.placement, str(self.placement))
print(f" Fonte: {f.family()}, tamanho={font_size:.1f} "
f"({['Pontos','MM','MapUnits','Pixels','%','Pol'][min(size_unit,5)]})"
f" → {font_size_px:.1f}px (DPI={dpi:.0f}), "
f"bold={f.bold()}, italic={f.italic()}, "
f"weight={f.weight()}, "
f"letter_sp={letter_sp_abs:.2f}, word_sp={word_sp:.2f}, "
f"buffer={self.buffer_size_mu:.6f} u.m., "
f"wrap={wrap_str}, autoWrap={self.auto_wrap_length}, "
f"align={align_str}, "
f"placement={pl_str}, curved={self.is_curved}, "
f"offset={self.offset_from_line_mu:.6f} u.m.")
# ══════════════════════════════════════════════════════════════════════════
# Funções auxiliares de geometria
# ══════════════════════════════════════════════════════════════════════════
def polygon_from_4points(p0, p1, p2, p3):
return QgsGeometry.fromPolygonXY([[p0, p1, p2, p3, p0]])
def decompose_label_geometry(corners):
"""Decompõe o retângulo do rótulo em origem + vetores direcionais."""
if len(corners) < 4:
return None
p0, p1, p2, p3 = corners[0], corners[1], corners[2], corners[3]
s01 = math.hypot(p1.x() - p0.x(), p1.y() - p0.y())
s12 = math.hypot(p2.x() - p1.x(), p2.y() - p1.y())
if s01 >= s12:
origin = p0
ux = (p1.x() - p0.x()) / s01 if s01 > 0 else 1.0
uy = (p1.y() - p0.y()) / s01 if s01 > 0 else 0.0
width = s01
s03 = math.hypot(p3.x() - p0.x(), p3.y() - p0.y())
vx = (p3.x() - p0.x()) / s03 if s03 > 0 else 0.0
vy = (p3.y() - p0.y()) / s03 if s03 > 0 else 1.0
height = s03
else:
origin = p1
ux = (p2.x() - p1.x()) / s12 if s12 > 0 else 1.0
uy = (p2.y() - p1.y()) / s12 if s12 > 0 else 0.0
width = s12
vx = (p0.x() - p1.x()) / s01 if s01 > 0 else 0.0
vy = (p0.y() - p1.y()) / s01 if s01 > 0 else 1.0
height = s01
if width == 0:
return None
return {
'origin': origin,
'ux': ux, 'uy': uy,
'vx': vx, 'vy': vy,
'width': width,
'height': height,
}
def style_layer_outline(layer, color, width_mm=0.3):
symbol = QgsFillSymbol.createSimple({
'color': '0,0,0,0',
'outline_color': color.name(),
'outline_width': str(width_mm),
'outline_style': 'solid',
})
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
layer.triggerRepaint()
# ══════════════════════════════════════════════════════════════════════════
# Renderização off-screen
# ══════════════════════════════════════════════════════════════════════════
def render_and_collect_labels(extent, render_layers, project_crs, mu_per_px, dpi):
ext_w = extent.width()
ext_h = extent.height()
if ext_w <= 0 or ext_h <= 0:
return []
px_w = max(64, int(math.ceil(ext_w / mu_per_px)))
px_h = max(64, int(math.ceil(ext_h / mu_per_px)))
if px_w > MAX_PX_PER_SIDE or px_h > MAX_PX_PER_SIDE:
print(f"\n ⚠ Tile grande: {px_w}×{px_h}px. "
f"Considere usar escala maior ou polígonos menores.")
if px_w > MAX_PX_PER_SIDE or px_h > MAX_PX_PER_SIDE:
ratio = min(MAX_PX_PER_SIDE / px_w, MAX_PX_PER_SIDE / px_h)
px_w = max(64, int(px_w * ratio))
px_h = max(64, int(px_h * ratio))
settings = QgsMapSettings()
settings.setOutputSize(QSize(px_w, px_h))
settings.setOutputDpi(dpi)
settings.setDestinationCrs(project_crs)
settings.setExtent(extent)
settings.setLayers(render_layers)
settings.setFlag(QgsMapSettings.UseAdvancedEffects, True)
settings.setFlag(QgsMapSettings.DrawLabeling, True)
job = QgsMapRendererSequentialJob(settings)
job.start()
job.waitForFinished()
labeling_results = job.takeLabelingResults()
if labeling_results is None:
return []
try:
return labeling_results.allLabels()
except AttributeError:
return []
# ══════════════════════════════════════════════════════════════════════════
# Divisão do texto em linhas (replica lógica do QGIS)
# ══════════════════════════════════════════════════════════════════════════
def split_label_into_lines(text, fi):
if fi.wrap_char:
lines = text.split(fi.wrap_char)
else:
lines = text.split('\n')
if fi.auto_wrap_length > 0:
new_lines = []
for line in lines:
if len(line) <= fi.auto_wrap_length:
new_lines.append(line)
continue
words = line.split(' ')
current = ''
for word in words:
test = (current + ' ' + word) if current else word
if current and len(test) > fi.auto_wrap_length:
new_lines.append(current)
current = word
else:
current = test
if current:
new_lines.append(current)
lines = new_lines
while lines and lines[0].strip() == '':
lines.pop(0)
while lines and lines[-1].strip() == '':
lines.pop()
if not lines:
lines = [text]
return lines
def compute_line_advances(lines, fm, display_transform):
result = []
for line in lines:
disp = display_transform(line)
advances = []
for ch in disp:
try:
adv = fm.horizontalAdvance(ch)
except AttributeError:
adv = fm.width(ch)
advances.append(max(adv, 0.001))
total = sum(advances)
result.append((line, disp, advances, total))
return result
# ══════════════════════════════════════════════════════════════════════════
# ★ NOVO v4: Funções auxiliares para labels curvos
# ══════════════════════════════════════════════════════════════════════════
def get_line_geometry_for_feature(layer, feature_id, project_crs, project):
"""
Obtém a geometria de linha da feature original.
Para polígonos (PerimeterCurved), extrai o contorno.
Transforma para o CRS do projeto.
Retorna QgsGeometry (linha) ou None.
"""
feat = layer.getFeature(feature_id)
if feat is None or not feat.isValid():
return None
geom = feat.geometry()
if geom.isEmpty():
return None
# Transformar CRS se necessário
if layer.crs() != project_crs:
xform = QgsCoordinateTransform(layer.crs(), project_crs, project)
geom.transform(xform)
# Se for polígono (PerimeterCurved), extrair o contorno como linha
geom_type = geom.type()
if geom_type == QgsWkbTypes.PolygonGeometry:
# boundary() retorna o anel exterior como linha
boundary = geom.constGet().boundary()
if boundary:
geom = QgsGeometry(boundary.clone())
else:
return None
# Verificar que temos uma geometria de linha
if geom.type() != QgsWkbTypes.LineGeometry:
return None
return geom
def select_closest_line_part(line_geom, reference_point):
"""
Para geometrias multi-part, seleciona a parte mais próxima
ao ponto de referência (tipicamente o centroide do label).
Retorna QgsGeometry de uma linha única.
"""
if not line_geom.isMultipart():
return line_geom
parts = line_geom.asGeometryCollection()
if not parts:
return line_geom
ref_geom = QgsGeometry.fromPointXY(reference_point) \
if isinstance(reference_point, QgsPointXY) else reference_point
min_dist = float('inf')
best_part = parts[0]
for part in parts:
d = part.distance(ref_geom)
if d < min_dist:
min_dist = d
best_part = part
return best_part
def tangent_angle_at_distance(line_geom, distance, line_length):
"""
Calcula o ângulo tangente (em radianos) num ponto da linha
a uma dada distância. Usa diferença finita com dois pontos próximos.
"""
epsilon = max(line_length * 0.0001, 0.001)
d_before = max(distance - epsilon, 0.0)
d_after = min(distance + epsilon, line_length)
# Se os pontos colapsam, ampliar epsilon
if abs(d_after - d_before) < 1e-12:
d_before = max(distance - epsilon * 10, 0.0)
d_after = min(distance + epsilon * 10, line_length)
p_before = line_geom.interpolate(d_before)
p_after = line_geom.interpolate(d_after)
if p_before.isEmpty() or p_after.isEmpty():
return 0.0
pb = p_before.asPoint()
pa = p_after.asPoint()
return math.atan2(pa.y() - pb.y(), pa.x() - pb.x())
def locate_label_on_line(label_geom, line_geom):
"""
Localiza onde o rótulo está posicionado ao longo da linha.
Projeta os vértices do polígono do label na linha para obter:
- start_dist: distância ao longo da linha onde o label começa
- end_dist: distância onde termina
- center_dist: centro
- perp_offset: offset perpendicular estimado (distância do centroide à linha)
Retorna dict com essas informações, ou None se falhar.
"""
line_length = line_geom.length()
if line_length <= 0:
return None
# Centroide do label → ponto mais próximo na linha
label_centroid = label_geom.centroid()
if label_centroid.isEmpty():
return None
nearest = line_geom.nearestPoint(label_centroid)
if nearest.isEmpty():
return None
center_dist = line_geom.lineLocatePoint(nearest)
# Offset perpendicular = distância do centroide à linha
perp_offset = label_centroid.distance(nearest)
# Determinar o sinal do offset (acima/abaixo da linha)
# Usando produto vetorial com a tangente no ponto mais próximo
angle = tangent_angle_at_distance(line_geom, center_dist, line_length)
lc = label_centroid.asPoint()
np_pt = nearest.asPoint()
# Vetor perpendicular (90° anti-horário da tangente)
perp_x = -math.sin(angle)
perp_y = math.cos(angle)
# Vetor do ponto na linha ao centroide
dx = lc.x() - np_pt.x()
dy = lc.y() - np_pt.y()
# Produto escalar com perpendicular → sinal do offset
dot = dx * perp_x + dy * perp_y
if dot < 0:
perp_offset = -perp_offset
# Projetar TODOS os vértices do label geometry na linha
# para estimar a extensão ao longo da curva
poly = label_geom.asPolygon()
if poly and poly[0]:
distances = []
for pt in poly[0]:
pt_geom = QgsGeometry.fromPointXY(pt)
near_pt = line_geom.nearestPoint(pt_geom)
if not near_pt.isEmpty():
d = line_geom.lineLocatePoint(near_pt)
distances.append(d)
if distances:
min_d = min(distances)
max_d = max(distances)
# A extensão projetada pode ser ligeiramente maior que o texto
# real (por causa da altura perpendicular), mas é uma boa
# estimativa para calibração
return {
'center_dist': center_dist,
'start_dist': min_d,
'end_dist': max_d,
'arc_extent': max_d - min_d,
'perp_offset': perp_offset,
'line_length': line_length,
}
return {
'center_dist': center_dist,
'start_dist': center_dist,
'end_dist': center_dist,
'arc_extent': 0.0,
'perp_offset': perp_offset,
'line_length': line_length,
}
def detect_text_direction(line_geom, center_dist, line_length):
"""
Detecta se o texto deve seguir a direção normal da linha
ou a direção inversa. QGIS mantém o texto sempre legível
(leitura da esquerda para a direita).
Retorna True se a direção deve ser invertida.
"""
angle = tangent_angle_at_distance(line_geom, center_dist, line_length)
# Normalizar para [-π, π]
angle = math.atan2(math.sin(angle), math.cos(angle))
# Se a tangente aponta predominantemente para a esquerda,
# o QGIS inverte a direção para manter legibilidade
# A faixa "para a esquerda" é: |angle| > π/2
# Mas usamos uma margem mais conservadora
return abs(angle) > math.pi / 2
def build_char_rect_on_curve(cx, cy, angle, char_w, char_h, perp_offset,
is_first, is_last, buf):
"""
Constrói um retângulo orientado para um caractere numa curva.
Parâmetros:
cx, cy – ponto na linha (antes do offset perpendicular)
angle – ângulo tangente em radianos
char_w – largura do caractere em map units
char_h – altura do caractere em map units
perp_offset – offset perpendicular em map units
is_first – True se é o primeiro caractere (buffer à esquerda)
is_last – True se é o último caractere (buffer à direita)
buf – tamanho do buffer em map units
Retorna QgsGeometry (polígono com 4 vértices).
"""
cos_a = math.cos(angle)
sin_a = math.sin(angle)
# Vetores locais do caractere
# u: ao longo da linha (tangente)
# v: perpendicular (90° anti-horário)
ux, uy = cos_a, sin_a
vx, vy = -sin_a, cos_a
# Aplicar offset perpendicular ao centro
px = cx + perp_offset * vx
py = cy + perp_offset * vy
# Extensões horizontais (ao longo da tangente)
hw = char_w / 2.0
extra_left = buf / 2.0 if is_first else 0.0
extra_right = buf / 2.0 if is_last else 0.0
left_ext = hw + extra_left
right_ext = hw + extra_right
# Extensões verticais (perpendicular à tangente)
hh = char_h / 2.0
bottom_ext = hh + (buf / 2.0)
top_ext = hh + (buf / 2.0)
# 4 cantos do retângulo orientado
c0 = QgsPointXY(
px - left_ext * ux - bottom_ext * vx,
py - left_ext * uy - bottom_ext * vy
)
c1 = QgsPointXY(
px + right_ext * ux - bottom_ext * vx,
py + right_ext * uy - bottom_ext * vy
)
c2 = QgsPointXY(
px + right_ext * ux + top_ext * vx,
py + right_ext * uy + top_ext * vy
)
c3 = QgsPointXY(
px - left_ext * ux + top_ext * vx,
py - left_ext * uy + top_ext * vy
)
return polygon_from_4points(c0, c1, c2, c3)
# ══════════════════════════════════════════════════════════════════════════
# ★ NOVO v4: Processamento de rótulos CURVOS
# ══════════════════════════════════════════════════════════════════════════
def process_curved_label(lbl_pos, project, label_layer, char_layer,
font_info_cache, mu_per_px):
"""
Processa um rótulo CURVO (Curved / PerimeterCurved).
Em vez de decompor a geometria retangular do label, obtém a
geometria da linha original e caminha ao longo dela posicionando
cada caractere com a orientação da tangente local.
Retorna (n_labels, n_chars).
"""
layer_id = lbl_pos.layerID
feature_id = lbl_pos.featureId
label_text = lbl_pos.labelText
if not label_text:
return 0, 0
layer = project.mapLayer(layer_id)
layer_name = layer.name() if layer else "Desconhecida"
label_geom = lbl_pos.labelGeometry
if label_geom.isEmpty():
return 0, 0
# ── Feature do RÓTULO (bounding polygon original) ──
lbl_dp = label_layer.dataProvider()
feat_lbl = QgsFeature(label_layer.fields())
feat_lbl.setGeometry(label_geom)
feat_lbl.setAttributes([layer_name, feature_id, label_text])
lbl_dp.addFeature(feat_lbl)
# ── Info da fonte ──
fi = font_info_cache.get(layer_id)
if fi is None or not fi.valid:
return 1, 0
# ── Obter geometria de linha da feature original ──
project_crs = project.crs()
line_geom = get_line_geometry_for_feature(
layer, feature_id, project_crs, project
)
if line_geom is None:
if DEBUG_CURVED:
print(f" ⚠ Curvo: sem geometria de linha para fid={feature_id}")
return 1, 0
# Selecionar parte mais próxima (multi-part)
label_centroid_pt = label_geom.centroid().asPoint()
line_geom = select_closest_line_part(line_geom, label_centroid_pt)
line_length = line_geom.length()
if line_length <= 0:
return 1, 0
# ── Métricas de fonte ──
fm = QFontMetricsF(fi.font)
cap = fi.font.capitalization()
def display_transform(t):
if cap == QFont.AllUppercase:
return t.upper()
elif cap == QFont.AllLowercase:
return t.lower()
elif cap == QFont.Capitalize:
return t.title()
return t
display_text = display_transform(label_text)
# Avanços por caractere (em pixels de fonte)
advances_px = []
for ch in display_text:
try:
adv = fm.horizontalAdvance(ch)
except AttributeError:
adv = fm.width(ch)
advances_px.append(max(adv, 0.001))
total_advance_px = sum(advances_px)
if total_advance_px <= 0:
return 1, 0
# ── Localizar label na linha ──
loc = locate_label_on_line(label_geom, line_geom)
if loc is None:
return 1, 0
center_dist = loc['center_dist']
arc_extent = loc['arc_extent']
perp_offset = loc['perp_offset']
# ── Calibração de escala ──
# A escala "ideal" seria mu_per_px, mas podemos calibrar usando
# a extensão real do label na curva (arc_extent) dividida pelo
# avanço total em pixels.
# NOTA: arc_extent inclui um pouco de "erro" da projeção perpendicular,
# mas é geralmente mais preciso que mu_per_px puro.
estimated_width_mu = total_advance_px * mu_per_px
if arc_extent > 0 and estimated_width_mu > 0:
# Fator de correção: quanto o QGIS realmente usou vs. estimativa
correction = arc_extent / estimated_width_mu
# Aceitar correção apenas se razoável (entre 0.5 e 2.0)
if 0.5 < correction < 2.0:
scale_px_to_mu = mu_per_px * correction
else:
scale_px_to_mu = mu_per_px
if DEBUG_CURVED:
print(f" ⚠ Curvo: correção fora de faixa ({correction:.3f}), "
f"usando mu_per_px direto")
else:
scale_px_to_mu = mu_per_px
# Avanços em map units (calibrados)
advances_mu = [a * scale_px_to_mu for a in advances_px]
total_width_mu = sum(advances_mu)
# ── Detectar direção do texto ──
reversed_dir = detect_text_direction(line_geom, center_dist, line_length)
# Distância de início
if reversed_dir:
start_dist = center_dist + total_width_mu / 2.0
else:
start_dist = center_dist - total_width_mu / 2.0
# ── Altura do caractere ──
font_height_px = fm.height()
font_ascent_px = fm.ascent()
font_height_mu = font_height_px * scale_px_to_mu
buf = fi.buffer_size_mu
if DEBUG_CURVED:
dir_str = "←(invertido)" if reversed_dir else "→(normal)"
cal_str = f"×{(scale_px_to_mu/mu_per_px):.4f}" if mu_per_px > 0 else "?"
print(f" Curvo: texto='{label_text}', dir={dir_str}, "
f"arc={arc_extent:.4f}, est={estimated_width_mu:.4f}, "
f"cal={cal_str}, offset={perp_offset:.4f}")
# ── Gerar retângulos por caractere ──
chr_dp = char_layer.dataProvider()
char_feats = []
n_chars_total = len(label_text)
current_dist = start_dist
direction = -1.0 if reversed_dir else 1.0
for ch_idx, ch in enumerate(label_text):
char_w_mu = advances_mu[ch_idx]
# Centro do caractere ao longo da linha
char_center_dist = current_dist + direction * char_w_mu / 2.0
# Avançar para o próximo caractere
current_dist += direction * char_w_mu
# Clampar ao comprimento da linha
if char_center_dist < 0 or char_center_dist > line_length:
continue
# Interpolar posição na linha
pt_geom = line_geom.interpolate(char_center_dist)
if pt_geom.isEmpty():
continue
pt = pt_geom.asPoint()
# Ângulo tangente
angle = tangent_angle_at_distance(
line_geom, char_center_dist, line_length
)
# Se direção invertida, rotacionar 180° para manter texto legível
if reversed_dir:
angle += math.pi
# ── Altura tight do caractere ──
disp_ch = display_transform(ch)
tight = fm.tightBoundingRect(disp_ch)
if tight.height() > 0 and ch.strip():
char_h_mu = tight.height() * scale_px_to_mu
else:
char_h_mu = font_height_mu
# Construir retângulo orientado
char_geom = build_char_rect_on_curve(
cx=pt.x(),
cy=pt.y(),
angle=angle,
char_w=char_w_mu,
char_h=char_h_mu,
perp_offset=perp_offset,
is_first=(ch_idx == 0),
is_last=(ch_idx == n_chars_total - 1),
buf=buf,
)
feat_chr = QgsFeature(char_layer.fields())
feat_chr.setGeometry(char_geom)
feat_chr.setAttributes([
layer_name, feature_id, label_text, ch, ch_idx
])
char_feats.append(feat_chr)
n_chars = len(char_feats)
if char_feats:
chr_dp.addFeatures(char_feats)
return 1, n_chars
# ══════════════════════════════════════════════════════════════════════════
# Processa um rótulo HORIZONTAL → features de saída (com multi-linha)
# (lógica original, sem alterações)
# ══════════════════════════════════════════════════════════════════════════
def process_horizontal_label(lbl_pos, project, label_layer, char_layer,
font_info_cache):
"""
Processa um QgsLabelPosition de label horizontal/retilíneo.
Lógica idêntica à v3 original.
Retorna (n_labels, n_chars).
"""
layer_id = lbl_pos.layerID
feature_id = lbl_pos.featureId
label_text = lbl_pos.labelText
if not label_text:
return 0, 0
layer = project.mapLayer(layer_id)
layer_name = layer.name() if layer else "Desconhecida"
label_geom = lbl_pos.labelGeometry
if label_geom.isEmpty():
return 0, 0
# ── Feature do RÓTULO ──
lbl_dp = label_layer.dataProvider()
feat_lbl = QgsFeature(label_layer.fields())
feat_lbl.setGeometry(label_geom)
feat_lbl.setAttributes([layer_name, feature_id, label_text])
lbl_dp.addFeature(feat_lbl)
# ── Decompor geometria do rótulo ──
poly = label_geom.asPolygon()
if not poly or not poly[0] or len(poly[0]) < 5:
return 1, 0
corners = poly[0]
geo_info = decompose_label_geometry(corners)
if geo_info is None:
return 1, 0
origin = geo_info['origin']
ux, uy = geo_info['ux'], geo_info['uy']
vx, vy = geo_info['vx'], geo_info['vy']
total_width = geo_info['width']
total_height = geo_info['height']
# ── Obter info da fonte ──
if layer_id not in font_info_cache:
return 1, 0
fi = font_info_cache[layer_id]
if not fi.valid:
return 1, 0
# ── Função de transformação de capitalização ──
cap = fi.font.capitalization()
def display_transform(t):
if cap == QFont.AllUppercase:
return t.upper()
elif cap == QFont.AllLowercase:
return t.lower()
elif cap == QFont.Capitalize:
return t.title()
return t
fm = QFontMetricsF(fi.font)
# ── Dividir em linhas ──
lines = split_label_into_lines(label_text, fi)
n_lines = len(lines)
# ── Calcular avanços por linha ──
line_data = compute_line_advances(lines, fm, display_transform)
widest_advance = max(ld[3] for ld in line_data) if line_data else 1.0
if widest_advance <= 0:
return 1, 0
scale_factor = total_width / widest_advance
line_height_mu = total_height / n_lines if n_lines > 0 else total_height
font_line_height = fm.height()
v_scale = line_height_mu / font_line_height if font_line_height > 0 else 1.0
buf = fi.buffer_size_mu
align = fi.multiline_align
if align == 3:
align = 1
# ── Gerar retângulos por caractere ──
chr_dp = char_layer.dataProvider()
char_feats = []
global_char_idx = 0
for line_idx, (line_orig, line_disp, advances, line_total) in enumerate(line_data):
line_width_mu = line_total * scale_factor
n_chars_line = len(line_orig)
if align == 1:
h_offset = (total_width - line_width_mu) / 2.0
elif align == 2:
h_offset = total_width - line_width_mu
else:
h_offset = 0.0
v_offset = (n_lines - 1 - line_idx) * line_height_mu
line_ox = origin.x() + h_offset * ux + v_offset * vx
line_oy = origin.y() + h_offset * uy + v_offset * vy
char_offset = 0.0
for ch_idx, ch in enumerate(line_orig):
ratio = advances[ch_idx] / line_total if line_total > 0 else 1.0 / max(n_chars_line, 1)
char_w = ratio * line_width_mu
disp_ch = display_transform(ch)
tight = fm.tightBoundingRect(disp_ch)
if tight.height() > 0 and ch.strip():
y_top = fm.ascent() + tight.top()
y_bottom = y_top + tight.height()
char_v_bottom = line_height_mu - y_bottom * v_scale
char_v_top = line_height_mu - y_top * v_scale
char_v_bottom -= buf / 2
char_v_top += buf / 2
char_h_mu = char_v_top - char_v_bottom
else:
char_v_bottom = 0.0
char_h_mu = line_height_mu
extra_left = buf / 2 if ch_idx == 0 else 0.0
extra_right = buf / 2 if ch_idx == n_chars_line - 1 else 0.0
final_w = char_w + extra_left + extra_right
cx = line_ox + (char_offset - extra_left) * ux + char_v_bottom * vx
cy = line_oy + (char_offset - extra_left) * uy + char_v_bottom * vy
c0 = QgsPointXY(cx, cy)
c1 = QgsPointXY(cx + final_w * ux, cy + final_w * uy)
c2 = QgsPointXY(cx + final_w * ux + char_h_mu * vx,
cy + final_w * uy + char_h_mu * vy)
c3 = QgsPointXY(cx + char_h_mu * vx, cy + char_h_mu * vy)
char_geom = polygon_from_4points(c0, c1, c2, c3)
feat_chr = QgsFeature(char_layer.fields())
feat_chr.setGeometry(char_geom)
feat_chr.setAttributes([
layer_name, feature_id, label_text, ch, global_char_idx
])
char_feats.append(feat_chr)
char_offset += char_w
global_char_idx += 1
n_chars = len(char_feats)
if char_feats:
chr_dp.addFeatures(char_feats)
return 1, n_chars
# ══════════════════════════════════════════════════════════════════════════
# Dispatcher: roteia para horizontal ou curvo
# ══════════════════════════════════════════════════════════════════════════
def process_label(lbl_pos, project, label_layer, char_layer,
font_info_cache, mu_per_px):
"""
Processa um QgsLabelPosition, roteando automaticamente para
o processador correto (horizontal ou curvo) com base no
modo de posicionamento da camada.
Retorna (n_labels, n_chars).
"""
layer_id = lbl_pos.layerID
fi = font_info_cache.get(layer_id)
if fi is not None and fi.is_curved:
return process_curved_label(
lbl_pos, project, label_layer, char_layer,
font_info_cache, mu_per_px
)
else:
return process_horizontal_label(
lbl_pos, project, label_layer, char_layer,
font_info_cache
)
# ══════════════════════════════════════════════════════════════════════════
# Função principal
# ══════════════════════════════════════════════════════════════════════════
def extract_label_bounds_by_polygon():
project = QgsProject.instance()
canvas = iface.mapCanvas()
project_crs = project.crs()
root = project.layerTreeRoot()
# ── Localizar camada de polígonos de referência ──
matches = project.mapLayersByName(POLYGON_LAYER_NAME)
if not matches:
raise RuntimeError(
f"Camada '{POLYGON_LAYER_NAME}' não encontrada no projeto.\n"
f"Ajuste a variável POLYGON_LAYER_NAME no início do script."
)
ref_layer = matches[0]
if not isinstance(ref_layer, QgsVectorLayer):
raise RuntimeError(f"'{POLYGON_LAYER_NAME}' não é uma camada vetorial.")
total_polys = ref_layer.featureCount()
print(f"\nCamada de referência: '{ref_layer.name()}' ({total_polys} polígonos)")
# ── Camadas visíveis ──
visible_layers = []
for layer in project.mapLayers().values():
if not layer.isSpatial():
continue
tree_layer = root.findLayer(layer.id())
if tree_layer is None or not tree_layer.isVisible():
continue
if layer.name() in (LABEL_LAYER_NAME, CHAR_LAYER_NAME):
continue
visible_layers.append(layer)
labeled = [l for l in visible_layers
if hasattr(l, 'labelsEnabled') and l.labelsEnabled()]
if not labeled:
raise RuntimeError("Nenhuma camada visível com rótulos habilitados.")
print(f"Camadas visíveis com rótulos: {[l.name() for l in labeled]}")
print(f"Total de camadas visíveis: {len(visible_layers)}")
# ── Resolução e DPI ──
canvas_dpi = canvas.mapSettings().outputDpi()
canvas_mupp = canvas.mapUnitsPerPixel()
canvas_scale = canvas.scale()
if RENDER_SCALE is not None and canvas_scale > 0:
mu_per_px = canvas_mupp * (RENDER_SCALE / canvas_scale)
use_scale = RENDER_SCALE
else:
mu_per_px = canvas_mupp
use_scale = canvas_scale
render_dpi = canvas_dpi
print(f"DPI do canvas: {canvas_dpi:.0f}")
print(f"Escala do canvas: 1:{canvas_scale:,.0f} ({canvas_mupp:.10f} u.m./px)")
print(f"Escala de renderização: 1:{use_scale:,.0f} ({mu_per_px:.10f} u.m./px)")
# ── Info de fonte de cada camada rotulada ──
print("\nExtraindo propriedades de fonte de cada camada:")
font_info_cache = {}
for layer in labeled:
print(f" [{layer.name()}]", end=" ")
font_info_cache[layer.id()] = LabelFontInfo(layer, mu_per_px, render_dpi)
# Resumo de camadas curvas
curved_layers = [l.name() for l in labeled
if font_info_cache.get(l.id()) and
font_info_cache[l.id()].is_curved]
if curved_layers:
print(f"\n ★ Camadas com labels CURVOS: {curved_layers}")
# ── Transformação CRS ──
needs_transform = (ref_layer.crs() != project_crs)
if needs_transform:
xform = QgsCoordinateTransform(ref_layer.crs(), project_crs, project)
print(f"\nTransformando de {ref_layer.crs().authid()} → {project_crs.authid()}")
# ── Remover camadas de saída anteriores ──
from qgis.PyQt.QtWidgets import QApplication
layers_to_remove = []
for existing in project.mapLayersByName(LABEL_LAYER_NAME):
layers_to_remove.append(existing.id())
for existing in project.mapLayersByName(CHAR_LAYER_NAME):
layers_to_remove.append(existing.id())
if layers_to_remove:
canvas.setRenderFlag(False)
QApplication.processEvents()
project.removeMapLayers(layers_to_remove)
QApplication.processEvents()
canvas.setRenderFlag(True)
QApplication.processEvents()
# ── Criar camadas de saída ──
crs_id = project_crs.authid()
label_layer = QgsVectorLayer(
f"Polygon?crs={crs_id}&index=yes", LABEL_LAYER_NAME, "memory"
)
lbl_dp = label_layer.dataProvider()
lbl_dp.addAttributes([
QgsField("camada", QVariant.String),
QgsField("fid", QVariant.LongLong),
QgsField("texto", QVariant.String),
])
label_layer.updateFields()
char_layer = QgsVectorLayer(
f"Polygon?crs={crs_id}&index=yes", CHAR_LAYER_NAME, "memory"
)
chr_dp = char_layer.dataProvider()
chr_dp.addAttributes([
QgsField("camada", QVariant.String),
QgsField("fid", QVariant.LongLong),
QgsField("texto", QVariant.String),
QgsField("caractere", QVariant.String),
QgsField("indice", QVariant.Int),
])
char_layer.updateFields()
seen_labels = set()
total_labels = 0
total_chars = 0
total_curved = 0
total_horiz = 0
# ══════════════════════════════════════════════════════════════════
# LOOP: cada polígono da camada de referência
# ══════════════════════════════════════════════════════════════════
print(f"\nProcessando {total_polys} polígonos…")
for i, ref_feat in enumerate(ref_layer.getFeatures()):
geom = ref_feat.geometry()
if geom.isEmpty():
continue
if needs_transform:
geom.transform(xform)
bbox = geom.boundingBox()
margin_w = max(bbox.width() * BBOX_MARGIN, 0.001)
margin_h = max(bbox.height() * BBOX_MARGIN, 0.001)
render_extent = QgsRectangle(
bbox.xMinimum() - margin_w,
bbox.yMinimum() - margin_h,
bbox.xMaximum() + margin_w,
bbox.yMaximum() + margin_h,
)
show_progress = ((i + 1) % 10 == 0) or (i == 0) or ((i + 1) == total_polys)
if show_progress:
print(f" Polígono {i + 1}/{total_polys}…", end="")
label_positions = render_and_collect_labels(
render_extent, visible_layers, project_crs, mu_per_px, render_dpi
)
poly_new = 0
for lbl_pos in label_positions:
lkey = (lbl_pos.layerID, lbl_pos.featureId, lbl_pos.labelText)
if not lbl_pos.labelText or lkey in seen_labels:
continue
seen_labels.add(lkey)
# Verificar se é curvo para estatísticas
fi = font_info_cache.get(lbl_pos.layerID)
is_this_curved = fi is not None and fi.is_curved
n_lbl, n_chr = process_label(
lbl_pos, project, label_layer, char_layer,
font_info_cache, mu_per_px
)
total_labels += n_lbl
total_chars += n_chr
poly_new += n_lbl
if is_this_curved:
total_curved += n_lbl
else:
total_horiz += n_lbl
if show_progress:
print(f" → {poly_new} novos rótulos")
# ── Resultado ──
label_layer.updateExtents()
char_layer.updateExtents()
project.addMapLayer(label_layer)
project.addMapLayer(char_layer)
style_layer_outline(label_layer, QColor(255, 0, 0), 0.4)
style_layer_outline(char_layer, QColor(0, 0, 255), 0.25)
canvas.refresh()
print("\n" + "═" * 60)
print(f" ✔ Polígonos processados : {total_polys}")
print(f" ✔ Rótulos capturados : {total_labels}")
print(f" ├─ Horizontais : {total_horiz}")
print(f" └─ Curvos : {total_curved}")
print(f" ✔ Caracteres gerados : {total_chars}")
print(f" ✔ Rótulos únicos : {len(seen_labels)}")
print(f" → Camada '{LABEL_LAYER_NAME}' (contorno vermelho)")
print(f" → Camada '{CHAR_LAYER_NAME}' (contorno azul)")
print("═" * 60)
# ══════════════════════════════════════════════════════════════════════════
# Execução
# ══════════════════════════════════════════════════════════════════════════
try:
extract_label_bounds_by_polygon()
except RuntimeError as e:
print(f"\n⚠ ERRO: {e}\n")
except Exception as e:
print(f"\n⚠ ERRO INESPERADO: {e}\n")
import traceback
traceback.print_exc()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment