Микроредакторы

.NET*
На свете существует много разных полезных редакторов, которые помогают авторам статей готовить контент к публикации на веб-ресурсах. Но к сожалению, очень часто бывает что лучшее – враг хорошего, и приходится пользоваться не очень удобными программами которые вместо того чтобы взять и заточить «свой» редактор под нужные цели. Мой подход к этому вопросу как раз заключается в написании своего редактора. В этом посте, я хочу рассказать про то, как и какие фичи я реализовал.

Содержание




Автогенерация контента


Если взять тот же Microsoft Word, то там есть например возможность добавить содержание (table of contents) или ссылке (references/endnotes/footnotes) в текст. Для публикации на веб-ресурсах эти фичи порой нужны, хотя не все порталы поддерживают, например, именованые ссылки. Но тем не менее, две фичи, которые мне удалось добавить в свой редактор – это автогенерация TOC и списка ссылок («заметок»).

Содержание


Список элементов содержания показан чуть выше в этом посте. Его суть – позволить быструю навигацию по элементам поста или статьи. Естественно, этот список автогенерируется используя тэги H1H8, которые он находит в документе. Просто выдирает их, находит самый верхний уровень заголовка (например для Хабра это H3), и делает соответствующие списки с помощью вложенных элементов UL и LI. Это не очень безопасно, но для поиска заголовков используется вот такое простенькое выражение:

var r = new Regex("<h([^<]+)>(.+)</h.>");

Содержание полезно в основном для больших постов, например если вы пишете статью на CodeProject. Кстати замечу, что если заголовков нет, то и содержание не создается. А уровень заголовка содержания (если оно есть) всегда соответствует верхнему уровню заголовка в файле.

Реализация такого списка была непростой задачей. Вот кусочек кода, который иллюстрирует как это сделано:

private static string GenerateToc([NotNull]string text, ConversionOptions options, out int minLevel)
{
  List<HeadingEntry> entries = new List<HeadingEntry>();
  var r = new Regex("<h([^<]+)>(.+)</h.>");
  var matches = r.Matches(text);
  int count = 0;
  foreach (Match m in matches)
  {
    int n;
    if (int.TryParse(m.Groups[1].Value, out n))
    {
      HeadingEntry he = new HeadingEntry(n, m.Groups[2].Value);
      // set tag text if clash
      bool bFound = false;
      foreach (HeadingEntry h in entries)
        if (h.SuggestedTagText == he.SuggestedTagText)
          bFound = true;
      he.TagText = he.SuggestedTagText + (bFound ? (count++).ToString() : string.Empty);
      entries.Add(he);
      // replace only first occurence
      //text = text.Replace(m.Groups[0].Value,
      //                    string.Format("<h{0}><a name=\"{2}\"></a>{1}</h{0}>", n, he.Text, he.TagText));
      int idx = text.IndexOf(m.Groups[0].Value);
      string emptyLink = options.UseImageHeadings ? string.Empty :
        string.Format("<a name=\"{0}\"></a>", he.TagText);
      text = text.Remove(idx, m.Groups[0].Value.Length).Insert(idx,
        string.Format("<h{0}>{2}{1}</h{0}>", n, he.Text, emptyLink));
    }
  }
  minLevel = int.MaxValue;
  if (entries.Count > 0)
  {
    var hb = new HtmlBuilder();
    // all are, essentially, ULs
    int lastLevel = -1;
    foreach (HeadingEntry he in entries)
    {
      // if this level > last, open a new UL
      if (he.Level > lastLevel)
        hb.AppendLine("<ul>").Indent();
      if (he.Level < lastLevel)
      {
        int diff = lastLevel - he.Level;
        while (--diff >= 0)
          hb.Unindent().AppendLine("</ul>");
      }
      hb.AppendLine(string.Format("<li><a href=\"#{0}\">{1}</a></li>", he.TagText, he.Text));
      minLevel = Math.Min(minLevel, he.Level);
      lastLevel = he.Level;
    }
    // close out all indent levels
    while (hb.IndentLevel > 0)
      hb.Unindent().AppendLine("</ul>");
    if (!string.IsNullOrEmpty(options.TocLabel))
      hb.Insert(string.Format("<h{0}>{1}</h{0}>{2}", minLevel,
        HttpUtility.HtmlEncode(options.TocLabel), Environment.NewLine), 0);
    // at this point, hb contains the TOC, so
    if (!string.IsNullOrEmpty(options.TocAfterToken))
    {
      int idx = text.IndexOf(options.TocAfterToken);
      if (idx >= 0)
        return text.Substring(0, idx + options.TocAfterToken.Length) +
               hb + text.Substring(idx + options.TocAfterToken.Length);
    }
    hb.Append(text);
    return hb.ToString();
  }
  return text;
}

Заметки


Заметки, или ссылки, это дополнительные, незначительные мысли, которые фигурируют после самой статьи. Я предпочитаю добавлять их в код в квадратных скобках, [вот так]. Каждой сноске присваивается цифра[1] в порядке встречи в файле, а потом они аккуратно группируются в конце документа.

