commit b587521d0093bbe5311e54389421d0b2f9e9cf48
parent 0c709bd9cb0d3c899ad9b6841ce956ee28463f0c
Author: Edgar Chen <echen@mozilla.com>
Date: Mon, 10 Nov 2025 20:21:45 +0000
Bug 1650720 - Part 3: Align link break coversion for clipboard copy with other browsers; r=masayuki
There are two main places that handle line break conversion:
1. https://searchfox.org/firefox-main/rev/0b21972a78f8915f73ce5579eeee2aa8c9c7d67e/dom/serializers/nsPlainTextSerializer.cpp#485-509
This converts all LF and CR characters to the platform line break or a space,
depending on whether the serializer is in preformatted mode.
2. https://searchfox.org/firefox-main/rev/0b21972a78f8915f73ce5579eeee2aa8c9c7d67e/dom/serializers/nsPlainTextSerializer.cpp#1628,1641,1658-1701
This converts also all LF and CR, but this also have special handling for CRLF,
to the platform line break or a space, depending on whether the serializer is
in preformatted mode.
Previously, text nodes for selection clipboard copy were handled using method (1),
which could cause an additional line break when a text node contained an LF.
Other browsers don’t convert LF, but only CR, to the platform line break when
copying a selection to the clipboard. They also preserve CRLF on Windows.
(Please see https://bugzilla.mozilla.org/show_bug.cgi?id=1650720#c15 for details)
This patch aligns our behavior with other browsers, whose approach seems more
reasonable.
Now, text nodes for selection clipboard copy are handled using method (2), which
takes CRLF into account during conversion and handles line break conversion
differently when serializing for clipboard copy, while preserving the original
behavior for other cases.
Differential Revision: https://phabricator.services.mozilla.com/D270545
Diffstat:
5 files changed, 66 insertions(+), 16 deletions(-)
diff --git a/dom/base/nsCopySupport.cpp b/dom/base/nsCopySupport.cpp
@@ -132,6 +132,7 @@ static nsresult EncodeForTextUnicode(nsIDocumentEncoder& aEncoder,
} else {
// Redo the encoding, but this time use pretty printing.
flags = nsIDocumentEncoder::OutputSelectionOnly |
+ nsIDocumentEncoder::OutputForPlainTextClipboardCopy |
nsIDocumentEncoder::OutputAbsoluteLinks |
nsIDocumentEncoder::SkipInvisibleContent |
nsIDocumentEncoder::OutputDropInvisibleBreak |
diff --git a/dom/serializers/nsPlainTextSerializer.cpp b/dom/serializers/nsPlainTextSerializer.cpp
@@ -219,8 +219,8 @@ void nsPlainTextSerializer::OutputManager::Append(const nsAString& aString) {
}
}
-void nsPlainTextSerializer::OutputManager::AppendLineBreak() {
- mOutput.Append(mLineBreak);
+void nsPlainTextSerializer::OutputManager::AppendLineBreak(bool aForceCRLF) {
+ mOutput.Append(aForceCRLF ? u"\r\n"_ns : mLineBreak);
mAtFirstColumn = true;
}
@@ -482,6 +482,13 @@ nsPlainTextSerializer::AppendText(Text* aText, int32_t aStartOffset,
TextEditor::MaskString(textstr, *aText, 0, aStartOffset);
}
+ if (mSettings.HasFlag(nsIDocumentEncoder::OutputForPlainTextClipboardCopy)) {
+ // XXX it would be nice if we could just use the Write() to handle the line
+ // breaks for all cases (bug 1993406).
+ Write(textstr);
+ return rv;
+ }
+
// We have to split the string across newlines
// to match parser behavior
int32_t start = 0;
@@ -1524,6 +1531,7 @@ void nsPlainTextSerializer::ConvertToLinesAndOutput(const nsAString& aString) {
// Done searching
nsAutoString stringpart;
bool outputLineBreak = false;
+ bool isNewLineCRLF = false;
if (newline == done_searching) {
// No new lines.
stringpart.Assign(Substring(bol, newline));
@@ -1537,12 +1545,12 @@ void nsPlainTextSerializer::ConvertToLinesAndOutput(const nsAString& aString) {
stringpart.Assign(Substring(bol, newline));
mInWhitespace = true;
outputLineBreak = true;
- mEmptyLines = 0;
if ('\r' == *iter++ && '\n' == *iter) {
// There was a CRLF in the input. This used to be illegal and
// stripped by the parser. Apparently not anymore. Let's skip
// over the LF.
- ++iter;
+ newline = iter++;
+ isNewLineCRLF = true;
}
}
@@ -1561,7 +1569,26 @@ void nsPlainTextSerializer::ConvertToLinesAndOutput(const nsAString& aString) {
mOutputManager->Append(mCurrentLine,
OutputManager::StripTrailingWhitespaces::kNo);
if (outputLineBreak) {
- mOutputManager->AppendLineBreak();
+ if (mSettings.HasFlag(
+ nsIDocumentEncoder::OutputForPlainTextClipboardCopy)) {
+ // This is aligned with other browsers that they don't convert CRLF to
+ // the platform line break.
+ if ('\n' == *newline) {
+ mOutputManager->AppendLineBreak(isNewLineCRLF);
+ // If there is preceding text, we are starting a new line, so reset
+ // mEmptyLines. If there is no preceding text, we are outputting
+ // multiple line breaks, so we count them toward mEmptyLines.
+ mEmptyLines = stringpart.IsEmpty() ? mEmptyLines + 1 : 0;
+ } else {
+ mOutputManager->Append(u"\r"_ns);
+ // `\r` isn’t treated as a line break here, so we’re now in the middle
+ // of the line.
+ mEmptyLines = -1;
+ }
+ } else {
+ mOutputManager->AppendLineBreak();
+ mEmptyLines = 0;
+ }
}
mCurrentLine.ResetContentAndIndentationHeader();
@@ -1664,10 +1691,14 @@ void nsPlainTextSerializer::Write(const nsAString& aStr) {
continue;
}
- if (nextpos == bol) {
+ if (nextpos == bol &&
+ !mSettings.HasFlag(
+ nsIDocumentEncoder::OutputForPlainTextClipboardCopy)) {
// Note that we are in whitespace.
mInWhitespace = true;
offsetIntoBuffer = str.get() + nextpos;
+ // XXX Why do we need to keep the very first character when compressing
+ // the reset?
AddToLine(offsetIntoBuffer, 1);
bol++;
continue;
diff --git a/dom/serializers/nsPlainTextSerializer.h b/dom/serializers/nsPlainTextSerializer.h
@@ -283,7 +283,12 @@ class nsPlainTextSerializer final : public nsIContentSerializer {
void Append(const CurrentLine& aCurrentLine,
StripTrailingWhitespaces aStripTrailingWhitespaces);
- void AppendLineBreak();
+ /**
+ * @param aString Last character is expected to not be a line break.
+ */
+ void Append(const nsAString& aString);
+
+ void AppendLineBreak(bool aForceCRLF = false);
/**
* This empties the current line cache without adding a NEWLINE.
@@ -300,11 +305,6 @@ class nsPlainTextSerializer final : public nsIContentSerializer {
uint32_t GetOutputLength() const;
private:
- /**
- * @param aString Last character is expected to not be a line break.
- */
- void Append(const nsAString& aString);
-
// As defined in nsIDocumentEncoder.idl.
const int32_t mFlags;
diff --git a/dom/serializers/tests/mochitest/test_plaintext_linebreak.html b/dom/serializers/tests/mochitest/test_plaintext_linebreak.html
@@ -40,14 +40,18 @@ function convertLineBreaksForTestResult(aText) {
return aText.replace(/\r?\n|\r(?!\n)/g, platformLineBreak);
}
-function selectAndEncode(aElement) {
+function convertLineBreaksForClipboardTestResult(aText) {
+ return aText.replace(/(?<!\r)\n/g, platformLineBreak);
+}
+
+function selectAndEncode(aElement, aEncoderFlags = 0) {
// Select the element.
const selection = window.getSelection();
selection.removeAllRanges();
selection.selectAllChildren(aElement);
const encoder = SpecialPowers.Cu.createHTMLCopyEncoder();
- encoder.init(document, "text/plain", de.OutputSelectionOnly);
+ encoder.init(document, "text/plain", de.OutputSelectionOnly | aEncoderFlags);
encoder.setSelection(selection);
return encoder.encodeToString();
}
@@ -63,6 +67,8 @@ CASES.forEach((lineBreaks) => {
is(selectAndEncode(pre), text.replace(/\r|\n/g, platformLineBreak),
`Encoded data for line breaks: ${JSON.stringify(lineBreaks)}`);
+ is(selectAndEncode(pre, de.OutputForPlainTextClipboardCopy), convertLineBreaksForClipboardTestResult(text),
+ `Encoded data for line breaks: ${JSON.stringify(lineBreaks)} (OutputForPlainTextClipboardCopy)`);
});
add_task(async function test_pre_img_alt() {
@@ -75,6 +81,8 @@ CASES.forEach((lineBreaks) => {
is(selectAndEncode(pre), convertLineBreaksForTestResult(text),
`Encoded data for line breaks: ${JSON.stringify(lineBreaks)}`);
+ is(selectAndEncode(pre, de.OutputForPlainTextClipboardCopy), convertLineBreaksForClipboardTestResult(text),
+ `Encoded data for line breaks: ${JSON.stringify(lineBreaks)} (OutputForPlainTextClipboardCopy)`);
});
add_task(async function test_pre_img_title() {
@@ -87,6 +95,8 @@ CASES.forEach((lineBreaks) => {
is(selectAndEncode(pre), ` [${convertLineBreaksForTestResult(text)}] `,
`Encoded data for line breaks: ${JSON.stringify(lineBreaks)}`);
+ is(selectAndEncode(pre, de.OutputForPlainTextClipboardCopy), ` [${convertLineBreaksForClipboardTestResult(text)}] `,
+ `Encoded data for line breaks: ${JSON.stringify(lineBreaks)} (OutputForPlainTextClipboardCopy)`);
});
add_task(async function test_div_text() {
@@ -97,6 +107,8 @@ CASES.forEach((lineBreaks) => {
is(selectAndEncode(div), ` First Second `,
`Encoded data for line breaks: ${JSON.stringify(lineBreaks)}`);
+ is(selectAndEncode(div, de.OutputForPlainTextClipboardCopy), ` First Second `,
+ `Encoded data for line breaks: ${JSON.stringify(lineBreaks)} (OutputForPlainTextClipboardCopy)`);
});
add_task(async function test_div_img_alt() {
@@ -110,6 +122,8 @@ CASES.forEach((lineBreaks) => {
// Our plain text serializer keep the first CR/LF.
is(selectAndEncode(div), `${lineBreaks[0]}First Second `,
`Encoded data for line breaks: ${JSON.stringify(lineBreaks)}`);
+ is(selectAndEncode(div, de.OutputForPlainTextClipboardCopy), ` First Second `,
+ `Encoded data for line breaks: ${JSON.stringify(lineBreaks)} (OutputForPlainTextClipboardCopy)`);
});
add_task(async function test_div_img_title() {
@@ -122,6 +136,8 @@ CASES.forEach((lineBreaks) => {
is(selectAndEncode(div), ` [ First Second ] `,
`Encoded data for line breaks: ${JSON.stringify(lineBreaks)}`);
+ is(selectAndEncode(div, de.OutputForPlainTextClipboardCopy), ` [ First Second ] `,
+ `Encoded data for line breaks: ${JSON.stringify(lineBreaks)} (OutputForPlainTextClipboardCopy)`);
});
});
diff --git a/dom/serializers/tests/mochitest/test_plaintext_linebreak_compress.html b/dom/serializers/tests/mochitest/test_plaintext_linebreak_compress.html
@@ -66,14 +66,14 @@ const TESTS = [
expectedResult: `First ${platformLineBreak}${platformLineBreak}Second`},
];
-function selectAndEncode(aElement) {
+function selectAndEncode(aElement, aEncoderFlags = 0) {
// Select the element.
const selection = window.getSelection();
selection.removeAllRanges();
selection.selectAllChildren(aElement);
const encoder = SpecialPowers.Cu.createHTMLCopyEncoder();
- encoder.init(document, "text/plain", de.OutputSelectionOnly);
+ encoder.init(document, "text/plain", de.OutputSelectionOnly | aEncoderFlags);
encoder.setSelection(selection);
return encoder.encodeToString();
}
@@ -88,6 +88,8 @@ TESTS.forEach((test) => {
is(selectAndEncode(div), test.expectedResult,
`Encoded data for ${JSON.stringify(innerHTML)}`);
+ is(selectAndEncode(div, de.OutputForPlainTextClipboardCopy), test.expectedResult,
+ `Encoded data for ${JSON.stringify(innerHTML)}`);
});
});
});