Created
February 6, 2026 16:29
-
-
Save dinizime/43227baa32727f7a1dbbe49c4069671a to your computer and use it in GitHub Desktop.
Cria poligonos ao redor das letras dos rótulos de forma precisa
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| ============================================================================ | |
| 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