Skip to content

Instantly share code, notes, and snippets.

@dinizime
Created February 6, 2026 20:03
Show Gist options
  • Select an option

  • Save dinizime/838af78536ed5965a3a2877c98a7c3bd to your computer and use it in GitHub Desktop.

Select an option

Save dinizime/838af78536ed5965a3a2877c98a7c3bd to your computer and use it in GitHub Desktop.
Cria linha passando pelos rótulos curvados de forma precisa
"""
============================================================================
Label Line Extractor para QGIS
---------------------------------------------------------------------------
Itera cada polígono de uma camada de referência, renderiza off-screen,
e captura todos os rótulos de LINHA. Gera uma linha por rótulo com
comprimento exato do texto, seguindo a geometria da feição original.
- Labels "on line": linha coincide com a feição
- Labels "above/below line": linha é deslocada (offset) à distância
do centro do rótulo
Saída:
• Camada "Linhas_Rotulos" – uma linha por rótulo
Uso: Console Python do QGIS (Ctrl+Alt+P) ou Editor de Scripts
Requisitos: QGIS 3.28+
============================================================================
"""
from qgis.core import (
QgsProject, QgsVectorLayer, QgsFeature, QgsField,
QgsGeometry, QgsPointXY, QgsRectangle,
QgsMapSettings, QgsMapRendererSequentialJob,
QgsCoordinateTransform, QgsUnitTypes,
QgsLineSymbol, QgsSingleSymbolRenderer,
QgsWkbTypes, Qgis,
)
from qgis.PyQt.QtCore import QVariant, QSize
from qgis.PyQt.QtGui import QFont, QFontMetricsF, QColor
import math
# ══════════════════════════════════════════════════════════════════════════
# ★ CONFIGURAÇÃO ★
# ══════════════════════════════════════════════════════════════════════════
POLYGON_LAYER_NAME = "aux_moldura_a"
OUTPUT_LAYER_NAME = "Linhas_Rotulos"
RENDER_SCALE = 25000 # None = escala do canvas atual
BBOX_MARGIN = 0.0
MAX_PX_PER_SIDE = 16384
# Tolerância (em map units) para considerar label "on line" vs "above/below"
ON_LINE_TOLERANCE_FACTOR = 0.5 # fração da altura do label
# ══════════════════════════════════════════════════════════════════════════
# Extração das propriedades de fonte 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.mu_per_px = mu_per_px
self.dpi = dpi
# Placement flags: on line vs above/below
self.is_on_line = True
self.is_above_line = False
self.is_below_line = False
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 flags (on line / above / below) ──
try:
# QGIS 3.16+: lineSettings().placementFlags()
line_settings = settings.lineSettings()
flags = line_settings.placementFlags()
# Flags são bit flags: OnLine=1, AboveLine=2, BelowLine=4
try:
from qgis.core import QgsLabeling
self.is_on_line = bool(flags & QgsLabeling.LinePlacementFlag.OnLine)
self.is_above_line = bool(flags & QgsLabeling.LinePlacementFlag.AboveLine)
self.is_below_line = bool(flags & QgsLabeling.LinePlacementFlag.BelowLine)
except (ImportError, AttributeError):
# Fallback: raw int flags
self.is_on_line = bool(flags & 1)
self.is_above_line = bool(flags & 2)
self.is_below_line = bool(flags & 4)
except AttributeError:
try:
# QGIS < 3.16 fallback
flags = settings.placementFlags
self.is_on_line = bool(flags & 1)
self.is_above_line = bool(flags & 2)
self.is_below_line = bool(flags & 4)
except Exception:
self.is_on_line = True
# ── 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 spacing ──
letter_sp = base_font.letterSpacing()
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
word_sp = base_font.wordSpacing()
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
unit_names = ['Pontos', 'MM', 'MapUnits', 'Pixels', '%', 'Pol']
placement_str = []
if self.is_on_line: placement_str.append('OnLine')
if self.is_above_line: placement_str.append('AboveLine')
if self.is_below_line: placement_str.append('BelowLine')
print(f" Fonte: {f.family()}, tamanho={font_size:.1f} "
f"({unit_names[min(size_unit, 5)]})"
f" → {font_size_px:.1f}px, "
f"bold={f.bold()}, italic={f.italic()}, "
f"letter_sp={letter_sp_abs:.2f}, word_sp={word_sp:.2f}, "
f"buffer={self.buffer_size_mu:.4f} u.m., "
f"placement=[{', '.join(placement_str)}]")
# ══════════════════════════════════════════════════════════════════════════
# Cálculo do comprimento do label em map units
# ══════════════════════════════════════════════════════════════════════════
def compute_label_length_mu(text, fi):
"""
Calcula o comprimento do texto do rótulo em unidades de mapa.
Usa fm.horizontalAdvance(string_completa) que já incorpora
letter_spacing, word_spacing e kerning configurados na QFont.
"""
if not fi.valid or not text:
return 0.0
# Aplicar capitalização
cap = fi.font.capitalization()
if cap == QFont.AllUppercase:
display_text = text.upper()
elif cap == QFont.AllLowercase:
display_text = text.lower()
elif cap == QFont.Capitalize:
display_text = text.title()
else:
display_text = text
fm = QFontMetricsF(fi.font)
# horizontalAdvance da string completa: inclui kerning, letter spacing,
# word spacing — tudo que está configurado na QFont
try:
total_advance_px = fm.horizontalAdvance(display_text)
except AttributeError:
total_advance_px = fm.width(display_text)
if total_advance_px <= 0:
return 0.0
# Converter pixels de fonte → unidades de mapa
label_length_mu = total_advance_px * fi.mu_per_px
return label_length_mu
# ══════════════════════════════════════════════════════════════════════════
# Cálculo da altura do label em map units (para tolerância on/above)
# ══════════════════════════════════════════════════════════════════════════
def compute_label_height_mu(fi):
"""Altura da linha de texto em map units."""
if not fi.valid:
return 0.0
fm = QFontMetricsF(fi.font)
return fm.height() * fi.mu_per_px
# ══════════════════════════════════════════════════════════════════════════
# 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:
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 []
# ══════════════════════════════════════════════════════════════════════════
# Extrair a parte correta de uma MultiLineString
# ══════════════════════════════════════════════════════════════════════════
def get_closest_singlepart(multi_geom, ref_point):
"""
Se a geometria for MultiLineString, retorna a parte mais próxima
do ponto de referência. Se for simples, retorna ela mesma.
"""
if multi_geom.isMultipart():
parts = multi_geom.asMultiPolyline()
best_dist = float('inf')
best_geom = None
ref_geom = QgsGeometry.fromPointXY(ref_point)
for part in parts:
g = QgsGeometry.fromPolylineXY(part)
d = g.distance(ref_geom)
if d < best_dist:
best_dist = d
best_geom = g
return best_geom if best_geom else multi_geom
return multi_geom
# ══════════════════════════════════════════════════════════════════════════
# Determinar o lado do offset (acima / abaixo) via produto vetorial
# ══════════════════════════════════════════════════════════════════════════
def determine_offset_side(line_geom, label_centroid_pt, center_distance):
"""
Retorna +1 (esquerda / acima) ou -1 (direita / abaixo) com base
na posição do centróide do label em relação à tangente da linha.
"""
line_length = line_geom.length()
if line_length <= 0:
return 1
# Obter dois pontos ao longo da linha para calcular tangente local
d = center_distance
delta = min(line_length * 0.001, 0.5) # pequeno incremento
d1 = max(0, d - delta)
d2 = min(line_length, d + delta)
p1 = line_geom.interpolate(d1).asPoint()
p2 = line_geom.interpolate(d2).asPoint()
# Vetor tangente
tx = p2.x() - p1.x()
ty = p2.y() - p1.y()
# Vetor: ponto na linha → centróide do label
base_pt = line_geom.interpolate(d).asPoint()
dx = label_centroid_pt.x() - base_pt.x()
dy = label_centroid_pt.y() - base_pt.y()
# Produto vetorial (z-component): tx*dy - ty*dx
cross = tx * dy - ty * dx
return 1 if cross >= 0 else -1
# ══════════════════════════════════════════════════════════════════════════
# Processar um rótulo → feature de linha
# ══════════════════════════════════════════════════════════════════════════
def process_label_to_line(lbl_pos, project, output_layer, font_info_cache,
project_crs, crs_transforms):
"""
Para cada label:
1. Calcula comprimento do texto em map units
2. Projeta centróide do labelGeometry na feição de linha
3. Extrai curveSubstring com comprimento exato
4. Se acima/abaixo da linha, aplica offsetCurve
Retorna 1 se gerou feature, 0 caso contrário.
"""
layer_id = lbl_pos.layerID
feature_id = lbl_pos.featureId
label_text = lbl_pos.labelText
if not label_text:
return 0
layer = project.mapLayer(layer_id)
if layer is None:
return 0
# Verificar se é camada de linhas
if layer.geometryType() != QgsWkbTypes.LineGeometry:
return 0
layer_name = layer.name()
label_geom = lbl_pos.labelGeometry
if label_geom.isEmpty():
return 0
# ── Info da fonte ──
if layer_id not in font_info_cache:
return 0
fi = font_info_cache[layer_id]
if not fi.valid:
return 0
# ── Comprimento do label em map units ──
label_length_mu = compute_label_length_mu(label_text, fi)
if label_length_mu <= 0:
return 0
# ── Obter feição de linha original ──
source_feat = layer.getFeature(feature_id)
if not source_feat.isValid() or source_feat.geometry().isEmpty():
return 0
line_geom = QgsGeometry(source_feat.geometry())
# Transformar para CRS do projeto se necessário
if layer_id in crs_transforms:
line_geom.transform(crs_transforms[layer_id])
# ── Centróide do label ──
label_centroid = label_geom.centroid()
if label_centroid.isEmpty():
return 0
label_centroid_pt = label_centroid.asPoint()
# ── Resolver multipart ──
line_geom = get_closest_singlepart(line_geom, label_centroid_pt)
line_length = line_geom.length()
if line_length <= 0:
return 0
# ── Projetar centróide na linha ──
closest_pt_geom = line_geom.nearestPoint(label_centroid)
center_distance = line_geom.lineLocatePoint(closest_pt_geom)
# ── Extrair substring com comprimento exato do label ──
half_len = label_length_mu / 2.0
start_d = center_distance - half_len
end_d = center_distance + half_len
# Ajustar se extrapolar os limites da linha
if start_d < 0:
start_d = 0.0
end_d = min(label_length_mu, line_length)
if end_d > line_length:
end_d = line_length
start_d = max(0.0, end_d - label_length_mu)
# curveSubstring opera na geometria abstrata
abstract_geom = line_geom.constGet()
if abstract_geom is None:
return 0
try:
substring = abstract_geom.curveSubstring(start_d, end_d)
except Exception:
return 0
if substring is None or substring.isEmpty():
return 0
result_geom = QgsGeometry(substring)
# ── Offset baseado na configuração da camada ──
offset_dist = 0.0
side_str = "on"
only_on_line = fi.is_on_line and not fi.is_above_line and not fi.is_below_line
only_above_below = (fi.is_above_line or fi.is_below_line) and not fi.is_on_line
mixed_flags = fi.is_on_line and (fi.is_above_line or fi.is_below_line)
if only_on_line:
# ON LINE exclusivo: nunca aplicar offset
offset_dist = 0.0
side_str = "on"
elif only_above_below:
# ABOVE/BELOW exclusivo: sempre aplicar offset
dist_centroid_to_line = line_geom.distance(label_centroid)
dist_bottom_to_line = line_geom.distance(label_geom)
real_offset = max(0.0, dist_centroid_to_line - dist_bottom_to_line)
offset_sign = determine_offset_side(
line_geom, label_centroid_pt, center_distance
)
offset_dist = real_offset * offset_sign
side_str = "above" if offset_sign > 0 else "below"
elif mixed_flags:
# Ambos habilitados: QGIS decide por label.
# Detectar empiricamente pela distância da borda à linha.
label_height = compute_label_height_mu(fi)
dist_bottom_to_line = line_geom.distance(label_geom)
if dist_bottom_to_line > label_height * ON_LINE_TOLERANCE_FACTOR:
# Este label está above/below
dist_centroid_to_line = line_geom.distance(label_centroid)
real_offset = max(0.0, dist_centroid_to_line - dist_bottom_to_line)
offset_sign = determine_offset_side(
line_geom, label_centroid_pt, center_distance
)
offset_dist = real_offset * offset_sign
side_str = "above" if offset_sign > 0 else "below"
else:
offset_dist = 0.0
side_str = "on"
# ── Aplicar offsetCurve se necessário (QGIS 3.28+) ──
if offset_dist != 0.0:
try:
offset_result = result_geom.offsetCurve(
offset_dist,
8, # segments
Qgis.JoinStyle.Round, # joinStyle (enum QGIS 3.28+)
5.0 # miterLimit
)
if offset_result and not offset_result.isEmpty():
result_geom = offset_result
else:
# Fallback: tentar com valor negado
offset_result = result_geom.offsetCurve(
-offset_dist, 8, Qgis.JoinStyle.Round, 5.0
)
if offset_result and not offset_result.isEmpty():
result_geom = offset_result
side_str = "below" if side_str == "above" else "above"
except Exception as e:
print(f" ⚠ offsetCurve falhou: {e}")
# ── Criar feature de saída ──
dp = output_layer.dataProvider()
feat = QgsFeature(output_layer.fields())
feat.setGeometry(result_geom)
feat.setAttributes([
layer_name,
feature_id,
label_text,
round(label_length_mu, 4),
round(result_geom.length(), 4),
round(offset_dist, 4),
side_str,
])
dp.addFeature(feat)
return 1
# ══════════════════════════════════════════════════════════════════════════
# Função principal
# ══════════════════════════════════════════════════════════════════════════
def extract_label_lines():
project = QgsProject.instance()
canvas = iface.mapCanvas()
project_crs = project.crs()
root = project.layerTreeRoot()
# ── Camada de referência ──
matches = project.mapLayersByName(POLYGON_LAYER_NAME)
if not matches:
raise RuntimeError(
f"Camada '{POLYGON_LAYER_NAME}' não encontrada.\n"
f"Ajuste POLYGON_LAYER_NAME no início do script."
)
ref_layer = matches[0]
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() == OUTPUT_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 com rótulos: {[l.name() for l in labeled]}")
# ── Resolução ──
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
print(f"DPI: {canvas_dpi:.0f}")
print(f"Escala de renderização: 1:{use_scale:,.0f} ({mu_per_px:.10f} u.m./px)")
# ── Info de fonte de cada camada ──
print("\nExtraindo propriedades de fonte:")
font_info_cache = {}
for layer in labeled:
print(f" [{layer.name()}]", end=" ")
font_info_cache[layer.id()] = LabelFontInfo(layer, mu_per_px, canvas_dpi)
# ── Transformações CRS ──
crs_transforms = {}
for layer in labeled:
if layer.crs() != project_crs:
crs_transforms[layer.id()] = QgsCoordinateTransform(
layer.crs(), project_crs, project
)
print(f" CRS transform: {layer.name()} "
f"({layer.crs().authid()} → {project_crs.authid()})")
needs_ref_transform = (ref_layer.crs() != project_crs)
if needs_ref_transform:
ref_xform = QgsCoordinateTransform(ref_layer.crs(), project_crs, project)
# ── Remover camada anterior ──
from qgis.PyQt.QtWidgets import QApplication
ids = [l.id() for l in project.mapLayersByName(OUTPUT_LAYER_NAME)]
if ids:
canvas.setRenderFlag(False)
QApplication.processEvents()
project.removeMapLayers(ids)
QApplication.processEvents()
canvas.setRenderFlag(True)
QApplication.processEvents()
# ── Criar camada de saída ──
output_layer = QgsVectorLayer(
f"LineString?crs={project_crs.authid()}&index=yes",
OUTPUT_LAYER_NAME, "memory"
)
dp = output_layer.dataProvider()
dp.addAttributes([
QgsField("camada", QVariant.String),
QgsField("fid", QVariant.LongLong),
QgsField("texto", QVariant.String),
QgsField("comp_label", QVariant.Double),
QgsField("comp_linha", QVariant.Double),
QgsField("offset", QVariant.Double),
QgsField("lado", QVariant.String),
])
output_layer.updateFields()
seen = set()
total_labels = 0
# ── Loop principal ──
print(f"\nProcessando {total_polys} polígonos…")
for i, feat in enumerate(ref_layer.getFeatures()):
geom = feat.geometry()
if geom.isEmpty():
continue
if needs_ref_transform:
geom.transform(ref_xform)
bbox = geom.boundingBox()
mw = max(bbox.width() * BBOX_MARGIN, 0.001)
mh = max(bbox.height() * BBOX_MARGIN, 0.001)
extent = QgsRectangle(
bbox.xMinimum() - mw, bbox.yMinimum() - mh,
bbox.xMaximum() + mw, bbox.yMaximum() + mh,
)
show = ((i + 1) % 10 == 0) or (i == 0) or ((i + 1) == total_polys)
if show:
print(f" Polígono {i + 1}/{total_polys}…", end="")
labels = render_and_collect_labels(
extent, visible_layers, project_crs, mu_per_px, canvas_dpi
)
n_new = 0
for lbl in labels:
key = (lbl.layerID, lbl.featureId, lbl.labelText)
if not lbl.labelText or key in seen:
continue
seen.add(key)
n = process_label_to_line(
lbl, project, output_layer, font_info_cache,
project_crs, crs_transforms
)
total_labels += n
n_new += n
if show:
print(f" → {n_new} novos rótulos")
# ── Resultado ──
output_layer.updateExtents()
project.addMapLayer(output_layer)
# Estilo: linha vermelha
symbol = QgsLineSymbol.createSimple({
'color': '255,50,50,200',
'width': '0.5',
'capstyle': 'round',
})
output_layer.setRenderer(QgsSingleSymbolRenderer(symbol))
output_layer.triggerRepaint()
canvas.refresh()
print(f"\n{'═' * 60}")
print(f" ✔ Polígonos processados : {total_polys}")
print(f" ✔ Rótulos capturados : {total_labels}")
print(f" ✔ Rótulos únicos : {len(seen)}")
print(f" → Camada '{OUTPUT_LAYER_NAME}' (linha vermelha)")
print(f"{'═' * 60}")
# ══════════════════════════════════════════════════════════════════════════
# Execução
# ══════════════════════════════════════════════════════════════════════════
try:
extract_label_lines()
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