'Can you Print the wavy lines generated by Spell check in writer?

As per google group, this macro can be used to print mis-spelled words in MS office.

https://groups.google.com/g/microsoft.public.word.spelling.grammar/c/OiFYPkLAbeU

Is there similar option in libre-office writer?



Solution 1:[1]

The following Subroutine replicates what the code in the Google group does. It is more verbose than the MS version but that is to be expected with LibreOffice / OpenOffice. It only does the spellchecker lines and not the green grammar checker ones, which is also the case with the MS version in the Google group.

Sub UnderlineMisspelledWords

    ' From OOME Listing 315 Page 336
    GlobalScope.BasicLibraries.loadLibrary( "Tools" )
    Dim sLocale As String
    sLocale = GetRegistryKeyContent("org.openoffice.Setup/L10N", FALSE).getByName("ooLocale")

    ' ooLocale appears to return a string that consists of the language and country
    ' seperated by a dash, e.g. en-GB
    Dim nDash As Integer
    nDash = InStr(sLocale, "-")

    Dim aLocale As New com.sun.star.lang.Locale
    aLocale.Language = Left(sLocale, nDash - 1)
    aLocale.Country = Right(sLocale, Len(sLocale) -nDash )

    Dim oSpeller As Variant
    oSpeller = createUnoService("com.sun.star.linguistic2.SpellChecker")

    Dim emptyArgs() as new com.sun.star.beans.PropertyValue

    Dim oCursor As Object
    oCursor = ThisComponent.getText.createTextCursor()
    oCursor.gotoStart(False)
    oCursor.collapseToStart()

    Dim s as String, bTest As Boolean
    Do 
        oCursor.gotoEndOfWord(True)
        s = oCursor.getString()
        bTest = oSpeller.isValid(s, aLocale, emptyArgs())

        If Not bTest Then    
            With oCursor
                .CharUnderlineHasColor = True
                .CharUnderlineColor = RGB(255, 0,0)
                .CharUnderline = com.sun.star.awt.FontUnderline.WAVE
                ' Possible alternatives include SMALLWAVE, DOUBLEWAVE and BOLDWAVE
            End With
        End If    
    Loop While oCursor.gotoNextWord(False)

End Sub    

This will change the actual formatting of the font to have a red wavy underline, which will print out like any other formatting. If any of the misspelled words in the document already have some sort of underlining then that will be lost.

You will probably want to remove the underlining after you have printed it. The following Sub removes underlining only where its style exactly matches that of the line added by the first routine.

Sub RemoveUnderlining

    Dim oCursor As Object
    oCursor = ThisComponent.getText.createTextCursor()
    oCursor.gotoStart(False)
    oCursor.collapseToStart()

    Dim s as String, bTest As Boolean
    Do 
    
        oCursor.gotoEndOfWord(True) 
        
        Dim bTest1 As Boolean        
        bTest1 = False
        If oCursor.CharUnderlineHasColor = True Then
            bTest1 = True
        End If
        
        Dim bTest2 As Boolean  
        bTest2 = False
        If oCursor.CharUnderlineColor = RGB(255, 0,0) Then
            bTest2 = True
        End If
        
        Dim bTest3 As Boolean  
        bTest3 = False
        If oCursor.CharUnderline = com.sun.star.awt.FontUnderline.WAVE Then
            bTest3 = True
        End If
        
        If bTest1 And bTest2 And bTest3 Then
            With oCursor
                .CharUnderlineHasColor = False
                .CharUnderline = com.sun.star.awt.FontUnderline.NONE
            End With
        End If
    Loop While oCursor.gotoNextWord(False)

End Sub

This will not restore any original underlining that was replaced by red wavy ones. Other ways of removing the wavy lines that would restore these are:

  1. Pressing undo (Ctrl Z) but you will need to do that once for every word in your document, which could be a bit of a pain.

  2. Running the subroutine UnderlineMisspelledWords on a temporary copy of the document and then discarding it after printing.

I hope this is what you were looking for.

Solution 2:[2]

In response to your above comment, it is straightforward to modify the above subroutine to do that instead of drawing wavy lines. The code below opens a new Writer document and writes into it a list of the misspelled words together with the alternatives that the spellchecker suggests:

