Чистим HTML-код при вставке текста из MS Word в HTML5 WYSIWYG редактор (contenteditable)

    Здравствуйте!

    При написании своего WYSIWYG редактора возникла проблема копирования текста из Ворда. Собственно проблем три:
    • Ворд вставляет много мусорного html кода, который необходимо чистить
    • Для представления списков Ворд почему-то использует параграфы вместо тегов UL и LI
    • Собственно как определить, что вставленный текст является вставленным из Ворда.

    В общем, для решения этих проблем, был написан jquery-плагин, полный исходный код которого доступен в конце статьи. Пример использования:

    $(‘#editor’). msword_html_filter();

    Плагин вешается на событие keyup и проверяет, является ли исходный код внутри редактора вставленным из Ворда, если да, то запускается функция очистки. В результирующем html прибивается все что только можно – неразрывные пробелы, атрибуты style и align, теги span, все Mso-классы, пустые параграфы.

    Детали реализации под катом.

    UPD Демо на CodePen



    Большинство используемых регулярок были подсмотрены у TinyMCE.

    Как определить, есть ли в строке html-код вставленный из Ворда:

    
    if (/class="?Mso|style="[^"]*\bmso-|style='[^'']*\bmso-|w:WordDocument/i.test( content )) {
        ...
    }
    


    Функция чистки кода (в функцию передается jquery объект редактора):

    
    function word_filter(editor){
                var content = editor.html();
    
                // Word comments like conditional comments etc
                content = content.replace(/<!--[\s\S]+?-->/gi, '');
    
                // Remove comments, scripts (e.g., msoShowComment), XML tag, VML content,
                // MS Office namespaced tags, and a few other tags
                content = content.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi, '');
    
                // Convert <s> into <strike> for line-though
                content = content.replace(/<(\/?)s>/gi, "<$1strike>");
    
                // Replace nbsp entites to char since it's easier to handle
                //content = content.replace(/ /gi, "\u00a0");
                content = content.replace(/ /gi, ' ');
    
                // Convert <span style="mso-spacerun:yes">___</span> to string of alternating
                // breaking/non-breaking spaces of same length
                content = content.replace(/<span\s+style\s*=\s*"\s*mso-spacerun\s*:\s*yes\s*;?\s*"\s*>([\s\u00a0]*)<\/span>/gi, function(str, spaces) {
                    return (spaces.length > 0) ? spaces.replace(/./, " ").slice(Math.floor(spaces.length/2)).split("").join("\u00a0") : '';
                });
    
                editor.html(content);
    
                // Parse out list indent level for lists
                $('p', editor).each(function(){
                    var str = $(this).attr('style');
                    var matches = /mso-list:\w+ \w+([0-9]+)/.exec(str);
                    if (matches) {
                        $(this).data('_listLevel',  parseInt(matches[1], 10));
                    }
                });
    
                // Parse Lists
                var last_level=0;
                var pnt = null;
                $('p', editor).each(function(){
                    var cur_level = $(this).data('_listLevel');
                    if(cur_level != undefined){
                        var txt = $(this).text();
                        var list_tag = '<ul></ul>';
                        if (/^\s*\w+\./.test(txt)) {
                            var matches = /([0-9])\./.exec(txt);
                            if (matches) {
                                var start = parseInt(matches[1], 10);
                                list_tag = start>1 ? '<ol start="' + start + '"></ol>' : '<ol></ol>';
                            }else{
                                list_tag = '<ol></ol>';
                            }
                        }
    
                        if(cur_level>last_level){
                            if(last_level==0){
                                $(this).before(list_tag);
                                pnt = $(this).prev();
                            }else{
                                pnt = $(list_tag).appendTo(pnt);
                            }
                        }
                        if(cur_level<last_level){
                            for(var i=0; i<last_level-cur_level; i++){
                                pnt = pnt.parent();
                            }
                        }
                        $('span:first', this).remove();
                        pnt.append('<li>' + $(this).html() + '</li>')
                        $(this).remove();
                        last_level = cur_level;
                    }else{
                        last_level = 0;
                    }
                })
    
                $('[style]', editor).removeAttr('style');
                $('[align]', editor).removeAttr('align');
                $('span', editor).replaceWith(function() {return $(this).contents();});
                $('span:empty', editor).remove();
                $("[class^='Mso']", editor).removeAttr('class');
                $('p:empty', editor).remove();
            }
    


    Полный исходный текст плагина под спойлером, сохранять в файл jquery.msword_html_filter.js

    исходный текст плагина
    
    (function($) {
        $.fn.msword_html_filter = function(options) {
            var settings = $.extend( {}, options);
    
            function word_filter(editor){
                var content = editor.html();
    
                // Word comments like conditional comments etc
                content = content.replace(/<!--[\s\S]+?-->/gi, '');
    
                // Remove comments, scripts (e.g., msoShowComment), XML tag, VML content,
                // MS Office namespaced tags, and a few other tags
                content = content.replace(/<(!|script[^>]*>.*?<\/script(?=[>\s])|\/?(\?xml(:\w+)?|img|meta|link|style|\w:\w+)(?=[\s\/>]))[^>]*>/gi, '');
    
                // Convert <s> into <strike> for line-though
                content = content.replace(/<(\/?)s>/gi, "<$1strike>");
    
                // Replace nbsp entites to char since it's easier to handle
                //content = content.replace(/ /gi, "\u00a0");
                content = content.replace(/ /gi, ' ');
    
                // Convert <span style="mso-spacerun:yes">___</span> to string of alternating
                // breaking/non-breaking spaces of same length
                content = content.replace(/<span\s+style\s*=\s*"\s*mso-spacerun\s*:\s*yes\s*;?\s*"\s*>([\s\u00a0]*)<\/span>/gi, function(str, spaces) {
                    return (spaces.length > 0) ? spaces.replace(/./, " ").slice(Math.floor(spaces.length/2)).split("").join("\u00a0") : '';
                });
    
                editor.html(content);
    
                // Parse out list indent level for lists
                $('p', editor).each(function(){
                    var str = $(this).attr('style');
                    var matches = /mso-list:\w+ \w+([0-9]+)/.exec(str);
                    if (matches) {
                        $(this).data('_listLevel',  parseInt(matches[1], 10));
                    }
                });
    
                // Parse Lists
                var last_level=0;
                var pnt = null;
                $('p', editor).each(function(){
                    var cur_level = $(this).data('_listLevel');
                    if(cur_level != undefined){
                        var txt = $(this).text();
                        var list_tag = '<ul></ul>';
                        if (/^\s*\w+\./.test(txt)) {
                            var matches = /([0-9])\./.exec(txt);
                            if (matches) {
                                var start = parseInt(matches[1], 10);
                                list_tag = start>1 ? '<ol start="' + start + '"></ol>' : '<ol></ol>';
                            }else{
                                list_tag = '<ol></ol>';
                            }
                        }
    
                        if(cur_level>last_level){
                            if(last_level==0){
                                $(this).before(list_tag);
                                pnt = $(this).prev();
                            }else{
                                pnt = $(list_tag).appendTo(pnt);
                            }
                        }
                        if(cur_level<last_level){
                            for(var i=0; i<last_level-cur_level; i++){
                                pnt = pnt.parent();
                            }
                        }
                        $('span:first', this).remove();
                        pnt.append('<li>' + $(this).html() + '</li>')
                        $(this).remove();
                        last_level = cur_level;
                    }else{
                        last_level = 0;
                    }
                })
    
                $('[style]', editor).removeAttr('style');
                $('[align]', editor).removeAttr('align');
                $('span', editor).replaceWith(function() {return $(this).contents();});
                $('span:empty', editor).remove();
                $("[class^='Mso']", editor).removeAttr('class');
                $('p:empty', editor).remove();
            }
    
            return this.each(function() {
                $(this).on('keyup', function(){
                    var content = $(this).html();
                    if (/class="?Mso|style="[^"]*\bmso-|style='[^'']*\bmso-|w:WordDocument/i.test( content )) {
                        word_filter( $(this) );
                    }
                });
            });
        };
    })( jQuery )
    


    Работоспособность проверялась только в последнем Фаерфоксе.
    Поделиться публикацией

    Похожие публикации

    Комментарии 18
      0
      Супер, как раз то что искал. А можно для простых пользователей сделать ссылку на рабочую страничку с wysiwyg для теста?
        0
        Добавил в статью рабочий пример на CodePen: codepen.io/anon/pen/hFAdk
          0
          нумерованные списки нумеруются два раза
          <ol><li>1.тест</li><li>2.тест2</li></ol>
            0
            Нет, должно быть все нормально.

            Word 2007, FireFox 24

              0
              Хроме нумерация двойная, в ФФ и Опере (12) — нормально.
        +2
        А чем $.htmlClean не угодил?
          0
          Спасибо за ссылку. Очень долго искал готовое решение в интернете. Но простого и удобного плагина именно для Ворда не нашел.

          Насколько я понял $.htmlClean не парсит списки, а это огромная проблема. У нас есть один пользователь, который использует Ворд вместо Mind map, а это списки списков списков. И при копировании они все превращались в параграфы.
            0
            Ещё есть Sanitize.js
            0
            Этот заточен на Word — отрезается то что не нужно из коробки.
              +2
              Вот несколько сервисов на эту тему, вдруг кому пригодится:
              word2cleanhtml.com (Есть API, но доступ к нему дают только по запросу)
              wordhtmlcleaner.co.uk
                0
                Есть где-нибудь рабочий пример, потестировать вживую?
                0
                Пользуясь случаем, задам вопрос: а нет аналогичной чистилки на PHP?
                0
                Получилось побороть «скачущий» курсор?

                зы: код из тини убивает разметку внутри списков
                ззы: первый десяток строк кода-зачистки можно сделать прям в dom, не перегоняя в строку — будет быстрее

                  0
                  Добавлю свою версию чистильщика.
                    0
                    Свои 5 копек
                      0
                      Кстати, для TinyMCE есть родной плагин paste.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое