Для проведения исследований работы программ и ОС существует очень много различного инструментария. Виртуальные машины, IDE, умные блокноты, IDA, radare, hex-редакторы, pe-редакторы, и даже одних утилит Sysinternals больше сотни — все это сделано для облегчения многих рутинных операций. Но иногда наступает момент, когда ты понимаешь, что среди всего этого многообразия тебе не хватает небольшой утилитки, которая просто сделает банальную и нехитрую работу. Можно написать скрипты на питоне или Powershell на коленке, но нередко на такие поделки без слез не взглянешь и с коллегами не поделишься.
Недавно такая ситуация снова наступила у меня. И я решил, что пора просто взять и написать аккуратную утилиту. О самой утилите я расскажу в одной из ближайших статей, но об одной из проблем во время разработки расскажу сейчас.
Ошибка проявляется так – если в WPF приложении, в стандартный контрол TextBox воткнуть много строк текста, то вызовы функции GetLineText() начиная с некоторого индекса будут возвращать неправильные строки.
Неправильность заключается в том, что хоть строки будут из установленного текста, но расположенные дальше, фактически GetLineText() будет просто пропускать некоторые строки. Ошибка проявляется при очень большом количестве строк. Так я ее и встретил – попытался отобразить в TextBox’е 25 мегабайт текста. Работа с последними строками выявила неожиданный эффект.
Гугл подсказывает, что ошибка существует с 2011 года и Microsoft не особо торопится что-то исправлять.
Требований к версии .NET особо нет. Создаем стандартный проект WPF и заполняем файлы так:
MainWindow.xaml
MainWindow.cs (пропустив using и namespace)
Приложение состоит из TextBox’а и двух кнопок. Нажимаем сначала “Fire 1!” (заполнит TextBox чиселками), затем “Fire 2!” (запросит строки по номерам и выведет).
Ожидаемый результат:
Реальность:
Видно, что для индексов меньше 1000 – все прекрасно, а для больших 15000 – пошли сдвиги. И чем дальше, тем больше.
Расчехляем ту часть решарпера, которая отвечает за просмотр исходного кода .NET и специальный класс «Расширитель возможностей и преодолятор ограничений на базе Reflection».
Опытным путем устанавливаем, что в конкретно взятом примере проблема начинается в районе 8510 строки. Если запросить txt.GetLineText(8510), то вернется “8510”. Для 8511 – 8511, а для 8512 – внезапно, 8513.
Смотрим на реализацию GetLineText() у TextBox:
Пропускаем проверки в первых строчках и видим вызов GetStartPositionOfLine(). Похоже, что проблема должна быть в этой функции, поскольку для неправильной строки должна вернуться неправильная позиция начала строки.
Вызываем в своем коде:
И правда – смещение первого объекта (начало 8510'ой строки) указано как 49950 символов, для второго объекта – 49956, а третьего – 49968. Между первыми двумя 6 символов, а между следующими 12. Непорядок — вот и пропущенная строка.
Идем внутрь GetStartPositionOfLine():
Снова пропускаем стартовые проверки и смотрим на реальные действия. Сначала высчитывается точка, которая должна попасть на строку с номером lineIndex. Берется высота всех строк и прибавляется половинка высоты строки – для того, чтобы попасть в ее центр. На this.VerticalOffset и this.HorizontalOffset не смотрим – они по нулям.
Считаем в своем коде:
Значения разумные, с логикой соотносятся, все в порядке. Идем дальше по коду GetStartPositionOfLine() – нас интересует следующая осмысленная строка (первая внутри условия), которая похожа на крокодила и оканчивается вызовом GetTextPositionFromPoint().
Раскрываем вызовы и дергаем их через рефлексию. Обратим внимание, что некоторые интерфейсы нам недоступны из-за ограничения видимости, поэтому приходится ссылаться на них, используя все тот же Reflection.
Полученные объекты показывают все те же смещения – 49950, 49956, 49568. Идем глубже, в реализацию GetTextPositionFromPoint() внутри TextBoxView.
Во, GetLineIndexFromPoint() выглядит многообещающе. Вызываем в своем коде.
Получаем 8510, 8511 и 8513 – бинго! К реализации:
Даже невооруженным взглядом видно, что это бинарный поиск. _lineMetrics – список характеристик строк (начало, длина, ширина границы). Радостно потираю ручки – я думал, что как это нередко бывает где-то забыли +1 воткнуть или поставили > вместо >=. Копируем функцию в код и отладим. Из-за закрытости типов _lineMetrics вытаскиваем через reflections, _lineHeight же мы уже достали раньше. Итого:
До отладки мы не добираемся. o30, o31 и o32 равны 8510, 8511 и 8512, соответственно. Такие какие и должны быть! Но o20, o21 и o22 с ними же не согласны. Как так? Мы же почти не поменяли код. Почти? И вот тут наступает озарение.
Вот она причина – разница 0.0009375. Причем если мы прикинем накопление ошибки — умножим на 8511, то получим 7.9790625. Это как раз около половины lineHeight, и поэтому при расчете координат точка вылетает за пределы нужной строки и попадает на следующую. Одна и та же переменная (по смыслу) подсчиталась двумя разными способами и, внезапно, не совпала.
На этом я решил остановиться. Возможно реально и докопаться почему высота столбца получилась разная, но я не вижу особого смысла. Сомнительно, что Microsoft будет это исправлять, поэтому смотрим на костыли для обхода. Reflection-костыль – устанавливать правильную _lineHeigh либо в одном, либо в другом месте. Звучит стремно, наверняка медленно и скорее всего ненадежно. Либо можно вести свой набор строк, параллельно TextBox’у и брать строки из него, благо получение номера строки по позиции курсора работает корректно.
От начинающих программистов можно часто услышать что-то об ошибках в компиляторе или стандартных компонентах. В реальности они встречаются не так часто, но все же от них не застрахован никто. Не бойтесь заглянуть внутрь того инструмента, который вам нужен – это увлекательно и интересно.
Пишите хороший код!
> Машинное обучение в Offensive Security
Никакая машина меня не заменит. Мухаха-ха. Надеюсь.
> Где вставить кавычку в IPv6
Ребята знают куда и что впихнуть, чтобы стало хорошо. После таких слов меня все-таки заменят роботом, наверняка. СР! УВЧ!
Недавно такая ситуация снова наступила у меня. И я решил, что пора просто взять и написать аккуратную утилиту. О самой утилите я расскажу в одной из ближайших статей, но об одной из проблем во время разработки расскажу сейчас.
Ошибка проявляется так – если в WPF приложении, в стандартный контрол TextBox воткнуть много строк текста, то вызовы функции GetLineText() начиная с некоторого индекса будут возвращать неправильные строки.
Неправильность заключается в том, что хоть строки будут из установленного текста, но расположенные дальше, фактически GetLineText() будет просто пропускать некоторые строки. Ошибка проявляется при очень большом количестве строк. Так я ее и встретил – попытался отобразить в TextBox’е 25 мегабайт текста. Работа с последними строками выявила неожиданный эффект.
Гугл подсказывает, что ошибка существует с 2011 года и Microsoft не особо торопится что-то исправлять.
Пример
Требований к версии .NET особо нет. Создаем стандартный проект WPF и заполняем файлы так:
MainWindow.xaml
<Window x:Class="wpf_textbox.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:wpf_textbox"
mc:Ignorable="d"
Title="WTF, WPF?" Height="350" Width="525">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="20"/>
<RowDefinition Height="20"/>
</Grid.RowDefinitions>
<TextBox Grid.Row="0" Margin="5" Name="txt" AcceptsReturn="True" AcceptsTab="True" />
<Button Grid.Row="1" Content="Fire 1!" Click="btn_OnClick" />
<Button Grid.Row="2" Content="Fire 2!" Click="btn2_OnClick" />
</Grid>
</Window>
MainWindow.cs (пропустив using и namespace)
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void btn_OnClick(object sender, RoutedEventArgs e)
{
var sb = new StringBuilder();
for (int i = 0; i < 90009; i++)
sb.AppendLine($"{i}");
txt.Text = sb.ToString();
}
private void btn2_OnClick(object sender, RoutedEventArgs e)
{
var sb = new StringBuilder();
for (var i = 1; i < 7; i++)
sb.AppendLine("req: " + 150 * i + ", get: " + txt.GetLineText(150 * i).Trim());
for (var i = 1; i < 7; i++)
sb.AppendLine("req: " + 15000 * i + ", get: " + txt.GetLineText(15000 * i).Trim());
txt.Text = sb.ToString();
}
}
Приложение состоит из TextBox’а и двух кнопок. Нажимаем сначала “Fire 1!” (заполнит TextBox чиселками), затем “Fire 2!” (запросит строки по номерам и выведет).
Ожидаемый результат:
req: 150, get: 150
req: 300, get: 300
req: 450, get: 450
req: 600, get: 600
req: 750, get: 750
req: 900, get: 900
req: 15000, get: 15000
req: 30000, get: 30000
req: 45000, get: 45000
req: 60000, get: 60000
req: 75000, get: 75000
req: 90000, get: 90000
Реальность:
Видно, что для индексов меньше 1000 – все прекрасно, а для больших 15000 – пошли сдвиги. И чем дальше, тем больше.
Исследуем баг
Расчехляем ту часть решарпера, которая отвечает за просмотр исходного кода .NET и специальный класс «Расширитель возможностей и преодолятор ограничений на базе Reflection».
Расширитель возможностей и преодолятор ограничений на базе Reflection
public static class ReflectionExtensions
{
public static T GetFieldValue<T>(this object obj, string name)
{
var bindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var field = obj.GetType().GetField(name, bindingFlags);
if (field == null)
field = obj.GetType().BaseType.GetField(name, bindingFlags);
return (T)field?.GetValue(obj);
}
public static object InvokeMethod(this object obj, string methodName, params object[] methodParams)
{
var methodParamTypes = methodParams?.Select(p => p.GetType()).ToArray() ?? new Type[] { };
var bindingFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static;
MethodInfo method = null;
var type = obj.GetType();
while (method == null && type != null)
{
method = type.GetMethod(methodName, bindingFlags, Type.DefaultBinder, methodParamTypes, null);
var intfs = type.GetInterfaces();
if (method != null)
break;
foreach (var intf in intfs)
{
method = intf.GetMethod(methodName, bindingFlags, Type.DefaultBinder, methodParamTypes, null);
if (method != null)
break;
}
type = type.BaseType;
}
return method?.Invoke(obj, methodParams);
}
}
Опытным путем устанавливаем, что в конкретно взятом примере проблема начинается в районе 8510 строки. Если запросить txt.GetLineText(8510), то вернется “8510”. Для 8511 – 8511, а для 8512 – внезапно, 8513.
Смотрим на реализацию GetLineText() у TextBox:
Пропускаем проверки в первых строчках и видим вызов GetStartPositionOfLine(). Похоже, что проблема должна быть в этой функции, поскольку для неправильной строки должна вернуться неправильная позиция начала строки.
Вызываем в своем коде:
var o00 = txt.InvokeMethod("GetStartPositionOfLine", 8510);
var o01 = txt.InvokeMethod("GetStartPositionOfLine", 8511);
var o02 = txt.InvokeMethod("GetStartPositionOfLine", 8512);
И правда – смещение первого объекта (начало 8510'ой строки) указано как 49950 символов, для второго объекта – 49956, а третьего – 49968. Между первыми двумя 6 символов, а между следующими 12. Непорядок — вот и пропущенная строка.
Идем внутрь GetStartPositionOfLine():
Снова пропускаем стартовые проверки и смотрим на реальные действия. Сначала высчитывается точка, которая должна попасть на строку с номером lineIndex. Берется высота всех строк и прибавляется половинка высоты строки – для того, чтобы попасть в ее центр. На this.VerticalOffset и this.HorizontalOffset не смотрим – они по нулям.
Считаем в своем коде:
var lineHeight = (double) txt.InvokeMethod("GetLineHeight", null);
var y0 = lineHeight * (double)8510 + lineHeight / 2.0 - txt.VerticalOffset;
var y1 = lineHeight * (double)8511 + lineHeight / 2.0 - txt.VerticalOffset;
var y2 = lineHeight * (double)8512 + lineHeight / 2.0 - txt.VerticalOffset;
Значения разумные, с логикой соотносятся, все в порядке. Идем дальше по коду GetStartPositionOfLine() – нас интересует следующая осмысленная строка (первая внутри условия), которая похожа на крокодила и оканчивается вызовом GetTextPositionFromPoint().
Раскрываем вызовы и дергаем их через рефлексию. Обратим внимание, что некоторые интерфейсы нам недоступны из-за ограничения видимости, поэтому приходится ссылаться на них, используя все тот же Reflection.
var renderScope = (txt.GetFieldValue<FrameworkElement>("_renderScope") as IServiceProvider);
// 7 - тип интерфейся ITextView
var textView = renderScope.GetService(renderScope.GetType().GetInterfaces()[7]);
var o10 = textView.InvokeMethod("GetTextPositionFromPoint", new Point(-txt.HorizontalOffset, y0), true);
var o11 = textView.InvokeMethod("GetTextPositionFromPoint", new Point(-txt.HorizontalOffset, y1), true);
var o12 = textView.InvokeMethod("GetTextPositionFromPoint", new Point(-txt.HorizontalOffset, y2), true);
Полученные объекты показывают все те же смещения – 49950, 49956, 49568. Идем глубже, в реализацию GetTextPositionFromPoint() внутри TextBoxView.
Во, GetLineIndexFromPoint() выглядит многообещающе. Вызываем в своем коде.
var o20 = textView.InvokeMethod("GetLineIndexFromPoint", new Point(-txt.HorizontalOffset, y0), true);
var o21 = textView.InvokeMethod("GetLineIndexFromPoint", new Point(-txt.HorizontalOffset, y1), true);
var o22 = textView.InvokeMethod("GetLineIndexFromPoint", new Point(-txt.HorizontalOffset, y2), true);
Получаем 8510, 8511 и 8513 – бинго! К реализации:
Даже невооруженным взглядом видно, что это бинарный поиск. _lineMetrics – список характеристик строк (начало, длина, ширина границы). Радостно потираю ручки – я думал, что как это нередко бывает где-то забыли +1 воткнуть или поставили > вместо >=. Копируем функцию в код и отладим. Из-за закрытости типов _lineMetrics вытаскиваем через reflections, _lineHeight же мы уже достали раньше. Итого:
var lm = textView.GetFieldValue<object>("_lineMetrics");
var c = (int)lm.InvokeMethod("get_Count");
var lineMetrics = new List<Tuple<int,int,int,double>>();
for (var i = 0; i < c; i++)
{
var arr_o = lm.InvokeMethod("get_Item", i);
var contLength = arr_o.GetFieldValue<int>("_contentLength");
var length = arr_o.GetFieldValue<int>("_length");
var offset = arr_o.GetFieldValue<int>("_offset");
var width = arr_o.GetFieldValue<double>("_width");
lineMetrics.Add(new Tuple<int, int, int, double>(contLength, length, offset, width));
}
var o30 = GetLineIndexFromPoint(lineMetrics, lineHeight, new Point(-txt.HorizontalOffset, y0), true);
var o31 = GetLineIndexFromPoint(lineMetrics, lineHeight, new Point(-txt.HorizontalOffset, y1), true);
var o32 = GetLineIndexFromPoint(lineMetrics, lineHeight, new Point(-txt.HorizontalOffset, y2), true);
/*<...>*/
private int GetLineIndexFromPoint(List<Tuple<int, int, int, double>> lm, double _lineHeight, Point point, bool snapToText)
{
if (point.Y < 0.0)
return !snapToText ? -1 : 0;
if (point.Y >= _lineHeight * (double)lm.Count)
{
if (!snapToText)
return -1;
return lm.Count - 1;
}
int index = -1;
int num1 = 0;
int num2 = lm.Count;
while (num1 < num2)
{
index = num1 + (num2 - num1) / 2;
var lineMetric = lm[index];
double num3 = _lineHeight * (double)index;
if (point.Y < num3)
num2 = index;
else if (point.Y >= num3 + _lineHeight)
{
num1 = index + 1;
}
else
{
if (!snapToText && (point.X < 0.0 || point.X >= lineMetric.Item4))
{
index = -1;
break;
}
break;
}
}
if (num1 >= num2)
return -1;
return index;
}
До отладки мы не добираемся. o30, o31 и o32 равны 8510, 8511 и 8512, соответственно. Такие какие и должны быть! Но o20, o21 и o22 с ними же не согласны. Как так? Мы же почти не поменяли код. Почти? И вот тут наступает озарение.
var lh = textView.GetFieldValue<double>("_lineHeight");
Вот она причина – разница 0.0009375. Причем если мы прикинем накопление ошибки — умножим на 8511, то получим 7.9790625. Это как раз около половины lineHeight, и поэтому при расчете координат точка вылетает за пределы нужной строки и попадает на следующую. Одна и та же переменная (по смыслу) подсчиталась двумя разными способами и, внезапно, не совпала.
На этом я решил остановиться. Возможно реально и докопаться почему высота столбца получилась разная, но я не вижу особого смысла. Сомнительно, что Microsoft будет это исправлять, поэтому смотрим на костыли для обхода. Reflection-костыль – устанавливать правильную _lineHeigh либо в одном, либо в другом месте. Звучит стремно, наверняка медленно и скорее всего ненадежно. Либо можно вести свой набор строк, параллельно TextBox’у и брать строки из него, благо получение номера строки по позиции курсора работает корректно.
Заключение
От начинающих программистов можно часто услышать что-то об ошибках в компиляторе или стандартных компонентах. В реальности они встречаются не так часто, но все же от них не застрахован никто. Не бойтесь заглянуть внутрь того инструмента, который вам нужен – это увлекательно и интересно.
Пишите хороший код!
Другие статьи блога
> Машинное обучение в Offensive Security
Никакая машина меня не заменит. Мухаха-ха. Надеюсь.
> Где вставить кавычку в IPv6
Ребята знают куда и что впихнуть, чтобы стало хорошо. После таких слов меня все-таки заменят роботом, наверняка. СР! УВЧ!
Комментарии (7)
Yankipl
16.08.2018 21:46Спасибо за статью. Я новичок в wpf, если что, все же, почему не флоу документ?
xi-tauw Автор
16.08.2018 21:52Решение было продиктовано чем-то типа бритвы Оккама — мне не требовались никакие специфичные функции. Отобразить текст, да достать строку по позиции курсора. Попытка сменить TextBox была только из расчета взять что-то с виртуализацией (без нее у приложения подвисает main-thread на отображение 25 мегабайт). Но таких текстовых контролов не было, поэтому я просто вернул TextBox и написал асинхронное заполнение текста.
InChaos
Я вообще в недоумении, почему индекс ищется через координаты?
Вопрос риторический, в сторону .Net а не ТС.
xi-tauw Автор
Могу только предположить, что дело в какой-то хитрой архитектуре компонентов и/или адской обратной совместимости с какими-то другими контролами для текста.
С вопросом по выбору такого подхода полностью согласен — без координат проблема указанная в статье даже не возникла бы.
Sabubu
Согласен, это имело бы смысл если бы строки могли бы быть разной высоты, но там вроде не rich text, а plain text.
UnclShura
Word wrapping
StingerFG
Такое чувство, что это рассчитано на другие компоненты, которые, я могу только гадать, работают с Touch сервисами (там же каша полная). Это просто предположение.