Sub ListMisSpelledWords

    ' From OOME Listing 315 Page 336
    GlobalScope.BasicLibraries.loadLibrary( "Tools" )
    Dim sLocale As String
    sLocale = GetRegistryKeyContent("org.openoffice.Setup/L10N", FALSE).getByName("ooLocale")

    ' ooLocale appears to return a string that consists of the language and country
    ' seperated by a dash, e.g. en-GB
    Dim nDash As Integer
    nDash = InStr(sLocale, "-")

    Dim aLocale As New com.sun.star.lang.Locale
    aLocale.Language = Left(sLocale, nDash - 1)
    aLocale.Country = Right(sLocale, Len(sLocale) -nDash )

    Dim oSource As Object 
    oSource = ThisComponent

    Dim oSourceCursor As Object
    oSourceCursor = oSource.getText.createTextCursor()
    oSourceCursor.gotoStart(False)
    oSourceCursor.collapseToStart()

    Dim oDestination As Object
    oDestination = StarDesktop.loadComponentFromURL( "private:factory/swriter",  "_blank", 0, Array() )

    Dim oDestinationText as Object
    oDestinationText = oDestination.getText()

    Dim oDestinationCursor As Object
    oDestinationCursor = oDestinationText.createTextCursor()

    Dim oSpeller As Object
    oSpeller = createUnoService("com.sun.star.linguistic2.SpellChecker")

    Dim oSpellAlternatives As Object, emptyArgs() as new com.sun.star.beans.PropertyValue
    Dim sMistake as String, oSpell As Object, sAlternatives() as String, bTest As Boolean, s As String, i as Integer

    Do

        oSourceCursor.gotoEndOfWord(True)
        sMistake = oSourceCursor.getString()

        bTest = oSpeller.isValid(sMistake, aLocale, emptyArgs())

        If Not bTest Then
            oSpell = oSpeller.spell(sMistake, aLocale, emptyArgs())
            sAlternatives = oSpell.getAlternatives()
            s = ""
            for i = LBound(sAlternatives) To Ubound(sAlternatives) - 1
                s = s & sAlternatives(i) & ", "
            Next i
            s = s & sAlternatives(Ubound(sAlternatives))
            oDestinationText.insertString(oDestinationCursor, sMistake & ":  " & s & Chr(13), False)
        End If    

    Loop While oSourceCursor.gotoNextWord(False)

End Sub

Solution 3:[3]

I don't know about the dictionaries but, in answer to your previous comment, if you paste the following code below Loop While and above End Sub it will result in the text in the newly opened Writer document being sorted without duplicates. It's not very elegant but it works on the text I've tried it on.

oDestinationCursor.gotoStart(False)
oDestinationCursor.gotoEnd(True)

Dim oSortDescriptor As Object
oSortDescriptor = oDestinationCursor.createSortDescriptor()
oDestinationCursor.sort(oSortDescriptor)

Dim sParagraphToBeChecked As String
Dim sThisWord As String
sThisWord = ""
Dim sPreviousWord As String
sPreviousWord = ""

oDestinationCursor.gotoStart(False)
oDestinationCursor.collapseToStart()

Dim k As Integer
Do
    oDestinationCursor.gotoEndOfParagraph(True)
    sParagraphToBeChecked = oDestinationCursor.getString()
    k = InStr(sParagraphToBeChecked, ":")
    If k <> 0 Then
        sThisWord = Left(sParagraphToBeChecked, k-1)
    End If
    If StrComp(sThisWord, sPreviousWord, 0) = 0 Then
        oDestinationCursor.setString("")
    End If
    sPreviousWord = sThisWord
Loop While oDestinationCursor.gotoNextParagraph(False)

Dim oReplaceDescriptor As Object
oReplaceDescriptor =  oDestination.createReplaceDescriptor()
oReplaceDescriptor.setPropertyValue("SearchRegularExpression", TRUE)
oReplaceDescriptor.setSearchString("^$")
oReplaceDescriptor.setReplaceString("")
oDestination.replaceAll(oReplaceDescriptor)

Solution 4:[4]

It seems I didn't spot that because the text I tested it on contained only words that were either correct or had more than zero alternatives. I managed to replicate the error by putting in a word consisting of random characters for which the spellchecker was unable to suggest any alternatives. If no alternatives are found the function .getAlternatives() returns an array of size -1 so the error can be avoided by testing for this condition before the array is used. Below is a modified version of the first Do loop in the subroutine with such a condition added. If you replace the existing loop with that it should eliminate the error.

