diff --git a/CHANGELOG.md b/CHANGELOG.md index bba227a..ab06029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v2.3.0 + +- [#5](https://github.com/procitec/qlitehtmlbrowser/issues/5): Implement selection + + ## v2.2.1 - [#17](https://github.com/procitec/qlitehtmlbrowser/issues/17): Scale changed signal diff --git a/CMakeLists.txt b/CMakeLists.txt index 56df0b7..2c710b0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.28) -project( QLiteHtmlBrowser VERSION 2.2.1 ) +project( QLiteHtmlBrowser VERSION 2.3.0 ) include(GNUInstallDirs) diff --git a/include/qlitehtmlbrowser/QLiteHtmlBrowser.h b/include/qlitehtmlbrowser/QLiteHtmlBrowser.h index 200766b..6856833 100644 --- a/include/qlitehtmlbrowser/QLiteHtmlBrowser.h +++ b/include/qlitehtmlbrowser/QLiteHtmlBrowser.h @@ -25,6 +25,11 @@ class QLITEHTMLBROWSER_EXPORT QLiteHtmlBrowser : public QWidget { Q_OBJECT +#ifdef UNIT_TEST + friend class FindTest; + friend class SelectionTest; +#endif + /// property to access the url that is currently shown in the browser Q_PROPERTY( QUrl source READ source WRITE setSource ) @@ -155,6 +160,8 @@ class QLITEHTMLBROWSER_EXPORT QLiteHtmlBrowser : public QWidget QColor highlightColor() const; void setHighlightColor( QColor color ); + QString selectedText() const; + public Q_SLOTS: /// set URL to given url. The URL may be an url to local file, QtHelp, http etc. /// The URL could contain an anchor element. Currently parameters to URL like @@ -188,7 +195,9 @@ public Q_SLOTS: /// send when scale changes void scaleChanged(); + /// send when selection changes + void selectionChanged(); + protected: -private: QLiteHtmlBrowserImpl* mImpl = nullptr; }; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 27f34fd..18ec27d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,8 @@ target_sources(QLiteHtmlBrowser QLiteHtmlBrowser.cpp QLiteHtmlBrowserImpl.cpp QLiteHtmlBrowserImpl.h + TextManager.h + TextManager.cpp container_qt.cpp container_qt.h browserdefinitions.h diff --git a/src/QLiteHtmlBrowser.cpp b/src/QLiteHtmlBrowser.cpp index ec850d4..bd4c801 100644 --- a/src/QLiteHtmlBrowser.cpp +++ b/src/QLiteHtmlBrowser.cpp @@ -19,6 +19,7 @@ QLiteHtmlBrowser::QLiteHtmlBrowser( QWidget* parent ) connect( mImpl, &QLiteHtmlBrowserImpl::urlChanged, this, &QLiteHtmlBrowser::sourceChanged ); connect( mImpl, &QLiteHtmlBrowserImpl::anchorClicked, this, &QLiteHtmlBrowser::anchorClicked ); connect( mImpl, &QLiteHtmlBrowserImpl::scaleChanged, this, &QLiteHtmlBrowser::scaleChanged ); + connect( mImpl, &QLiteHtmlBrowserImpl::selectionChanged, this, &QLiteHtmlBrowser::selectionChanged ); } QUrl QLiteHtmlBrowser::source() const @@ -187,3 +188,8 @@ void QLiteHtmlBrowser::setHighlightColor( QColor color ) { mImpl->setHighlightColor( color ); } + +QString QLiteHtmlBrowser::selectedText() const +{ + return mImpl->selectedText(); +} diff --git a/src/QLiteHtmlBrowserImpl.cpp b/src/QLiteHtmlBrowserImpl.cpp index 9f000de..cc048e6 100644 --- a/src/QLiteHtmlBrowserImpl.cpp +++ b/src/QLiteHtmlBrowserImpl.cpp @@ -25,6 +25,7 @@ QLiteHtmlBrowserImpl::QLiteHtmlBrowserImpl( QWidget* parent ) shortcut = new QShortcut( QKeySequence{ QKeySequence::Back }, this ); connect( shortcut, &QShortcut::activated, this, &QLiteHtmlBrowserImpl::backward ); connect( mContainer, &container_qt::scaleChanged, this, &QLiteHtmlBrowserImpl::scaleChanged ); + connect( mContainer, &container_qt::selectionChanged, this, &QLiteHtmlBrowserImpl::selectionChanged ); auto* layout = new QVBoxLayout; layout->setContentsMargins( 0, 0, 0, 0 ); @@ -494,3 +495,12 @@ void QLiteHtmlBrowserImpl::previousFindMatch() mContainer->findPreviousMatch(); } } +QString QLiteHtmlBrowserImpl::selectedText() const +{ + QString text; + if ( mContainer ) + { + text = mContainer->selectedText(); + } + return text; +} diff --git a/src/QLiteHtmlBrowserImpl.h b/src/QLiteHtmlBrowserImpl.h index 17ad6e0..95b6253 100644 --- a/src/QLiteHtmlBrowserImpl.h +++ b/src/QLiteHtmlBrowserImpl.h @@ -70,6 +70,8 @@ class QLiteHtmlBrowserImpl : public QWidget QColor highlightColor() const; void setHighlightColor( QColor color ); + QString selectedText() const; + protected: void changeEvent( QEvent* ) override; void mousePressEvent( QMouseEvent* ) override; @@ -79,6 +81,11 @@ class QLiteHtmlBrowserImpl : public QWidget void urlChanged( const QUrl& ); void anchorClicked( const QUrl& ); void scaleChanged(); + void selectionChanged(); + +#ifdef UNIT_TEST + container_qt* container() const { return mContainer; } +#endif private: class HistoryEntry diff --git a/src/TextManager.cpp b/src/TextManager.cpp new file mode 100644 index 0000000..ea7080c --- /dev/null +++ b/src/TextManager.cpp @@ -0,0 +1,598 @@ +#include "TextManager.h" +#include +#include + +void TextManager::buildFromDocument( litehtml::document::ptr doc ) +{ + m_fragments.clear(); + m_fullText.clear(); + + if ( doc && doc->root() ) + { + collectTextFragments( doc->root() ); + if ( !m_fullText.empty() && m_fullText.back() == ' ' ) + { + m_fullText.pop_back(); + } + + // qDebug() << "\n=== DOM Text Manager initialisiert ==="; + // qDebug() << "Fragmente gesammelt:" << m_fragments.size(); + // qDebug() << "Gesamttext-Länge:" << m_fullText.length(); + + // qDebug() << "\n=== Gesammelte Text-Fragmente ==="; + // qDebug() << "Anzahl Fragmente:" << m_fragments.size(); + // for ( size_t i = 0; i < m_fragments.size(); ++i ) + // { + // const auto& frag = m_fragments[i]; + // qDebug() << "Fragment" << i << ":" + // << "Text:" << QString::fromStdString( frag.text ).left( 30 ) << "| Leaf:" << frag.is_leaf << "| Pos:" << frag.pos.x << frag.pos.y + // << "| Size:" << frag.pos.width << "x" << frag.pos.height << "| Valid:" << frag.hasValidPosition(); + // } + // qDebug() << "================================\n"; + } +} + +TextManager::TextPosition TextManager::getPositionAtCoordinates( int x, int y ) +{ + TextPosition result; + TextFragment* bestMatch = nullptr; + int smallestArea = INT_MAX; + + for ( auto& fragment : m_fragments ) + { + if ( !fragment.is_leaf || !fragment.hasValidPosition() ) + { + continue; + } + + const auto& pos = fragment.pos; + + if ( x >= pos.x && x <= pos.x + pos.width && y >= pos.y && y <= pos.y + pos.height ) + { + + int area = pos.width * pos.height; + + if ( area < smallestArea ) + { + smallestArea = area; + bestMatch = &fragment; + } + } + } + + if ( bestMatch ) + { + result.element = bestMatch->element; + result.element_pos = bestMatch->pos; + + const auto& pos = bestMatch->pos; + if ( pos.width > 0 && bestMatch->text.length() > 0 ) + { + float relativeX = static_cast( x - pos.x ) / pos.width; + result.char_offset = static_cast( relativeX * bestMatch->text.length() ); + result.char_offset = std::max( 0, std::min( result.char_offset, static_cast( bestMatch->text.length() ) ) ); + } + else + { + result.char_offset = 0; + } + } + + return result; +} + +// Extrahiert Text und Fragmente zwischen zwei Positionen +TextManager::SelectionRange TextManager::getSelectionBetween( const TextManager::TextPosition& start, const TextManager::TextPosition& end ) +{ + SelectionRange selection; + selection.start = start; + selection.end = end; + + if ( !start.isValid() || !end.isValid() ) + { + return selection; + } + + TextPosition actualStart = start; + TextPosition actualEnd = end; + if ( end < start ) + { + std::swap( actualStart, actualEnd ); + } + + bool inSelection = false; + + for ( const auto& fragment : m_fragments ) + { + if ( !fragment.is_leaf || !fragment.hasValidPosition() ) + { + continue; + } + + bool isStartFragment = ( fragment.element == actualStart.element ); + bool isEndFragment = ( fragment.element == actualEnd.element ); + + if ( isStartFragment ) + { + inSelection = true; + } + + if ( inSelection ) + { + TextFragment selectedFragment = fragment; + int startOffset = 0; + int endOffset = fragment.text.length(); + + if ( isStartFragment ) + { + startOffset = actualStart.char_offset; + } + + if ( isEndFragment ) + { + endOffset = actualEnd.char_offset; + } + + selectedFragment.start_char_offset = startOffset; + selectedFragment.end_char_offset = endOffset; + + // WICHTIG: Position anpassen + litehtml::position adjustedPos = fragment.pos; + if ( fragment.text.length() > 0 && fragment.pos.width > 0 ) + { + float charWidth = static_cast( fragment.pos.width ) / static_cast( fragment.text.length() ); + adjustedPos.x = fragment.pos.x + static_cast( charWidth * startOffset ); + adjustedPos.width = static_cast( charWidth * ( endOffset - startOffset ) ); + } + selectedFragment.pos = adjustedPos; + + selection.fragments.push_back( selectedFragment ); + + if ( isEndFragment ) + { + break; + } + } + } + + return selection; +} + +std::vector TextManager::findText( const std::string& search_term, bool case_sensitive ) +{ + std::vector results; + if ( search_term.empty() || m_fullText.empty() ) + { + return results; + } + + std::string searchText = m_fullText; + std::string searchFor = search_term; + + if ( !case_sensitive ) + { + std::transform( searchText.begin(), searchText.end(), searchText.begin(), ::tolower ); + std::transform( searchFor.begin(), searchFor.end(), searchFor.begin(), ::tolower ); + } + + qDebug() << "\n=== Suche ==="; + qDebug() << "Suchbegriff:" << QString::fromStdString( searchFor ); + qDebug() << "Durchsuche m_fullText (RAW):" << QString::fromStdString( searchText ).left( 100 ); + + // Alle Vorkommen finden + size_t pos = 0; + while ( ( pos = searchText.find( searchFor, pos ) ) != std::string::npos ) + { + TextFindMatch result; + result.matched_text = m_fullText.substr( pos, searchFor.length() ); // Jetzt korrekt! + + int searchStart = static_cast( pos ); + int searchEnd = static_cast( pos + searchFor.length() ); + + qDebug() << "Treffer bei:" << searchStart << "-" << searchEnd << "| Text:" << QString::fromStdString( result.matched_text ); + + // Finde Fragmente in diesem Bereich (jetzt mit korrekten Positionen) + result.bounding_box = calculateBoundingBox( searchStart, searchEnd, result.fragments ); + + results.push_back( result ); + pos += searchFor.length(); + } + + qDebug() << "Treffer gesamt:" << results.size() << "\n"; + return results; +} + +void TextManager::collectTextFragments( litehtml::element::ptr el ) +{ + if ( !el ) + return; + + // Prüfe ob Element Kinder hat + bool hasChildren = !el->children().empty(); + + // Text aus dem Element holen + std::string text; + el->get_text( text ); + + // NUR Leaf-Nodes (Elemente ohne Kinder) mit Text speichern + // Dadurch vermeiden wir, dass Parent-Elemente den gesamten + // aggregierten Text ihrer Kinder zurückgeben + if ( !hasChildren && !text.empty() ) + { + litehtml::position pos = el->get_placement(); + + if ( pos.width > 0 && pos.height > 0 ) + { + TextFragment fragment; + fragment.text = text; // Original-Text + fragment.parent_element = el->parent(); + fragment.element = el; + fragment.pos = pos; + fragment.start_char_offset = 0; + fragment.end_char_offset = text.length(); + fragment.global_start_offset = m_fullText.length(); // Start VOR dem Text + fragment.is_leaf = true; + + // Text zum Volltext hinzufügen + m_fullText += text; + fragment.global_end_offset = m_fullText.length(); // Ende NACH dem Text + fragment.element_tag = getElementTag( el ); + fragment.is_block_element = isBlockLevelElement( fragment.element_tag ); + fragment.table_cell_parent = findParentWithTag( el, "td" ); + if ( !fragment.table_cell_parent ) + { + fragment.table_cell_parent = findParentWithTag( el, "th" ); + } + fragment.table_row_parent = findParentWithTag( el, "tr" ); + + m_fragments.push_back( fragment ); + + // WICHTIG: Leerzeichen NACH dem Speichern des Fragments hinzufügen + // So zeigt global_end_offset auf das Ende des Textes, nicht auf das Leerzeichen + // m_fullText += " "; + + // qDebug() << "Leaf-Fragment:" << QString::fromStdString( fragment.text ).left( 30 ) << "| Pos:" << pos.x << pos.y << "| Size:" << pos.width + // << "x" << pos.height; + } + } + + // Rekursiv durch alle Kindelemente + for ( auto it = el->children().begin(); it != el->children().end(); ++it ) + { + collectTextFragments( *it ); + } +} + +litehtml::element::ptr TextManager::findParentWithTag( litehtml::element::ptr element, const std::string& tag ) const +{ + if ( !element ) + return nullptr; + + auto current = element->parent(); + while ( current ) + { + const char* tagName = current->get_tagName(); + if ( tagName ) + { + std::string currentTag( tagName ); + std::transform( currentTag.begin(), currentTag.end(), currentTag.begin(), ::tolower ); + + if ( currentTag == tag ) + { + return current; + } + } + current = current->parent(); + } + + return nullptr; +} + +litehtml::position TextManager::calculateBoundingBox( int searchStart, int searchEnd, std::vector& matchedFragments ) +{ + litehtml::position boundingBox = { 0, 0, 0, 0 }; + bool firstFragment = true; + + qDebug() << "\n=== Berechne BBox ==="; + qDebug() << "Suchbereich im m_fullText:" << searchStart << "-" << searchEnd; + qDebug() << "Gesuchter Text:" + << QString::fromStdString( m_fullText.substr( std::min( (size_t)searchStart, m_fullText.length() ), + std::min( (size_t)( searchEnd - searchStart ), m_fullText.length() - searchStart ) ) ); + + for ( const auto& fragment : m_fragments ) + { + if ( !fragment.is_leaf || !fragment.hasValidPosition() ) + { + continue; + } + + // qDebug() << "Prüfe Fragment:" << QString::fromStdString( fragment.text ).left( 20 ) << "| Global:" << fragment.global_start_offset << "-" + // << fragment.global_end_offset; + + // Überlappung zwischen Suchbereich und Fragment + // WICHTIG: Fragment geht von global_start_offset bis global_end_offset + // Das Leerzeichen danach ist NICHT Teil des Fragments! + int overlapStart = std::max( searchStart, fragment.global_start_offset ); + int overlapEnd = std::min( searchEnd, fragment.global_end_offset ); + + if ( overlapStart < overlapEnd ) + { + qDebug() << " → Überlappung:" << overlapStart << "-" << overlapEnd; + + TextFragment matchedFragment = fragment; + + // Lokale Offsets im Fragment-Text berechnen + int localStart = overlapStart - fragment.global_start_offset; + int localEnd = overlapEnd - fragment.global_start_offset; + + // Bounds checking + int textLen = static_cast( fragment.text.length() ); + localStart = std::clamp( localStart, 0, textLen ); + localEnd = std::clamp( localEnd, 0, textLen ); + + if ( localEnd <= localStart ) + { + qDebug() << " → FEHLER: Ungültige lokale Offsets!"; + continue; + } + + matchedFragment.start_char_offset = localStart; + matchedFragment.end_char_offset = localEnd; + + qDebug() << " → Lokale Offsets:" << localStart << "-" << localEnd + << "| Text:" << QString::fromStdString( fragment.text.substr( localStart, localEnd - localStart ) ); + + // Position für diesen Teil des Fragments berechnen + litehtml::position adjustedPos = fragment.pos; + + if ( textLen > 0 && fragment.pos.width > 0 ) + { + float charWidth = static_cast( fragment.pos.width ) / static_cast( textLen ); + + // X-Position anpassen + adjustedPos.x = fragment.pos.x + static_cast( charWidth * localStart ); + + // Breite anpassen + adjustedPos.width = static_cast( charWidth * ( localEnd - localStart ) ); + + qDebug() << " → Angepasste Position:" << adjustedPos.x << adjustedPos.y << "| Größe:" << adjustedPos.width << "x" << adjustedPos.height + << "| CharWidth:" << charWidth; + } + + // Speichere angepasste Position im Fragment + matchedFragment.pos = adjustedPos; + matchedFragments.push_back( matchedFragment ); + + // Bounding Box erweitern/initialisieren + if ( firstFragment ) + { + boundingBox = adjustedPos; + firstFragment = false; + qDebug() << " → Initiale BBox:" << boundingBox.x << boundingBox.y << boundingBox.width << "x" << boundingBox.height; + } + else + { + int minX = std::min( boundingBox.x, adjustedPos.x ); + int minY = std::min( boundingBox.y, adjustedPos.y ); + int maxX = std::max( boundingBox.x + boundingBox.width, adjustedPos.x + adjustedPos.width ); + int maxY = std::max( boundingBox.y + boundingBox.height, adjustedPos.y + adjustedPos.height ); + + boundingBox.x = minX; + boundingBox.y = minY; + boundingBox.width = maxX - minX; + boundingBox.height = maxY - minY; + + qDebug() << " → Erweiterte BBox:" << boundingBox.x << boundingBox.y << boundingBox.width << "x" << boundingBox.height; + } + } + } + + qDebug() << "Finale BBox:" << boundingBox.x << boundingBox.y << boundingBox.width << "x" << boundingBox.height; + qDebug() << "Anzahl Fragmente:" << matchedFragments.size(); + qDebug() << "===================\n"; + return boundingBox; +} + +// Ermittle HTML-Tag des Elements oder seiner Eltern +std::string TextManager::getElementTag( litehtml::element::ptr element ) const +{ + if ( !element ) + return ""; + + // Gehe zum Parent-Element, da Textknoten selbst kein Tag haben + auto parent = element->parent(); + if ( !parent ) + return ""; + + // Hole Tag-Name + const char* tag = parent->get_tagName(); + if ( tag ) + { + std::string tagName( tag ); + std::transform( tagName.begin(), tagName.end(), tagName.begin(), ::tolower ); + return tagName; + } + + return ""; +} + +// Prüfe ob Element ein Block-Level-Element ist +bool TextManager::isBlockLevelElement( const std::string& tag ) const +{ + // Liste der gängigen Block-Level-Elemente + static const std::vector blockElements = { "address", "article", "aside", "blockquote", "canvas", "dd", "div", "dl", + "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", + "h3", "h4", "h5", "h6", "header", "hr", "li", "main", + "nav", "noscript", "ol", "p", "pre", "section", "ul", "video", + "br", // BR ist speziell: inline aber erzeugt Zeilenumbruch + "table", "thead", "tbody", "tfoot" }; + + return std::find( blockElements.begin(), blockElements.end(), tag ) != blockElements.end(); +} + +bool TextManager::shouldInsertNewline( const TextFragment& prev, const TextFragment& current ) const +{ + // WICHTIG: Prüfe ob die Fragmente das GLEICHE Parent-Element haben + if ( prev.parent_element == current.parent_element ) + { + // Gleicher Parent = kein Newline (z.B. mehrere Textknoten in einem

) + return false; + } + + // Unterschiedliche Parents: Prüfe ob eines davon ein Block-Element ist + if ( prev.is_block_element || current.is_block_element ) + { + return true; + } + + return false; +} + +std::string TextManager::selectedText( const SelectionRange& selection, TextFormatMode mode ) const +{ + if ( selection.isEmpty() ) + { + return {}; + } + + std::string result; + + if ( mode == TextFormatMode::Structured ) + { + for ( size_t i = 0; i < selection.fragments.size(); ++i ) + { + const auto& fragment = selection.fragments[i]; + + if ( fragment.end_char_offset > fragment.start_char_offset ) + { + std::string part = fragment.text.substr( fragment.start_char_offset, fragment.end_char_offset - fragment.start_char_offset ); + + if ( i > 0 ) + { + const auto& prevFragment = selection.fragments[i - 1]; + + if ( prevFragment.table_row_parent != fragment.table_row_parent ) + { + // part = "\n" + part; + result += "\n"; + } + + if ( prevFragment.parent_element != fragment.parent_element ) + { + if ( prevFragment.element_tag == "p" && fragment.element_tag == "p" ) + { + // Zwischen zwei

-Elementen: Doppelter Newline + result += "\n\n"; + } + else if ( prevFragment.is_block_element || fragment.is_block_element ) + { + // Anderes Block-Element: Einfacher Newline + result += "\n"; + } + else + { + if ( prevFragment.element_tag != fragment.element_tag ) + { + if ( !result.empty() && !std::isspace( result.back() ) && !std::isspace( part.front() ) ) + { + result += ' '; + } + } + else + { + if ( !result.empty() && !std::isspace( result.back() ) && !std::isspace( part.front() ) ) + { + result += ' '; + } + } + } + } + else + { + + // if ( !result.empty() && !std::isspace( result.back() ) ) + // { + // result += ' '; + // } + } + } + + result += part; + } + } + } + else // Plain mode + { + bool first = true; + for ( const auto& fragment : selection.fragments ) + { + if ( fragment.end_char_offset > fragment.start_char_offset ) + { + std::string part = fragment.text.substr( fragment.start_char_offset, fragment.end_char_offset - fragment.start_char_offset ); + + if ( !first ) + { + result += ' '; + } + result += part; + first = false; + } + } + } + + // Trimme Whitespace + auto start = result.find_first_not_of( " \t\n\r" ); + auto end = result.find_last_not_of( " \t\n\r" ); + + if ( start == std::string::npos ) + { + return ""; + } + + return result.substr( start, end - start + 1 ); +} + +TextManager::SelectionRange TextManager::selectAll() +{ + SelectionRange selection; + + // Wenn keine Fragmente vorhanden, leere Selektion zurückgeben + if ( m_fragments.empty() ) + { + return selection; + } + + // Start-Position: Erstes Fragment, erstes Zeichen + const auto& firstFragment = m_fragments.front(); + selection.start.element = firstFragment.element; + selection.start.char_offset = 0; + selection.start.element_pos = firstFragment.pos; + + // End-Position: Letztes Fragment, letztes Zeichen + const auto& lastFragment = m_fragments.back(); + selection.end.element = lastFragment.element; + selection.end.char_offset = lastFragment.text.length(); + selection.end.element_pos = lastFragment.pos; + + // Alle Fragmente zur Selektion hinzufügen + for ( const auto& fragment : m_fragments ) + { + if ( !fragment.hasValidPosition() ) + { + continue; + } + + TextFragment selectedFragment = fragment; + + // Vollständiges Fragment auswählen + selectedFragment.start_char_offset = 0; + selectedFragment.end_char_offset = fragment.text.length(); + + // Position bleibt unverändert (gesamtes Fragment) + selectedFragment.pos = fragment.pos; + + selection.fragments.push_back( selectedFragment ); + } + + return selection; +} diff --git a/src/TextManager.h b/src/TextManager.h new file mode 100644 index 0000000..feaccc0 --- /dev/null +++ b/src/TextManager.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include + +class TextManager +{ +private: + struct TextFragment + { + std::string text; + litehtml::element::ptr element; + litehtml::position pos; + int start_char_offset; // Offset in element text + int end_char_offset; // end Offset in element text + int global_start_offset; + int global_end_offset; + bool is_leaf; + litehtml::element::ptr parent_element; + + std::string element_tag; // z.B. "p", "div", "span", "br" + bool is_block_element; // true für Block-Level-Elemente + litehtml::element::ptr table_cell_parent; // oder + litehtml::element::ptr table_row_parent; // + + bool hasValidPosition() const { return pos.width > 0 && pos.height > 0; } + }; + +public: + struct TextPosition + { + litehtml::element::ptr element; + int char_offset = 0; + litehtml::position element_pos; + + bool operator<( const TextPosition& other ) const + { + if ( element_pos.y != other.element_pos.y ) + return element_pos.y < other.element_pos.y; + if ( element_pos.x != other.element_pos.x ) + return element_pos.x < other.element_pos.x; + return char_offset < other.char_offset; + } + bool isValid() const { return element && element_pos.width > 0 && element_pos.height > 0; } + }; + + struct TextFindMatch + { + std::string matched_text; + std::vector fragments; + litehtml::position bounding_box; + + bool isEmpty() const { return fragments.empty(); } + }; + + struct SelectionRange + { + TextPosition start; + TextPosition end; + std::vector fragments; // Alle Textfragmente in der Selektion + + bool isEmpty() const { return !start.isValid() || !end.isValid(); } + + void clear() + { + start = TextPosition(); + end = TextPosition(); + fragments.clear(); + } + }; + + enum class TextFormatMode + { + Plain, // Nur Leerzeichen zwischen Fragmenten + Structured, // Kontextsensitiv: Berücksichtigt Block-Elemente + Html // Behält HTML-Struktur bei (für zukünftige Erweiterung) + }; + +private: + std::vector m_fragments; + std::string m_fullText; + +public: + void buildFromDocument( litehtml::document::ptr doc ); + TextPosition getPositionAtCoordinates( int x, int y ); + SelectionRange getSelectionBetween( const TextPosition& start, const TextPosition& end ); + std::vector findText( const std::string& search_term, bool case_sensitive = true ); + std::string selectedText( const SelectionRange& selection, TextFormatMode mode = TextFormatMode::Structured ) const; + SelectionRange selectAll(); + +private: + void collectTextFragments( litehtml::element::ptr el ); + litehtml::position calculateBoundingBox( int searchStart, int searchEnd, std::vector& matchedFragments ); + bool isBlockLevelElement( const std::string& tag ) const; + std::string getElementTag( litehtml::element::ptr element ) const; + bool shouldInsertNewline( const TextFragment& prev, const TextFragment& current ) const; + litehtml::element::ptr findParentWithTag( litehtml::element::ptr element, const std::string& tag ) const; +}; diff --git a/src/container_qt.cpp b/src/container_qt.cpp index 18c8f85..0857156 100644 --- a/src/container_qt.cpp +++ b/src/container_qt.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -46,6 +47,7 @@ container_qt::container_qt( QWidget* parent ) connect( shortcut, &QShortcut::activated, this, [this]() { setScale( 1.0 ); } ); setMouseTracking( true ); + setFocusPolicy( Qt::StrongFocus ); } void container_qt::setCSS( const QString& master_css, const QString& user_css ) @@ -72,7 +74,10 @@ void container_qt::setHtml( const QString& html, const QUrl& source_url ) mDocumentSource, this, mMasterCSS.isEmpty() ? litehtml::master_css : mMasterCSS.toUtf8().constData(), mUserCSS.toUtf8().constData() ); verticalScrollBar()->setValue( 0 ); horizontalScrollBar()->setValue( 0 ); + + m_currentSelection.clear(); render(); + m_textManager.buildFromDocument( mDocument ); auto frag = source_url.fragment(); if ( !frag.isEmpty() ) @@ -127,6 +132,10 @@ void container_qt::paintEvent( QPaintEvent* event ) mDocument->draw( hdc, margins.left() + scroll_pos.x(), margins.top() + scroll_pos.y(), &clipRect ); draw_highlights( hdc ); + if ( !m_currentSelection.isEmpty() ) + { + drawSelection( p ); + } } } } @@ -869,10 +878,12 @@ void container_qt::setScale( double scale ) ( 0 < horizontalScrollBar()->maximum() ) ? static_cast( horizontalScrollBar()->value() ) / horizontalScrollBar()->maximum() : 0.0; auto relV = ( 0 < verticalScrollBar()->maximum() ) ? static_cast( verticalScrollBar()->value() ) / verticalScrollBar()->maximum() : 0.0; mScale = std::clamp( scale, mMinScale, mMaxScale ); + m_currentSelection.clear(); render(); - findText( mFindText ); horizontalScrollBar()->setValue( std::floor( relH * horizontalScrollBar()->maximum() ) ); verticalScrollBar()->setValue( std::floor( relV * verticalScrollBar()->maximum() ) ); + m_textManager.buildFromDocument( mDocument ); + findText( mFindText ); emit scaleChanged(); } @@ -906,9 +917,13 @@ void container_qt::resizeEvent( QResizeEvent* event ) ( 0 < horizontalScrollBar()->maximum() ) ? static_cast( horizontalScrollBar()->value() ) / horizontalScrollBar()->maximum() : 0.0; auto relV = ( 0 < verticalScrollBar()->maximum() ) ? static_cast( verticalScrollBar()->value() ) / verticalScrollBar()->maximum() : 0.0; QAbstractScrollArea::resizeEvent( event ); + m_currentSelection.clear(); render(); horizontalScrollBar()->setValue( std::floor( relH * horizontalScrollBar()->maximum() ) ); verticalScrollBar()->setValue( std::floor( relV * verticalScrollBar()->maximum() ) ); + + m_textManager.buildFromDocument( mDocument ); + findText( mFindText ); } void container_qt::wheelEvent( QWheelEvent* e ) @@ -979,7 +994,6 @@ QRect container_qt::inv_scaled( const QRect& rect ) const void container_qt::mouseMoveEvent( QMouseEvent* e ) { - if ( e && mDocument ) { @@ -989,6 +1003,18 @@ void container_qt::mouseMoveEvent( QMouseEvent* e ) { // something changed, redraw of boxes required; } + if ( m_isSelecting && m_selectionStart.isValid() ) + { + e->accept(); + TextManager::TextPosition currentPos = m_textManager.getPositionAtCoordinates( mousePos.html.x(), mousePos.html.y() ); + if ( currentPos.isValid() ) + { + m_currentSelection = m_textManager.getSelectionBetween( m_selectionStart, currentPos ); + emit selectionChanged(); + viewport()->update(); + } + } + // bool litehtml::document::on_mouse_leave( position::vector& redraw_boxes ); } } @@ -1000,13 +1026,37 @@ void container_qt::mouseReleaseEvent( QMouseEvent* e ) { litehtml::position::vector redraw_boxes; const auto mousePos = convertMousePos( e ); - if ( mDocument->on_lbutton_up( mousePos.html.x(), mousePos.html.y(), mousePos.client.x(), mousePos.client.y(), redraw_boxes ) ) + if ( mDocument->on_lbutton_up( mousePos.html.x(), mousePos.html.y(), mousePos.html.x(), mousePos.html.y(), redraw_boxes ) ) { // something changed, redraw of boxes required; e->accept(); } - else - e->ignore(); + if ( e->button() == Qt::LeftButton ) + { + e->accept(); + m_isSelecting = false; + + // update selection position from this mouse pos + TextManager::TextPosition currentPos = m_textManager.getPositionAtCoordinates( mousePos.html.x(), mousePos.html.y() ); + + if ( currentPos.isValid() ) + { + m_currentSelection = m_textManager.getSelectionBetween( m_selectionStart, currentPos ); + viewport()->update(); + } + + if ( !m_currentSelection.isEmpty() ) + { + qDebug() << "selection got selected text with " << selectedText().count() << "fragments"; + qDebug() << "\n=== Selektierter Text ==="; + qDebug() << selectedText(); + qDebug() << "================================\n"; + emit selectionChanged(); + // copySelectionToClipboard(); + } + } + // else + // e->ignore(); } else e->ignore(); @@ -1023,15 +1073,14 @@ void container_qt::mousePressEvent( QMouseEvent* e ) if ( mDocument->on_lbutton_down( mousePos.html.x(), mousePos.html.y(), mousePos.client.x(), mousePos.client.y(), redraw_boxes ) ) { // something changed, redraw of boxes required; - e->accept(); } - // else - // { - // auto elem = mDocument->get_over_element(); - // qDebug() << elem->get_placement().x << elem->get_tagName(); + m_currentSelection.clear(); - // e->ignore(); - // } + m_isSelecting = true; + m_selectionStart = m_textManager.getPositionAtCoordinates( mousePos.html.x(), mousePos.html.y() ); + // qDebug() << "selection started with" << QPoint( m_selectionStart.element_pos.x, m_selectionStart.element_pos.y ); + e->accept(); + viewport()->update(); } else e->ignore(); @@ -1095,188 +1144,6 @@ int container_qt::findText( const QString& text ) return mFindMatches.size(); } -// normalize Whitespace: multiple whitespaces to one -std::string container_qt::normalizeWhitespace( const std::string& text ) -{ - std::string normalized; - bool lastWasSpace = false; - - for ( char c : text ) - { - if ( std::isspace( static_cast( c ) ) ) - { - if ( !lastWasSpace && !normalized.empty() ) - { - normalized += ' '; - lastWasSpace = true; - } - } - else - { - normalized += c; - lastWasSpace = false; - } - } - - return normalized; -} - -void container_qt::collect_text_fragments( litehtml::element::ptr el, std::vector& fragments, std::string& fullText ) -{ - if ( !el ) - return; - - std::string text; - el->get_text( text ); - if ( !text.empty() && el->is_text() ) - { - TextFragment fragment; - fragment.text = text; - fragment.element = el; - fragment.pos = el->get_placement(); - fragment.start_offset = static_cast( fullText.length() ); - - std::string normalized = normalizeWhitespace( text ); - fullText += normalized; - - fragment.end_offset = static_cast( fullText.length() ); - fragments.push_back( fragment ); - - // Leerzeichen zwischen Elementen - if ( !fullText.empty() && !std::isspace( fullText.back() ) ) - { - fullText += ' '; - } - } - - for ( auto it = el->children().begin(); it != el->children().end(); ++it ) - { - collect_text_fragments( ( *it ), fragments, fullText ); - } -} - -// calculate bounding box for all elements found in search -litehtml::position container_qt::calculate_precise_bounding_box( const std::vector& allFragments, - int searchStart, - int searchEnd, - std::vector& matchedFragments ) -{ - litehtml::position boundingBox = { 0, 0, 0, 0 }; - bool firstFragment = true; - - for ( const auto& fragment : allFragments ) - { - // check if this fragment is part of the search - int overlapStart = std::max( searchStart, fragment.start_offset ); - int overlapEnd = std::min( searchEnd, fragment.end_offset ); - - if ( overlapStart < overlapEnd ) - { - // yes this fragment is part of the search - TextFragment matchedFragment = fragment; - - // offsets relative to start - int fragmentTextStart = overlapStart - fragment.start_offset; - int fragmentTextEnd = overlapEnd - fragment.start_offset; - - matchedFragment.start_offset = fragmentTextStart; - matchedFragment.end_offset = fragmentTextEnd; - - // calculate width of text - // Importnat: use text_width from document_container - std::string matchedText = fragment.text.substr( fragmentTextStart, fragmentTextEnd - fragmentTextStart ); - - // estimated position (kann mit text_width verfeinert werden) - litehtml::position fragmentPos = fragment.pos; - - // for more precise positioning text_width would be helpfull - // int textWidthBefore = container->text_width( - // fragment.text.substr(0, fragmentTextStart).c_str(), - // font - // ); - // fragmentPos.x += textWidthBefore; - // fragmentPos.width = container->text_width(matchedText.c_str(), font); - - // simplified approximation - if ( fragment.text.length() > 0 ) - { - float charWidth = static_cast( fragment.pos.width ) / static_cast( fragment.text.length() ); - fragmentPos.x += static_cast( charWidth * fragmentTextStart ); - fragmentPos.width = static_cast( charWidth * matchedText.length() ); - } - - matchedFragments.push_back( matchedFragment ); - - // extend Bounding Box - if ( firstFragment ) - { - boundingBox = fragmentPos; - firstFragment = false; - } - else - { - // Min/Max for containing Box - int minX = std::min( boundingBox.x, fragmentPos.x ); - int minY = std::min( boundingBox.y, fragmentPos.y ); - int maxX = std::max( boundingBox.x + boundingBox.width, fragmentPos.x + fragmentPos.width ); - int maxY = std::max( boundingBox.y + boundingBox.height, fragmentPos.y + fragmentPos.height ); - - boundingBox.x = minX; - boundingBox.y = minY; - boundingBox.width = maxX - minX; - boundingBox.height = maxY - minY; - } - } - } - - return boundingBox; -} - -// search document for Text including multiword phrases -void container_qt::find_text_in_document( litehtml::document::ptr doc, - const std::string& search_term, - std::vector& matches, - bool case_sensitive ) -{ - if ( !doc || search_term.empty() ) - return; - - // Sammle alle Text-Fragmente mit Positionen - std::vector fragments; - std::string fullText; - collect_text_fragments( doc->root(), fragments, fullText ); - - // Normalisiere Text für Suche - std::string normalizedFullText = normalizeWhitespace( fullText ); - std::string normalizedSearchTerm = normalizeWhitespace( search_term ); - - std::string findText = normalizedFullText; - std::string searchFor = normalizedSearchTerm; - - if ( !case_sensitive ) - { - std::transform( findText.begin(), findText.end(), findText.begin(), ::tolower ); - std::transform( searchFor.begin(), searchFor.end(), searchFor.begin(), ::tolower ); - } - - // Alle Vorkommen finden - size_t pos = 0; - while ( ( pos = findText.find( searchFor, pos ) ) != std::string::npos ) - { - TextFindMatch match; - match.matched_text = normalizedFullText.substr( pos, searchFor.length() ); - - int searchStart = static_cast( pos ); - int searchEnd = static_cast( pos + searchFor.length() ); - - // Berechne präzise Bounding Box - match.bounding_box = calculate_precise_bounding_box( fragments, searchStart, searchEnd, match.fragments ); - - matches.push_back( match ); - pos += searchFor.length(); - } -} - // Textsuche durchführen int container_qt::find_text( litehtml::document::ptr doc, const std::string& search_term, bool case_sensitive ) { @@ -1288,8 +1155,7 @@ int container_qt::find_text( litehtml::document::ptr doc, const std::string& sea return 0; } - // Suche vom Root-Element starten - find_text_in_document( mDocument, search_term, mFindMatches, case_sensitive ); + mFindMatches = m_textManager.findText( search_term, case_sensitive ); if ( !mFindMatches.empty() ) { @@ -1328,7 +1194,7 @@ bool container_qt::find_previous_match() } // Aktuelles Suchergebnis mit Position holen -const container_qt::TextFindMatch* container_qt::find_current_match() const +const TextManager::TextFindMatch* container_qt::find_current_match() const { if ( mFindCurrentMatchIndex >= 0 && mFindCurrentMatchIndex < static_cast( mFindMatches.size() ) ) { @@ -1342,20 +1208,23 @@ void container_qt::draw_highlights( litehtml::uint_ptr hdc ) for ( auto it = mFindMatches.begin(); it != mFindMatches.end(); ++it ) { const auto match = ( *it ); - for ( auto it = match.fragments.begin(); it != match.fragments.end(); ++it ) - { - litehtml::position pos = ( *it ).pos; - highlight_text_at_position( hdc, pos, match.matched_text ); - } + // for ( auto it = match.fragments.begin(); it != match.fragments.end(); ++it ) + // { + // litehtml::position pos = ( *it ).pos; + // highlight_text_at_position( hdc, pos, match ); + // } + litehtml::position pos = ( *it ).bounding_box; + highlight_text_at_position( hdc, pos, match ); } } // draw highlighted text at position -void container_qt::highlight_text_at_position( litehtml::uint_ptr hdc, const litehtml::position& pos, const std::string& text ) +void container_qt::highlight_text_at_position( litehtml::uint_ptr hdc, const litehtml::position& pos, const TextManager::TextFindMatch& match ) { - qDebug() << "Highlighting text '" << QString::fromStdString( text ) << "' at position (" << pos.x << ", " << pos.y << ") with size " << pos.width - << "x" << pos.height; + // qDebug() << "Highlighting text '" << QString::fromStdString( match.matched_text ) << "' at position (" << pos.x << ", " << pos.y << ") with size + // " + // << pos.width << "x" << pos.height; QPainter* p( reinterpret_cast( hdc ) ); p->save(); @@ -1368,6 +1237,10 @@ void container_qt::highlight_text_at_position( litehtml::uint_ptr hdc, const lit p->drawRect( ( QRect( pos.x + scroll_pos.x(), pos.y + scroll_pos.y(), pos.width, pos.height ) ) ); + // p->setPen( QPen( Qt::green, 3, Qt::DotLine ) ); + // p->setBrush( Qt::NoBrush ); + // p->drawRect( match.bounding_box.x + scroll_pos.x(), match.bounding_box.y + scroll_pos.y(), match.bounding_box.width, match.bounding_box.height ); + p->restore(); } @@ -1389,7 +1262,7 @@ void container_qt::findPreviousMatch() } } -void container_qt::scroll_to_find_match( const TextFindMatch* match ) +void container_qt::scroll_to_find_match( const TextManager::TextFindMatch* match ) { if ( !match ) { @@ -1398,3 +1271,100 @@ void container_qt::scroll_to_find_match( const TextFindMatch* match ) horizontalScrollBar()->setValue( match->bounding_box.left() ); verticalScrollBar()->setValue( match->bounding_box.top() ); } + +void container_qt::copySelectionToClipboard() +{ + if ( m_currentSelection.isEmpty() ) + { + return; + } + + QString text = selectedText(); + if ( !text.isEmpty() ) + { + QClipboard* clipboard = QApplication::clipboard(); + clipboard->setText( text ); + } +} + +// Gibt selektierten Text zurück +QString container_qt::selectedText() const +{ + if ( m_currentSelection.isEmpty() ) + { + return QString(); + } + + return QString::fromStdString( m_textManager.selectedText( m_currentSelection ) ); +} + +void container_qt::clearSelection() +{ + m_currentSelection.clear(); + viewport()->update(); + emit selectionChanged(); +} + +void container_qt::keyPressEvent( QKeyEvent* event ) +{ + // Strg+C zum Kopieren + if ( event->matches( QKeySequence::Copy ) ) + { + copySelectionToClipboard(); + } + // Escape zum Abbrechen der Selektion + else if ( event->key() == Qt::Key_Escape ) + { + clearSelection(); + } + // Strg+A alles selektieren + else if ( event->matches( QKeySequence::SelectAll ) ) + { + selectAll(); + } + + QWidget::keyPressEvent( event ); +} + +void container_qt::drawSelection( QPainter& painter ) +{ + painter.save(); + painter.setPen( Qt::NoPen ); + painter.setBrush( mSelectionColor ); + + // Zeichne Rechtecke für alle Fragmente + for ( const auto& fragment : m_currentSelection.fragments ) + { + const auto& pos = fragment.pos; + + // Berechne präzise Breite basierend auf Zeichen-Offsets + float charWidth = 0.0f; + if ( pos.width > 0 && fragment.text.length() > 0 ) + { + charWidth = static_cast( pos.width ) / static_cast( fragment.text.length() ); + } + + int startX = pos.x + static_cast( charWidth * fragment.start_char_offset ); + // int width = static_cast( charWidth * ( fragment.end_char_offset - fragment.start_char_offset ) ); + int width = pos.width; + + auto scroll_pos = -scrollBarPos(); + + QRect selectionRect( startX + scroll_pos.x(), pos.y + scroll_pos.y(), width, pos.height ); + painter.fillRect( selectionRect, mSelectionColor ); + + // // Optional: Rahmen zeichnen + // painter.setPen( QPen( borderColor, 1 ) ); + // painter.drawRect( selectionRect ); + // painter.setPen( Qt::NoPen ); + } + + painter.restore(); +} + +void container_qt::selectAll() +{ + m_currentSelection = m_textManager.selectAll(); + viewport()->update(); + emit selectionChanged(); +} diff --git a/src/container_qt.h b/src/container_qt.h index 3101166..b2b5fe3 100644 --- a/src/container_qt.h +++ b/src/container_qt.h @@ -1,6 +1,7 @@ #pragma once #include "litehtml.h" +#include "TextManager.h" #include "browserdefinitions.h" #include @@ -14,7 +15,14 @@ class container_qt : public QAbstractScrollArea, protected litehtml::document_container { + Q_OBJECT + +#ifdef UNIT_TEST + friend class FindTest; + friend class SelectionTest; +#endif + public: container_qt( QWidget* parent = nullptr ); @@ -40,6 +48,8 @@ class container_qt : public QAbstractScrollArea, protected litehtml::document_co void setHighlightColor( const QColor& color ) { mHighlightColor = color; } QColor highlightColor() const { return mHighlightColor; } + QString selectedText() const; + protected: void paintEvent( QPaintEvent* ) override; void wheelEvent( QWheelEvent* ) override; @@ -50,6 +60,7 @@ class container_qt : public QAbstractScrollArea, protected litehtml::document_co Q_SIGNALS: void anchorClicked( const QUrl& ); void scaleChanged(); + void selectionChanged(); protected: litehtml::uint_ptr create_font( @@ -84,8 +95,16 @@ class container_qt : public QAbstractScrollArea, protected litehtml::document_co void get_language( litehtml::string& language, litehtml::string& culture ) const override; void resizeEvent( QResizeEvent* event ) override; bool event( QEvent* event ) override; + void keyPressEvent( QKeyEvent* event ) override; + // const DOMTextManager::SelectionRange& getCurrentSelection() const; + void copySelectionToClipboard(); + void clearSelection(); + void selectAll(); + +protected: + // selection + void drawSelection( QPainter& painter ); -private: void applyClip( QPainter* p ); bool checkClipRect( QPainter* p, const QRect& rect ) const; @@ -115,63 +134,45 @@ class container_qt : public QAbstractScrollArea, protected litehtml::document_co QByteArray loadResource( Browser::ResourceType type, const QUrl& url ); MousePos convertMousePos( const QMouseEvent* event ); -private: - struct TextFragment - { - std::string text; - litehtml::element::ptr element; - litehtml::position pos; - int start_offset; // Offset in element text - int end_offset; // end Offset in element text - }; - - struct TextFindMatch - { - std::string matched_text; - std::vector fragments; // may contains multiple elements - litehtml::position bounding_box; // bounding box over all elements - }; - - int find_text( litehtml::document::ptr doc, const std::string& search_term, bool case_sensitive = true ); - bool find_next_match(); - bool find_previous_match(); - const TextFindMatch* find_current_match() const; - void highlight_text_at_position( litehtml::uint_ptr hdc, const litehtml::position& pos, const std::string& text ); - void draw_highlights( litehtml::uint_ptr hdc ); - void clear_highlights() { mFindMatches.clear(); } - std::string normalizeWhitespace( const std::string& text ); - void find_text_in_document( litehtml::document::ptr doc, - const std::string& search_term, - std::vector& matches, - bool case_sensitive = true ); - void collect_text_fragments( litehtml::element::ptr el, std::vector& fragments, std::string& fullText ); - litehtml::position calculate_precise_bounding_box( const std::vector& allFragments, - int searchStart, - int searchEnd, - std::vector& matchedFragments ); - void scroll_to_find_match( const TextFindMatch* ); - - std::shared_ptr mDocument; - QByteArray mDocumentSource; - QUrl mBaseUrl; - QUrl mSourceUrl; - int mFontSize = 12; - double mScale = 1.0; - double mMinScale = 0.1; - double mMaxScale = 4.0; - QHash mPixmapCache = {}; - Browser::ResourceHandlerType mResourceHandler = {}; - bool mOpenLinks = true; - bool mOpenExternLinks = false; - QByteArray mFontInfo = {}; - QString mCaption = {}; - Browser::UrlResolveHandlerType mUrlResolverHandler = {}; - QString mMasterCSS; - QString mUserCSS; - QStack mClipStack; - litehtml::position mClip = {}; - std::vector mFindMatches = {}; - int mFindCurrentMatchIndex = -1; - QColor mHighlightColor = QColor( 255, 255, 0, 30 ); - QString mFindText; +protected: + int find_text( litehtml::document::ptr doc, const std::string& search_term, bool case_sensitive = true ); + bool find_next_match(); + bool find_previous_match(); + const TextManager::TextFindMatch* find_current_match() const; + void highlight_text_at_position( litehtml::uint_ptr hdc, const litehtml::position& pos, const TextManager::TextFindMatch& match ); + void draw_highlights( litehtml::uint_ptr hdc ); + void clear_highlights() { mFindMatches.clear(); } + void scroll_to_find_match( const TextManager::TextFindMatch* ); + + std::shared_ptr mDocument; + QByteArray mDocumentSource; + QUrl mBaseUrl; + QUrl mSourceUrl; + int mFontSize = 12; + double mScale = 1.0; + double mMinScale = 0.1; + double mMaxScale = 4.0; + QHash mPixmapCache = {}; + Browser::ResourceHandlerType mResourceHandler = {}; + bool mOpenLinks = true; + bool mOpenExternLinks = false; + QByteArray mFontInfo = {}; + QString mCaption = {}; + Browser::UrlResolveHandlerType mUrlResolverHandler = {}; + QString mMasterCSS; + QString mUserCSS; + QStack mClipStack; + litehtml::position mClip = {}; + std::vector mFindMatches = {}; + int mFindCurrentMatchIndex = -1; + QColor mHighlightColor = QColor( 255, 255, 0, 30 ); + QColor mSelectionColor = QColor( 0, 120, 215, 100 ); + QString mFindText; + + // for selection + + TextManager m_textManager; + TextManager::SelectionRange m_currentSelection; + bool m_isSelecting; + TextManager::TextPosition m_selectionStart; }; diff --git a/test/browser/testbrowser.cpp b/test/browser/testbrowser.cpp index c9e50a5..31be347 100644 --- a/test/browser/testbrowser.cpp +++ b/test/browser/testbrowser.cpp @@ -107,8 +107,20 @@ TestBrowser::TestBrowser() mScale->setText( mScaleText.arg( static_cast( std::round( mBrowser->scale() * 100.0 ) ) ) ); statusbar->addPermanentWidget( mScale ); + mSelection = new QLabel(); + mSelection->setText( QString() ); + + statusbar->addWidget( mSelection ); + connect( mBrowser, &QHelpBrowser::scaleChanged, this, [this]() { mScale->setText( mScaleText.arg( static_cast( std::round( mBrowser->scale() * 100.0 ) ) ) ); } ); + + connect( mBrowser, &QHelpBrowser::selectionChanged, this, + [this]() + { + auto text = mBrowser->selectedText(); + mSelection->setText( mSelectionText.arg( text.length() ) ); + } ); } TestBrowser::~TestBrowser() diff --git a/test/browser/testbrowser.h b/test/browser/testbrowser.h index f3a6485..9065603 100644 --- a/test/browser/testbrowser.h +++ b/test/browser/testbrowser.h @@ -53,4 +53,6 @@ class TestBrowser : public QMainWindow QAction* mPreviousFindMatch = nullptr; QLabel* mScale = nullptr; QString mScaleText = tr( "Zoom: %1%" ); + QLabel* mSelection = nullptr; + QString mSelectionText = tr( "Selected %1 chars" ); }; diff --git a/test/library/CMakeLists.txt b/test/library/CMakeLists.txt index 2ca2bfe..df1aabd 100644 --- a/test/library/CMakeLists.txt +++ b/test/library/CMakeLists.txt @@ -8,11 +8,14 @@ else() set(QT_VERSION_MAJOR 5) endif() +add_compile_definitions(-DUNIT_TEST) + set( test_names test_html_content test_css_content test_history test_find + test_selection ) set( runpath "$;$") @@ -29,6 +32,10 @@ foreach( name ${test_names}) qt_wrap_cpp ( ${name}_mocs ${name}.h test_base.h + ${QLiteHtmlBrowser_SOURCE_DIR}/src/container_qt.h + ${QLiteHtmlBrowser_SOURCE_DIR}/src/QLiteHtmlBrowserImpl.h + ${QLiteHtmlBrowser_SOURCE_DIR}/src/TextManager.h + ${QLiteHtmlBrowser_SOURCE_DIR}/include/qlitehtmlbrowser/QLiteHtmlBrowser.h ) target_sources(${name} @@ -37,11 +44,19 @@ foreach( name ${test_names}) ${name}.h test_base.h ${${name}_mocs} + ${QLiteHtmlBrowser_SOURCE_DIR}/include/qlitehtmlbrowser/QLiteHtmlBrowser.h + ${QLiteHtmlBrowser_SOURCE_DIR}/src/QLiteHtmlBrowser.cpp + ${QLiteHtmlBrowser_SOURCE_DIR}/src/QLiteHtmlBrowserImpl.cpp + ${QLiteHtmlBrowser_SOURCE_DIR}/src/QLiteHtmlBrowserImpl.h + ${QLiteHtmlBrowser_SOURCE_DIR}/src/container_qt.h + ${QLiteHtmlBrowser_SOURCE_DIR}/src/container_qt.cpp + ${QLiteHtmlBrowser_SOURCE_DIR}/src/TextManager.cpp + ${QLiteHtmlBrowser_SOURCE_DIR}/src/TextManager.h ) - target_include_directories( ${name} PRIVATE ${QLiteHtmlBrowser_SOURCE_DIR}/include ) + target_include_directories( ${name} PRIVATE ${QLiteHtmlBrowser_SOURCE_DIR}/include ${QLiteHtmlBrowser_SOURCE_DIR}/src) - target_link_libraries(${name} PRIVATE QLiteHtmlBrowser Qt::Widgets Qt::Gui Qt::Test ) + target_link_libraries(${name} PUBLIC litehtml Qt::Widgets Qt::Gui Qt::Test ) target_compile_definitions(${name} PRIVATE TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/test/library/selection/simple_inline.html b/test/library/selection/simple_inline.html new file mode 100644 index 0000000..d7e1611 --- /dev/null +++ b/test/library/selection/simple_inline.html @@ -0,0 +1,5 @@ + + +This is bold text with one strong word + + diff --git a/test/library/selection/simple_paragraph.html b/test/library/selection/simple_paragraph.html new file mode 100644 index 0000000..5afa1e1 --- /dev/null +++ b/test/library/selection/simple_paragraph.html @@ -0,0 +1,7 @@ + + +

Abschnitt 1: Erste Zeile

+

Abschnitt 2: Zweite Zeile

+

Abschnitt 3: Dritte Zeile

+ + diff --git a/test/library/selection/simple_table.html b/test/library/selection/simple_table.html new file mode 100644 index 0000000..545d2c1 --- /dev/null +++ b/test/library/selection/simple_table.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + +
CompanyContactCountry
Alfreds FutterkisteMaria AndersGermany
Centro comercial MoctezumaFrancisco ChangMexico
+ + diff --git a/test/library/selection/simple_text.html b/test/library/selection/simple_text.html new file mode 100644 index 0000000..7df49c6 --- /dev/null +++ b/test/library/selection/simple_text.html @@ -0,0 +1,5 @@ + + +This is pure text only in one sentence + + diff --git a/test/library/test_css_content.h b/test/library/test_css_content.h index 6b050e3..dc82531 100644 --- a/test/library/test_css_content.h +++ b/test/library/test_css_content.h @@ -10,7 +10,7 @@ class HTMLCssTest : public TestBase Q_OBJECT public: HTMLCssTest() - : TestBase( qApp->arguments() ){}; + : TestBase( qApp->arguments() ) {}; virtual ~HTMLCssTest() = default; private Q_SLOTS: diff --git a/test/library/test_find.cpp b/test/library/test_find.cpp index 7fb528a..f2c44a5 100644 --- a/test/library/test_find.cpp +++ b/test/library/test_find.cpp @@ -1,6 +1,7 @@ #include "test_find.h" #include - +#include "QLiteHtmlBrowserImpl.h" +#include "container_qt.h" #include #include @@ -12,6 +13,18 @@ int main( int argc, char** argv ) return QTest::qExec( &mContentTest, mContentTest.args() ); } +bool FindTest::skipUITest() const +{ + auto skip = false; + auto qpa = qgetenv( "QT_QPA_PLATFORM" ); + if ( !qpa.isEmpty() ) + { + auto value = QString::fromLocal8Bit( qpa ); + skip = value != "xcb"; + } + return skip; +} + QLiteHtmlBrowser* FindTest::createMainWindow( const QSize& size ) { mWnd = std::make_unique(); @@ -24,8 +37,20 @@ QLiteHtmlBrowser* FindTest::createMainWindow( const QSize& size ) return browser; } +void FindTest::test_find_single_word_data() +{ + QTest::addColumn( "find_text" ); + QTest::addColumn( "matches" ); + QTest::addRow( "%d", 0 ) << QString( "Sätze" ) << 40; + QTest::addRow( "%d", 1 ) << QString( "Er" ) << 46; + QTest::addRow( "%d", 2 ) << QString( "" ) << 0; +} + void FindTest::test_find_single_word() { + QFETCH( QString, find_text ); + QFETCH( int, matches ); + auto browser = createMainWindow( mBrowserSize ); // QVERIFY( mWnd->centralWidget() ); @@ -34,21 +59,29 @@ void FindTest::test_find_single_word() auto base = QString{ TEST_SOURCE_DIR } + "/search/"; browser->setSource( QUrl::fromLocalFile( base + "/LongGermanText.html" ) ); - auto found = browser->findText( ( "Sätze" ) ); - qApp->processEvents(); - QCOMPARE( found, 40 ); - found = browser->findText( ( " Er" ) ); + auto found = browser->findText( ( find_text ) ); qApp->processEvents(); - QCOMPARE( found, 46 ); + QCOMPARE( found, matches ); +} - found = browser->findText( "" ); - qApp->processEvents(); - QCOMPARE( found, 0 ); +void FindTest::test_find_phrase_data() +{ + QTest::addColumn( "find_text" ); + QTest::addColumn( "matches" ); + QTest::addColumn>( "bounding_boxes" ); + QList boxes = { { 252, 995, 151, 17 } }; + QTest::addRow( "%d", 0 ) << QString( "allgemeine Beobachtungen" ) << 1 << boxes; + boxes = { { 0, 0, 0, 0 } }; + QTest::addRow( "%d", 0 ) << QString( "schliesst ab" ) << 0 << boxes; } void FindTest::test_find_phrase() { + QFETCH( QString, find_text ); + QFETCH( int, matches ); + QFETCH( QList, bounding_boxes ); + auto browser = createMainWindow( mBrowserSize ); // QVERIFY( mWnd->centralWidget() ); @@ -57,24 +90,80 @@ void FindTest::test_find_phrase() auto base = QString{ TEST_SOURCE_DIR } + "/search/"; browser->setSource( QUrl::fromLocalFile( base + "/LongGermanText.html" ) ); - auto found = browser->findText( "allgemeine Beobachtungen" ); + auto found = browser->findText( find_text ); qApp->processEvents(); - QCOMPARE( found, 1 ); + QCOMPARE( found, matches ); + + if ( 0 < found ) + { + if ( skipUITest() ) + { + QSKIP( "skip tests due QT_QPA_PLATFORM_PLUGIN" ); + } + + auto container = browser->mImpl->container(); + auto find_machtes = container->mFindMatches; + for ( size_t i = 0; i < find_machtes.size(); ++i ) + { + const auto& match = find_machtes[i]; + auto bb = QRect( match.bounding_box.x, match.bounding_box.y, match.bounding_box.width, match.bounding_box.height ); + if ( bb != bounding_boxes[i] ) + { + auto img = container->grab(); // QMainWindow, QWidget usw. + img.save( QString( "screenshot_ui_find_phrase_%1.png" ).arg( i ) ); + } + + QCOMPARE( bb, bounding_boxes[i] ); + } + } +} - found = browser->findText( "schliesst ab" ); - qApp->processEvents(); - QCOMPARE( found, 0 ); +void FindTest::test_find_phrase_multi_element_data() +{ + QTest::addColumn( "find_text" ); + QTest::addColumn( "matches" ); + QTest::addColumn>( "bounding_boxes" ); + QList boxes = { { 8, 2145, 64, 46 } }; + QTest::addRow( "%d", 0 ) << QString( "schließt ab.Absatz 48" ) << 1 << boxes; } void FindTest::test_find_phrase_multi_element() { + QFETCH( QString, find_text ); + QFETCH( int, matches ); + QFETCH( QList, bounding_boxes ); + auto browser = createMainWindow( mBrowserSize ); // QVERIFY( mWnd->centralWidget() ); auto base = QString{ TEST_SOURCE_DIR } + "/search/"; - browser->setSource( QUrl::fromLocalFile( base + "/LongGermanText.html" ) ); - auto found = browser->findText( QStringLiteral( "schließt ab. Absatz 48" ) ); + + auto found = browser->findText( find_text ); qApp->processEvents(); - QCOMPARE( found, 1 ); + // QTest::qWaitForWindowExposed( browser->mImpl->container() ); + QCOMPARE( found, matches ); + if ( 0 < found ) + { + if ( skipUITest() ) + { + QSKIP( "skip tests due QT_QPA_PLATFORM_PLUGIN" ); + } + + auto container = browser->mImpl->container(); + qApp->processEvents(); + auto find_machtes = container->mFindMatches; + for ( size_t i = 0; i < find_machtes.size(); ++i ) + { + const auto& match = find_machtes[i]; + auto bb = QRect( match.bounding_box.x, match.bounding_box.y, match.bounding_box.width, match.bounding_box.height ); + if ( bb != bounding_boxes[i] ) + { + auto img = container->grab(); // QMainWindow, QWidget usw. + img.save( QDir::currentPath() + QString( "/screenshot_ui_multi_element_%1.png" ).arg( i ) ); + } + + QCOMPARE( bb, bounding_boxes[i] ); + } + } } diff --git a/test/library/test_find.h b/test/library/test_find.h index c88c610..ba5a679 100644 --- a/test/library/test_find.h +++ b/test/library/test_find.h @@ -10,7 +10,7 @@ class FindTest : public TestBase Q_OBJECT public: FindTest() - : TestBase( qApp->arguments() ){}; + : TestBase( qApp->arguments() ) {}; virtual ~FindTest() = default; private Q_SLOTS: @@ -21,12 +21,16 @@ private Q_SLOTS: mWnd.reset(); mWnd = nullptr; } + void test_find_single_word_data(); void test_find_single_word(); + void test_find_phrase_data(); void test_find_phrase(); + void test_find_phrase_multi_element_data(); void test_find_phrase_multi_element(); private: QLiteHtmlBrowser* createMainWindow( const QSize& ); + bool skipUITest() const; std::unique_ptr mWnd = nullptr; QSize mBrowserSize = { 800, 600 }; }; diff --git a/test/library/test_history.h b/test/library/test_history.h index bc46c0d..e62b700 100644 --- a/test/library/test_history.h +++ b/test/library/test_history.h @@ -10,7 +10,7 @@ class HistoryTest : public TestBase Q_OBJECT public: HistoryTest() - : TestBase( qApp->arguments() ){}; + : TestBase( qApp->arguments() ) {}; virtual ~HistoryTest() = default; private Q_SLOTS: diff --git a/test/library/test_html_content.h b/test/library/test_html_content.h index 7a688e3..e61250a 100644 --- a/test/library/test_html_content.h +++ b/test/library/test_html_content.h @@ -10,7 +10,7 @@ class HTMLContentTest : public TestBase Q_OBJECT public: HTMLContentTest() - : TestBase( qApp->arguments() ){}; + : TestBase( qApp->arguments() ) {}; virtual ~HTMLContentTest() = default; private Q_SLOTS: diff --git a/test/library/test_selection.cpp b/test/library/test_selection.cpp new file mode 100644 index 0000000..d1193dc --- /dev/null +++ b/test/library/test_selection.cpp @@ -0,0 +1,58 @@ +#include "test_selection.h" +#include +#include "QLiteHtmlBrowserImpl.h" +#include "container_qt.h" +#include +#include + +int main( int argc, char** argv ) +{ + QApplication app( argc, argv ); + + SelectionTest mContentTest; + return QTest::qExec( &mContentTest, mContentTest.args() ); +} + +QLiteHtmlBrowser* SelectionTest::createMainWindow( const QSize& size ) +{ + mWnd = std::make_unique(); + auto browser = new QLiteHtmlBrowser( nullptr ); + mWnd->setCentralWidget( browser ); + browser->setMinimumSize( size ); + browser->update(); + browser->show(); + mWnd->show(); + return browser; +} + +void SelectionTest::testSelectionText_data() +{ + QTest::addColumn( "html" ); + QTest::addColumn( "selection" ); + + QTest::addRow( "%d", 0 ) << QString( "simple_text.html" ) << QString( "This is pure text only in one sentence" ); + QTest::addRow( "%d", 1 ) << QString( "simple_inline.html" ) << QString( "This is bold text with one strong word" ); + QTest::addRow( "%d", 2 ) << QString( "simple_paragraph.html" ) + << QString( "Abschnitt 1: Erste Zeile\n\nAbschnitt 2: Zweite Zeile\n\nAbschnitt 3: Dritte Zeile" ); + QTest::addRow( "%d", 3 ) + << QString( "simple_table.html" ) + << QString( "Company Contact Country\nAlfreds Futterkiste Maria Anders Germany\nCentro comercial Moctezuma Francisco Chang Mexico" ); +} + +void SelectionTest::testSelectionText() +{ + QFETCH( QString, html ); + QFETCH( QString, selection ); + + auto browser = createMainWindow( mBrowserSize ); + auto base = QString{ TEST_SOURCE_DIR } + "/selection/"; + + browser->setSource( QUrl::fromLocalFile( base + "/" + html ) ); + + auto container = browser->mImpl->container(); + + container->selectAll(); + + auto text = container->selectedText(); + QCOMPARE( text, selection ); +} diff --git a/test/library/test_selection.h b/test/library/test_selection.h new file mode 100644 index 0000000..ed2ef7d --- /dev/null +++ b/test/library/test_selection.h @@ -0,0 +1,32 @@ + +#include +#include +#include "test_base.h" + +class QLiteHtmlBrowser; + +class SelectionTest : public TestBase +{ + Q_OBJECT +public: + SelectionTest() + : TestBase( qApp->arguments() ) {}; + virtual ~SelectionTest() = default; + +private Q_SLOTS: + void init() { TestBase::init(); } + void cleanup() + { + TestBase::cleanup(); + mWnd.reset(); + mWnd = nullptr; + } + + void testSelectionText_data(); + void testSelectionText(); + +private: + QLiteHtmlBrowser* createMainWindow( const QSize& ); + std::unique_ptr mWnd = nullptr; + QSize mBrowserSize = { 800, 600 }; +};