Sources
Delphi Russian Knowledge Base
DRKB - это самая большая и удобная в использовании база знаний по Дельфи в рунете, составленная Виталием Невзоровым

Дедушка RTF еще послужит

01.01.2007

"Дедушка RTF" еще послужит

Мы живем в роскошное время - большинство ресурсов тратится человечеством впустую, буквально на ветер. Это тем более верно для ресурсов компьютерных: типичная загрузка процессора среднего (например, моего) компьютера - что-то около 10%, огромный винчестер завален никому не нужными файлами, из которых вряд ли используется более 20-30%, а до многих очередь так никогда и не доедет, из полутора же гигабайт оперативной памяти я нагружаю, максимум, 600-700 мег.

Аналогичная "роскошная" ситуация и на уровне прикладного программирования: типичная программа содержит массу не используемого кода и ресурсов. Вполне естественно, что такие программы порождают столь же толстые и бестолковые документы. Ситуация отчасти объясняется новыми технологиями программирования, нацеленными на получение быстрых результатов в ущерб оптимизации и надежности кода. Возможно, не обходится и без "тихого сговора" с производителями комплектующих, непрестанно ищущих повод для нашего апгрейда за наш же счет.

В качестве иллюстрации можете открыть любой "документ MS Word" с расширением doc и посмотреть, каково соотношение между полезной информацией (это еще предполагая, что напечатанный текст априори является такой информацией) и различной "пургой". На самом деле применение интегрированного COM-формата представления оправдано в одном случае из десяти тысяч. В большинстве же ситуаций вполне достаточно вообще неформатированного текстового представления, с которым справится любой первобытный vim. В других случаях требуется минимум форматирования - вроде автоматического центрирования, отступов и выделения курсивом. Для этих целей вовсе не обязательно таскать за собой COM-storage. Два лучших кандидата на представление "слегка украшенного" текста - это HTML (ака XML-совместимый) и RTF.

О HTML вы, вероятно, и так уже все знаете, а вот о RTF речь пойдет ниже - и, естественно, не с точки зрения "эникейщика", а с позиции программирования.

Не станем следовать примеру американских "конвейерных" программистов, уверовавших в огромную пользу всевозможных библиотек и классов, а посему полжизни проводящих: а) в поисках библиотеки, которая делает именно то, что нужно; б) в чатах по поводу "а почему не работает найденная библиотека так-то и так-то".

Как следствие - мы опускаем (в натуре) такую припару, как RichTextEdit. Помимо прочего, вы все равно не сможете программно ничего сделать "rich" в этом элементе управления без знания RTF. Это не говоря уже о том, что знание сила - и, один раз постигнув, как что-то устроено, вы сможете сколько угодно использовать эти знания.

Не станем мы использовать и соответствующий класс Java - там тоже один туман, а пользы от этой субстанции не больше, чем от квалификатора final abstract.

В качестве инструмента мы выберем C# - просто он сейчас под рукой, к тому же дока Visual Studio, MSDN, имеет неплохой референс по RTF. Вообще, DOT NET - это, как по мне, самая удобная иерархия классов за всю историю ООП. Плохо только, что она стоит на COM - а потому не переносима никуда дальше Windows. Однако не нужно думать, что мы используем что-то, зависящее от NET,- напротив, наш "натуралистический" подход позволит переносить идеи на любой язык, существующий в природе, будь то Perl или COBOL, лишь бы он мог выводить текстовые файлы.

Как и что устроено в RTF

Итак, что же представляет собой RTF, который мы собрались порвать на куски и скормить нашим программам? По определению это "богатый текст" - в смысле, текст с украшениями в виде форматирования. А на самом-то деле… на самом деле это тоже текст, но не в том смысле, как мы привыкли его воспринимать (и видеть на экране), а в том, что все элементы форматирования - суть текстовые, то есть все символы "printable quotable". Это удобно по многим причинам и роднит RTF с такими языками разметки, как HTML и PostScropt. Одна из "радостей" - это возможность визуальной отладки для программы, то есть возможность собственными глазами увидеть, что делает наша программа. Сделать это не просто, а очень просто - достаточно открыть RTF в любом текстовом редакторе, который не станет интерпретировать символы разметки особым образом,- взять тот же Notepad.