Do

    oSourceCursor.gotoEndOfWord(True)
    sMistake = oSourceCursor.getString()

    bTest = oSpeller.isValid(sMistake, aLocale, emptyArgs())

    If Not bTest Then
        oSpell = oSpeller.spell(sMistake, aLocale, emptyArgs())
        sAlternatives = oSpell.getAlternatives()
        s = ""
        If Ubound(sAlternatives) >= 0 Then
            for i = LBound(sAlternatives) To Ubound(sAlternatives) - 1
                s = s & sAlternatives(i) & ", "
            Next i
            s = s & sAlternatives(Ubound(sAlternatives))
        End If            
        oDestinationText.insertString(oDestinationCursor, sMistake & ":  " & s & Chr(13), False)
    End If    

Loop While oSourceCursor.gotoNextWord(False)

On re-reading the whole subroutine I think it would improve its readability if the variable sMistake were renamed to something like sWordToBeChecked, as the string this variable contains isn't always misspelled. This would of course need to be changed everywhere in the routine and not just in the above snippet.

Solution 5:[5]

Below is a modified version that uses the dispatcher as suggested by Jim K in his answer go to end of word is not always followed. I have written it out in its entirety because the changes are more extensive than just adding or replacing a block. In particular, it is necessary to get the view cursor before creating the empty destination document, otherwise the routine will spell check that.

Sub ListMisSpelledWords2

    ' From OOME Listing 315 Page 336
    GlobalScope.BasicLibraries.loadLibrary( "Tools" )
    Dim sLocale As String
    sLocale = GetRegistryKeyContent("org.openoffice.Setup/L10N", FALSE).getByName("ooLocale")

    ' ooLocale appears to return a string that consists of the language and country
    ' seperated by a dash, e.g. en-GB
    Dim nDash As Integer
    nDash = InStr(sLocale, "-")

    Dim aLocale As New com.sun.star.lang.Locale
    aLocale.Language = Left(sLocale, nDash - 1)
    aLocale.Country = Right(sLocale, Len(sLocale) -nDash )

    Dim oSourceDocument As Object 
    oSourceDocument = ThisComponent

    Dim nWordCount as Integer
    nWordCount = oSourceDocument.WordCount    

    Dim oFrame  As Object, oViewCursor As Object
    With oSourceDocument.getCurrentController
        oFrame = .getFrame()
        oViewCursor = .getViewCursor()
    End With

    Dim oDispatcher as Object
    oDispatcher = createUnoService("com.sun.star.frame.DispatchHelper")
    oDispatcher.executeDispatch(oFrame, ".uno:GoToStartOfDoc", "", 0, Array()) 

    Dim oDestinationDocument As Object
    oDestinationDocument = StarDesktop.loadComponentFromURL( "private:factory/swriter",  "_blank", 0, Array() )

    Dim oDestinationText as Object
    oDestinationText = oDestinationDocument.getText()

    Dim oDestinationCursor As Object
    oDestinationCursor = oDestinationText.createTextCursor()

    Dim oSpeller As Object
    oSpeller = createUnoService("com.sun.star.linguistic2.SpellChecker")

    Dim oSpellAlternatives As Object, emptyArgs() as new com.sun.star.beans.PropertyValue
    Dim sMistake as String, oSpell As Object, sAlternatives() as String, bTest As Boolean, s As String, i as Integer

    For i = 0 To nWordCount - 1

        oDispatcher.executeDispatch(oFrame, ".uno:WordRightSel", "", 0, Array())
        sWordToBeChecked = RTrim( oViewCursor.String )

        bTest = oSpeller.isValid(sWordToBeChecked, aLocale, emptyArgs())

        If Not bTest Then
            oSpell = oSpeller.spell(sWordToBeChecked, aLocale, emptyArgs())
            sAlternatives = oSpell.getAlternatives()
            s = ""
            If Ubound(sAlternatives) >= 0 Then
                for i = LBound(sAlternatives) To Ubound(sAlternatives) - 1
                    s = s & sAlternatives(i) & ", "
                Next i
                s = s & sAlternatives(Ubound(sAlternatives))
            End If            
            oDestinationText.insertString(oDestinationCursor, sWordToBeChecked & ":  " & s & Chr(13), False)
        End If

        oDispatcher.executeDispatch(oFrame, ".uno:GoToPrevWord", "", 0, Array())
        oDispatcher.executeDispatch(oFrame, ".uno:GoToNextWord", "", 0, Array())

    Next i

    oDestinationCursor.gotoStart(False)
    oDestinationCursor.gotoEnd(True)

    ' Sort the paragraphs
    Dim oSortDescriptor As Object
    oSortDescriptor = oDestinationCursor.createSortDescriptor()
    oDestinationCursor.sort(oSortDescriptor)

    ' Remove duplicates
    Dim sParagraphToBeChecked As String, sThisWord As String, sPreviousWord As String
    sThisWord = ""
    sPreviousWord = ""

    oDestinationCursor.gotoStart(False)
    oDestinationCursor.collapseToStart()

    Dim k As Integer
    Do
        oDestinationCursor.gotoEndOfParagraph(True)
        sParagraphToBeChecked = oDestinationCursor.getString()
        k = InStr(sParagraphToBeChecked, ":")
        If k <> 0 Then
            sThisWord = Left(sParagraphToBeChecked, k-1)
        End If
        If StrComp(sThisWord, sPreviousWord, 0) = 0 Then
            oDestinationCursor.setString("")
        End If
        sPreviousWord = sThisWord
    Loop While oDestinationCursor.gotoNextParagraph(False)

    ' Remove empty paragraphs
    Dim oReplaceDescriptor As Object
    oReplaceDescriptor =  oDestinationDocument.createReplaceDescriptor()
    oReplaceDescriptor.setPropertyValue("SearchRegularExpression", TRUE)
    oReplaceDescriptor.setSearchString("^$")
    oReplaceDescriptor.setReplaceString("")
    oDestinationDocument.replaceAll(oReplaceDescriptor)

End Sub

Solution 6:[6]

It looks like the problem is caused by one of the while loops in the function TrimWord, which removes punctuation from before and after a word before it is fed to the spell-check service. If the word is only one character long and if it is a valid punctuation character then the condition at the beginning of the loop is true, so the loop is entered and the counter n is decremented to zero. Then at the beginning of the next traversal of the loop, even though the condition is false anyway, it still asks the Mid function to return the 0th character of the word, which it can't do because the characters are numbered from 1, so it throws an error. Some languages would ignore the error if the truth value of the condition could be unambiguously determined from the other parts of the expression. It looks like BASIC doesn't do that.

The following modified version of the function gets round the problem in a rather inelegant way, but it seems to work:

Function TrimWord(sWord As String) As String

    Dim n as Long
    n = Len(sWord)
    
    If n > 0 Then
    
        Dim m as Long :  m = 1
        Dim bTest As Boolean
        
        bTest = m <= n
        Do While IsPermissiblePrefix( ASC(Mid(sWord, m, 1) ) ) And bTest
            if (m < n) Then
                m = m + 1
            Else
                bTest = False
            End If
        Loop       
        
        bTest = n > 0
        Do While IsPermissibleSuffix( ASC(Mid(sWord, n, 1) ) ) And bTest
            if (n > 1) Then
                n = n - 1 
            Else
                bTest = False
            End If         
        Loop
        
        If n > m Then
            TrimWord = Mid(sWord, m, (n + 1) - m)
        Else
            TrimWord = sWord
        End If
            
    Else
        TrimWord = ""
    End If

End Function

This works for me.

Solution 7:[7]

Firstly, in response to your question about the bug, I'm not a maintainer so I can't fix that. However, as the bug concerns moving a text cursor to the start and end of a word it should be possible to get round it by searching for the white-space between words instead. Since the white-space characters are (I think) the same in all languages, any problems recognising certain characters from certain alphabets shouldn't matter. The easiest way to do it would be to first read the entire text of the document into a string but LibreOffice strings have a maximum length of 2^16 = 65536 characters and while this seems like a lot it could easily be too small for a reasonable sized document. The limit can be avoided by navigating through the text one paragraph at a time. According to Andrew Pitonyak (OOME Page 388): "I found gotoNextSentence() and gotoNextWord() to be unreliable, but the paragraph cursor worked well."

