Repository: microsoft/terminal (language: cpp)

Issue:
Font variant ignored by copyFormatting
### Windows Terminal version

1.17.11461.0

### Windows build number

10.0.19045.0

### Other Software

_No response_

### Steps to reproduce

Add `copyFormatting: "html"` to config,
open a markdown document in neovim, type `*asdf* **asdf** <u>asfd</u>`
![image](https://github.com/microsoft/terminal/assets/66909116/a0deae88-9e5c-4d0f-a756-1bd077cdf3db)
Then copy it to MS word.

### Expected Behavior

Italic, bold and underline should be preserved.

### Actual Behavior

The variants are ignored.

![image](https://github.com/microsoft/terminal/assets/66909116/a9aa545c-0b6b-4604-aed5-489f3380b332)



Discussion / hints:
That probably makes sense. I think GenHTML predates our support for bold and italic. Good find!

> **Note**
> ## Walkthrough

Just about here is where we'd need to fix this:
https://github.com/microsoft/terminal/blob/174585740759f3e473c5a4efe3207a20d2c78511/src/buffer/out/textBuffer.cpp#L2124-L2141

That'll need to account for other [`TextAttribute`](https://github.com/microsoft/terminal/blob/main/src/buffer/out/TextAttribute.hpp)s too, beyond just color.
We should really be using all useful TextAttributes during formatted copy. Do note that some clients (that receives) formatting information might ignore unsupported properties.

https://github.com/microsoft/terminal/blob/174585740759f3e473c5a4efe3207a20d2c78511/src/buffer/out/textBuffer.cpp#L2015-L2018

Instead of `TextAndColor`, we can send the current `TextAttributes` and properties can be read directly from it.

> Italic, bold and underline should be preserved.

I tried this for underlines, and already has something that works. Two problems though:
- Couldn't find clients that would actually use those CSS underline properties (color/style). So, they're getting ignored anyway.
- Doesn't make sense to do it only for underlines. All (useful) `TextAttributes` should be read and copied. (`TextAtributes` properties that cannot be represented in HTML can be ignored.)


Fix patch:
diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt
index 95ee8bc4cd3..784398d349f 100644
--- a/.github/actions/spelling/expect/expect.txt
+++ b/.github/actions/spelling/expect/expect.txt
@@ -1112,8 +1112,8 @@ msix
 msrc
 MSVCRTD
 MTSM
-munged
 munges
+Munged
 murmurhash
 muxes
 myapplet
@@ -1869,7 +1869,12 @@ uiautomationcore
 uielem
 UIELEMENTENABLEDONLY
 UINTs
+ul
 ulcch
+uld
+uldb
+uldash
+ulwave
 Unadvise
 unattend
 UNCPRIORITY
diff --git a/src/buffer/out/Row.cpp b/src/buffer/out/Row.cpp
index 4036b991d81..87ab005d3bb 100644
--- a/src/buffer/out/Row.cpp
+++ b/src/buffer/out/Row.cpp
@@ -404,6 +404,18 @@ til::CoordType ROW::AdjustToGlyphStart(til::CoordType column) const noexcept
     return _adjustBackward(_clampedColumn(column));
 }
 
+// Returns the (exclusive) ending column of the glyph at the given column.
+// In other words, if you have 3 wide glyphs
+//   AA BB CC
+//   01 23 45 <-- column
+// Examples:
+// - `AdjustToGlyphEnd(4)` returns 6.
+// - `AdjustToGlyphEnd(3)` returns 4.
+til::CoordType ROW::AdjustToGlyphEnd(til::CoordType column) const noexcept
+{
+    return _adjustForward(_clampedColumnInclusive(column));
+}
+
 // Routine Description:
 // - clears char data in column in row
 // Arguments:
@@ -939,6 +951,32 @@ uint16_t ROW::size() const noexcept
     return _columnCount;
 }
 
+// Routine Description:
+// - Retrieves the column that is one after the last non-space character in the row.
+til::CoordType ROW::GetLastNonSpaceColumn() const noexcept
+{
+    const auto text = GetText();
+    const auto beg = text.begin();
+    const auto end = text.end();
+    auto it = end;
+
+    for (; it != beg; --it)
+    {
+        // it[-1] is safe as `it` is always greater than `beg` (loop invariant).
+        if (til::at(it, -1) != L' ')
+        {
+            break;
+        }
+    }
+
+    // We're supposed to return the measurement in cells and not characters
+    // and therefore simply calculating `it - beg` would be wrong.
+    //
+    // An example: The row is 10 cells wide and `it` points to the second character.
+    // `it - beg` would return 1, but it's possible it's actually 1 wide glyph and 8 whitespace.
+    return gsl::narrow_cast<til::CoordType>(GetReadableColumnCount() - (end - it));
+}
+
 til::CoordType ROW::MeasureLeft() const noexcept
 {
     const auto text = GetText();
@@ -957,6 +995,8 @@ til::CoordType ROW::MeasureLeft() const noexcept
     return gsl::narrow_cast<til::CoordType>(it - beg);
 }
 
+// Routine Description:
+// - Retrieves the column that is one after the last valid character in the row.
 til::CoordType ROW::MeasureRight() const noexcept
 {
     if (_wrapForced)
@@ -969,26 +1009,7 @@ til::CoordType ROW::MeasureRight() const noexcept
         return width;
     }
 
-    const auto text = GetText();
-    const auto beg = text.begin();
-    const auto end = text.end();
-    auto it = end;
-
-    for (; it != beg; --it)
-    {
-        // it[-1] is safe as `it` is always greater than `beg` (loop invariant).
-        if (til::at(it, -1) != L' ')
-        {
-            break;
-        }
-    }
-
-    // We're supposed to return the measurement in cells and not characters
-    // and therefore simply calculating `it - beg` would be wrong.
-    //
-    // An example: The row is 10 cells wide and `it` points to the second character.
-    // `it - beg` would return 1, but it's possible it's actually 1 wide glyph and 8 whitespace.
-    return gsl::narrow_cast<til::CoordType>(_columnCount - (end - it));
+    return GetLastNonSpaceColumn();
 }
 
 bool ROW::ContainsText() const noexcept
diff --git a/src/buffer/out/Row.hpp b/src/buffer/out/Row.hpp
index 29f7fe18b8c..af8088c3ccd 100644
--- a/src/buffer/out/Row.hpp
+++ b/src/buffer/out/Row.hpp
@@ -137,6 +137,7 @@ class ROW final
     til::CoordType NavigateToPrevious(til::CoordType column) const noexcept;
     til::CoordType NavigateToNext(til::CoordType column) const noexcept;
     til::CoordType AdjustToGlyphStart(til::CoordType column) const noexcept;
+    til::CoordType AdjustToGlyphEnd(til::CoordType column) const noexcept;
 
     void ClearCell(til::CoordType column);
     OutputCellIterator WriteCells(OutputCellIterator it, til::CoordType columnBegin, std::optional<bool> wrap = std::nullopt, std::optional<til::CoordType> limitRight = std::nullopt);
@@ -151,6 +152,7 @@ class ROW final
     TextAttribute GetAttrByColumn(til::CoordType column) const;
     std::vector<uint16_t> GetHyperlinks() const;
     uint16_t size() const noexcept;
+    til::CoordType GetLastNonSpaceColumn() const noexcept;
     til::CoordType MeasureLeft() const noexcept;
     til::CoordType MeasureRight() const noexcept;
     bool ContainsText() const noexcept;
diff --git a/src/buffer/out/textBuffer.cpp b/src/buffer/out/textBuffer.cpp
index 528d8fbf4d3..f65f9953e82 100644
--- a/src/buffer/out/textBuffer.cpp
+++ b/src/buffer/out/textBuffer.cpp
@@ -1916,135 +1916,6 @@ void TextBuffer::_ExpandTextRow(til::inclusive_rect& textRow) const
     }
 }
 
-// Routine Description:
-// - Retrieves the text data from the selected region and presents it in a clipboard-ready format (given little post-processing).
-// Arguments:
-// - includeCRLF - inject CRLF pairs to the end of each line
-// - trimTrailingWhitespace - remove the trailing whitespace at the end of each line
-// - textRects - the rectangular regions from which the data will be extracted from the buffer (i.e.: selection rects)
-// - GetAttributeColors - function used to map TextAttribute to RGB COLORREFs. If null, only extract the text.
-// - formatWrappedRows - if set we will apply formatting (CRLF inclusion and whitespace trimming) on wrapped rows
-// Return Value:
-// - The text, background color, and foreground color data of the selected region of the text buffer.
-const TextBuffer::TextAndColor TextBuffer::GetText(const bool includeCRLF,
-                                                   const bool trimTrailingWhitespace,
-                                                   const std::vector<til::inclusive_rect>& selectionRects,
-                                                   std::function<std::pair<COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors,
-                                                   const bool formatWrappedRows) const
-{
-    TextAndColor data;
-    const auto copyTextColor = GetAttributeColors != nullptr;
-
-    // preallocate our vectors to reduce reallocs
-    const auto rows = selectionRects.size();
-    data.text.reserve(rows);
-    if (copyTextColor)
-    {
-        data.FgAttr.reserve(rows);
-        data.BkAttr.reserve(rows);
-    }
-
-    // for each row in the selection
-    for (size_t i = 0; i < rows; i++)
-    {
-        const auto iRow = selectionRects.at(i).top;
-
-        const auto highlight = Viewport::FromInclusive(selectionRects.at(i));
-
-        // retrieve the data from the screen buffer
-        auto it = GetCellDataAt(highlight.Origin(), highlight);
-
-        // allocate a string buffer
-        std::wstring selectionText;
-        std::vector<COLORREF> selectionFgAttr;
-        std::vector<COLORREF> selectionBkAttr;
-
-        // preallocate to avoid reallocs
-        selectionText.reserve(gsl::narrow<size_t>(highlight.Width()) + 2); // + 2 for \r\n if we munged it
-        if (copyTextColor)
-        {
-            selectionFgAttr.reserve(gsl::narrow<size_t>(highlight.Width()) + 2);
-            selectionBkAttr.reserve(gsl::narrow<size_t>(highlight.Width()) + 2);
-        }
-
-        // copy char data into the string buffer, skipping trailing bytes
-        while (it)
-        {
-            const auto& cell = *it;
-
-            if (cell.DbcsAttr() != DbcsAttribute::Trailing)
-            {
-                const auto chars = cell.Chars();
-                selectionText.append(chars);
-
-                if (copyTextColor)
-                {
-                    const auto cellData = cell.TextAttr();
-                    const auto [CellFgAttr, CellBkAttr] = GetAttributeColors(cellData);
-                    for (size_t j = 0; j < chars.size(); ++j)
-                    {
-                        selectionFgAttr.push_back(CellFgAttr);
-                        selectionBkAttr.push_back(CellBkAttr);
-                    }
-                }
-            }
-
-            ++it;
-        }
-
-        // We apply formatting to rows if the row was NOT wrapped or formatting of wrapped rows is allowed
-        const auto shouldFormatRow = formatWrappedRows || !GetRowByOffset(iRow).WasWrapForced();
-
-        if (trimTrailingWhitespace)
-        {
-            if (shouldFormatRow)
-            {
-                // remove the spaces at the end (aka trim the trailing whitespace)
-                while (!selectionText.empty() && selectionText.back() == UNICODE_SPACE)
-                {
-                    selectionText.pop_back();
-                    if (copyTextColor)
-                    {
-                        selectionFgAttr.pop_back();
-                        selectionBkAttr.pop_back();
-                    }
-                }
-            }
-        }
-
-        // apply CR/LF to the end of the final string, unless we're the last line.
-        // a.k.a if we're earlier than the bottom, then apply CR/LF.
-        if (includeCRLF && i < selectionRects.size() - 1)
-        {
-            if (shouldFormatRow)
-            {
-                // then we can assume a CR/LF is proper
-                selectionText.push_back(UNICODE_CARRIAGERETURN);
-                selectionText.push_back(UNICODE_LINEFEED);
-
-                if (copyTextColor)
-                {
-                    // can't see CR/LF so just use black FG & BK
-                    const auto Blackness = RGB(0x00, 0x00, 0x00);
-                    selectionFgAttr.push_back(Blackness);
-                    selectionFgAttr.push_back(Blackness);
-                    selectionBkAttr.push_back(Blackness);
-                    selectionBkAttr.push_back(Blackness);
-                }
-            }
-        }
-
-        data.text.emplace_back(std::move(selectionText));
-        if (copyTextColor)
-        {
-            data.FgAttr.emplace_back(std::move(selectionFgAttr));
-            data.BkAttr.emplace_back(std::move(selectionBkAttr));
-        }
-    }
-
-    return data;
-}
-
 size_t TextBuffer::SpanLength(const til::point coordStart, const til::point coordEnd) const
 {
     const auto bufferSize = GetSize();
@@ -2083,186 +1954,292 @@ std::wstring TextBuffer::GetPlainText(const til::point& start, const til::point&
 }
 
 // Routine Description:
-// - Generates a CF_HTML compliant structure based on the passed in text and color data
+// - Given a copy request and a row, retrieves the row bounds [begin, end) and
+//   a boolean indicating whether a line break should be added to this row.
 // Arguments:
-// - rows - the text and color data we will format & encapsulate
-// - backgroundColor - default background color for characters, also used in padding
+// - req - the copy request
+// - iRow - the row index
+// - row - the row
+// Return Value:
+// - The row bounds and a boolean for line break
+std::tuple<til::CoordType, til::CoordType, bool> TextBuffer::_RowCopyHelper(const TextBuffer::CopyRequest& req, const til::CoordType iRow, const ROW& row) const
+{
+    til::CoordType rowBeg = 0;
+    til::CoordType rowEnd = 0;
+    if (req.blockSelection)
+    {
+        const auto lineRendition = row.GetLineRendition();
+        const auto minX = req.bufferCoordinates ? req.minX : ScreenToBufferLine(til::point{ req.minX, iRow }, lineRendition).x;
+        const auto maxX = req.bufferCoordinates ? req.maxX : ScreenToBufferLine(til::point{ req.maxX, iRow }, lineRendition).x;
+
+        rowBeg = minX;
+        rowEnd = maxX + 1; // +1 to get an exclusive end
+    }
+    else
+    {
+        const auto lineRendition = row.GetLineRendition();
+        const auto beg = req.bufferCoordinates ? req.beg : ScreenToBufferLine(req.beg, lineRendition);
+        const auto end = req.bufferCoordinates ? req.end : ScreenToBufferLine(req.end, lineRendition);
+
+        rowBeg = iRow != beg.y ? 0 : beg.x;
+        rowEnd = iRow != end.y ? row.GetReadableColumnCount() : end.x + 1; // +1 to get an exclusive end
+    }
+
+    // Our selection mechanism doesn't stick to glyph boundaries at the moment.
+    // We need to adjust begin and end points manually to avoid partially
+    // selected glyphs.
+    rowBeg = row.AdjustToGlyphStart(rowBeg);
+    rowEnd = row.AdjustToGlyphEnd(rowEnd);
+
+    // When `formatWrappedRows` is set, apply formatting on all rows (wrapped
+    // and non-wrapped), but when it's false, format non-wrapped rows only.
+    const auto shouldFormatRow = req.formatWrappedRows || !row.WasWrapForced();
+
+    // trim trailing whitespace
+    if (shouldFormatRow && req.trimTrailingWhitespace)
+    {
+        rowEnd = std::min(rowEnd, row.GetLastNonSpaceColumn());
+    }
+
+    // line breaks
+    const auto addLineBreak = shouldFormatRow && req.includeLineBreak;
+
+    return { rowBeg, rowEnd, addLineBreak };
+}
+
+// Routine Description:
+// - Retrieves the text data from the buffer and presents it in a clipboard-ready format.
+// Arguments:
+// - req - the copy request having the bounds of the selected region and other related configuration flags.
+// Return Value:
+// - The text data from the selected region of the text buffer. Empty if the copy request is invalid.
+std::wstring TextBuffer::GetPlainText(const CopyRequest& req) const
+{
+    if (req.beg > req.end)
+    {
+        return {};
+    }
+
+    std::wstring selectedText;
+
+    for (auto iRow = req.beg.y; iRow <= req.end.y; ++iRow)
+    {
+        const auto& row = GetRowByOffset(iRow);
+        const auto& [rowBeg, rowEnd, addLineBreak] = _RowCopyHelper(req, iRow, row);
+
+        // save selected text
+        selectedText += row.GetText(rowBeg, rowEnd);
+
+        if (addLineBreak && iRow != req.end.y)
+        {
+            selectedText += L"\r\n";
+        }
+    }
+
+    return selectedText;
+}
+
+// Routine Description:
+// - Generates a CF_HTML compliant structure from the selected region of the buffer
+// Arguments:
+// - req - the copy request having the bounds of the selected region and other related configuration flags.
 // - fontHeightPoints - the unscaled font height
 // - fontFaceName - the name of the font used
+// - backgroundColor - default background color for characters, also used in padding
+// - isIntenseBold - true if being intense is treated as being bold
+// - GetAttributeColors - function to get the colors of the text attributes as they're rendered
 // Return Value:
-// - string containing the generated HTML
-std::string TextBuffer::GenHTML(const TextAndColor& rows,
+// - string containing the generated HTML. Empty if the copy request is invalid.
+std::string TextBuffer::GenHTML(const CopyRequest& req,
                                 const int fontHeightPoints,
                                 const std::wstring_view fontFaceName,
-                                const COLORREF backgroundColor)
+                                const COLORREF backgroundColor,
+                                const bool isIntenseBold,
+                                std::function<std::tuple<COLORREF, COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors) const noexcept
 {
+    // GH#5347 - Don't provide a title for the generated HTML, as many
+    // web applications will paste the title first, followed by the HTML
+    // content, which is unexpected.
+
+    if (req.beg > req.end)
+    {
+        return {};
+    }
+
     try
     {
-        std::ostringstream htmlBuilder;
+        std::string htmlBuilder;
 
-        // First we have to add some standard
-        // HTML boiler plate required for CF_HTML
-        // as part of the HTML Clipboard format
-        const std::string htmlHeader =
-            "<!DOCTYPE><HTML><HEAD></HEAD><BODY>";
-        htmlBuilder << htmlHeader;
+        // First we have to add some standard HTML boiler plate required for
+        // CF_HTML as part of the HTML Clipboard format
+        constexpr std::string_view htmlHeader = "<!DOCTYPE><HTML><HEAD></HEAD><BODY>";
+        htmlBuilder += htmlHeader;
 
-        htmlBuilder << "<!--StartFragment -->";
+        htmlBuilder += "<!--StartFragment -->";
 
         // apply global style in div element
         {
-            htmlBuilder << "<DIV STYLE=\"";
-            htmlBuilder << "display:inline-block;";
-            htmlBuilder << "white-space:pre;";
-
-            htmlBuilder << "background-color:";
-            htmlBuilder << Utils::ColorToHexString(backgroundColor);
-            htmlBuilder << ";";
-
-            htmlBuilder << "font-family:";
-            htmlBuilder << "'";
-            htmlBuilder << ConvertToA(CP_UTF8, fontFaceName);
-            htmlBuilder << "',";
+            htmlBuilder += "<DIV STYLE=\"";
+            htmlBuilder += "display:inline-block;";
+            htmlBuilder += "white-space:pre;";
+            fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("background-color:{};"), Utils::ColorToHexString(backgroundColor));
+
             // even with different font, add monospace as fallback
-            htmlBuilder << "monospace;";
+            fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("font-family:'{}',monospace;"), til::u16u8(fontFaceName));
 
-            htmlBuilder << "font-size:";
-            htmlBuilder << fontHeightPoints;
-            htmlBuilder << "pt;";
+            fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("font-size:{}pt;"), fontHeightPoints);
 
             // note: MS Word doesn't support padding (in this way at least)
-            htmlBuilder << "padding:";
-            htmlBuilder << 4; // todo: customizable padding
-            htmlBuilder << "px;";
+            // todo: customizable padding
+            htmlBuilder += "padding:4px;";
 
-            htmlBuilder << "\">";
+            htmlBuilder += "\">";
         }
 
-        // copy text and info color from buffer
-        auto hasWrittenAnyText = false;
-        std::optional<COLORREF> fgColor = std::nullopt;
-        std::optional<COLORREF> bkColor = std::nullopt;
-        for (size_t row = 0; row < rows.text.size(); row++)
+        for (auto iRow = req.beg.y; iRow <= req.end.y; ++iRow)
         {
-            size_t startOffset = 0;
-
-            if (row != 0)
+            const auto& row = GetRowByOffset(iRow);
+            const auto [rowBeg, rowEnd, addLineBreak] = _RowCopyHelper(req, iRow, row);
+            const auto rowBegU16 = gsl::narrow_cast<uint16_t>(rowBeg);
+            const auto rowEndU16 = gsl::narrow_cast<uint16_t>(rowEnd);
+            const auto runs = row.Attributes().slice(rowBegU16, rowEndU16).runs();
+
+            auto x = rowBegU16;
+            for (const auto& [attr, length] : runs)
             {
-                htmlBuilder << "<BR>";
-            }
-
-            for (size_t col = 0; col < rows.text.at(row).length(); col++)
-            {
-                const auto writeAccumulatedChars = [&](bool includeCurrent) {
-                    if (col >= startOffset)
-                    {
-                        const auto unescapedText = ConvertToA(CP_UTF8, std::wstring_view(rows.text.at(row)).substr(startOffset, col - startOffset + includeCurrent));
-                        for (const auto c : unescapedText)
-                        {
-                            switch (c)
-                            {
-                            case '<':
-                                htmlBuilder << "&lt;";
-                                break;
-                            case '>':
-                                htmlBuilder << "&gt;";
-                                break;
-                            case '&':
-                                htmlBuilder << "&amp;";
-                                break;
-                            default:
-                                htmlBuilder << c;
-                            }
-                        }
-
-                        startOffset = col;
-                    }
-                };
-
-                if (rows.text.at(row).at(col) == '\r' || rows.text.at(row).at(col) == '\n')
+                const auto nextX = gsl::narrow_cast<uint16_t>(x + length);
+                const auto [fg, bg, ul] = GetAttributeColors(attr);
+                const auto fgHex = Utils::ColorToHexString(fg);
+                const auto bgHex = Utils::ColorToHexString(bg);
+                const auto ulHex = Utils::ColorToHexString(ul);
+                const auto ulStyle = attr.GetUnderlineStyle();
+                const auto isUnderlined = ulStyle != UnderlineStyle::NoUnderline;
+                const auto isCrossedOut = attr.IsCrossedOut();
+                const auto isOverlined = attr.IsOverlined();
+
+                htmlBuilder += "<SPAN STYLE=\"";
+                fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("color:{};"), fgHex);
+                fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("background-color:{};"), bgHex);
+
+                if (isIntenseBold && attr.IsIntense())
                 {
-                    // do not include \r nor \n as they don't have color attributes
-                    // and are not HTML friendly. For line break use '<BR>' instead.
-                    writeAccumulatedChars(false);
-                    break;
+                    htmlBuilder += "font-weight:bold;";
                 }
 
-                auto colorChanged = false;
-                if (!fgColor.has_value() || rows.FgAttr.at(row).at(col) != fgColor.value())
+                if (attr.IsItalic())
                 {
-                    fgColor = rows.FgAttr.at(row).at(col);
-                    colorChanged = true;
+                    htmlBuilder += "font-style:italic;";
                 }
 
