Содержимое буфера обмена перепутано при копировании из Firefox и чтения с использованием Java в Ubuntu

Фон

Я пытаюсь получить данные буфера обмена в контексте HTML-данных, используя Java. Таким образом, я копирую их в буфер обмена из браузеров. Затем я использую java.awt.datatransfer.Clipboard, чтобы получить их.

Это правильно работает в системах Windows. Но в Ubuntu есть некоторые странные проблемы. Худшим является копирование данных в буфер обмена из браузера Firefox.

Пример для воспроизведения поведения

Код Java:

import java.io.*;

import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;

public class WorkingWithClipboadData {

 static void doSomethingWithBytesFromClipboard(byte[] dataBytes, String paramCharset, int number) throws Exception {

  String fileName = "Result " + number + " " + paramCharset + ".txt";

  OutputStream fileOut = new FileOutputStream(fileName);
  fileOut.write(dataBytes, 0, dataBytes.length);
  fileOut.close();

 }

 public static void main(String[] args) throws Exception {

  Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();

  int count = 0;

  for (DataFlavor dataFlavor : clipboard.getAvailableDataFlavors()) {

System.out.println(dataFlavor);

   String mimeType = dataFlavor.getHumanPresentableName();
   if ("text/html".equalsIgnoreCase(mimeType)) {
    String paramClass = dataFlavor.getParameter("class");
    if ("java.io.InputStream".equals(paramClass)) {
     String paramCharset = dataFlavor.getParameter("charset");
     if (paramCharset != null  && paramCharset.startsWith("UTF")) {

System.out.println("============================================");
System.out.println(paramCharset);
System.out.println("============================================");

      InputStream inputStream = (InputStream)clipboard.getData(dataFlavor);

      ByteArrayOutputStream data = new ByteArrayOutputStream();

      byte[] buffer = new byte[1024];
      int length = -1;
      while ((length = inputStream.read(buffer)) != -1) {
       data.write(buffer, 0, length);
      }
      data.flush();
      inputStream.close();

      byte[] dataBytes = data.toByteArray();
      data.close();

      doSomethingWithBytesFromClipboard(dataBytes, paramCharset, ++count);

     }
    }
   }
  }
 }

}

Описание проблемы

Я делаю это, открывая URL https://en.wikipedia.org/wiki/Germanic_umlaut в Firefox. Затем я выбираю "буквы: ä" и копирую их в буфер обмена. Затем я запускаю свою программу на Java. После этого результирующие файлы (только некоторые из них в качестве примеров) выглядят следующим образом:

[email protected]r:~/Dokumente/JAVA/poi/poi-3.17$ xxd "./Result 1 UTF-16.txt" 
00000000: feff fffd fffd 006c 0000 0065 0000 0074  .......l...e...t
00000010: 0000 0074 0000 0065 0000 0072 0000 0073  ...t...e...r...s
00000020: 0000 003a 0000 0020 0000 003c 0000 0069  ...:... ...<...i
00000030: 0000 003e 0000 fffd 0000 003c 0000 002f  ...>.......<.../
00000040: 0000 0069 0000 003e 0000                 ...i...>..

OK, FEFF в начале выглядит как байт-знак UTF-16BE. Но что такое FFFD? И почему существуют эти 0000 байт между единственными буквами? UTF-16 кодирование l только 006C. Кажется, что все буквы кодируются в 32 бит. Но это неправильно для UTF-16. И все символы без ASCII закодированы с FFFD 0000 и поэтому теряются.

[email protected]:~/Dokumente/JAVA/poi/poi-3.17$ xxd "./Result 4 UTF-8.txt" 
00000000: efbf bdef bfbd 6c00 6500 7400 7400 6500  ......l.e.t.t.e.
00000010: 7200 7300 3a00 2000 3c00 6900 3e00 efbf  r.s.:. .<.i.>...
00000020: bd00 3c00 2f00 6900 3e00                 ..<./.i.>.

Здесь EFBF BDEF BFBD не похож на любую известную байтовую марку. И все буквы кажутся закодированными в 16 бит, что является двойным количеством необходимых бит в UTF-8. Таким образом, используемые биты всегда являются двойным счетом по мере необходимости. См. Пример выше в UTF-16. И все буквы ASCII кодируются как EFBFBD и поэтому также теряются.

[email protected]:~/Dokumente/JAVA/poi/poi-3.17$ xxd "./Result 7 UTF-16BE.txt" 
00000000: fffd fffd 006c 0000 0065 0000 0074 0000  .....l...e...t..
00000010: 0074 0000 0065 0000 0072 0000 0073 0000  .t...e...r...s..
00000020: 003a 0000 0020 0000 003c 0000 0069 0000  .:... ...<...i..
00000030: 003e 0000 fffd 0000 003c 0000 002f 0000  .>.......<.../..
00000040: 0069 0000 003e 0000                      .i...>..

Такое же изображение, как в приведенных выше примерах. Все буквы кодируются с использованием 32 бит. В UTF-16 должно использоваться только 16 бит, за исключением дополнительных символов, которые используют суррогатные пары. И все буквы ASCII кодируются FFFD 0000 и поэтому теряются.

[email protected]:~/Dokumente/JAVA/poi/poi-3.17$ xxd "./Result 10 UTF-16LE.txt" 
00000000: fdff fdff 6c00 0000 6500 0000 7400 0000  ....l...e...t...
00000010: 7400 0000 6500 0000 7200 0000 7300 0000  t...e...r...s...
00000020: 3a00 0000 2000 0000 3c00 0000 6900 0000  :... ...<...i...
00000030: 3e00 0000 fdff 0000 3c00 0000 2f00 0000  >.......<.../...
00000040: 6900 0000 3e00 0000                      i...>...

Только для того, чтобы быть полным. Такое же изображение, как указано выше.

Таким образом, вывод состоит в том, что буфер обмена Ubuntu полностью перепутался после копирования чего-либо в него из Firefox. По крайней мере, для HTML-данных, а также при чтении буфера обмена с использованием Java.

Другой используемый браузер

Когда я делаю то же самое, используя браузер Chromium в качестве источника данных, проблемы становятся меньше.

Поэтому я открываю URL https://en.wikipedia.org/wiki/Germanic_umlaut в Chromium. Затем я выбираю "буквы: ä" и копирую их в буфер обмена. Затем я запускаю свою программу на Java.

Результат выглядит так:

[email protected]:~/Dokumente/JAVA/poi/poi-3.17$ xxd "./Result 1 UTF-16.txt" 
00000000: feff 003c 006d 0065 0074 0061 0020 0068  ...<.m.e.t.a. .h
...
00000800: 0061 006c 003b 0022 003e 00e4 003c 002f  .a.l.;.".>...<./
00000810: 0069 003e 0000                           .i.>..

Chromium имеет больше HTML вокруг выбранных в HTML-данных вкусов в буфере обмена. Но кодировка выглядит правильно. Также для не ASCII ä= 00E4. Но есть и небольшая проблема: в конце есть дополнительные байты 0000 которых не должно быть. В UTF-16 есть 2 дополнительных 00 байта в конце.

[email protected]:~/Dokumente/JAVA/poi/poi-3.17$ xxd "./Result 4 UTF-8.txt" 
00000000: 3c6d 6574 6120 6874 7470 2d65 7175 6976  <meta http-equiv
...
000003f0: 696f 6e2d 636f 6c6f 723a 2069 6e69 7469  ion-color: initi
00000400: 616c 3b22 3ec3 a43c 2f69 3e00            al;">..</i>.

То же, что и выше. Кодировка выглядит правильно для UTF-8. Но здесь также есть один дополнительные 00 байт в конце, который не должен быть там.

Среда

DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04.4 LTS"


Mozilla Firefox 61.0.1 (64-Bit)


java version "1.8.0_101"
Java(TM) SE Runtime Environment (build 1.8.0_101-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.101-b13, mixed mode)

Вопросы

Я что-то делаю в своем коде?

Может ли кто-нибудь посоветовать, как избежать этого испорченного контента в буфере обмена? Поскольку символы ASCII теряются, по крайней мере, при копировании из Firefox, я не думаю, что мы можем восстановить этот контент.

Это как-то известная проблема? Может ли кто-то подтвердить такое же поведение? Если это так, есть ли в Firefox отчет об ошибке?

Или это проблема, которая возникает только в том случае, если код Java читает содержимое буфера обмена? Кажется, как будто. Потому что, если я скопирую контент из Firefox и вставлю его в Libreoffice Writer, тогда Unicode появится правильно. И если я затем копирую контент из Writer в буфер обмена и читаю его с помощью своей Java-программы, то UTF верны, за исключением дополнительных 00 байтов в конце. Таким образом, содержимое буфера обмена, скопированное из Writer, ведет себя как контент, скопированный из браузера Chromium.


Новые идеи

Байты 0xFFFD кажутся символами Unicode "ЗАМЕНА ХАРАКТЕРА" (U + FFFD). Таким образом, 0xFDFF - это малое 0xFDFF представление этого, а 0xEFBFBD - кодировка UTF-8. Таким образом, все результаты, как представляется, являются результатом неправильного декодирования и повторного кодирования Unicode.

Похоже, что содержимое буфера обмена, поступающее из Firefox, всегда есть UTF-16LE с BOM. Но тогда Java получает это как UTF-8. Таким образом, 2-байтовая спецификация становится двумя перепутанными символами, которые заменяются 0xEFBFBD, каждая дополнительная последовательность 0x00 становится их собственными символами NUL а все последовательности байтов, которые не являются правильными байтовыми последовательностями UTF-8, становятся испорченными символами, которые заменяются 0xEFBFBD. Затем этот псевдо UTF-8 будет закодирован. Теперь мусор завершен.

Пример:

Последовательность aɛaüa в UTF-16LE с спецификацией будет 0xFFFE 6100 5B02 6100 FC00 6100.

Это принято как UTF-8 (0xEFBFBD = не правильная последовательность байтов UTF-8) = 0xEFBFBD 0xEFBFBD a NUL [ STX a NUL 0xEFBFBD NUL a NUL.

Этот псевдо-ASCII- 0xFDFF FDFF 6100 0000 5B00 0200 6100 0000 FDFF 0000 6100 0000 кодированный в UTF-16LE, будет: 0xFDFF FDFF 6100 0000 5B00 0200 6100 0000 FDFF 0000 6100 0000

Этот псевдо-ASCII- 0xEFBF BDEF BFBD 6100 5B02 6100 EFBF BD00 6100 закодированный в UTF-8, будет 0xEFBF BDEF BFBD 6100 5B02 6100 EFBF BD00 6100

И это именно то, что происходит.

Другие примеры:

Â= 0x00C2 = C200 в UTF-16LE = 0xEFBFBD00 в псевдо UTF-8

= 0x80C2 = C280 в UTF-16LE = 0xC280 в псевдо UTF-8

Поэтому я думаю, что Firefox не виноват в этом, кроме среды Ubuntu или Java. И поскольку копирование/вставка из Firefox в Writer работает в Ubuntu, я думаю, что среда выполнения Java не обрабатывает флаги данных Firefox в буфере обмена Ubuntu правильно.


Новые идеи:

Я сравнивал файлы flavormap.properties с моей Windows 10 и моей Ubuntu и есть разница. В Ubuntu собственное имя text/html - UTF8_STRING а в Windows - HTML Format. Поэтому я подумал, что это может быть проблемой. Поэтому я добавил строку

HTML\ Format=text/html;charset=utf-8;eoln="\n";terminators=0

в мой файл flavormap.properties в Ubuntu.

После этого:

Map<DataFlavor,String> nativesForFlavors = SystemFlavorMap.getDefaultFlavorMap().getNativesForFlavors(
   new DataFlavor[]{
   new DataFlavor("text/html;charset=UTF-16LE")
   });

System.out.println(nativesForFlavors);

печать

{java.awt.datatransfer.DataFlavor[mimetype=text/html;representationclass=java.io.InputStream;charset=UTF-16LE]=HTML Format}

Но никаких изменений в результатах содержимого буфера обмена Ubuntu при чтении Java.

Ответ 1

Посмотрев на это довольно немного, похоже, что это давняя ошибка с Java (даже более старый отчет здесь).

Похоже, что компоненты X11 Java ожидают, что данные буфера обмена всегда будут кодироваться в кодировке UTF-8, а Firefox кодирует данные с помощью UTF-16. Из-за предположений Java делает это искажает текст, заставляя синтаксический разбор UTF-16 как UTF-8. Я попытался, но не смог найти хороший способ обойти эту проблему. "Текстовая" часть "text/html", по-видимому, указывает на Java, что байты, полученные из буфера обмена, всегда должны интерпретироваться как текст сначала, а затем предлагаться в различных вариантах. Я не мог найти прямой способ доступа к предварительно преобразованному массиву байтов из X11.

Ответ 2

Поскольку до сих пор нет важного ответа, нам кажется, что нам нужно уродливое обходное решение для работы с системным буфером обмена Ubuntu с использованием Java. Очень жаль. O tempora, o нравы. Мы живем во времена, когда Windows лучше использует кодировку Unicode, чем Ubuntu Linux.

То, что мы знаем, уже указано в ответе. Таким образом, у нас есть правильный закодированный text/plain результат, но результат испорченного text/html. И мы знаем, как результат text/html испорчен.

Так что мы могли бы сделать, это "восстановить" неправильный кодированный HTML, сначала заменив все испорченные символы правильными символами замены. Затем мы можем заменить заменяющие символы правильными символами, полученными из правильного закодированного простого текста. Конечно, это можно сделать только для части HTML, которая является видимым текстом, а не внутри атрибутов. Поскольку содержимое атрибута, конечно, не входит в обычный текст.

Временное решение:

import java.io.*;

import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;

import java.nio.charset.Charset;

public class WorkingWithClipboadDataBytesUTF8 {

 static byte[] repairUTF8HTMLDataBytes(byte[] plainDataBytes, byte[] htmlDataBytes) throws Exception {

  //get all the not ASCII characters from plainDataBytes
  //we need them for replacement later
  String plain = new String(plainDataBytes, Charset.forName("UTF-8"));
  char[] chars = plain.toCharArray();
  StringBuffer unicodeChars = new StringBuffer();
  for (int i = 0; i < chars.length; i++) {
   if (chars[i] > 127) unicodeChars.append(chars[i]);
  }
System.out.println(unicodeChars);

  //ommit the first 6 bytes from htmlDataBytes which are the wrong BOM
  htmlDataBytes = java.util.Arrays.copyOfRange(htmlDataBytes, 6, htmlDataBytes.length);

  //The wrong UTF-8 encoded single bytes which are not replaced by '0xefbfbd' 
  //are coincidentally UTF-16LE if two bytes immediately following each other.
  //So we are "repairing" this accordingly. 
  //Goal: all garbage shall be the replacement character 0xFFFD.

  //replace parts of a surrogate pair with 0xFFFD
  //replace the wrong UFT-8 bytes 0xefbfbd for replacement character with 0xFFFD
  ByteArrayInputStream in = new ByteArrayInputStream(htmlDataBytes);
  ByteArrayOutputStream out = new ByteArrayOutputStream();
  int b = -1;
  int[] btmp = new int[6];
  while ((b = in.read()) != -1) {
   btmp[0] = b;
   btmp[1] = in.read(); //there must always be two bytes because of wron encoding 16 bit Unicode
   if (btmp[0] != 0xef && btmp[1] != 0xef) { // not a replacement character
    if (btmp[1] > 0xd7 && btmp[1] < 0xe0) { // part of a surrogate pair
     out.write(0xFD); out.write(0xFF);
    } else {
     out.write(btmp[0]); out.write(btmp[1]); //two default bytes
    }
   } else { // at least one must be the replacelement 0xefbfbd
    btmp[2] = in.read(); btmp[3] = in.read(); //there must be at least two further bytes
    if (btmp[0] != 0xef && btmp[1] == 0xef && btmp[2] == 0xbf && btmp[3] == 0xbd ||
        btmp[0] == 0xef && btmp[1] == 0xbf && btmp[2] == 0xbd && btmp[3] != 0xef) {
     out.write(0xFD); out.write(0xFF);
    } else if (btmp[0] == 0xef && btmp[1] == 0xbf && btmp[2] == 0xbd && btmp[3] == 0xef) {
     btmp[4] = in.read(); btmp[5] = in.read();
     if (btmp[4] == 0xbf &&  btmp[5] == 0xbd) {
      out.write(0xFD); out.write(0xFF);
     } else {
      throw new Exception("Wrong byte sequence: "
      + String.format("%02X%02X%02X%02X%02X%02X", btmp[0], btmp[1], btmp[2], btmp[3], btmp[4], btmp[5]), 
      new Throwable().fillInStackTrace());
     }
    } else {
     throw new Exception("Wrong byte sequence: " 
      + String.format("%02X%02X%02X%02X%02X%02X", btmp[0], btmp[1], btmp[2], btmp[3], btmp[4], btmp[5]),
      new Throwable().fillInStackTrace());
    }
   }
  }

  htmlDataBytes = out.toByteArray();

  //now get this as UTF_16LE (2 byte for each character, little endian)
  String html = new String(htmlDataBytes, Charset.forName("UTF-16LE"));
System.out.println(html);

  //replace all of the wrongUnicode with the unicodeChars selected from plainDataBytes
  boolean insideTag = false;
  int unicodeCharCount = 0;
  char[] textChars = html.toCharArray();
  StringBuffer newHTML = new StringBuffer();
  for (int i = 0; i < textChars.length; i++) {
   if (textChars[i] == '<') insideTag = true;
   if (textChars[i] == '>') insideTag = false;
   if (!insideTag && textChars[i] > 127) {
    if (unicodeCharCount >= unicodeChars.length()) 
     throw new Exception("Unicode chars count don't match. " 
      + "We got from plain text " + unicodeChars.length() + " chars. Text until now:\n" + newHTML,
      new Throwable().fillInStackTrace());

    newHTML.append(unicodeChars.charAt(unicodeCharCount++));
   } else {
    newHTML.append(textChars[i]);
   }
  }

  html = newHTML.toString();
System.out.println(html);

  return html.getBytes("UTF-8");

 }

 static void doSomethingWithUTF8BytesFromClipboard(byte[] plainDataBytes, byte[] htmlDataBytes) throws Exception {

  if (plainDataBytes != null && htmlDataBytes != null) {

   String fileName; 
   OutputStream fileOut;

   fileName = "ResultPlainText.txt";
   fileOut = new FileOutputStream(fileName);
   fileOut.write(plainDataBytes, 0, plainDataBytes.length);
   fileOut.close();

   fileName = "ResultHTMLRaw.txt";
   fileOut = new FileOutputStream(fileName);
   fileOut.write(htmlDataBytes, 0, htmlDataBytes.length);
   fileOut.close();

   //do we have wrong encoded UTF-8 in htmlDataBytes?
   if (htmlDataBytes[0] == (byte)0xef && htmlDataBytes[1] == (byte)0xbf && htmlDataBytes[2] == (byte)0xbd 
    && htmlDataBytes[3] == (byte)0xef && htmlDataBytes[4] == (byte)0xbf && htmlDataBytes[5] == (byte)0xbd) {
    //try repair the UTF-8 HTML data bytes
    htmlDataBytes = repairUTF8HTMLDataBytes(plainDataBytes, htmlDataBytes);
          //do we have additional 0x00 byte at the end?
   } else if (htmlDataBytes[htmlDataBytes.length-1] == (byte)0x00) {
    //do repair this
    htmlDataBytes = java.util.Arrays.copyOf(htmlDataBytes, htmlDataBytes.length-1);
   }

   fileName = "ResultHTML.txt";
   fileOut = new FileOutputStream(fileName);
   fileOut.write(htmlDataBytes, 0, htmlDataBytes.length);
   fileOut.close();

  }

 }

 public static void main(String[] args) throws Exception {

  Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();

  byte[] htmlDataBytes = null;
  byte[] plainDataBytes = null;

  for (DataFlavor dataFlavor : clipboard.getAvailableDataFlavors()) {

   String mimeType = dataFlavor.getHumanPresentableName();

   if ("text/html".equalsIgnoreCase(mimeType)) {
    String paramClass = dataFlavor.getParameter("class");
    if ("[B".equals(paramClass)) {
     String paramCharset = dataFlavor.getParameter("charset");
     if (paramCharset != null  && "UTF-8".equalsIgnoreCase(paramCharset)) {

      htmlDataBytes = (byte[])clipboard.getData(dataFlavor);

     }
    } //else if("java.io.InputStream".equals(paramClass)) ...

   } else if ("text/plain".equalsIgnoreCase(mimeType)) {
    String paramClass = dataFlavor.getParameter("class");
    if ("[B".equals(paramClass)) {
     String paramCharset = dataFlavor.getParameter("charset");
     if (paramCharset != null  && "UTF-8".equalsIgnoreCase(paramCharset)) {

      plainDataBytes = (byte[])clipboard.getData(dataFlavor);

     }
    } //else if("java.io.InputStream".equals(paramClass)) ...
   }
  }

  doSomethingWithUTF8BytesFromClipboard(plainDataBytes, htmlDataBytes);

 }

}