The code below is yet another modification of the subroutines in previous answers. This time it gets a string from a paragraph and splits it up into words by finding the white-space between the words. It then spell checks the words as before. The subroutine depends on some other functions that are listed below it. These allow you to specify which characters to designate as word separators (i.e. white-space) and which characters to ignore if they are found at the beginning or end of a word. This is necessary so that, for example, the quotes surrounding a quoted word are not counted as part of the word, which would lead to it being recognised as a spelling mistake even if the word inside the quotes is correctly spelled.

I am not familiar with non-latin alphabets and I don't have an appropriate dictionary installed, but I pasted the words from your question go to end of word is not always followed, namely test?, ???? and ?????? and they all appeared unmodified in the output document.

On the question of looking up synonyms, as each misspelled word has multiple suggestions, and each of those will have multiple synonyms, the output could rapidly become very large and confusing. It may be better for your user to look them up individually if they want to use a different word.

Sub ListMisSpelledWords3

    ' From OOME Listing 315 Page 336
    GlobalScope.BasicLibraries.loadLibrary( "Tools" )
    Dim sLocale As String
    sLocale = GetRegistryKeyContent("org.openoffice.Setup/L10N", FALSE).getByName("ooLocale")

    ' ooLocale appears to return a string that consists of the language and country
    ' seperated by a dash, e.g. en-GB
    Dim nDash As Integer
    nDash = InStr(sLocale, "-")

    Dim aLocale As New com.sun.star.lang.Locale
    aLocale.Language = Left( sLocale, nDash - 1)
    aLocale.Country = Right( sLocale, Len(sLocale) - nDash )

    Dim oSource As Object 
    oSource = ThisComponent

    Dim oSourceCursor As Object
    oSourceCursor = oSource.getText.createTextCursor()
    oSourceCursor.gotoStart(False)
    oSourceCursor.collapseToStart()

    Dim oDestination As Object
    oDestination = StarDesktop.loadComponentFromURL( "private:factory/swriter",  "_blank", 0, Array() )

    Dim oDestinationText as Object
    oDestinationText = oDestination.getText()

    Dim oDestinationCursor As Object
    oDestinationCursor = oDestinationText.createTextCursor()

    Dim oSpeller As Object
    oSpeller = createUnoService("com.sun.star.linguistic2.SpellChecker")

    Dim oSpellAlternatives As Object, emptyArgs() as new com.sun.star.beans.PropertyValue
    Dim sWordToCheck as String, oSpell As Object, sAlternatives() as String, bTest As Boolean
    Dim s As String, i as Integer, j As Integer, sParagraph As String, nWordStart As Integer, nWordEnd As Integer
    Dim nChar As Integer

    Do

        oSourceCursor.gotoEndOfParagraph(True)

        sParagraph = oSourceCursor.getString() & " " 'It is necessary to add a space to the end of
        'the string otherwise the last word of the paragraph is not recognised.

        nWordStart = 1
        nWordEnd = 1

        For i = 1 to Len(sParagraph)

            nChar = ASC(Mid(sParagraph, i, 1))

            If IsWordSeparator(nChar) Then   '1

                If nWordEnd > nWordStart Then   '2

                sWordToCheck = TrimWord( Mid(sParagraph, nWordStart, nWordEnd - nWordStart) )

                    bTest = oSpeller.isValid(sWordToCheck, aLocale, emptyArgs())

                    If Not bTest Then   '3
                        oSpell = oSpeller.spell(sWordToCheck, aLocale, emptyArgs())
                        sAlternatives = oSpell.getAlternatives()
                        s = ""                        
                        If Ubound(sAlternatives) >= 0 Then   '4
                            for j = LBound(sAlternatives) To Ubound(sAlternatives) - 1
                                s = s & sAlternatives(j) & ", "
                            Next j
                                s = s & sAlternatives(Ubound(sAlternatives))
                        End If          '4 
                        oDestinationText.insertString(oDestinationCursor, sWordToCheck & " :  " & s & Chr(13), False)
                    End If  '3

                End If   '2
                    nWordEnd = nWordEnd + 1
                    nWordStart = nWordEnd
                Else
                    nWordEnd = nWordEnd + 1
            End If    '1

        Next i

    Loop While oSourceCursor.gotoNextParagraph(False)

    oDestinationCursor.gotoStart(False)
    oDestinationCursor.gotoEnd(True)

    Dim oSortDescriptor As Object
    oSortDescriptor = oDestinationCursor.createSortDescriptor()
    oDestinationCursor.sort(oSortDescriptor)

    Dim sParagraphToBeChecked As String
    Dim sThisWord As String
    sThisWord = ""
    Dim sPreviousWord As String
    sPreviousWord = ""

    oDestinationCursor.gotoStart(False)
    oDestinationCursor.collapseToStart()

    Dim k As Integer
    Do
        oDestinationCursor.gotoEndOfParagraph(True)
        sParagraphToBeChecked = oDestinationCursor.getString()
        k = InStr(sParagraphToBeChecked, ":")
        If k <> 0 Then
            sThisWord = Left(sParagraphToBeChecked, k-1)
        End If
            If StrComp(sThisWord, sPreviousWord, 0) = 0 Then
            oDestinationCursor.setString("")
        End If
        sPreviousWord = sThisWord
    Loop While oDestinationCursor.gotoNextParagraph(False)

    Dim oReplaceDescriptor As Object
    oReplaceDescriptor =  oDestination.createReplaceDescriptor()
    oReplaceDescriptor.setPropertyValue("SearchRegularExpression", TRUE)
    oReplaceDescriptor.setSearchString("^$")
    oReplaceDescriptor.setReplaceString("")
    oDestination.replaceAll(oReplaceDescriptor)

End Sub

'----------------------------------------------------------------------------

' From OOME Listing 360. 
Function IsWordSeparator(iChar As Integer) As Boolean

    ' Horizontal tab \t 9
    ' New line \n 10
    ' Carriage return \r 13
    ' Space   32
    ' Non-breaking space   160     

    Select Case iChar
    Case 9, 10, 13, 32, 160
        IsWordSeparator = True
    Case Else
        IsWordSeparator = False
    End Select    
End Function

'-------------------------------------

' Characters to be trimmed off beginning of word before spell checking
Function IsPermissiblePrefix(iChar As Integer) As Boolean

    ' Symmetric double quote " 34
    ' Left parenthesis ( 40
    ' Left square bracket [ 91
    ' Back-tick ` 96
    ' Left curly bracket { 123
    ' Left double angle quotation marks « 171
    ' Left single quotation mark ‘ 8216
    ' Left single reversed 9 quotation mark ? 8219
    ' Left double quotation mark “ 8220
    ' Left double reversed 9 quotation mark ? 8223

    Select Case iChar
    Case 34, 40, 91, 96, 123, 171, 8216, 8219, 8220, 8223
        IsPermissiblePrefix = True
    Case Else
        IsPermissiblePrefix = False
    End Select 

End Function

'-------------------------------------

' Characters to be trimmed off end of word before spell checking
Function IsPermissibleSuffix(iChar As Integer) As Boolean

    ' Exclamation mark ! 33
    ' Symmetric double quote " 34
    ' Apostrophe ' 39
    ' Right parenthesis ) 41
    ' Comma , 44
    ' Full stop . 46
    ' Colon : 58
    ' Semicolon ; 59
    ' Question mark ? 63
    ' Right square bracket ] 93
    ' Right curly bracket } 125
    ' Right double angle quotation marks » 187
    ' Right single quotation mark ‘ 8217
    ' Right double quotation mark “ 8221

    Select Case iChar
    Case 33, 34, 39, 41, 44, 46, 58, 59, 63, 93, 125, 187, 8217, 8221
        IsPermissibleSuffix = True
    Case Else
        IsPermissibleSuffix = False
    End Select    

End Function

'-------------------------------------

Function TrimWord( sWord As String) As String

    Dim n as Integer
    n = Len(sWord)
    
    If n > 0 Then
    
        Dim m as Integer :  m = 1
        Do While IsPermissiblePrefix( ASC(Mid(sWord, m, 1) ) ) And m <= n
                m = m + 1
        Loop
    
        Do While IsPermissibleSuffix( ASC(Mid(sWord, n, 1) ) ) And n >= 1
                n = n - 1
        Loop
        
        If n > m Then
            TrimWord = Mid(sWord, m, (n + 1) - m)
        Else
            TrimWord = sWord
        End If
            
    Else
        TrimWord = ""
    End If

End Function

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Howard Rudd
Solution 2 Howard Rudd
Solution 3 Howard Rudd
Solution 4 Howard Rudd
Solution 5 Howard Rudd
Solution 6 Howard Rudd
Solution 7 Howard Rudd