-                if (!bkColor.has_value() || rows.BkAttr.at(row).at(col) != bkColor.value())
+                if (isCrossedOut || isOverlined)
                 {
-                    bkColor = rows.BkAttr.at(row).at(col);
-                    colorChanged = true;
+                    fmt::format_to(std::back_inserter(htmlBuilder),
+                                   FMT_COMPILE("text-decoration:{} {} {};"),
+                                   isCrossedOut ? "line-through" : "",
+                                   isOverlined ? "overline" : "",
+                                   fgHex);
                 }
 
-                if (colorChanged)
+                if (isUnderlined)
                 {
-                    writeAccumulatedChars(false);
+                    // Since underline, overline and strikethrough use the same css property,
+                    // we cannot apply different colors to them at the same time. However, we
+                    // can achieve the desired result by creating a nested <span> and applying
+                    // underline style and color to it.
+                    htmlBuilder += "\"><SPAN STYLE=\"";
 
-                    if (hasWrittenAnyText)
+                    switch (ulStyle)
                     {
-                        htmlBuilder << "</SPAN>";
+                    case UnderlineStyle::NoUnderline:
+                        break;
+                    case UnderlineStyle::DoublyUnderlined:
+                        fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("text-decoration:underline double {};"), ulHex);
+                        break;
+                    case UnderlineStyle::CurlyUnderlined:
+                        fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("text-decoration:underline wavy {};"), ulHex);
+                        break;
+                    case UnderlineStyle::DottedUnderlined:
+                        fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("text-decoration:underline dotted {};"), ulHex);
+                        break;
+                    case UnderlineStyle::DashedUnderlined:
+                        fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("text-decoration:underline dashed {};"), ulHex);
+                        break;
+                    case UnderlineStyle::SinglyUnderlined:
+                    default:
+                        fmt::format_to(std::back_inserter(htmlBuilder), FMT_COMPILE("text-decoration:underline {};"), ulHex);
+                        break;
                     }
-
-                    htmlBuilder << "<SPAN STYLE=\"";
-                    htmlBuilder << "color:";
-                    htmlBuilder << Utils::ColorToHexString(fgColor.value());
-                    htmlBuilder << ";";
-                    htmlBuilder << "background-color:";
-                    htmlBuilder << Utils::ColorToHexString(bkColor.value());
-                    htmlBuilder << ";";
-                    htmlBuilder << "\">";
                 }
 
-                hasWrittenAnyText = true;
+                htmlBuilder += "\">";
+
+                // text
+                std::string unescapedText;
+                THROW_IF_FAILED(til::u16u8(row.GetText(x, nextX), unescapedText));
+                for (const auto c : unescapedText)
+                {
+                    switch (c)
+                    {
+                    case '<':
+                        htmlBuilder += "&lt;";
+                        break;
+                    case '>':
+                        htmlBuilder += "&gt;";
+                        break;
+                    case '&':
+                        htmlBuilder += "&amp;";
+                        break;
+                    default:
+                        htmlBuilder += c;
+                    }
+                }
 
-                // if this is the last character in the row, flush the whole row
-                if (col == rows.text.at(row).length() - 1)
+                if (isUnderlined)
                 {
-                    writeAccumulatedChars(true);
+                    // close the nested span we created for underline
+                    htmlBuilder += "</SPAN>";
                 }
+
+                htmlBuilder += "</SPAN>";
+
+                // advance to next run of text
+                x = nextX;
             }
-        }
 
-        if (hasWrittenAnyText)
-        {
-            // last opened span wasn't closed in loop above, so close it now
-            htmlBuilder << "</SPAN>";
+            // never add line break to the last row.
+            if (addLineBreak && iRow < req.end.y)
+            {
+                htmlBuilder += "<BR>";
+            }
         }
 
-        htmlBuilder << "</DIV>";
+        htmlBuilder += "</DIV>";
 
-        htmlBuilder << "<!--EndFragment -->";
+        htmlBuilder += "<!--EndFragment -->";
 
         constexpr std::string_view HtmlFooter = "</BODY></HTML>";
-        htmlBuilder << HtmlFooter;
+        htmlBuilder += HtmlFooter;
 
         // once filled with values, there will be exactly 157 bytes in the clipboard header
         constexpr size_t ClipboardHeaderSize = 157;
 
         // these values are byte offsets from start of clipboard
         const auto htmlStartPos = ClipboardHeaderSize;
-        const auto htmlEndPos = ClipboardHeaderSize + gsl::narrow<size_t>(htmlBuilder.tellp());
+        const auto htmlEndPos = ClipboardHeaderSize + gsl::narrow<size_t>(htmlBuilder.length());
         const auto fragStartPos = ClipboardHeaderSize + gsl::narrow<size_t>(htmlHeader.length());
         const auto fragEndPos = htmlEndPos - HtmlFooter.length();
 
         // header required by HTML 0.9 format
-        std::ostringstream clipHeaderBuilder;
-        clipHeaderBuilder << "Version:0.9\r\n";
-        clipHeaderBuilder << std::setfill('0');
-        clipHeaderBuilder << "StartHTML:" << std::setw(10) << htmlStartPos << "\r\n";
-        clipHeaderBuilder << "EndHTML:" << std::setw(10) << htmlEndPos << "\r\n";
-        clipHeaderBuilder << "StartFragment:" << std::setw(10) << fragStartPos << "\r\n";
-        clipHeaderBuilder << "EndFragment:" << std::setw(10) << fragEndPos << "\r\n";
-        clipHeaderBuilder << "StartSelection:" << std::setw(10) << fragStartPos << "\r\n";
-        clipHeaderBuilder << "EndSelection:" << std::setw(10) << fragEndPos << "\r\n";
-
-        return clipHeaderBuilder.str() + htmlBuilder.str();
+        std::string clipHeaderBuilder;
+        clipHeaderBuilder += "Version:0.9\r\n";
+        fmt::format_to(std::back_inserter(clipHeaderBuilder), FMT_COMPILE("StartHTML:{:0>10}\r\n"), htmlStartPos);
+        fmt::format_to(std::back_inserter(clipHeaderBuilder), FMT_COMPILE("EndHTML:{:0>10}\r\n"), htmlEndPos);
+        fmt::format_to(std::back_inserter(clipHeaderBuilder), FMT_COMPILE("StartFragment:{:0>10}\r\n"), fragStartPos);
+        fmt::format_to(std::back_inserter(clipHeaderBuilder), FMT_COMPILE("EndFragment:{:0>10}\r\n"), fragEndPos);
+        fmt::format_to(std::back_inserter(clipHeaderBuilder), FMT_COMPILE("StartSelection:{:0>10}\r\n"), fragStartPos);
+        fmt::format_to(std::back_inserter(clipHeaderBuilder), FMT_COMPILE("EndSelection:{:0>10}\r\n"), fragEndPos);
+
+        return clipHeaderBuilder + htmlBuilder;
     }
     catch (...)
     {
@@ -2272,25 +2249,36 @@ std::string TextBuffer::GenHTML(const TextAndColor& rows,
 }
 
 // Routine Description:
-// - Generates an RTF document based on the passed in text and color data
+// - Generates an RTF document from the selected region of the buffer
 //   RTF 1.5 Spec: https://www.biblioscape.com/rtf15_spec.htm
 //   RTF 1.9.1 Spec: https://msopenspecs.azureedge.net/files/Archive_References/[MSFT-RTF].pdf
 // Arguments:
-// - rows - the text and color data we will format & encapsulate
-// - backgroundColor - default background color for characters, also used in padding
+// - req - the copy request having the bounds of the selected region and other related configuration flags.
 // - fontHeightPoints - the unscaled font height
 // - fontFaceName - the name of the font used
-// - htmlTitle - value used in title tag of html header. Used to name the application
+// - backgroundColor - default background color for characters, also used in padding
+// - isIntenseBold - true if being intense is treated as being bold
+// - GetAttributeColors - function to get the colors of the text attributes as they're rendered
 // Return Value:
-// - string containing the generated RTF
-std::string TextBuffer::GenRTF(const TextAndColor& rows, const int fontHeightPoints, const std::wstring_view fontFaceName, const COLORREF backgroundColor)
+// - string containing the generated RTF. Empty if the copy request is invalid.
+std::string TextBuffer::GenRTF(const CopyRequest& req,
+                               const int fontHeightPoints,
+                               const std::wstring_view fontFaceName,
+                               const COLORREF backgroundColor,
+                               const bool isIntenseBold,
+                               std::function<std::tuple<COLORREF, COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors) const noexcept
 {
+    if (req.beg > req.end)
+    {
+        return {};
+    }
+
     try
     {
-        std::ostringstream rtfBuilder;
+        std::string rtfBuilder;
 
         // start rtf
-        rtfBuilder << "{";
+        rtfBuilder += "{";
 
         // Standard RTF header.
         // This is similar to the header generated by WordPad.
@@ -2306,10 +2294,11 @@ std::string TextBuffer::GenRTF(const TextAndColor& rows, const int fontHeightPoi
         //   Some features are blocked by default to maintain compatibility
         //   with older programs (Eg. Word 97-2003). `nouicompat` disables this
         //   behavior, and unblocks these features. See: Spec 1.9.1, Pg. 51.
-        rtfBuilder << "\\rtf1\\ansi\\ansicpg1252\\deff0\\nouicompat";
+        rtfBuilder += "\\rtf1\\ansi\\ansicpg1252\\deff0\\nouicompat";
 
         // font table
-        rtfBuilder << "{\\fonttbl{\\f0\\fmodern\\fcharset0 " << ConvertToA(CP_UTF8, fontFaceName) << ";}}";
+        // Brace escape: add an extra brace (of same kind) after a brace to escape it within the format string.
+        fmt::format_to(std::back_inserter(rtfBuilder), FMT_COMPILE("{{\\fonttbl{{\\f0\\fmodern\\fcharset0 {};}}}}"), til::u16u8(fontFaceName));
 
         // map to keep track of colors:
         // keys are colors represented by COLORREF
@@ -2317,8 +2306,8 @@ std::string TextBuffer::GenRTF(const TextAndColor& rows, const int fontHeightPoi
         std::unordered_map<COLORREF, size_t> colorMap;
 
         // RTF color table
-        std::ostringstream colorTableBuilder;
-        colorTableBuilder << "{\\colortbl ;";
+        std::string colorTableBuilder;
+        colorTableBuilder += "{\\colortbl ;";
 
         const auto getColorTableIndex = [&](const COLORREF color) -> size_t {
             // Exclude the 0 index for the default color, and start with 1.
@@ -2326,103 +2315,127 @@ std::string TextBuffer::GenRTF(const TextAndColor& rows, const int fontHeightPoi
             const auto [it, inserted] = colorMap.emplace(color, colorMap.size() + 1);
             if (inserted)
             {
-                colorTableBuilder << "\\red" << static_cast<int>(GetRValue(color))
-                                  << "\\green" << static_cast<int>(GetGValue(color))
-                                  << "\\blue" << static_cast<int>(GetBValue(color))
-                                  << ";";
+                const auto red = static_cast<int>(GetRValue(color));
+                const auto green = static_cast<int>(GetGValue(color));
+                const auto blue = static_cast<int>(GetBValue(color));
+                fmt::format_to(std::back_inserter(colorTableBuilder), FMT_COMPILE("\\red{}\\green{}\\blue{};"), red, green, blue);
             }
             return it->second;
         };
 
         // content
-        std::ostringstream contentBuilder;
-        contentBuilder << "\\viewkind4\\uc4";
+        std::string contentBuilder;
+
+        // \viewkindN: View mode of the document to be used. N=4 specifies that the document is in Normal view. (maybe unnecessary?)
+        // \ucN: Number of unicode fallback characters after each codepoint. (global)
+        contentBuilder += "\\viewkind4\\uc1";
 
         // paragraph styles
-        // \fs specifies font size in half-points i.e. \fs20 results in a font size
-        // of 10 pts. That's why, font size is multiplied by 2 here.
-        contentBuilder << "\\pard\\slmult1\\f0\\fs" << std::to_string(2 * fontHeightPoints)
-                       // Set the background color for the page. But, the
-                       // standard way (\cbN) to do this isn't supported in Word.
-                       // However, the following control words sequence works
-                       // in Word (and other RTF editors also) for applying the
-                       // text background color. See: Spec 1.9.1, Pg. 23.
-                       << "\\chshdng0\\chcbpat" << getColorTableIndex(backgroundColor)
-                       << " ";
-
-        std::optional<COLORREF> fgColor = std::nullopt;
-        std::optional<COLORREF> bkColor = std::nullopt;
-        for (size_t row = 0; row < rows.text.size(); ++row)
+        // \pard: paragraph description
+        // \slmultN: line-spacing multiple
+        // \fN: font to be used for the paragraph, where N is the font index in the font table
+        contentBuilder += "\\pard\\slmult1\\f0";
+
+        // \fsN: specifies font size in half-points. E.g. \fs20 results in a font
+        // size of 10 pts. That's why, font size is multiplied by 2 here.
+        fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\fs{}"), std::to_string(2 * fontHeightPoints));
+
+        // Set the background color for the page. But the standard way (\cbN) to do
+        // this isn't supported in Word. However, the following control words sequence
+        // works in Word (and other RTF editors also) for applying the text background
+        // color. See: Spec 1.9.1, Pg. 23.
+        fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\chshdng0\\chcbpat{}"), getColorTableIndex(backgroundColor));
+
+        for (auto iRow = req.beg.y; iRow <= req.end.y; ++iRow)
         {
-            size_t startOffset = 0;
-
-            if (row != 0)
+            const auto& row = GetRowByOffset(iRow);
+            const auto [rowBeg, rowEnd, addLineBreak] = _RowCopyHelper(req, iRow, row);
+            const auto rowBegU16 = gsl::narrow_cast<uint16_t>(rowBeg);
+            const auto rowEndU16 = gsl::narrow_cast<uint16_t>(rowEnd);
+            const auto runs = row.Attributes().slice(rowBegU16, rowEndU16).runs();
+
+            auto x = rowBegU16;
+            for (auto& [attr, length] : runs)
             {
-                contentBuilder << "\\line "; // new line
-            }
+                const auto nextX = gsl::narrow_cast<uint16_t>(x + length);
+                const auto [fg, bg, ul] = GetAttributeColors(attr);
+                const auto fgIdx = getColorTableIndex(fg);
+                const auto bgIdx = getColorTableIndex(bg);
+                const auto ulIdx = getColorTableIndex(ul);
+                const auto ulStyle = attr.GetUnderlineStyle();
 
-            for (size_t col = 0; col < rows.text.at(row).length(); ++col)
-            {
-                const auto writeAccumulatedChars = [&](bool includeCurrent) {
-                    if (col >= startOffset)
-                    {
-                        const auto text = std::wstring_view{ rows.text.at(row) }.substr(startOffset, col - startOffset + includeCurrent);
-                        _AppendRTFText(contentBuilder, text);
+                // start an RTF group that can be closed later to restore the
+                // default attribute.
+                contentBuilder += "{";
 
-                        startOffset = col;
-                    }
-                };
+                fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\cf{}"), fgIdx);
+                fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\chshdng0\\chcbpat{}"), bgIdx);
 
-                if (rows.text.at(row).at(col) == '\r' || rows.text.at(row).at(col) == '\n')
+                if (isIntenseBold && attr.IsIntense())
                 {
-                    // do not include \r nor \n as they don't have color attributes.
-                    // For line break use \line instead.
-                    writeAccumulatedChars(false);
-                    break;
+                    contentBuilder += "\\b";
                 }
 
-                auto colorChanged = false;
-                if (!fgColor.has_value() || rows.FgAttr.at(row).at(col) != fgColor.value())
+                if (attr.IsItalic())
                 {
-                    fgColor = rows.FgAttr.at(row).at(col);
-                    colorChanged = true;
+                    contentBuilder += "\\i";
                 }
 
-                if (!bkColor.has_value() || rows.BkAttr.at(row).at(col) != bkColor.value())
+                if (attr.IsCrossedOut())
                 {
-                    bkColor = rows.BkAttr.at(row).at(col);
-                    colorChanged = true;
+                    contentBuilder += "\\strike";
                 }
 
-                if (colorChanged)
+                switch (ulStyle)
                 {
-                    writeAccumulatedChars(false);
-                    contentBuilder << "\\chshdng0\\chcbpat" << getColorTableIndex(bkColor.value())
-                                   << "\\cf" << getColorTableIndex(fgColor.value())
-                                   << " ";
+                case UnderlineStyle::NoUnderline:
+                    break;
+                case UnderlineStyle::DoublyUnderlined:
+                    fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\uldb\\ulc{}"), ulIdx);
+                    break;
+                case UnderlineStyle::CurlyUnderlined:
+                    fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\ulwave\\ulc{}"), ulIdx);
+                    break;
+                case UnderlineStyle::DottedUnderlined:
+                    fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\uld\\ulc{}"), ulIdx);
+                    break;
+                case UnderlineStyle::DashedUnderlined:
+                    fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\uldash\\ulc{}"), ulIdx);
+                    break;
+                case UnderlineStyle::SinglyUnderlined:
+                default:
+                    fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\ul\\ulc{}"), ulIdx);
+                    break;
                 }
 
-                // if this is the last character in the row, flush the whole row
-                if (col == rows.text.at(row).length() - 1)
-                {
-                    writeAccumulatedChars(true);
-                }
+                // RTF commands and the text data must be separated by a space.
+                // Otherwise, if the text begins with a space then that space will
+                // be interpreted as part of the last command, and will be lost.
+                contentBuilder += " ";
+
+                const auto unescapedText = row.GetText(x, nextX); // including character at nextX
+                _AppendRTFText(contentBuilder, unescapedText);
+
+                contentBuilder += "}"; // close RTF group
+
+                // advance to next run of text
+                x = nextX;
             }
-        }
 
-        // end colortbl
-        colorTableBuilder << "}";
+            // never add line break to the last row.
+            if (addLineBreak && iRow < req.end.y)
+            {
+                contentBuilder += "\\line";
+            }
+        }
 
         // add color table to the final RTF
-        rtfBuilder << colorTableBuilder.str();
+        rtfBuilder += colorTableBuilder + "}";
 
         // add the text content to the final RTF
-        rtfBuilder << contentBuilder.str();
+        rtfBuilder += contentBuilder + "}";
 
-        // end rtf
-        rtfBuilder << "}";
-
-        return rtfBuilder.str();
+        return rtfBuilder;
     }
     catch (...)
     {
@@ -2431,7 +2444,7 @@ std::string TextBuffer::GenRTF(const TextAndColor& rows, const int fontHeightPoi
     }
 }
 
-void TextBuffer::_AppendRTFText(std::ostringstream& contentBuilder, const std::wstring_view& text)
+void TextBuffer::_AppendRTFText(std::string& contentBuilder, const std::wstring_view& text)
 {
     for (const auto codeUnit : text)
     {
@@ -2442,16 +2455,18 @@ void TextBuffer::_AppendRTFText(std::ostringstream& contentBuilder, const std::w
             case L'\\':
             case L'{':
             case L'}':
-                contentBuilder << "\\" << gsl::narrow<char>(codeUnit);
-                break;
+                contentBuilder += "\\";
+                [[fallthrough]];
             default:
-                contentBuilder << gsl::narrow<char>(codeUnit);
+                contentBuilder += gsl::narrow_cast<char>(codeUnit);
             }
         }
         else
         {
             // Windows uses unsigned wchar_t - RTF uses signed ones.
-            contentBuilder << "\\u" << std::to_string(til::bit_cast<int16_t>(codeUnit)) << "?";
+            // '?' is the fallback ascii character.
+            const auto codeUnitRTFStr = std::to_string(til::bit_cast<int16_t>(codeUnit));
+            fmt::format_to(std::back_inserter(contentBuilder), FMT_COMPILE("\\u{}?"), codeUnitRTFStr);
         }
     }
 }
diff --git a/src/buffer/out/textBuffer.hpp b/src/buffer/out/textBuffer.hpp
index 8ba16f97e75..061b4cd380e 100644
--- a/src/buffer/out/textBuffer.hpp
+++ b/src/buffer/out/textBuffer.hpp
@@ -229,33 +229,94 @@ class TextBuffer final
     std::wstring GetCustomIdFromId(uint16_t id) const;
     void CopyHyperlinkMaps(const TextBuffer& OtherBuffer);
 
-    class TextAndColor
-    {
-    public:
-        std::vector<std::wstring> text;
-        std::vector<std::vector<COLORREF>> FgAttr;
-        std::vector<std::vector<COLORREF>> BkAttr;
-    };
-
     size_t SpanLength(const til::point coordStart, const til::point coordEnd) const;
 
-    const TextAndColor GetText(const bool includeCRLF,
-                               const bool trimTrailingWhitespace,
-                               const std::vector<til::inclusive_rect>& textRects,
-                               std::function<std::pair<COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors = nullptr,
-                               const bool formatWrappedRows = false) const;
-
     std::wstring GetPlainText(const til::point& start, const til::point& end) const;
 
-    static std::string GenHTML(const TextAndColor& rows,
-                               const int fontHeightPoints,
-                               const std::wstring_view fontFaceName,
-                               const COLORREF backgroundColor);
+    struct CopyRequest
+    {
+        // beg and end coordinates are inclusive
+        til::point beg;
+        til::point end;
+
+        til::CoordType minX;
+        til::CoordType maxX;
+        bool blockSelection = false;
+        bool trimTrailingWhitespace = true;
+        bool includeLineBreak = true;
+        bool formatWrappedRows = false;
+
+        // whether beg, end coordinates are in buffer coordinates or screen coordinates
+        bool bufferCoordinates = false;
+
+        CopyRequest() = default;
+
+        constexpr CopyRequest(const TextBuffer& buffer, const til::point& beg, const til::point& end, const bool blockSelection, const bool includeLineBreak, const bool trimTrailingWhitespace, const bool formatWrappedRows, const bool bufferCoordinates = false) noexcept :
+            beg{ std::max(beg, til::point{ 0, 0 }) },
+            end{ std::min(end, til::point{ buffer._width - 1, buffer._height - 1 }) },
+            minX{ std::min(this->beg.x, this->end.x) },
+            maxX{ std::max(this->beg.x, this->end.x) },
+            blockSelection{ blockSelection },
+            includeLineBreak{ includeLineBreak },
+            trimTrailingWhitespace{ trimTrailingWhitespace },
+            formatWrappedRows{ formatWrappedRows },
+            bufferCoordinates{ bufferCoordinates }
+        {
+        }
+
+        static CopyRequest FromConfig(const TextBuffer& buffer,
+                                      const til::point& beg,
+                                      const til::point& end,
+                                      const bool singleLine,
+                                      const bool blockSelection,
+                                      const bool trimBlockSelection,
+                                      const bool bufferCoordinates = false) noexcept
+        {
+            return {
+                buffer,
+                beg,
+                end,
+                blockSelection,
+
+                /* includeLineBreak */
+                // - SingleLine mode collapses all rows into one line, unless we're in
+                //   block selection mode.
+                // - Block selection should preserve the visual structure by including
+                //   line breaks on all rows (together with `formatWrappedRows`).
+                //   (Selects like a box, pastes like a box)
+                !singleLine || blockSelection,
+
+                /* trimTrailingWhitespace */
+                // Trim trailing whitespace if we're not in single line mode and — either
+                // we're not in block selection mode or, we're in block selection mode and
+                // trimming is allowed.
+                !singleLine && (!blockSelection || trimBlockSelection),
+
+                /* formatWrappedRows */
+                // In block selection, we should apply formatting to wrapped rows as well.
+                // (Otherwise, they're only applied to non-wrapped rows.)
+                blockSelection,
+
+                bufferCoordinates
+            };
+        }
+    };
+
+    std::wstring GetPlainText(const CopyRequest& req) const;
+
+    std::string GenHTML(const CopyRequest& req,
+                        const int fontHeightPoints,
+                        const std::wstring_view fontFaceName,
+                        const COLORREF backgroundColor,
+                        const bool isIntenseBold,
+                        std::function<std::tuple<COLORREF, COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors) const noexcept;
 
-    static std::string GenRTF(const TextAndColor& rows,
-                              const int fontHeightPoints,
-                              const std::wstring_view fontFaceName,
-                              const COLORREF backgroundColor);
+    std::string GenRTF(const CopyRequest& req,
+                       const int fontHeightPoints,
+                       const std::wstring_view fontFaceName,
+                       const COLORREF backgroundColor,
+                       const bool isIntenseBold,
+                       std::function<std::tuple<COLORREF, COLORREF, COLORREF>(const TextAttribute&)> GetAttributeColors) const noexcept;
 
     struct PositionInformation
     {
@@ -303,8 +364,9 @@ class TextBuffer final
     til::point _GetWordEndForSelection(const til::point target, const std::wstring_view wordDelimiters) const;
     void _PruneHyperlinks();
     void _trimMarksOutsideBuffer();
+    std::tuple<til::CoordType, til::CoordType, bool> _RowCopyHelper(const CopyRequest& req, const til::CoordType iRow, const ROW& row) const;
 
-    static void _AppendRTFText(std::ostringstream& contentBuilder, const std::wstring_view& text);
+    static void _AppendRTFText(std::string& contentBuilder, const std::wstring_view& text);
 
     Microsoft::Console::Render::Renderer& _renderer;
 
diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp
index 481fce33620..cd39873b52c 100644
--- a/src/cascadia/TerminalApp/AppActionHandlers.cpp
+++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp
@@ -1065,10 +1065,7 @@ namespace winrt::TerminalApp::implementation
         {
             if (termControl.HasSelection())
             {
-                const auto selections{ termControl.SelectedText(true) };
-
-                // concatenate the selection into a single line
-                auto searchText = std::accumulate(selections.begin(), selections.end(), std::wstring());
+                std::wstring searchText{ termControl.SelectedText(true) };
 
                 // make it compact by replacing consecutive whitespaces with a single space
                 searchText = std::regex_replace(searchText, std::wregex(LR"(\s+)"), L" ");
diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp
index ba33a59ea27..68702b27500 100644
--- a/src/cascadia/TerminalControl/ControlCore.cpp
+++ b/src/cascadia/TerminalControl/ControlCore.cpp
@@ -1254,37 +1254,12 @@ namespace winrt::Microsoft::Terminal::Control::implementation
         // set copyFormatting.
         const auto copyFormats = formats != nullptr ? formats.Value() : _settings->CopyFormatting();
 
+        const auto copyHtml = WI_IsFlagSet(copyFormats, CopyFormat::HTML);
+        const auto copyRtf = WI_IsFlagSet(copyFormats, CopyFormat::RTF);
+
         // extract text from buffer
         // RetrieveSelectedTextFromBuffer will lock while it's reading
-        const auto bufferData = _terminal->RetrieveSelectedTextFromBuffer(singleLine);
-
-        // convert text: vector<string> --> string
-        std::wstring textData;
-        for (const auto& text : bufferData.text)
-        {
-            textData += text;
-        }
-
-        const auto bgColor = _terminal->GetAttributeColors({}).second;
-
-        // convert text to HTML format
-        // GH#5347 - Don't provide a title for the generated HTML, as many
-        // web applications will paste the title first, followed by the HTML
-        // content, which is unexpected.
-        const auto htmlData = WI_IsFlagSet(copyFormats, CopyFormat::HTML) ?
-                                  TextBuffer::GenHTML(bufferData,
-                                                      _actualFont.GetUnscaledSize().height,
-                                                      _actualFont.GetFaceName(),
-                                                      bgColor) :
-                                  "";
-
-        // convert to RTF format
-        const auto rtfData = WI_IsFlagSet(copyFormats, CopyFormat::RTF) ?
-                                 TextBuffer::GenRTF(bufferData,
-                                                    _actualFont.GetUnscaledSize().height,
-                                                    _actualFont.GetFaceName(),
-                                                    bgColor) :
-                                 "";
+        const auto& [textData, htmlData, rtfData] = _terminal->RetrieveSelectedTextFromBuffer(singleLine, copyHtml, copyRtf);
 
         // send data up for clipboard
         _CopyToClipboardHandlers(*this,
@@ -1616,24 +1591,28 @@ namespace winrt::Microsoft::Terminal::Control::implementation
         return _terminal->IsSelectionActive();
     }
 
+    // Method Description:
+    // - Checks if the currently active selection spans multiple lines
+    // Return Value:
+    // - true if selection is multi-line
+    bool ControlCore::HasMultiLineSelection() const
+    {
+        const auto lock = _terminal->LockForReading();
+        assert(_terminal->IsSelectionActive()); // should only be called when selection is active
+        return _terminal->GetSelectionAnchor().y != _terminal->GetSelectionEnd().y;
+    }
+
     bool ControlCore::CopyOnSelect() const
     {
         return _settings->CopyOnSelect();
     }
 
-    Windows::Foundation::Collections::IVector<winrt::hstring> ControlCore::SelectedText(bool trimTrailingWhitespace) const
+    winrt::hstring ControlCore::SelectedText(bool trimTrailingWhitespace) const
     {
         // RetrieveSelectedTextFromBuffer will lock while it's reading
         const auto lock = _terminal->LockForReading();
-        const auto internalResult{ _terminal->RetrieveSelectedTextFromBuffer(trimTrailingWhitespace).text };
-
-        auto result = winrt::single_threaded_vector<winrt::hstring>();
-
-        for (const auto& row : internalResult)
-        {
-            result.Append(winrt::hstring{ row });
-        }
-        return result;
+        const auto internalResult{ _terminal->RetrieveSelectedTextFromBuffer(!trimTrailingWhitespace) };
+        return winrt::hstring{ internalResult.plainText };
     }
 
     ::Microsoft::Console::Render::IRenderData* ControlCore::GetRenderData() const
diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h
index 90cc5fca4d4..6b513f2bf86 100644
--- a/src/cascadia/TerminalControl/ControlCore.h
+++ b/src/cascadia/TerminalControl/ControlCore.h
@@ -156,7 +156,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation
         int BufferHeight() const;
 
         bool HasSelection() const;
-        Windows::Foundation::Collections::IVector<winrt::hstring> SelectedText(bool trimTrailingWhitespace) const;
+        bool HasMultiLineSelection() const;
+        winrt::hstring SelectedText(bool trimTrailingWhitespace) const;
 
         bool BracketedPasteEnabled() const noexcept;
 
diff --git a/src/cascadia/TerminalControl/HwndTerminal.cpp b/src/cascadia/TerminalControl/HwndTerminal.cpp
index 6bec410cda8..b5a930f701a 100644
--- a/src/cascadia/TerminalControl/HwndTerminal.cpp
+++ b/src/cascadia/TerminalControl/HwndTerminal.cpp
@@ -106,9 +106,12 @@ try
             {
                 try
                 {
-                    const auto lock = publicTerminal->_terminal->LockForWriting();
-                    const auto bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false);
-                    LOG_IF_FAILED(publicTerminal->_CopyTextToSystemClipboard(bufferData, true));
+                    Terminal::TextCopyData bufferData;
+                    {
+                        const auto lock = publicTerminal->_terminal->LockForWriting();
+                        bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false, true, true);
+                    }
+                    LOG_IF_FAILED(publicTerminal->_CopyTextToSystemClipboard(bufferData.plainText, bufferData.html, bufferData.rtf));
                     publicTerminal->_ClearSelection();
                 }
                 CATCH_LOG();
@@ -666,20 +669,14 @@ try
         return nullptr;
     }
 
-    TextBuffer::TextAndColor bufferData;
+    std::wstring selectedText;
     {
         const auto lock = publicTerminal->_terminal->LockForWriting();
-        bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false);
+        auto bufferData = publicTerminal->_terminal->RetrieveSelectedTextFromBuffer(false);
+        selectedText = std::move(bufferData.plainText);
         publicTerminal->_ClearSelection();
     }
 
-    // convert text: vector<string> --> string
-    std::wstring selectedText;
-    for (const auto& text : bufferData.text)
-    {
-        selectedText += text;
-    }
-
     auto returnText = wil::make_cotaskmem_string_nothrow(selectedText.c_str());
     return returnText.release();
 }
@@ -963,22 +960,16 @@ void __stdcall TerminalKillFocus(void* terminal)
 // Routine Description:
 // - Copies the text given onto the global system clipboard.
 // Arguments:
-// - rows - Rows of text data to copy
-// - fAlsoCopyFormatting - true if the color and formatting should also be copied, false otherwise
-HRESULT HwndTerminal::_CopyTextToSystemClipboard(const TextBuffer::TextAndColor& rows, const bool fAlsoCopyFormatting)
+// - text - selected text in plain-text format
+// - htmlData - selected text in HTML format
+// - rtfData - selected text in RTF format
+HRESULT HwndTerminal::_CopyTextToSystemClipboard(const std::wstring& text, const std::string& htmlData, const std::string& rtfData) const
 try
 {
     RETURN_HR_IF_NULL(E_NOT_VALID_STATE, _terminal);
-    std::wstring finalString;
-
-    // Concatenate strings into one giant string to put onto the clipboard.
-    for (const auto& str : rows.text)
-    {
-        finalString += str;
-    }
 
     // allocate the final clipboard data
-    const auto cchNeeded = finalString.size() + 1;
+    const auto cchNeeded = text.size() + 1;
     const auto cbNeeded = sizeof(wchar_t) * cchNeeded;
     wil::unique_hglobal globalHandle(GlobalAlloc(GMEM_MOVEABLE | GMEM_DDESHARE, cbNeeded));
     RETURN_LAST_ERROR_IF_NULL(globalHandle.get());
@@ -988,7 +979,7 @@ try
 
     // The pattern gets a bit strange here because there's no good wil built-in for global lock of this type.
     // Try to copy then immediately unlock. Don't throw until after (so the hglobal won't be freed until we unlock).
-    const auto hr = StringCchCopyW(pwszClipboard, cchNeeded, finalString.data());
+    const auto hr = StringCchCopyW(pwszClipboard, cchNeeded, text.data());
     GlobalUnlock(globalHandle.get());
     RETURN_IF_FAILED(hr);
 
@@ -1003,21 +994,14 @@ try
         RETURN_LAST_ERROR_IF(!EmptyClipboard());
         RETURN_LAST_ERROR_IF_NULL(SetClipboardData(CF_UNICODETEXT, globalHandle.get()));
 
-        if (fAlsoCopyFormatting)
+        if (!htmlData.empty())
         {
-            const auto& fontData = _actualFont;
-            const int iFontHeightPoints = fontData.GetUnscaledSize().height; // this renderer uses points already
-            COLORREF bgColor;
-            {
-                const auto lock = _terminal->LockForReading();
-                bgColor = _terminal->GetAttributeColors({}).second;
-            }
-
-            auto HTMLToPlaceOnClip = TextBuffer::GenHTML(rows, iFontHeightPoints, fontData.GetFaceName(), bgColor);
-            _CopyToSystemClipboard(HTMLToPlaceOnClip, L"HTML Format");
+            RETURN_IF_FAILED(_CopyToSystemClipboard(htmlData, L"HTML Format"));
+        }
 
-            auto RTFToPlaceOnClip = TextBuffer::GenRTF(rows, iFontHeightPoints, fontData.GetFaceName(), bgColor);
-            _CopyToSystemClipboard(RTFToPlaceOnClip, L"Rich Text Format");
+        if (!rtfData.empty())
+        {
+            RETURN_IF_FAILED(_CopyToSystemClipboard(rtfData, L"Rich Text Format"));
         }
     }
 
@@ -1035,7 +1019,7 @@ CATCH_RETURN()
 // Arguments:
 // - stringToCopy - The string to copy
 // - lpszFormat - the name of the format
-HRESULT HwndTerminal::_CopyToSystemClipboard(std::string stringToCopy, LPCWSTR lpszFormat)
+HRESULT HwndTerminal::_CopyToSystemClipboard(const std::string& stringToCopy, LPCWSTR lpszFormat) const
 {
     const auto cbData = stringToCopy.size() + 1; // +1 for '\0'
     if (cbData)
diff --git a/src/cascadia/TerminalControl/HwndTerminal.hpp b/src/cascadia/TerminalControl/HwndTerminal.hpp
index b88acd03504..6646fd56506 100644
--- a/src/cascadia/TerminalControl/HwndTerminal.hpp
+++ b/src/cascadia/TerminalControl/HwndTerminal.hpp
@@ -109,8 +109,8 @@ struct HwndTerminal : ::Microsoft::Console::Types::IControlAccessibilityInfo
 
     void _UpdateFont(int newDpi);
     void _WriteTextToConnection(const std::wstring_view text) noexcept;
-    HRESULT _CopyTextToSystemClipboard(const TextBuffer::TextAndColor& rows, const bool fAlsoCopyFormatting);
-    HRESULT _CopyToSystemClipboard(std::string stringToCopy, LPCWSTR lpszFormat);
+    HRESULT _CopyTextToSystemClipboard(const std::wstring& text, const std::string& htmlData, const std::string& rtfData) const;
+    HRESULT _CopyToSystemClipboard(const std::string& stringToCopy, LPCWSTR lpszFormat) const;
     void _PasteTextFromClipboard() noexcept;
 
     const unsigned int _NumberOfClicks(til::point clickPos, std::chrono::steady_clock::time_point clickTime) noexcept;
diff --git a/src/cascadia/TerminalControl/ICoreState.idl b/src/cascadia/TerminalControl/ICoreState.idl
index ed60ab4ea6c..0073b70788a 100644
--- a/src/cascadia/TerminalControl/ICoreState.idl
+++ b/src/cascadia/TerminalControl/ICoreState.idl
@@ -46,7 +46,8 @@ namespace Microsoft.Terminal.Control
         Int32 BufferHeight { get; };
 
         Boolean HasSelection { get; };
-        IVector<String> SelectedText(Boolean trimTrailingWhitespace);
+        Boolean HasMultiLineSelection { get; };
+        String SelectedText(Boolean trimTrailingWhitespace);
 
         Boolean BracketedPasteEnabled { get; };
 
diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp
index 2437be05452..e24168488bf 100644
--- a/src/cascadia/TerminalControl/TermControl.cpp
+++ b/src/cascadia/TerminalControl/TermControl.cpp
@@ -416,10 +416,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation
                     // Currently we populate the search box only if a single line is selected.
                     // Empirically, multi-line selection works as well on sample scenarios,
                     // but since code paths differ, extra work is required to ensure correctness.
-                    auto bufferText = _core.SelectedText(true);
-                    if (bufferText.Size() == 1)
+                    if (!_core.HasMultiLineSelection())
                     {
-                        const auto selectedLine{ bufferText.GetAt(0) };
+                        const auto selectedLine{ _core.SelectedText(true) };
                         _searchBox->PopulateTextbox(selectedLine);
                     }
                 }
@@ -3487,7 +3486,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation
     {
         return _core.HasSelection();
     }
-    Windows::Foundation::Collections::IVector<winrt::hstring> TermControl::SelectedText(bool trimTrailingWhitespace) const
+    bool TermControl::HasMultiLineSelection() const
+    {
+        return _core.HasMultiLineSelection();
+    }
+    winrt::hstring TermControl::SelectedText(bool trimTrailingWhitespace) const
     {
         return _core.SelectedText(trimTrailingWhitespace);
     }
diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h
index 20449f98129..7dc74d7136b 100644
--- a/src/cascadia/TerminalControl/TermControl.h
+++ b/src/cascadia/TerminalControl/TermControl.h
@@ -72,7 +72,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation
         int BufferHeight() const;
 
         bool HasSelection() const;
-        Windows::Foundation::Collections::IVector<winrt::hstring> SelectedText(bool trimTrailingWhitespace) const;
+        bool HasMultiLineSelection() const;
+        winrt::hstring SelectedText(bool trimTrailingWhitespace) const;
 
         bool BracketedPasteEnabled() const noexcept;
 
diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp
index 603e0e521ba..80c5e3cc905 100644
--- a/src/cascadia/TerminalCore/Terminal.hpp
+++ b/src/cascadia/TerminalCore/Terminal.hpp
@@ -293,6 +293,13 @@ class Microsoft::Terminal::Core::Terminal final :
         End = 0x2
     };
 
+    struct TextCopyData
+    {
+        std::wstring plainText;
+        std::string html;
+        std::string rtf;
+    };
+
     void MultiClickSelection(const til::point viewportPos, SelectionExpansion expansionMode);
     void SetSelectionAnchor(const til::point position);
     void SetSelectionEnd(const til::point position, std::optional<SelectionExpansion> newExpansionMode = std::nullopt);
@@ -311,7 +318,7 @@ class Microsoft::Terminal::Core::Terminal final :
     til::point SelectionEndForRendering() const;
     const SelectionEndpoint SelectionEndpointTarget() const noexcept;
 
-    const TextBuffer::TextAndColor RetrieveSelectedTextFromBuffer(bool trimTrailingWhitespace);
+    TextCopyData RetrieveSelectedTextFromBuffer(const bool singleLine, const bool html = false, const bool rtf = false) const;
 #pragma endregion
 
 #ifndef NDEBUG
diff --git a/src/cascadia/TerminalCore/TerminalSelection.cpp b/src/cascadia/TerminalCore/TerminalSelection.cpp
index cfbc004b7d7..fd440470f86 100644
--- a/src/cascadia/TerminalCore/TerminalSelection.cpp
+++ b/src/cascadia/TerminalCore/TerminalSelection.cpp
@@ -867,27 +867,53 @@ void Terminal::ClearSelection()
 }
 
 // Method Description:
-// - get wstring text from highlighted portion of text buffer
+// - Get text from highlighted portion of text buffer
+// - Optionally, get the highlighted text in HTML and RTF formats
 // Arguments:
-// - singleLine: collapse all of the text to one line
+// - singleLine: collapse all of the text to one line. (Turns off trailing whitespace trimming)
+// - html: also get text in HTML format
+// - rtf: also get text in RTF format
 // Return Value:
-// - wstring text from buffer. If extended to multiple lines, each line is separated by \r\n
-const TextBuffer::TextAndColor Terminal::RetrieveSelectedTextFromBuffer(bool singleLine)
+// - Plain and formatted selected text from buffer. Empty string represents no data for that format.
+// - If extended to multiple lines, each line is separated by \r\n
+Terminal::TextCopyData Terminal::RetrieveSelectedTextFromBuffer(const bool singleLine, const bool html, const bool rtf) const
 {
-    const auto selectionRects = _GetSelectionRects();
+    TextCopyData data;
+
+    if (!IsSelectionActive())
+    {
+        return data;
+    }
 
     const auto GetAttributeColors = [&](const auto& attr) {
-        return _renderSettings.GetAttributeColors(attr);
+        const auto [fg, bg] = _renderSettings.GetAttributeColors(attr);
+        const auto ul = _renderSettings.GetAttributeUnderlineColor(attr);
+        return std::tuple{ fg, bg, ul };
     };
 
-    // GH#6740: Block selection should preserve the visual structure:
-    // - CRLFs need to be added - so the lines structure is preserved
-    // - We should apply formatting above to wrapped rows as well (newline should be added).
-    // GH#9706: Trimming of trailing white-spaces in block selection is configurable.
-    const auto includeCRLF = !singleLine || _blockSelection;
-    const auto trimTrailingWhitespace = !singleLine && (!_blockSelection || _trimBlockSelection);
-    const auto formatWrappedRows = _blockSelection;
-    return _activeBuffer().GetText(includeCRLF, trimTrailingWhitespace, selectionRects, GetAttributeColors, formatWrappedRows);
+    const auto& textBuffer = _activeBuffer();
+
+    const auto req = TextBuffer::CopyRequest::FromConfig(textBuffer, _selection->start, _selection->end, singleLine, _blockSelection, _trimBlockSelection);
+    data.plainText = textBuffer.GetPlainText(req);
+
+    if (html || rtf)
+    {
+        const auto bgColor = _renderSettings.GetAttributeColors({}).second;
+        const auto isIntenseBold = _renderSettings.GetRenderMode(::Microsoft::Console::Render::RenderSettings::Mode::IntenseIsBold);
+        const auto fontSizePt = _fontInfo.GetUnscaledSize().height; // already in points
+        const auto& fontName = _fontInfo.GetFaceName();
+
+        if (html)
+        {
+            data.html = textBuffer.GenHTML(req, fontSizePt, fontName, bgColor, isIntenseBold, GetAttributeColors);
+        }
+        if (rtf)
+        {
+            data.rtf = textBuffer.GenRTF(req, fontSizePt, fontName, bgColor, isIntenseBold, GetAttributeColors);
+        }
+    }
+
+    return data;
 }
 
 // Method Description:
diff --git a/src/host/selection.hpp b/src/host/selection.hpp
index 5edb37cd4d8..e7e0dfcb4b4 100644
--- a/src/host/selection.hpp
+++ b/src/host/selection.hpp
@@ -127,6 +127,7 @@ class Selection
 
     DWORD GetPublicSelectionFlags() const noexcept;
     til::point GetSelectionAnchor() const noexcept;
+    std::pair<til::point, til::point> GetSelectionAnchors() const noexcept;
     til::inclusive_rect GetSelectionRectangle() const noexcept;
 
     void SetLineSelection(const bool fLineSelectionOn);
diff --git a/src/host/selectionState.cpp b/src/host/selectionState.cpp
index a630b490e14..a7c8a4b553d 100644
--- a/src/host/selectionState.cpp
+++ b/src/host/selectionState.cpp
@@ -197,6 +197,38 @@ til::point Selection::GetSelectionAnchor() const noexcept
     return _coordSelectionAnchor;
 }
 
+// Routine Description:
+// - Gets the current selection begin and end (inclusive) anchor positions. The
+//   first anchor is at the top left, and the second is at the bottom right
+//   corner of the selection area.
+// Return Value:
+// - The current selection anchors
+std::pair<til::point, til::point> Selection::GetSelectionAnchors() const noexcept
+{
+    if (!_fSelectionVisible)
+    {
+        // return anchors that represent an empty selection
+        return { { 0, 0 }, { -1, -1 } };
+    }
+
+    auto startSelectionAnchor = _coordSelectionAnchor;
+
+    // _coordSelectionAnchor is at one of the corners of _srSelectionRects
+    // endSelectionAnchor is at the exact opposite corner
+    til::point endSelectionAnchor;
+    endSelectionAnchor.x = (_coordSelectionAnchor.x == _srSelectionRect.left) ? _srSelectionRect.right : _srSelectionRect.left;
+    endSelectionAnchor.y = (_coordSelectionAnchor.y == _srSelectionRect.top) ? _srSelectionRect.bottom : _srSelectionRect.top;
+
+    if (startSelectionAnchor > endSelectionAnchor)
+    {
+        return { endSelectionAnchor, startSelectionAnchor };
+    }
+    else
+    {
+        return { startSelectionAnchor, endSelectionAnchor };
+    }
+}
+
 // Routine Description:
 // - Gets the current selection rectangle
 // Arguments:
diff --git a/src/host/ut_host/ClipboardTests.cpp b/src/host/ut_host/ClipboardTests.cpp
index 2f9e03c88bb..30b45179095 100644
--- a/src/host/ut_host/ClipboardTests.cpp
+++ b/src/host/ut_host/ClipboardTests.cpp
@@ -67,7 +67,16 @@ class ClipboardTests
 
     const UINT cRectsSelected = 4;
 
-    std::vector<std::wstring> SetupRetrieveFromBuffers(bool fLineSelection, std::vector<til::inclusive_rect>& selection)
+    std::pair<til::CoordType, til::CoordType> GetBufferSize()
+    {
+        const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
+        const auto& screenInfo = gci.GetActiveOutputBuffer();
+        const auto& buffer = screenInfo.GetTextBuffer();
+        const auto bufferBounds = buffer.GetSize();
+        return { bufferBounds.Width(), bufferBounds.Height() };
+    }
+
+    std::wstring SetupRetrieveFromBuffer(bool fLineSelection)
     {
         const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
         // NOTE: This test requires innate knowledge of how the common buffer text is emitted in order to test all cases
@@ -75,82 +84,86 @@ class ClipboardTests
 
         // set up and try to retrieve the first 4 rows from the buffer
         const auto& screenInfo = gci.GetActiveOutputBuffer();
-
-        selection.clear();
-        selection.emplace_back(til::inclusive_rect{ 0, 0, 8, 0 });
-        selection.emplace_back(til::inclusive_rect{ 0, 1, 14, 1 });
-        selection.emplace_back(til::inclusive_rect{ 0, 2, 14, 2 });
-        selection.emplace_back(til::inclusive_rect{ 0, 3, 8, 3 });
-
         const auto& buffer = screenInfo.GetTextBuffer();
-        return buffer.GetText(true, fLineSelection, selection).text;
+
+        constexpr til::point_span selection = { { 0, 0 }, { 14, 3 } };
+        const auto req = TextBuffer::CopyRequest::FromConfig(buffer, selection.start, selection.end, false, !fLineSelection, false);
+        return buffer.GetPlainText(req);
     }
 
-#pragma prefast(push)
-#pragma prefast(disable : 26006, "Specifically trying to check unterminated strings in this test.")
-    TEST_METHOD(TestRetrieveFromBuffer)
+    TEST_METHOD(TestRetrieveBlockSelectionFromBuffer)
     {
         // NOTE: This test requires innate knowledge of how the common buffer text is emitted in order to test all cases
         // Please see CommonState.hpp for information on the buffer state per row, the row contents, etc.
 
-        std::vector<til::inclusive_rect> selection;
-        const auto text = SetupRetrieveFromBuffers(false, selection);
-
-        // verify trailing bytes were trimmed
-        // there are 2 double-byte characters in our sample string (see CommonState.hpp for sample)
-        // the width is right - left
-        VERIFY_ARE_EQUAL((til::CoordType)wcslen(text[0].data()), selection[0].right - selection[0].left + 1);
-
-        // since we're not in line selection, the line should be \r\n terminated
-        auto tempPtr = text[0].data();
-        tempPtr += text[0].size();
-        tempPtr -= 2;
-        VERIFY_ARE_EQUAL(String(tempPtr), String(L"\r\n"));
-
-        // since we're not in line selection, spaces should be trimmed from the end
-        tempPtr = text[0].data();
-        tempPtr += selection[0].right - selection[0].left - 2;
-        tempPtr++;
-        VERIFY_IS_NULL(wcsrchr(tempPtr, L' '));
-
-        // final line of selection should not contain CR/LF
-        tempPtr = text[3].data();
-        tempPtr += text[3].size();
-        tempPtr -= 2;
-        VERIFY_ARE_NOT_EQUAL(String(tempPtr), String(L"\r\n"));
+        const auto text = SetupRetrieveFromBuffer(false);
+
+        std::wstring expectedText;
+
+        // Block selection:
+        // - Add line breaks on wrapped and non-wrapped rows.
+        // - No trimming of trailing whitespace because trimming in Block
+        //   selection is disabled.
+
+        // All rows:
+        // First 15 columns selected -> 7 characters (9 columns) + 6 spaces
+        // Add CR/LF (except the last row)
+
+        // row 0
+        expectedText += L"AB\u304bC\u304dDE      ";
+        expectedText += L"\r\n";
+
+        // row 1
+        expectedText += L"AB\u304bC\u304dDE      ";
+        expectedText += L"\r\n";
+
+        // row 2
+        expectedText += L"AB\u304bC\u304dDE      ";
+        expectedText += L"\r\n";
+
+        // row 3
+        // last row -> no CR/LF
+        expectedText += L"AB\u304bC\u304dDE      ";
+
+        VERIFY_ARE_EQUAL(expectedText, text);
     }
-#pragma prefast(pop)
 
     TEST_METHOD(TestRetrieveLineSelectionFromBuffer)
     {
         // NOTE: This test requires innate knowledge of how the common buffer text is emitted in order to test all cases
         // Please see CommonState.hpp for information on the buffer state per row, the row contents, etc.
-
-        std::vector<til::inclusive_rect> selection;
-        const auto text = SetupRetrieveFromBuffers(true, selection);
-
-        // row 2, no wrap
-        // no wrap row before the end should have CR/LF
-        auto tempPtr = text[2].data();
-        tempPtr += text[2].size();
-        tempPtr -= 2;
-        VERIFY_ARE_EQUAL(String(tempPtr), String(L"\r\n"));
-
-        // no wrap row should trim spaces at the end
-        tempPtr = text[2].data();
-        VERIFY_IS_NULL(wcsrchr(tempPtr, L' '));
-
-        // row 1, wrap
-        // wrap row before the end should *not* have CR/LF
-        tempPtr = text[1].data();
-        tempPtr += text[1].size();
-        tempPtr -= 2;
-        VERIFY_ARE_NOT_EQUAL(String(tempPtr), String(L"\r\n"));
-
-        // wrap row should have spaces at the end
-        tempPtr = text[1].data();
-        auto ptr = wcsrchr(tempPtr, L' ');
-        VERIFY_IS_NOT_NULL(ptr);
+        const auto text = SetupRetrieveFromBuffer(true);
+
+        std::wstring expectedText;
+
+        // Line Selection:
+        // - Add line breaks on non-wrapped rows.
+        // - Trim trailing whitespace on non-wrapped rows.
+
+        // row 0
+        // no wrap -> trim trailing whitespace, add CR/LF
+        // All columns selected -> 7 characters, trimmed trailing spaces
+        expectedText += L"AB\u304bC\u304dDE";
+        expectedText += L"\r\n";
+
+        // row 1
+        // wrap -> no trimming of trailing whitespace, no CR/LF
+        // All columns selected -> 7 characters (9 columns) + (bufferWidth - 9) spaces
+        const auto [bufferWidth, bufferHeight] = GetBufferSize();
+        expectedText += L"AB\u304bC\u304dDE" + std::wstring(bufferWidth - 9, L' ');
+
+        // row 2
+        // no wrap -> trim trailing whitespace, add CR/LF
+        // All columns selected -> 7 characters (9 columns), trimmed trailing spaces
+        expectedText += L"AB\u304bC\u304dDE";
+        expectedText += L"\r\n";
+
+        // row 3
+        // wrap -> no trimming of trailing whitespace, no CR/LF
+        // First 15 columns selected -> 7 characters (9 columns) + 6 spaces
+        expectedText += L"AB\u304bC\u304dDE      ";
+
+        VERIFY_ARE_EQUAL(expectedText, text);
     }
 
     TEST_METHOD(CanConvertText)
diff --git a/src/host/ut_host/TextBufferTests.cpp b/src/host/ut_host/TextBufferTests.cpp
index 9b330e91d86..648b054778b 100644
--- a/src/host/ut_host/TextBufferTests.cpp
+++ b/src/host/ut_host/TextBufferTests.cpp
@@ -157,7 +157,7 @@ class TextBufferTests
     TEST_METHOD(GetGlyphBoundaries);
 
     TEST_METHOD(GetTextRects);
-    TEST_METHOD(GetText);
+    TEST_METHOD(GetPlainText);
 
     TEST_METHOD(HyperlinkTrim);
     TEST_METHOD(NoHyperlinkTrim);
@@ -2087,31 +2087,31 @@ void TextBufferTests::TestRowReplaceText()
 void TextBufferTests::TestAppendRTFText()
 {
     {
-        std::ostringstream contentStream;
+        std::string contentStream;
         const auto ascii = L"This is some Ascii \\ {}";
         TextBuffer::_AppendRTFText(contentStream, ascii);
-        VERIFY_ARE_EQUAL("This is some Ascii \\\\ \\{\\}", contentStream.str());
+        VERIFY_ARE_EQUAL("This is some Ascii \\\\ \\{\\}", contentStream);
     }
     {
-        std::ostringstream contentStream;
+        std::string contentStream;
         // "Low code units: á é í ó ú ⮁ ⮂" in UTF-16
         const auto lowCodeUnits = L"Low code units: \x00E1 \x00E9 \x00ED \x00F3 \x00FA \x2B81 \x2B82";
         TextBuffer::_AppendRTFText(contentStream, lowCodeUnits);
-        VERIFY_ARE_EQUAL("Low code units: \\u225? \\u233? \\u237? \\u243? \\u250? \\u11137? \\u11138?", contentStream.str());
+        VERIFY_ARE_EQUAL("Low code units: \\u225? \\u233? \\u237? \\u243? \\u250? \\u11137? \\u11138?", contentStream);
     }
     {
-        std::ostringstream contentStream;
+        std::string contentStream;
         // "High code units: ꞵ ꞷ" in UTF-16
         const auto highCodeUnits = L"High code units: \xA7B5 \xA7B7";
         TextBuffer::_AppendRTFText(contentStream, highCodeUnits);
-        VERIFY_ARE_EQUAL("High code units: \\u-22603? \\u-22601?", contentStream.str());
+        VERIFY_ARE_EQUAL("High code units: \\u-22603? \\u-22601?", contentStream);
     }
     {
-        std::ostringstream contentStream;
+        std::string contentStream;
         // "Surrogates: 🍦 👾 👀" in UTF-16
         const auto surrogates = L"Surrogates: \xD83C\xDF66 \xD83D\xDC7E \xD83D\xDC40";
         TextBuffer::_AppendRTFText(contentStream, surrogates);
-        VERIFY_ARE_EQUAL("Surrogates: \\u-10180?\\u-8346? \\u-10179?\\u-9090? \\u-10179?\\u-9152?", contentStream.str());
+        VERIFY_ARE_EQUAL("Surrogates: \\u-10180?\\u-8346? \\u-10179?\\u-9090? \\u-10179?\\u-9152?", contentStream);
     }
 }
 
@@ -2535,9 +2535,9 @@ void TextBufferTests::GetTextRects()
     }
 }
 
