1
\$\begingroup\$

I have this class which deals with the task of wrapping input text in a way that preserves the words if possible, i.e. will only split a word if the word is longer than the desired line width.

I'm interested in feedback on how to further reduce method size. Also, I'm unhappy with how the WrapWordsAtLineWidth method deals with the remaining portion of the input string, but I was struggling to find a cleaner way to deal with it.

I would appreciate and welcome any suggestions.

/// <summary>
/// This class provides methods for manipulating input text to add multiple lines, e.g. wrapping, in a "smart" manner that preserves word boundaries.
/// </summary>
public static class TextWrapping
{
    /// <summary>
    /// Return a version of the provided string which is broken into lines that are no wider than the provided
    /// line width and where word boundaries are maintained wherever possible.
    /// </summary>
    /// <param name="toWrap">The string that will be processed and broken as needed into
    /// multiple lines that are no wider than the provided line width.</param>
    /// <param name="atLineWidth">The number of characters that represents
    /// the maximum line width that any line in the returned string will ahve.</param>
    /// <returns>Return a version of the provided string which is broken into lines that are no wider than the provided
    /// line width and where word boundaries are maintained wherever possible.</returns>
    /// <remarks></remarks>
    /// <exception cref="ArgumentException">An error will occur if <paramref name="atLineWidth">the provided line width</paramref> is not valid for use with this method.</exception>
    public static string WrapWordsAtLineWidth(string toWrap, int atLineWidth)
    {
        ValidateWordWrapLineWidth(atLineWidth);
        var inputBuilder = GetSingleLineStringBuilder(toWrap);
        if ((inputBuilder.Length < atLineWidth)) return inputBuilder.ToString(); // no more processing needed.
        const int LineFirstColumnIndex = 0;
        var currentCharOverallIndex = 0;
        var currentLineCurrentColumnIndex = LineFirstColumnIndex;
        var currentLineStartColumnIndex = 0;
        var resultBuilder = new StringBuilder();

        while (currentCharOverallIndex < inputBuilder.Length)
        {
            if ((currentLineCurrentColumnIndex >= atLineWidth))
            {
                currentCharOverallIndex = CalculateLineBoundariesExtractAppendToBuilder(inputBuilder, resultBuilder, currentLineStartColumnIndex, currentCharOverallIndex);
                currentLineStartColumnIndex = currentCharOverallIndex; //set new column start to the result overall index.
                var remainingLength = inputBuilder.Length - currentCharOverallIndex;

                // if the remainder of the string is shorter than the desired line width,
                // just append the remainder and exit.
                if ((remainingLength <= atLineWidth))
                {
                    ExtractAppendLineToBuilder(inputBuilder, currentLineStartColumnIndex, remainingLength, resultBuilder);
                    break; // no more processing needed; entire input has been processed.
                }

                currentLineCurrentColumnIndex = LineFirstColumnIndex;//reset
            }
            else // desired line width not yet reached.
            {
                currentCharOverallIndex += 1;
                currentLineCurrentColumnIndex += 1;
            }
        }

        return resultBuilder.ToString();
    }

    /// <summary>
    /// Validate the provided line width for use with <see cref="WrapWordsAtLineWidth"/> and throw an argument exception if it's not acceptable for use
    /// with that method.
    /// </summary>
    /// <param name="toValidate">The line width value that will be validated.</param>
    /// <exception cref="ArgumentException">An error will occur if the provide value is not valid for use as a line width.</exception>
    private static void ValidateWordWrapLineWidth(int toValidate)
    {
        const int MinimumBreakLineWidth = 2;
        if ((toValidate < MinimumBreakLineWidth)) throw new ArgumentException(string.Format("{0} is not a valid line width.  The line width must be greater than {1}.", toValidate, MinimumBreakLineWidth));
    }

    /// <summary>
    /// Return a string builder that contains the provided input string with no vertical whitespace characters, i.e. all "on one line". 
    /// </summary>
    /// <param name="fromMultiLineInput">The input string which may contain multiple lines, i.e. vertical whitespace characters.</param>
    /// <returns>Return a string builder that contains the provided input string with no vertical whitespace characters, i.e. all "on one line". </returns>
    private static StringBuilder GetSingleLineStringBuilder(string fromMultiLineInput)
    {
        return new StringBuilder(ReplaceLineEndingsWithStandardSpace(TrimTrailingLineHorizontalWhitespace(fromMultiLineInput)));
    }

    /// <summary>
    /// A regular expression pattern which matches any vertical whitespace character.
    /// </summary>
    /// <remarks></remarks>
    private const string VerticalWhitespaceRegexPattern = "[\\r\\v\\f\\n\\u2028\\u2029]";

    /// <summary>
    /// Trim all horizontal whitespace (e.g. standard space or tab chars, etc.) that occurs just before any line ending
    /// characters (i.e. vertical whitespace) from the provided string and return the result.
    /// </summary>
    /// <param name="fromString">The string that will be processed.</param>
    /// <returns>The provided string with all instances removed of horizontal whitespace that occurs just before
    /// line endings, i.e. vertical whitespace.</returns>
    /// <remarks></remarks>
    private static string TrimTrailingLineHorizontalWhitespace(string fromString)
    {
        var reg = new Regex(string.Format("( +){0}", VerticalWhitespaceRegexPattern));
        return reg.Replace(fromString, "");
    }