Что же мы увидим? Хех, нечто вроде показанного ниже:

{\rtf1\ansi\deflang1049{\fonttbl {\f4\fnil\fcharset204\fprq0 Arial;}
{\f35\fnil\fcharset204\fprq0 Times New Roman;}}{\stylesheet {\s0{\*
\keycode \shift\ctrl N}\snext0\f4\fs20\sl240\slmult1\ql\nowidctlpar
\widctlpar Normal;}}{\info {\*\company Comizdat}{\creatim\yr2004\mo3
\dy19\hr13\min39\sec0}{\author ac2k1}}\viewscale150\margl1701
\margr850\margt1134\margb1134\widowctrl\plain\f35\fs24\pard\f4\fs18
\lang1033 Hello World\par}

Как вы поняли (гы-ы), это традиционное приветствие "Hello World" в виде RTF. "Капец! - скажут некоторые из вас.- Какой же это, блин, читабельный текст?!". Ну, это как посмотреть - для папуаса наши книжки тоже 100% все-непонятное, также, как и текст на C++ для непосвященного. Кстати, это еще "милое" представление от текстового редактора Atlantis, а если посмотреть, что делает MS Word (ну, примерно то же, что он делает с HTML) - то там вообще грустная картина.

С другой стороны в кошмарном виде этого текста нет совершенно ничего страшного, по крайней мере не больше, чем в выражениях RegExp-а. Просто этот текст плохо отформатирован, поскольку никакой редактор не ожидает, что у вас хватит наглости читать RTF в ноутпаде. Попробуем "растопырить" наш RTF и получим что-то вроде:

{\rtf1\ansi\deflang1049
{\fonttbl
{\f4\fnil\fcharset204\fprq0 Arial;}
{\f35\fnil\fcharset204\fprq0 Times New Roman;}
{\stylesheet
{\s0
{\*\keycode \shift\ctrl N}
\snext0\f4\fs20\sl240\slmult1
\ql\nowidctlpar\widctlpar Normal;
}
}
{\info
{\*\company Comizdat}
{\creatim\yr2004\mo3\dy19\hr13\min39\sec0}
{\author ac2k1}}
\viewscale150\margl1701\margr850\margt1134\margb1134
\widowctrl\plain\f35\fs24\pard\f4\fs18
\lang1033 Hello World\par
}

Это уже совсем другое дело. Во-первых, мы видим, что RTF, так же как и XML, имеет иерархическую структуру, то есть документ состоит из элементов, каждый из которых, в свою очередь, также состоит из элементов,- и так далее. Как видно, элементы (группы) заключены в фигурные скобки. В начале документа идет какая-то служебная информация, то есть как бы HEADER.

Следующее - мы видим подобие атрибутов, которые, правда, хоть и начинаются с "\", но заканчиваются… ничем они не заканчиваются, кроме следующего атрибута или символа конца элемента. Пробельные символы здесь не то чтобы игнорируются - но лучше не разрывать ничего на куски, а аккуратно складывать поэлементно в ровные строчки. Имена атрибутов вполне мнемоничны - например, даже мне, неучу, ясно, что \margt1134 обозначает "верхний отступ 1134 сам-знаешь-чего" (твипов на самом деле, тысячных долей дюйма - то есть отступ равен 1,134 дюйма).

Все программы по обработке RTF условно делятся на читателей и писателей. Такое различие оправдано, поскольку многие программы просто генерируют свой "аутпут" в этом формате, и их совсем не парит разбирать чужие документы. Писатель, как обычно, "полуграмотный", то есть использует только 3% всего лексикона. Разбор RTF - операция, на порядок более сложная, с учетом всего множества атрибутов. В качестве допустимого минимума читатель должен просто игнорировать непонятные ему команды форматирования, так чтобы, пропустив неясный блок (конечно, при условии, что этот блок корректно упакован в ограничители), продолжить интерпретацию с понятного места. Таким образом, RTF, в общем-то, легко расширяется: можно добавить любые теги, не волнуясь, что это приведет к "непоняткам" с текстом или форматированием. Именно таким образом в RTF можно вставить любые бинарные данные (например, в MIME-кодировке) или специально обрабатываемые элементы (как гиперссылки или поля MS Word).

Итак, все, в общем-то, понятно, попробуем теперь сгенерировать какой-нибудь RTF.

Архитектура "писателя"

Задача генерации текстовых, в том числе вложенных, структур имеет два главных решения, известные по генерации HTML: первое - просто формировать выходной поток как текст (возможно, с небольшой "автоматизацией") - так, как это делает, например, perl или С++; второе - формировать основной шаблон и подставлять в него вычисляемые текстовые макроподстановки, как это делают ASP, PHP, ColdFusion и Delphi.

Мы применим второй вариант, несколько модифицированный и с такими "фичами":

·В макроподстановке будут использоваться не выражения, а только переменные; все вычисления, если они нужны (например, подстановка даты, времени или полей из баз данных) должны производиться в программе - в том числе раскрутка циклических включений.
·Для упрощения манипуляций с документом разрешим шаблону включать другой шаблон - причем, если тот не найдется, ошибкой это считаться не будет (видимо, нас просто заломало его писать).
·Макропеременные по возможности должны быть "красивыми" - то есть будут иметь читабельный вид с точечной нотацией или пробелами, где префикс будет выступать в качестве квалификатора, то есть типа %header.charset=\ansi%. На самом деле это только красота - в таблице атомов (символов, переменных - как угодно) мы не станем строить дерево, а будем сравнивать текстуально с точками вместе. Впрочем, поиск в хеш-таблице и без нашего ведома будет производиться по короткой схеме.
·Макропеременные будут иметь значения по умолчанию, так что можно как снабжать чем-то шаблон, так и вообще ничем не снабжать его,- ошибки не будет. Если переменная не снабжена значением по умолчанию и при этом ей не присвоено значение - это тоже не ошибка: значит, эта переменная просто не нужна.
·При передаче значений в макросы не будет никакого "квотирования" - то есть мы не будем фильтровать спецсимволы RTF и не будем никак препятствовать передаче "голого" RTF - это открывает двери для всякого "хака" макета, то есть в любом месте можно "вломиться" с новым форматированием или вставить служебные элементы. Предполагается, что мы генерируем документы в подконтрольной среде (локально) и, если что-то делается, то мы знаем, что и зачем.
·Вывод нашего генератора будет направляться в указанный файл - все проблемы (относительно проверки корректности путей, а также того, перезаписывать ли существующий, и т.п.) при этом полагаются на вызывающую программу.
·Интерфейс будущего класса-генератора будет самым что ни на есть примитивным: создать документ на основе шаблона такого-то, подставить поля и получить вывод. Такой механизм позволяет генерировать серии документов по одному шаблону в таком слегка оптимизированном режиме:

создать документ
установить глобальные переменные, типа шрифтов,стандартных реквизитов и т.п.
цикл {
установить переменные поля, обычно из БД
получить результат (сохранить, отпечатать, т.п.)

}

·Еще парочка соглашений: шаблоны не будут иметь никакого особого расширения, а будут представлять собой обычные TXT, чтобы разработчик мог просто щелкнуть - и редактировать без проблем. Это расширение не указывается при задании имени шаблона. Шаблоны по умолчанию будут находиться в специальном подкаталоге Templates. Это упростит управление массой файлов, но при этом позволит оптимально использовать вложенных секций, поскольку они часто будут повторяться. Рекурсию мы не станем проверять - эта возможность будет включена в коммерческую версию.

В качестве ограничителей макроподстановок будут использоваться угловые скобки - я не нашел их в тексте RTF, и это дает надежду, что они не используются самим RTF. Для файлов будем использовать "<" и ">", для переменных - %имя=значение%. В реальной жизни мы, естественно, будем квотировать эти символы, но пока будем считать, что наш текст не включает таких редких и экзотических символов.

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

RTFgen rtf=new RTFgen ("main"); \\
rtf.addvar ("text","HELL-O-WORLD");
rtf.generate (savefile); 

Реализация

После таких детальных установок реализовать полученное довольно просто. Результирующая программа правильно обрабатывает, в основном, корректный ввод - и колбасится от некорректного. Обработка и анализ ошибок - удел коммерческих продуктов, да и то, как правило, со многими глюками и оговорками. Конечно, злобный хакер легко может предположить, что со времени создания нашего объекта, который при создании аккуратно ничего не открывает, некая посторонняя программа подменит наш корневой шаблон, уж не говоря обо всем многообразии возможных ошибок в самом шаблоне,- но отлов этих полутора миллионов вариантов остается вам в качестве домашнего задания.

Итак, вот как просто выглядит наш класс для генерации RTF-документов на основании иерархических шаблонов:

using System;
using System.IO;
using System.Collections;
using System.Text;
namespace Catch {
public class RTFgen
{
private string BaseTemp;
private StreamWriter RTFout;
private string path;
private Hashtable variables;
public RTFgen (string root) {
path=Directory.GetCurrentDirectory ()+"\\Templates\\";
if ((new FileInfo (path+root+".txt")).Exists) {
BaseTemp=root;
variables=new Hashtable ();
}
else
throw (new FileNotFoundException ());
}
public void addvar (string varname, string varvalue) {
variables.Add (varname, varvalue);
}
public void generate (string filename) {
RTFout = File.CreateText (filename);
ProcessFile (BaseTemp);
RTFout.Close ();
}
private void ProcessFile (string Temp) {
string input;
string filename=path+Temp+".txt";
if ((new FileInfo (filename)).Exists) {
StreamReader TempIn=File.OpenText (filename);
while ((input=TempIn.ReadLine ())!=null)
ProcessLine (input);
TempIn.Close ();
}
}
private void ProcessLine (string line2do) {
int pos1=0, pos2=0;
if ((pos1=line2do.IndexOfAny (new char [2]{'<','%'}))>-1) {
Out (line2do.Substring (0, pos1));
if (line2do [pos1]=='<')
if ((pos2=line2do.IndexOf ('>', pos1+1))>0)
ProcessFile (line2do.Substring (pos1+1, pos2-pos1-1));
if (line2do [pos1]=='%')
if ((pos2=line2do.IndexOf ('%', pos1+1))>0)
ProcessVariable (line2do.Substring (pos1+1, pos2-pos1-1));
ProcessLine (line2do.Substring (pos2+1, line2do.Length-pos2-1));
} else
Out (line2do);
}
private void ProcessVariable (string var) {
string varname=var, varvalue="";
int pos1;
if ((pos1=var.IndexOf ('='))>-1) {
varname=var.Substring (0, pos1);
varvalue=var.Substring (pos1+1, var.Length-pos1-1);
}
Out (variables.ContainsKey (varname)?(string) variables[varname]: varvalue);
}
private void Out (string s){
byte [] bt=Encoding.GetEncoding (1251).GetBytes (s);
foreach (byte b in bt)
RTFout.Write (b<128?((char) b).ToString ():"\\'"+b.ToString ("x"));
}
}
}

Как видите, все вращается вокруг рекурсии. Говоря попросту, обработать файл шаблона - значит обработать его строки одна за одной (ProcessFile). Обработать строку - значит найти в ней первую макроподстановку файла или переменной, затем распечатать начало до макро, сам макрос обработать в зависимости от типа, а остаток строки рекурсивно обработать как отдельную строку. Заметьте, что обработка файла порождает еще один цикл косвенной рекурсии - что, в общем, делает программу весьма интересной.

Переменные реализованы через хеш-таблицу, так что сами переменные и значения могут быть довольно "странными", по крайней мере с точки зрения традиционных языков: в значении может встречаться знак равенства, поскольку после первого вхождения дальнейшее воспринимается как единая строка. Имена и значения могут содержать пробелы и любые спецсимволы, кроме знаков "=" и "%", в том числе знаки "<" и ">", хотя такое использование позже может вас же ввести в заблуждение, так что злоупотреблять этим не стоит.

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

Последнее, что осталось тут не освещенным, это собственно шаблон. Мелочь, на которую нужно обратить внимание: не форматируйте шаблоны с помощью красивых отступов табуляцией - некоторые редакторы, вроде MS Word, поймут это как пробелы в тексте, и результат будет не самым лучшим. Впрочем, переносы строки игнорируются всеми "читателями", так что все-таки какой-то приличный вид всегда можно поддерживать, тем более что в шаблоны вы поместите фрагменты одного-двух уровней вложения. Для приведенного выше примера использовался следующий текст main.txt:

{\rtf1\ansi\ansicpg1251\uc1\deff1\stshfdbch0\stshfloch0\stshfhich0\stshfbi0\deflang1049\deflangfe1049
<fonttbl>
<colortbl>
<stylesheet>
{\*\rsidtbl \rsid5533391}
{\*\generator %Application=RTFgen 1.0%;}
<info>
<pnsec>
<pageset>
{\f48\fs18%text=Hello World%}
}

Этот шаблон рессембирует структуру RTF-файла, порождаемого MS Word. Вообще, MS Word, как правило, вставляет секции независимо от того, нужны ли они будут в документе. Например, секция colortabl будет вставлена, даже если в тексте ни единого цветового выделения. Точно так же pnsec описывает "пульки" и отступы для списков до девяти уровней вложения включительно - хотя ваш текст может вообще не включать списков. Насколько это ваш стиль - судите сами, другие редакторы ведут себя более компактно, создавая служебные таблицы динамически на основе реально используемых элементов документа. В конце концов, у вас целых две штатные возможности исключить ненужные вам части: удалить включение файла из шаблона и просто удалить вложенный файл (грубо, но работает без ошибок по определению).

Сам формат секций объяснять не стану - долго и вообще не особо касается программирования. Самое простое:

·\ql, \qc, \qr - абзац с выравниванием влево, по центру, вправо;
·\fs18 - размер шрифта в полупунктах (в данном случае 9pt);
·\f48 - ссылка шрифт по номеру в таблице fontbl;
·\b-\b0, \i-\i0, \ul-\ulnone - жирное, наклонное и подчеркнутое начертание соответственно; первый тег начинает действие модификатора, второй завешает.

Что касается получения полного списка возможностей: поищите строку RTF в MSDN - вас должно интересовать два раздела Header и Document Area, в которых приводятся все теги стандарта RTF 1.6.

Немного о кодировках

У современного программирования своя специфика - в виде множества систем кодировки даже для такого изначально простого и детерминированного объекта, как текстовый файл. Чтобы у вас все работало с русским языком, шаблоны должны быть текстовыми файлами Unicode (сигнатура FEFF) - что у вас не получится по умолчанию, если вы воспользуетесь Notepad. Для получения правильного результата с русскими буквами следует принудительно сохранить файл в этом формате. Это нужно делать только для файлов (типично-одного), в котором вы выводите русский текст и делаете подстановки русских макросов.

Аналогично при выводе - NET всегда будет пытаться вывести текст как UTF8, заменяя не-ANSI символы двухбайтными последовательностями. RTF такого не понимает, он будет воспринимать два байта как два отдельных символа - в результате вывод в текстовом редакторе будет некорректным. Решений, как минимум, три:

·первый метод (как, собственно, все и сделано в приведенном листинге) - для не-ASCI кодов нужно транслировать из юникода в специфическую кодовую таблицу, после чего заменить каждый не-ASCI символ на специфическую ESC-последовательность. Таким образом работает Athlantis Ocean Mind, хотя MS Word тоже понимает эту нотацию. При этом не забывайте устанавливать нужную кодовую таблицу по умолчанию для документа или изменяйте ее по месту - например, если вы захотите вставить пару турецких фраз в сборник финских анекдотов;
·второй метод - хак, то есть недокументированная возможность. Если русская локализация установлена у вас по умолчанию, то символы вовсе не обязательно эскейпить - просто выведите их "как есть" после перекодировки, это даст экономию размера четыре к одному в длинных документах на национальных языках, но, возможно, не всегда такой текст будет отображаться правильно на других системах. Процедура Out для такой "оптимизации" будет выглядеть следующим образом:
 

private void Out (string s){
byte [] bt=Encoding.GetEncoding (1251).GetBytes (s);
foreach (byte b in bt)
RTFout.BaseStream.WriteByte (b);
} 
·и, наконец, третий вариант - работать в юникоде "от и до", то есть просто выводить строку посимвольно, заменяя по ходу не-ASCI на последовательности \uNNNC (где NNN - десятичное представление символа в юникоде, а C - калька в транслите). Этот способ применяется в последних версиях MS Word, и реализовать его тоже не представляет труда - но при этом придется создавать таблицу транслитерального представления, поскольку автоматически сгенерировать ее штатными средствами не получится.

Вообще-то говоря, эти все проблемы относятся не к NET или нашей программе, а, скорее, к несовершенству мира. Таким же образом вам придется проворачиваться при работе с Java и производными системами, вроде ColdFusion,- там эти проблемы решаются с помощью специальной функции, заставляющей передавать не-ANSI символы одним байтом в терминах кодовых страниц.

Наглядное представление

Кстати, несложно также и написать простую программу для форматирования RTF в читабельном виде. Если использование каких-то тегов будет вам неясно из документации, всегда можно реализовать этот эффект в текстовом редакторе - и посмотреть, как это выглядит в терминах RTF. Ниже - текст простого форматировщика, по аналогии с известной утилитой названного RTFnice:

static void Main (string [] args) {
String s;
Class1 c=new Class1();
while ((s = Console.ReadLine ())!=null)
c.ProcessLine (s,-2);
}
bool firstline=true;
void ProcessLine (string s, int level) {
int pos1, pos2;
if ((pos1=s.IndexOfAny (new Char [2]{'{','}'}))>-1) {
if (firstline)
firstline=false;
else
Console.WriteLine (s.Substring (0, pos1));
if (s [pos1]=='{') {
if ((pos2=s.IndexOfAny (new Char [2]{'{','}'}, pos1+1))>-1) {
Shift (level);
if (s [pos2]=='}') {
Console.Write ("{"+s.Substring (pos1+1, pos2-pos1));
ProcessLine (s.Substring (pos2+1, s.Length-pos2-1), level);
} else {
Console.Write ("{");
ProcessLine (s.Substring (pos1+1, s.Length-pos1-1), level+1);
}
}
}
if (s [pos1]=='}') {
Console.Write ("}");
ProcessLine (s.Substring (pos1+1, s.Length-pos1-1), level-1);
}
} else
Console.WriteLine (s);
}
void Shift (int n) {for (int i=0; i<=n; i++) Console.Write ('\t');}

 

Я отнюдь не уверяю, что это самый корректный или наиболее правильный способ форматирования - просто это то, как нравится лично мне, в том числе "листочки" на одной строке, несколько первых уровней без отступов и так далее. Если бы не эти "исключения", программа могла бы быть и проще, и короче. Вызывается эта консольная программа командой вроде:

RTFnice < file.rtf >out.txt

По ходу обратите внимание на то, как вызывается нестатический метод из статического: поскольку на момент вызова статического main может не существовать ни одного экземпляра класса, то попытка обращения к его обычным нестатическим методам некорректна - нужно явно создать экземпляр и потом вызывать другие функции нашей программы. Для новичков в NET зачастую это шокирующая новость.

Итого

RTF - хоть и не самый новый, но, тем не менее, мощный и достаточно гибкий формат для документов, переносимых на различные платформы. Если вы заинтересованы в консистентной генерации документов и обмене ими вне зависимости от платформы и без привязки к MS Office - RTF очень даже способен вам в этом помочь. Предлагаемая технология позволяет сравнительно просто и компактно генерировать RTF с помощью несложного кода. Показанный класс RTFgen тривиально портируется на языки, штатно поддерживающие хеш-таблицы, такие как Java или Perl, и чуть сложнее - на те, где таблицы символов придется реализовать самостоятельно. Относительно сложный синтаксис шаблонов и большое количество параметров на поверку оказались мнимыми проблемами - по крайней мере, все оказалось не сложнее HTML, а повторное использование шаблонов может вообще свести вашу работу к минимуму.

Кстати, сама "машинка" после небольших переделок сможет генерировать и другие структурированные форматы, такие как HTML, XML, PDF, PS и т.д., так что описанные принципы могут иметь самое различное применение.

На КП-диске выложены образцы шаблонов (реально используемые мной в системе управления публикациями Catch) и сам класс RTFgen.cs. Обратите внимание: каталог Temlates должен находиться уровнем ниже каталога приложения - так что, если вы будете строить приложение в Visual Studio для отладки скопируйте Templates в Debug и\или Release.

 
2004.04.27 Автор: Арсений Чеботарёв
https://www.cpp.com.ua