К сожалению, заметки не вытащить regexp’ами – ведь у вас могу быть просто квадратные скобки, например в секции PRE. Поэтому «отлов» квадратных скобок реален только в процессе обхода файла (по букве) трансформатором. Да-да, это тот же трансформатор, что делает красивые прочерки и кавычки.

Текст как картинки


Иногда нужно продемонстрировать какой-то шрифт, иногда хочется что-то написать что не будет поисковиком индексировано, иногда просто хочется защитить свой контент от плагиата. Поэтому, иметь возможность вывести текст как графику – это полезно. А иметь возможность вывести его используя ClearType и OpenType – вообще супер. Поэтому для этих целей мне пришлось к своему управляемому (WPF) приложению дописать «неуправляемую» часть, которая рисует красивый текст используя Direct2D – новый API для двумерной графики от Microsoft. Как это работает? Ну вот пишу я например [{Hello, World!}] а система в ответ на это выдает:



После того как у меня заработал обычный текст, я добавил собственно поддержку шрифтов, размеров и фич OpenType чтобы все рисовалось еще красивее.



Получив возможность безнаказанно подменять текст графикой, я автоматизировал этот процесс так, что например могу заменить все заголовки графикой собственного производства.

Один ценный сниппет, который я наконец-то написал – это метод измерения полезных размеров битмапа после того как на нем нарисован текст. Раньше я делал это в GDI+, но недавно разозлился и написал на С++:

MYAPI RECT MeasureCropArea(BYTE* src, int width, int height, int stride, int color)
{
  Pixel& bgr = *reinterpret_cast<Pixel*>(&color);
  RECT result;
  // find the first non-conforming row of pixels
  for (int y = 0; y < height; ++y) 
  {
    int y_offset = y * stride;
    for (int x = 0; x < width; ++x)
    {
      int offset = x * sizeof(Pixel) + y_offset;
      Pixel& s = *reinterpret_cast<Pixel*>(src + offset);
      // if this pixel is non-conforming, so is the row
      if (s != bgr)
      {
        result.top = y;
        // cause soft eject
        x = width;
        y = height;
      }
    }
  }
  // find the last non-conforming row of pixels
  for (int y = height - 1; y >= 0; --y) 
  {
    int y_offset = y * stride;
    for (int x = 0; x < width; ++x)
    {
      int offset = x * sizeof(Pixel) + y_offset;
      Pixel& s = *reinterpret_cast<Pixel*>(src + offset);
      // if this pixel is non-conforming, so is the row
      if (s != bgr)
      {
        result.bottom = y;
        // cause soft eject
        x = width;
        y = -1;
      }
    }
  }
  // find the first non-conforming column of pixels
  for (int x = 0; x < width; ++x)
  {
    for (int y = 0; y < height; ++y) 
    {
      int offset = x * sizeof(Pixel) + y * stride;
      Pixel& s = *reinterpret_cast<Pixel*>(src + offset);
      // if this pixel is non-conforming, so is the column
      if (s != bgr)
      {
        result.left = x;
        // cause soft eject
        x = width;
        y = height;
      }
    }
  }
  // find the last non-conforming column of pixels
  for (int x = width - 1; x >= 0; --x)
  {
    for (int y = 0; y < height; ++y) 
    {
      int offset = x * sizeof(Pixel) + y * stride;
      Pixel& s = *reinterpret_cast<Pixel*>(src + offset);
      // if this pixel is non-conforming, so is the column
      if (s != bgr)
      {
        result.right = x;
        // cause soft eject
        x = -1;
        y = height;
      }
    }
  }
  int w = result.right - result.left;
  int h = result.bottom - result.top;
  if (w < 1) { result.left = 0; result.right = 1; }
  if (h < 1) { result.top = 0; result.bottom = 1; }
  return result;
}

Подсветка синтаксиса


RANT: мне надоело видеть на Хабре статьи где после куска кода написано: This code was highlighted with CodeSyntaxHighlighter. Это неадекват.

Мне для редактора потребовалась точно такая же функциональность. В результате, я воспользовался той же библиотекой что и заспамивший всех хайлайтер (не помню точно как его там), подпил чуть-чуть АПИ и вот, у меня получилось – можно писать {{ мой код }} и подсветка будет работать. Примеры кода есть прямо в этом посте :)

Графы


Графы – это мое новое увлечение. Иногда просто по другому не объяснить мысль. Опять же, автогенерация графика графа это в принципе просто, а на практике привело к созданию DSL которая позволяет мне писать простые команды, а на выходе рисует граф. Из такой вот спеки

edge basic advanced
edge advanced TOC`generation
edge advanced Text-as-image`generation
edge advanced Graph`generation
edge Heading`substitution
edge TOC`generation Heading`substitution
edge Heading`substitution Migration`from`FlowDocument`to`Direct2D
edge Text-as-image`generation Heading`substitution
edge Graph`generation Subpixel`hinting`(future)

Получается такая вот картинка:




Это конечно DSLка, причем реализовал я ее через reflection. Каждая директива edge превращается в реальный вызов метода edge. Вот как выглядит парсер этих команд:

public void AddInstruction(string line)
{
  if (string.IsNullOrEmpty(line)) return;
  foreach (Match m in Regex.Matches(line, "([\"'])(?:\\\\\\1|.)*?\\1"))
    line = line.Replace(m.Value, m.Value.Replace(' ''`'));
  string[] parts = line.Split(' ').Select(p => p.Replace('`'' ').Unquote()).ToArray();
  if (parts.Length < 1) return;
  // having acquired the parts, see if there's a matching method
  MethodInfo mi = null;
  if (cachedMethodInfo.ContainsKey(new KeyValuePair<string,int>(parts[0], parts.Length - 1)))
    mi = cachedMethodInfo[new KeyValuePair<stringint>(parts[0], parts.Length - 1)];
  else
  {
    var methods = GetType().GetMethods().Where(m => m.Name.ToLower() == parts[0]
      && m.GetParameters().Length == (parts.Length - 1));
    if (methods.Any())
    {
      mi = methods.First();
      cachedMethodInfo.Add(new KeyValuePair<stringint>(mi.Name, parts.Length - 1), mi);
    }
  }
  if (mi != null)
  {
    var pars = mi.GetParameters();
    object[] ps = new object[0];
    if (pars.Length == parts.Length - 1)
    {
      // try building a parameter array
      ps = new object[pars.Length];
      for (int i = 0; i < pars.Length; i++)
      {
        var par = pars[i];
        var source = parts[i + 1];
        ps[i] = ConvertString(source, par.ParameterType);
      }
    }
    // parameters ready - call it
    try { mi.Invoke(this, ps); }
    catch (Exception ex) { }
  }
}

В кусочке кода выше, я постарался закэшировать дорогой поиск инфы о методе. Возможно есть решения и получше, но у меня все работает. Это в очередной раз показывает, что не всегда нужно использовать F# или лезть в DLR чтобы быстро получить интерпретатор маленькой DSL.

Вот собственно все, о чем хотел рассказать.

Заметки


  1. Вот у этой сноски цифра 1
+34
15 октября 2009, 22:49
21
mezastel 49,4

комментарии (17)

+4
XaocCPS #
интересная статья, по моему ее можно открыть, зачем под замком висит?
0
DreamWalker #
Абсолютно согласен. Статья замечательная и замок хорошо бы снять
0
Deavy #
Согласен чуть более, чем полностью. Статья бесподобна, замок тут явно не нужен.
+2
mezastel #
Снял. :)
+2
homm #
Спасибо, замок явно был лишний. А статья и вправду мега-полезная.
+1
graycrow #
А редактор сам можно потом будет увидеть?
0
mezastel #
Да, сейчас готовлю. К сожалени, после добавления неуправляемого кода, Visual Studio перестала работать с ClickOnce, теперь придется вручную делать :(
–1
stoune #
Гм. Вы знаете часть ваших задач я делаю с помошью reStructuredText -> html. Правда это из другой планеты Python генератор sphinx. TOC, подсветка кода. Графы с помощью пришлёпки к GraphViz.
То что вы предлагаете делает сейчас почти любой развинутый wiki-движок.
0
mezastel #
Я использовал аналог GraphViz от Microsoft. Что касается генераторов, то это только начало… я и Вики-синтаксисе подумывал, но не получилось — другой способ разрабора текста.
0
stoune #
Просто заставлять пользователя проставлять html -тэги(возможно это и не так в вашем редакторе), это нехорошо. Да и польоветель должен разметить структуру тескта, а не разметку. Разметка дожна подтягиватся из шаблона. Ваш код с TOC ориентируется по сути на конечное представление. Мне кажется что правильнее работать с исходником.
Хотя к сожалению большинство пользователей не хотят работать с wiki-синтаксисом, им WISYWIG подавай аля-ворд. Изза этого проблемы с сопровждением сайтов непрофесионалами.
0
mezastel #
У меня нет пользователей. Это программа для личного использования. Мне удобно работать с HTML, я добавил поддержку HTML Zen, пока особых проблем не наблюдается.
0
stoune #
Ну я тоже больше для своих целей. Это дело вкуса.
Вот у меня вопрос, а какой смысл в неуправляемом коде, если требования позволяют обойтись управляемым?
+2
mezastel #
Во-первых, обертки для Direct2D еще нет, поэтому приходится использовать нативно. Что касается других фич, то главная причина — .Net очень сильно тормозит в плане графики и многопоточной обработки данных. У меня все что я перевел на С++ работает раз в 10 быстрее.
+1
MaxSergeev #
Мне больше всего понравился функционал: «Автогенерация контента» :).
0
mezastel #
К сожалению, сгенерированный контент не очень-то хорошо работает на Хабре. Но на других ресурсах — приемлимо.
0
MaxSergeev #
Вообще же, насколько я понимаю, все зависит от поддержки ресурсом того же «контента».
0
mezastel #
Да. На каждом ресурсе свои завихрения, в основном из-за того что авторы не хотят проработать свои HTML фильтры на 100% чтобы всем удобно было.

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