519 lines
21 KiB
Python
519 lines
21 KiB
Python
"""Fenêtre principale : navigateur + panneau latéral de capture PDF."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import QStandardPaths, QUrl, Qt
|
||
from PySide6.QtGui import QAction, QKeySequence
|
||
from PySide6.QtWebChannel import QWebChannel
|
||
from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineScript
|
||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||
from PySide6.QtWidgets import (
|
||
QCheckBox,
|
||
QDockWidget,
|
||
QFileDialog,
|
||
QHBoxLayout,
|
||
QInputDialog,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMainWindow,
|
||
QMessageBox,
|
||
QPushButton,
|
||
QToolBar,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from .block_picker import BLOCK_PICKER_INIT_JS, SET_STICKY_JS_TEMPLATE
|
||
from .bridge import Bridge, build_bridge_init_script
|
||
from .link_detector import LINK_DETECTOR_INIT_JS, SELECTION_JS
|
||
from .models import Clip
|
||
from .pdf_builder import build_pdf
|
||
from .pdf_style import PdfStyle
|
||
from .preview_window import PdfPreviewWindow
|
||
|
||
HOME_URL = "https://www.google.com/"
|
||
|
||
|
||
class BrowserPage(QWebEnginePage):
|
||
"""Page web qui ouvre les liens target="_blank" dans la même vue
|
||
plutôt que de les laisser disparaître silencieusement."""
|
||
|
||
def createWindow(self, _window_type): # noqa: N802 (nom imposé par Qt)
|
||
return self
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.setWindowTitle("PdfScrapper")
|
||
|
||
self.clips: list[Clip] = []
|
||
self.last_suggested_relnext: str | None = None
|
||
self.block_picker_enabled = False
|
||
self.insert_above = True
|
||
self.pdf_style = PdfStyle()
|
||
self.preview_window: PdfPreviewWindow | None = None
|
||
|
||
self._build_webview()
|
||
self._build_toolbar()
|
||
self._build_sidebar()
|
||
|
||
self.webview.setUrl(QUrl(HOME_URL))
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Construction de l'UI
|
||
# ------------------------------------------------------------------ #
|
||
def _build_webview(self) -> None:
|
||
storage_dir = Path(
|
||
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
||
) / "pdfscrapper"
|
||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
profile = QWebEngineProfile("pdfscrapper_profile", self)
|
||
profile.setPersistentCookiesPolicy(
|
||
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
|
||
)
|
||
profile.setPersistentStoragePath(str(storage_dir))
|
||
profile.setCachePath(str(storage_dir / "cache"))
|
||
|
||
# Injecté au tout début de chaque page pour établir le pont
|
||
# QWebChannel (window.pdfscrapperBridge) utilisé par le mode
|
||
# "capture au survol" — voir bridge.py / block_picker.py.
|
||
bridge_script = QWebEngineScript()
|
||
bridge_script.setName("pdfscrapper_bridge_init")
|
||
bridge_script.setSourceCode(build_bridge_init_script())
|
||
bridge_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||
bridge_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||
bridge_script.setRunsOnSubFrames(False)
|
||
profile.scripts().insert(bridge_script)
|
||
|
||
# Script persistant du mode "capture au survol" (surlignage de
|
||
# bloc + activation par Ctrl maintenu ou par bouton) — voir
|
||
# block_picker.py pour le détail du fonctionnement.
|
||
block_picker_script = QWebEngineScript()
|
||
block_picker_script.setName("pdfscrapper_block_picker_init")
|
||
block_picker_script.setSourceCode(BLOCK_PICKER_INIT_JS)
|
||
block_picker_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||
block_picker_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||
block_picker_script.setRunsOnSubFrames(False)
|
||
profile.scripts().insert(block_picker_script)
|
||
|
||
# Script persistant de détection des liens de "suite" — scanne en
|
||
# continu via un MutationObserver (plutôt qu'une seule fois au
|
||
# chargement) pour détecter aussi les liens ajoutés dynamiquement
|
||
# par la page elle-même (SPA, contenu chargé après coup). Voir
|
||
# link_detector.py.
|
||
link_detector_script = QWebEngineScript()
|
||
link_detector_script.setName("pdfscrapper_link_detector_init")
|
||
link_detector_script.setSourceCode(LINK_DETECTOR_INIT_JS)
|
||
link_detector_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
|
||
link_detector_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||
link_detector_script.setRunsOnSubFrames(False)
|
||
profile.scripts().insert(link_detector_script)
|
||
|
||
self.webview = QWebEngineView(self)
|
||
self.webview.setPage(BrowserPage(profile, self.webview))
|
||
self.setCentralWidget(self.webview)
|
||
|
||
self.bridge = Bridge(self)
|
||
self.bridge.block_captured.connect(self._handle_block_capture)
|
||
self.bridge.links_detected.connect(self._on_links_detected)
|
||
self.web_channel = QWebChannel(self)
|
||
self.web_channel.registerObject("pdfscrapperBridge", self.bridge)
|
||
self.webview.page().setWebChannel(self.web_channel)
|
||
|
||
self.webview.urlChanged.connect(self._on_url_changed)
|
||
self.webview.loadFinished.connect(self._on_load_finished)
|
||
self.webview.titleChanged.connect(self._on_title_changed)
|
||
|
||
def _build_toolbar(self) -> None:
|
||
toolbar = QToolBar("Navigation", self)
|
||
toolbar.setMovable(False)
|
||
self.addToolBar(toolbar)
|
||
|
||
back_action = QAction("◀", self)
|
||
back_action.setToolTip("Page précédente")
|
||
back_action.triggered.connect(self.webview.back)
|
||
toolbar.addAction(back_action)
|
||
|
||
forward_action = QAction("▶", self)
|
||
forward_action.setToolTip("Page suivante")
|
||
forward_action.triggered.connect(self.webview.forward)
|
||
toolbar.addAction(forward_action)
|
||
|
||
reload_action = QAction("⟳", self)
|
||
reload_action.setToolTip("Recharger")
|
||
reload_action.triggered.connect(self.webview.reload)
|
||
toolbar.addAction(reload_action)
|
||
|
||
home_action = QAction("🏠", self)
|
||
home_action.setToolTip("Page d'accueil")
|
||
home_action.triggered.connect(lambda: self.webview.setUrl(QUrl(HOME_URL)))
|
||
toolbar.addAction(home_action)
|
||
|
||
self.address_bar = QLineEdit(self)
|
||
self.address_bar.returnPressed.connect(self._navigate_to_address_bar)
|
||
toolbar.addWidget(self.address_bar)
|
||
|
||
go_action = QAction("Go", self)
|
||
go_action.triggered.connect(self._navigate_to_address_bar)
|
||
toolbar.addAction(go_action)
|
||
|
||
toolbar.addSeparator()
|
||
|
||
capture_action = QAction("➕ Ajouter la sélection au PDF", self)
|
||
capture_action.setShortcut(QKeySequence("Ctrl+Shift+A"))
|
||
capture_action.setToolTip("Ajoute le texte actuellement sélectionné dans la page (Ctrl+Shift+A)")
|
||
capture_action.triggered.connect(self.capture_selection)
|
||
toolbar.addAction(capture_action)
|
||
|
||
self.block_picker_action = QAction("🎯 Mode capture au survol", self)
|
||
self.block_picker_action.setCheckable(True)
|
||
self.block_picker_action.setShortcut(QKeySequence("Ctrl+Shift+B"))
|
||
self.block_picker_action.setToolTip(
|
||
"Survolez un paragraphe/bloc pour le surligner, cliquez pour l'ajouter au PDF.\n"
|
||
"• Ctrl+Shift+B (ou ce bouton) : reste activé jusqu'à nouvelle bascule.\n"
|
||
"• Maintenir Ctrl enfoncé dans la page : activation temporaire, désactivée au "
|
||
"relâchement.\n"
|
||
"Désactive temporairement le clic normal sur la page tant qu'actif."
|
||
)
|
||
self.block_picker_action.toggled.connect(self._toggle_block_picker)
|
||
toolbar.addAction(self.block_picker_action)
|
||
|
||
def _build_sidebar(self) -> None:
|
||
dock = QDockWidget("Capture PDF", self)
|
||
dock.setFeatures(
|
||
QDockWidget.DockWidgetFeature.DockWidgetMovable
|
||
| QDockWidget.DockWidgetFeature.DockWidgetFloatable
|
||
)
|
||
|
||
panel = QWidget()
|
||
layout = QVBoxLayout(panel)
|
||
|
||
self.clip_count_label = QLabel("Extraits capturés : 0")
|
||
layout.addWidget(self.clip_count_label)
|
||
|
||
self.insert_above_checkbox = QCheckBox("Insérer les nouvelles captures au-dessus de la sélection")
|
||
self.insert_above_checkbox.setChecked(self.insert_above)
|
||
self.insert_above_checkbox.setToolTip(
|
||
"Coché : chaque nouvelle capture s'insère juste au-dessus de l'extrait "
|
||
"sélectionné dans la liste.\n"
|
||
"Décoché : elle s'insère juste en dessous.\n"
|
||
"Sans sélection, elle s'ajoute toujours à la fin."
|
||
)
|
||
self.insert_above_checkbox.toggled.connect(self._toggle_insert_direction)
|
||
layout.addWidget(self.insert_above_checkbox)
|
||
|
||
self.clip_list = QListWidget()
|
||
self.clip_list.setToolTip("Double-cliquez sur un extrait pour le renommer.")
|
||
self.clip_list.itemDoubleClicked.connect(lambda _item: self._rename_selected_clip())
|
||
layout.addWidget(self.clip_list, stretch=2)
|
||
|
||
clip_buttons = QHBoxLayout()
|
||
rename_btn = QPushButton("✏️ Renommer")
|
||
up_btn = QPushButton("▲ Monter")
|
||
down_btn = QPushButton("▼ Descendre")
|
||
delete_btn = QPushButton("🗑 Supprimer")
|
||
rename_btn.clicked.connect(self._rename_selected_clip)
|
||
up_btn.clicked.connect(lambda: self._move_clip(-1))
|
||
down_btn.clicked.connect(lambda: self._move_clip(1))
|
||
delete_btn.clicked.connect(self._delete_selected_clip)
|
||
clip_buttons.addWidget(rename_btn)
|
||
clip_buttons.addWidget(up_btn)
|
||
clip_buttons.addWidget(down_btn)
|
||
clip_buttons.addWidget(delete_btn)
|
||
layout.addLayout(clip_buttons)
|
||
|
||
clear_btn = QPushButton("Vider tout")
|
||
clear_btn.clicked.connect(self._clear_clips)
|
||
layout.addWidget(clear_btn)
|
||
|
||
preview_btn = QPushButton("🖌 Aperçu et style du PDF…")
|
||
preview_btn.clicked.connect(self.open_pdf_preview)
|
||
layout.addWidget(preview_btn)
|
||
|
||
layout.addWidget(_separator_label("Liens suggérés sur cette page"))
|
||
self.suggested_list = QListWidget()
|
||
self.suggested_list.itemDoubleClicked.connect(self._navigate_to_suggested)
|
||
layout.addWidget(self.suggested_list, stretch=1)
|
||
|
||
self.next_page_btn = QPushButton("→ Aller à la suite détectée")
|
||
self.next_page_btn.setEnabled(False)
|
||
self.next_page_btn.clicked.connect(self._go_to_relnext)
|
||
layout.addWidget(self.next_page_btn)
|
||
|
||
export_btn = QPushButton("📄 Exporter en PDF…")
|
||
export_btn.clicked.connect(self.export_pdf)
|
||
layout.addWidget(export_btn)
|
||
|
||
dock.setWidget(panel)
|
||
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Navigation
|
||
# ------------------------------------------------------------------ #
|
||
def _navigate_to_address_bar(self) -> None:
|
||
text = self.address_bar.text().strip()
|
||
if not text:
|
||
return
|
||
if "://" not in text and " " not in text and "." in text:
|
||
text = "https://" + text
|
||
elif "://" not in text:
|
||
text = "https://www.google.com/search?q=" + text.replace(" ", "+")
|
||
self.webview.setUrl(QUrl(text))
|
||
|
||
def _on_url_changed(self, url: QUrl) -> None:
|
||
self.address_bar.setText(url.toString())
|
||
# Efface les suggestions de la page précédente : le script de
|
||
# détection (persistant, voir link_detector.py) rescanne tout seul
|
||
# et repoussera des résultats frais sous peu — inutile d'attendre
|
||
# loadFinished, qui ne se déclenche d'ailleurs pas pour les
|
||
# navigations internes de type SPA (pushState).
|
||
self.suggested_list.clear()
|
||
self.next_page_btn.setEnabled(False)
|
||
self.last_suggested_relnext = None
|
||
|
||
def _on_title_changed(self, title: str) -> None:
|
||
self.setWindowTitle(f"PdfScrapper — {title}" if title else "PdfScrapper")
|
||
|
||
def _on_load_finished(self, ok: bool) -> None:
|
||
if not ok:
|
||
return
|
||
# L'état JS "sticky" (bascule bouton/raccourci) ne survit pas à une
|
||
# navigation : on le reporte sur la page fraîchement chargée. Le
|
||
# maintien de Ctrl, lui, est purement géré côté JS en continu. La
|
||
# détection de liens, elle, est poussée en continu par le script
|
||
# persistant (MutationObserver) — rien à déclencher ici.
|
||
self._sync_sticky_state()
|
||
|
||
def _sync_sticky_state(self) -> None:
|
||
value = "true" if self.block_picker_enabled else "false"
|
||
self.webview.page().runJavaScript(SET_STICKY_JS_TEMPLATE.format(value=value))
|
||
|
||
def _on_links_detected(self, links_json: str) -> None:
|
||
try:
|
||
result = json.loads(links_json)
|
||
except (TypeError, ValueError):
|
||
return
|
||
|
||
self.suggested_list.clear()
|
||
for link in result.get("links", []):
|
||
item = QListWidgetItem(link["text"] or link["href"])
|
||
item.setData(Qt.ItemDataRole.UserRole, link["href"])
|
||
item.setToolTip(link["href"])
|
||
self.suggested_list.addItem(item)
|
||
|
||
self.last_suggested_relnext = result.get("relNext")
|
||
self.next_page_btn.setEnabled(bool(self.last_suggested_relnext))
|
||
|
||
def _navigate_to_suggested(self, item: QListWidgetItem) -> None:
|
||
href = item.data(Qt.ItemDataRole.UserRole)
|
||
if href:
|
||
self.webview.setUrl(QUrl(href))
|
||
|
||
def _go_to_relnext(self) -> None:
|
||
if self.last_suggested_relnext:
|
||
self.webview.setUrl(QUrl(self.last_suggested_relnext))
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Capture de texte
|
||
# ------------------------------------------------------------------ #
|
||
def capture_selection(self) -> None:
|
||
self.webview.page().runJavaScript(SELECTION_JS, self._on_selection_captured)
|
||
|
||
def _on_selection_captured(self, selected_text: str | None) -> None:
|
||
if not selected_text or not selected_text.strip():
|
||
self.statusBar().showMessage("Aucun texte sélectionné dans la page.", 4000)
|
||
return
|
||
self._add_clip(selected_text)
|
||
|
||
def _toggle_block_picker(self, enabled: bool) -> None:
|
||
self.block_picker_enabled = enabled
|
||
self._sync_sticky_state()
|
||
if enabled:
|
||
self.statusBar().showMessage(
|
||
"Mode capture au survol activé en continu : survolez un bloc puis cliquez "
|
||
"pour l'ajouter au PDF (le clic normal est suspendu tant que ce mode est actif).",
|
||
6000,
|
||
)
|
||
else:
|
||
self.statusBar().showMessage(
|
||
"Mode capture au survol désactivé (astuce : maintenez Ctrl enfoncé dans la "
|
||
"page pour l'activer temporairement sans bouton).",
|
||
4000,
|
||
)
|
||
|
||
def _handle_block_capture(self, text: str) -> None:
|
||
self._add_clip(text)
|
||
|
||
def _toggle_insert_direction(self, above: bool) -> None:
|
||
self.insert_above = above
|
||
self.statusBar().showMessage(
|
||
"Nouvelles captures insérées au-dessus de la sélection." if above
|
||
else "Nouvelles captures insérées en dessous de la sélection.",
|
||
3000,
|
||
)
|
||
|
||
def _add_clip(self, text: str) -> None:
|
||
clip = Clip(
|
||
text=text.strip(),
|
||
title=self.webview.title() or self.webview.url().toString(),
|
||
url=self.webview.url().toString(),
|
||
captured_at=datetime.now(),
|
||
)
|
||
|
||
# Insère juste au-dessus ou en dessous de l'extrait actuellement
|
||
# sélectionné (selon self.insert_above), plutôt que toujours à la
|
||
# fin — pour pouvoir intercaler une capture au milieu d'un ordre
|
||
# déjà établi.
|
||
selected_row = self.clip_list.currentRow()
|
||
has_selection = 0 <= selected_row < len(self.clips)
|
||
|
||
if not has_selection:
|
||
# Pas de repère : simple accumulation chronologique à la fin.
|
||
# On ne sélectionne rien après coup, pour que les captures
|
||
# suivantes continuent d'être de simples ajouts à la fin,
|
||
# quel que soit le mode — le mode au-dessus/en dessous ne
|
||
# prend effet que si l'utilisateur choisit lui-même un repère.
|
||
insert_at = len(self.clips)
|
||
self.clips.insert(insert_at, clip)
|
||
self._refresh_clip_list()
|
||
self.clip_list.setCurrentRow(-1)
|
||
elif self.insert_above:
|
||
insert_at = selected_row
|
||
self.clips.insert(insert_at, clip)
|
||
self._refresh_clip_list()
|
||
# On garde le repère (l'extrait qui était sélectionné, décalé
|
||
# d'un cran) comme sélection courante, pour que les captures
|
||
# suivantes continuent de s'empiler au-dessus de lui, dans
|
||
# l'ordre de capture.
|
||
self.clip_list.setCurrentRow(selected_row + 1)
|
||
else:
|
||
insert_at = selected_row + 1
|
||
self.clips.insert(insert_at, clip)
|
||
self._refresh_clip_list()
|
||
# La nouvelle capture devient elle-même le repère, pour que
|
||
# les suivantes s'enchaînent juste en dessous d'elle.
|
||
self.clip_list.setCurrentRow(insert_at)
|
||
|
||
self.statusBar().showMessage("Extrait ajouté au PDF.", 3000)
|
||
|
||
def _refresh_clip_list(self) -> None:
|
||
# Préserve la sélection courante : cette méthode est aussi appelée
|
||
# en arrière-plan par la fenêtre d'aperçu (à chaque correction de
|
||
# texte), qui ne doit pas faire perdre le repère d'insertion
|
||
# au-dessus/en dessous choisi dans le panneau latéral.
|
||
preserved_row = self.clip_list.currentRow()
|
||
|
||
self.clip_list.clear()
|
||
for clip in self.clips:
|
||
label = f"[{clip.captured_at.strftime('%H:%M')}] {clip.title} — {clip.preview()}"
|
||
item = QListWidgetItem(label)
|
||
item.setToolTip(clip.url)
|
||
self.clip_list.addItem(item)
|
||
self.clip_count_label.setText(f"Extraits capturés : {len(self.clips)}")
|
||
|
||
if 0 <= preserved_row < len(self.clips):
|
||
self.clip_list.setCurrentRow(preserved_row)
|
||
|
||
def _move_clip(self, offset: int) -> None:
|
||
row = self.clip_list.currentRow()
|
||
new_row = row + offset
|
||
if row < 0 or not (0 <= new_row < len(self.clips)):
|
||
return
|
||
self.clips[row], self.clips[new_row] = self.clips[new_row], self.clips[row]
|
||
self._refresh_clip_list()
|
||
self.clip_list.setCurrentRow(new_row)
|
||
|
||
def _delete_selected_clip(self) -> None:
|
||
row = self.clip_list.currentRow()
|
||
if row < 0:
|
||
return
|
||
del self.clips[row]
|
||
self._refresh_clip_list()
|
||
|
||
def _rename_selected_clip(self) -> None:
|
||
row = self.clip_list.currentRow()
|
||
if not (0 <= row < len(self.clips)):
|
||
self.statusBar().showMessage("Sélectionnez d'abord un extrait à renommer.", 3000)
|
||
return
|
||
|
||
clip = self.clips[row]
|
||
new_title, ok = QInputDialog.getText(
|
||
self,
|
||
"Renommer l'extrait",
|
||
"Titre affiché (utilisé aussi comme titre de section dans le PDF) :",
|
||
QLineEdit.EchoMode.Normal,
|
||
clip.title,
|
||
)
|
||
if ok and new_title.strip():
|
||
clip.title = new_title.strip()
|
||
self._refresh_clip_list()
|
||
self.clip_list.setCurrentRow(row)
|
||
|
||
def _clear_clips(self) -> None:
|
||
if not self.clips:
|
||
return
|
||
confirm = QMessageBox.question(
|
||
self,
|
||
"Vider les extraits",
|
||
"Supprimer tous les extraits capturés ?",
|
||
)
|
||
if confirm == QMessageBox.StandardButton.Yes:
|
||
self.clips.clear()
|
||
self._refresh_clip_list()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Aperçu & style du PDF
|
||
# ------------------------------------------------------------------ #
|
||
def open_pdf_preview(self) -> None:
|
||
if self.preview_window is None:
|
||
self.preview_window = PdfPreviewWindow(
|
||
style=self.pdf_style,
|
||
get_clips=lambda: self.clips,
|
||
parent=self,
|
||
on_clips_changed=self._refresh_clip_list,
|
||
)
|
||
self.preview_window.refresh_preview()
|
||
self.preview_window.show()
|
||
self.preview_window.raise_()
|
||
self.preview_window.activateWindow()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# Export PDF
|
||
# ------------------------------------------------------------------ #
|
||
def export_pdf(self) -> None:
|
||
if not self.clips:
|
||
QMessageBox.warning(self, "Export PDF", "Aucun extrait à exporter.")
|
||
return
|
||
|
||
default_name = f"extraits_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf"
|
||
path, _ = QFileDialog.getSaveFileName(
|
||
self, "Exporter en PDF", default_name, "Fichiers PDF (*.pdf)"
|
||
)
|
||
if not path:
|
||
return
|
||
if not path.lower().endswith(".pdf"):
|
||
path += ".pdf"
|
||
|
||
try:
|
||
build_pdf(self.clips, path, self.pdf_style)
|
||
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
|
||
QMessageBox.critical(self, "Export PDF", f"Échec de l'export :\n{exc}")
|
||
return
|
||
|
||
self.statusBar().showMessage(f"PDF exporté : {path}", 5000)
|
||
QMessageBox.information(self, "Export PDF", f"PDF généré avec succès :\n{path}")
|
||
|
||
|
||
def _separator_label(text: str) -> QLabel:
|
||
label = QLabel(text)
|
||
label.setStyleSheet("font-weight: bold; margin-top: 8px;")
|
||
return label
|