-void TextBufferTests::GetText()
+void TextBufferTests::GetPlainText()
 {
-    // GetText() is used by...
+    // GetPlainText() is used by...
     //  - Copying text to the clipboard regularly
     //  - Copying text to the clipboard, with shift held (collapse to one line)
     //  - Extracting text from a UiaTextRange
@@ -2573,14 +2573,10 @@ void TextBufferTests::GetText()
         WriteLinesToBuffer(bufferText, *_buffer);
 
         // simulate a selection from origin to {4,4}
-        const auto textRects = _buffer->GetTextRects({ 0, 0 }, { 4, 4 }, blockSelection, false);
+        constexpr til::point_span selection = { { 0, 0 }, { 4, 4 } };
 
-        std::wstring result = L"";
-        const auto textData = _buffer->GetText(includeCRLF, trimTrailingWhitespace, textRects).text;
-        for (auto& text : textData)
-        {
-            result += text;
-        }
+        const auto req = TextBuffer::CopyRequest{ *_buffer, selection.start, selection.end, blockSelection, includeCRLF, trimTrailingWhitespace, false };
+        const auto result = _buffer->GetPlainText(req);
 
         std::wstring expectedText = L"";
         if (includeCRLF)
@@ -2672,16 +2668,11 @@ void TextBufferTests::GetText()
         // |_____|
 
         // simulate a selection from origin to {4,5}
-        const auto textRects = _buffer->GetTextRects({ 0, 0 }, { 4, 5 }, blockSelection, false);
-
-        std::wstring result = L"";
+        constexpr til::point_span selection = { { 0, 0 }, { 4, 5 } };
 
         const auto formatWrappedRows = blockSelection;
-        const auto textData = _buffer->GetText(includeCRLF, trimTrailingWhitespace, textRects, nullptr, formatWrappedRows).text;
-        for (auto& text : textData)
-        {
-            result += text;
-        }
+        const auto req = TextBuffer::CopyRequest{ *_buffer, selection.start, selection.end, blockSelection, includeCRLF, trimTrailingWhitespace, formatWrappedRows };
+        const auto result = _buffer->GetPlainText(req);
 
         std::wstring expectedText = L"";
         if (formatWrappedRows)
diff --git a/src/interactivity/win32/Clipboard.cpp b/src/interactivity/win32/Clipboard.cpp
index 684eb6d70a1..ec7277aef30 100644
--- a/src/interactivity/win32/Clipboard.cpp
+++ b/src/interactivity/win32/Clipboard.cpp
@@ -212,6 +212,9 @@ InputEventQueue Clipboard::TextToKeyEvents(_In_reads_(cchData) const wchar_t* co
 //   <none>
 void Clipboard::StoreSelectionToClipboard(const bool copyFormatting)
 {
+    std::wstring text;
+    std::string htmlData, rtfData;
+
     const auto& selection = Selection::Instance();
 
     // See if there is a selection to get
@@ -228,46 +231,48 @@ void Clipboard::StoreSelectionToClipboard(const bool copyFormatting)
     const auto& renderSettings = gci.GetRenderSettings();
 
     const auto GetAttributeColors = [&](const auto& attr) {
-        return renderSettings.GetAttributeColors(attr);
+        const auto [fg, bg] = renderSettings.GetAttributeColors(attr);
+        const auto ul = renderSettings.GetAttributeUnderlineColor(attr);
+        return std::tuple{ fg, bg, ul };
     };
 
-    bool includeCRLF, trimTrailingWhitespace;
+    bool singleLine = false;
     if (WI_IsFlagSet(OneCoreSafeGetKeyState(VK_SHIFT), KEY_PRESSED))
     {
         // When shift is held, put everything in one line
-        includeCRLF = trimTrailingWhitespace = false;
+        singleLine = true;
     }
-    else
+
+    const auto& [selectionStart, selectionEnd] = selection.GetSelectionAnchors();
+
+    const auto req = TextBuffer::CopyRequest::FromConfig(buffer, selectionStart, selectionEnd, singleLine, !selection.IsLineSelection(), false);
+    text = buffer.GetPlainText(req);
+
+    if (copyFormatting)
     {
-        includeCRLF = trimTrailingWhitespace = true;
+        const auto& fontData = gci.GetActiveOutputBuffer().GetCurrentFont();
+        const auto& fontName = fontData.GetFaceName();
+        const auto fontSizePt = fontData.GetUnscaledSize().height * 72 / ServiceLocator::LocateGlobals().dpi;
+        const auto bgColor = renderSettings.GetAttributeColors({}).second;
+        const auto isIntenseBold = renderSettings.GetRenderMode(::Microsoft::Console::Render::RenderSettings::Mode::IntenseIsBold);
+
+        htmlData = buffer.GenHTML(req, fontSizePt, fontName, bgColor, isIntenseBold, GetAttributeColors);
+        rtfData = buffer.GenRTF(req, fontSizePt, fontName, bgColor, isIntenseBold, GetAttributeColors);
     }
 
-    const auto text = buffer.GetText(includeCRLF,
-                                     trimTrailingWhitespace,
-                                     selectionRects,
-                                     GetAttributeColors,
-                                     !selection.IsLineSelection());
-
-    CopyTextToSystemClipboard(text, copyFormatting);
+    CopyTextToSystemClipboard(text, htmlData, rtfData);
 }
 
 // Routine Description:
 // - Copies the text given onto the global system clipboard.
 // Arguments:
-// - rows - Rows of text data to copy
-// - fAlsoCopyFormatting - true if the color and formatting should also be copied, false otherwise
-void Clipboard::CopyTextToSystemClipboard(const TextBuffer::TextAndColor& rows, const bool fAlsoCopyFormatting)
+// - text - plain-text data
+// - htmlData - HTML copy data
+// - rtfData - RTF copy data
+void Clipboard::CopyTextToSystemClipboard(const std::wstring& text, const std::string& htmlData, const std::string& rtfData) const
 {
-    std::wstring finalString;
-
-    // Concatenate strings into one giant string to put onto the clipboard.
-    for (const auto& str : rows.text)
-    {
-        finalString += str;
-    }
-
     // allocate the final clipboard data
-    const auto cchNeeded = finalString.size() + 1;
+    const auto cchNeeded = text.size() + 1;
     const auto cbNeeded = sizeof(wchar_t) * cchNeeded;
     wil::unique_hglobal globalHandle(GlobalAlloc(GMEM_MOVEABLE | GMEM_DDESHARE, cbNeeded));
     THROW_LAST_ERROR_IF_NULL(globalHandle.get());
@@ -277,7 +282,7 @@ void Clipboard::CopyTextToSystemClipboard(const TextBuffer::TextAndColor& rows,
 
     // The pattern gets a bit strange here because there's no good wil built-in for global lock of this type.
     // Try to copy then immediately unlock. Don't throw until after (so the hglobal won't be freed until we unlock).
-    const auto hr = StringCchCopyW(pwszClipboard, cchNeeded, finalString.data());
+    const auto hr = StringCchCopyW(pwszClipboard, cchNeeded, text.data());
     GlobalUnlock(globalHandle.get());
     THROW_IF_FAILED(hr);
 
@@ -292,18 +297,13 @@ void Clipboard::CopyTextToSystemClipboard(const TextBuffer::TextAndColor& rows,
         THROW_LAST_ERROR_IF(!EmptyClipboard());
         THROW_LAST_ERROR_IF_NULL(SetClipboardData(CF_UNICODETEXT, globalHandle.get()));
 
-        if (fAlsoCopyFormatting)
+        if (!htmlData.empty())
         {
-            const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
-            const auto& fontData = gci.GetActiveOutputBuffer().GetCurrentFont();
-            const auto iFontHeightPoints = fontData.GetUnscaledSize().height * 72 / ServiceLocator::LocateGlobals().dpi;
-            const auto bgColor = gci.GetRenderSettings().GetAttributeColors({}).second;
-
-            auto HTMLToPlaceOnClip = TextBuffer::GenHTML(rows, iFontHeightPoints, fontData.GetFaceName(), bgColor);
-            CopyToSystemClipboard(HTMLToPlaceOnClip, L"HTML Format");
-
-            auto RTFToPlaceOnClip = TextBuffer::GenRTF(rows, iFontHeightPoints, fontData.GetFaceName(), bgColor);
-            CopyToSystemClipboard(RTFToPlaceOnClip, L"Rich Text Format");
+            CopyToSystemClipboard(htmlData, L"HTML Format");
+        }
+        if (!rtfData.empty())
+        {
+            CopyToSystemClipboard(rtfData, L"Rich Text Format");
         }
     }
 
@@ -318,7 +318,7 @@ void Clipboard::CopyTextToSystemClipboard(const TextBuffer::TextAndColor& rows,
 // Arguments:
 // - stringToCopy - The string to copy
 // - lpszFormat - the name of the format
-void Clipboard::CopyToSystemClipboard(std::string stringToCopy, LPCWSTR lpszFormat)
+void Clipboard::CopyToSystemClipboard(const std::string& stringToCopy, LPCWSTR lpszFormat) const
 {
     const auto cbData = stringToCopy.size() + 1; // +1 for '\0'
     if (cbData)
diff --git a/src/interactivity/win32/clipboard.hpp b/src/interactivity/win32/clipboard.hpp
index f6bab9254cf..4bca367fee7 100644
--- a/src/interactivity/win32/clipboard.hpp
+++ b/src/interactivity/win32/clipboard.hpp
@@ -41,8 +41,8 @@ namespace Microsoft::Console::Interactivity::Win32
 
         void StoreSelectionToClipboard(_In_ const bool fAlsoCopyFormatting);
 
-        void CopyTextToSystemClipboard(const TextBuffer::TextAndColor& rows, _In_ const bool copyFormatting);
-        void CopyToSystemClipboard(std::string stringToPlaceOnClip, LPCWSTR lpszFormat);
+        void CopyTextToSystemClipboard(const std::wstring& text, const std::string& htmlData, const std::string& rtfData) const;
+        void CopyToSystemClipboard(const std::string& stringToPlaceOnClip, LPCWSTR lpszFormat) const;
 
         bool FilterCharacterOnPaste(_Inout_ WCHAR* const pwch);
 
diff --git a/src/types/UiaTextRangeBase.cpp b/src/types/UiaTextRangeBase.cpp
index 419acf1ff0b..788386fa8df 100644
--- a/src/types/UiaTextRangeBase.cpp
+++ b/src/types/UiaTextRangeBase.cpp
@@ -985,29 +985,15 @@ std::wstring UiaTextRangeBase::_getTextValue(til::CoordType maxLength) const
         auto inclusiveEnd = _end;
         bufferSize.DecrementInBounds(inclusiveEnd, true);
 
-        // reserve size in accordance to extracted text
-        const auto textRects = buffer.GetTextRects(_start, inclusiveEnd, _blockRange, true);
-        const auto bufferData = buffer.GetText(true,
-                                               false,
-                                               textRects);
-        const size_t textDataSize = bufferData.text.size() * bufferSize.Width();
-        textData.reserve(textDataSize);
-        for (const auto& text : bufferData.text)
-        {
-            if (textData.size() >= maxLengthAsSize)
-            {
-                // early exit; we're already at/past max length
-                break;
-            }
-            textData += text;
-        }
+        const auto req = TextBuffer::CopyRequest{ buffer, _start, inclusiveEnd, _blockRange, true, false, false, true };
+        auto plainText = buffer.GetPlainText(req);
 
-        // only use maxLength to resize down.
-        // if maxLength > size, we don't want to resize and append unnecessary L'\0'.
-        if (textData.size() > maxLengthAsSize)
+        if (plainText.size() > maxLengthAsSize)
         {
-            textData.resize(maxLengthAsSize);
+            plainText.resize(maxLengthAsSize);
         }
+
+        textData = std::move(plainText);
     }
 
     return textData;


Test patch:
diff --git a/src/inc/test/CommonState.hpp b/src/inc/test/CommonState.hpp
index 1803acb0ae8..b6e8ad153be 100644
--- a/src/inc/test/CommonState.hpp
+++ b/src/inc/test/CommonState.hpp
@@ -259,9 +259,10 @@ class CommonState
     void FillRow(ROW* pRow, bool wrapForced)
     {
         // fill a row
-        // 9 characters, 6 spaces. 15 total
-        // か = \x304b
-        // き = \x304d
+        // - Each row is populated with L"AB\u304bC\u304dDE      "
+        // - 7 characters, 6 spaces. 13 total
+        // - The characters take up first 9 columns. (The wide glyphs take up 2 columns each)
+        // - か = \x304b, き = \x304d
 
         uint16_t column = 0;
         for (const auto& ch : std::wstring_view{ L"AB\u304bC\u304dDE      " })