    /// <summary>
    /// Replace all line ending characters -- i.e. vertical whitespace -- with a single standard space character.
    /// </summary>
    /// <param name="inString">The string that will be processed.</param>
    /// <returns>The provided string with all instances of vertical whitespace replaced with a single "standard" space character.</returns>
    /// <remarks></remarks>
    private static string ReplaceLineEndingsWithStandardSpace(string inString)
    {
        var reg = new Regex(string.Format("{0}+", VerticalWhitespaceRegexPattern));
        return reg.Replace(inString, " ");
    }

    /// <summary>
    /// Determine the boundaries of a line using a sub-string from within the provided input builder based on
    /// the provided start and end index within that builder, but adjust the boundaries to make it so the line
    /// does not break in the middle of a word unless the word takes up more than an entire line (i.e. there are no
    /// word boundary characters in the sub-string); extract the sub-string, and append it to the provided result builder.
    /// </summary>
    /// <param name="fromInputBuilder">The input string builder from which the line sub-string will be determined and extracted.</param>
    /// <param name="toResultBuilder">The output builder to which the extracted line will be appended.</param>
    /// <param name="lineStartIndex">The index within the input string builder where the sub-string for the line beings.</param>
    /// <param name="lineEndIndex">The index within the input string builder where the sub-string for the line ends.</param>
    /// <returns>The index within the input string builder that will be the next character after this operation has completed.</returns>
    /// <remarks></remarks>
    private static int CalculateLineBoundariesExtractAppendToBuilder(StringBuilder fromInputBuilder, StringBuilder toResultBuilder, int lineStartIndex, int lineEndIndex)
    {
        var functionReturnValue = 0;
        var additionalAppendage = ""; // if needed, this will be appended at the end of the line before the line break.
        const char WordBoundaryCharacter = ' ';
        var wordBoundaryCharIndex = GetLastIndexOfChar(WordBoundaryCharacter, fromInputBuilder, lineStartIndex, lineEndIndex);
        var splitLine = (wordBoundaryCharIndex == -1); // line contains no boundary char -- i.e. it consists of a single word longer than the desired length and thus needs to be split.

        if ((splitLine))
        {
            lineEndIndex -= 1; // step back one to allow room for the split character.
            functionReturnValue = lineEndIndex; // the next char is already queued up, i.e. it's the one right after the split char.
            const string WordBreakCharacter = "-";
            additionalAppendage = WordBreakCharacter; // word boundary WAS found.
        }
        else
        {
            lineEndIndex = wordBoundaryCharIndex; // this is where we want to break the line.
            functionReturnValue = lineEndIndex + 1; // only advance to the next char if the line wasn't split.
        }

        var currentLineLength = (lineEndIndex - lineStartIndex);
        ExtractAppendLineToBuilder(fromInputBuilder, lineStartIndex, currentLineLength, toResultBuilder);
        if ((!string.IsNullOrEmpty(additionalAppendage))) toResultBuilder.Append(additionalAppendage);
        return functionReturnValue;
    }

    /// <summary>
    /// Return the last index of the provided character, if it exists, within the provided string builder between the two provided indexes, or else return -1.
    /// </summary>
    /// <param name="toFind">The character whose index is being sought within the provide string builder.</param>
    /// <param name="withinStringBuilder">The string builder that will be searched for the provided character.</param>
    /// <param name="startIndex">The index within the string builder that will be the starting point for the search.</param>
    /// <param name="endIndex">The index within the string that will be the ending point for the search.</param>
    /// <returns>Return the last index of the provided character, if it exists, within the provided string builder between the two provided indexes, or else return -1.</returns>
    /// <remarks></remarks>
    private static int GetLastIndexOfChar(char toFind, StringBuilder withinStringBuilder, int startIndex, int endIndex)
    {
        // backtrack until the search char is found or the stopping index is encountered.
        for (var i = endIndex; i >= startIndex; i--)
        {
            if ((withinStringBuilder[i] == toFind)) return i;
        }

        return -1; // catch-all
    }

    /// <summary>
    /// Extract a sub-string from the provided source string builder starting at the provided index and consisting
    /// of the provided length; append the string as a new line to the provided result builder.
    /// </summary>
    /// <param name="fromSourceStringBuilder">The string builder which contains the entire source string from which the sub-string will be extracted.</param>
    /// <param name="substringStartIndex">The start index within the source string that will be the start of the sub-string to-be-extracted.</param>
    /// <param name="substringLength">The length of the sub-string to-be-extracted.</param>
    /// <param name="toResultBuilder">The string builder to which the extracted sub-string will be appended as a new line.</param>
    /// <remarks></remarks>
    private static void ExtractAppendLineToBuilder(StringBuilder fromSourceStringBuilder, int substringStartIndex, int substringLength, StringBuilder toResultBuilder)
    {
        if ((toResultBuilder.Length > 0)) toResultBuilder.AppendLine();
        toResultBuilder.Append(fromSourceStringBuilder.ToString(substringStartIndex, substringLength));
    }
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.