В первой части я разбирал, как сделать, чтобы поля в одной строке не противоречили друг другу: пол, имя и диагноз перестают жить сами по себе и начинают сходиться. Заканчивалось всё карточками, напечатанными через запятую. Откуда в них взялись запятые, я не сказал ни слова. А это очень важно для понимания!

Вот тот кусок конфига, который печатал карточки:

<block>
    <line><data>${{Пол}}, ${{Имя}}, ${{Диагноз}}</data></line>
</block>

Так выглядит вывод:

Мужской, Владимир, Простатит
Мужской, Александр, Хламидиоз
Женский, Наталья, Хронический артрит

Обе запятые и оба пробела напечатал я. Руками, прямо внутри тега. Движок не добавил от себя ни одного символа: он нашёл три имени в фигурных скобках, заменил их значениями, а весь остальной текст перенёс на выход как был.

Замена имени на значение называется подстановкой, или интерполяцией. Само имя в скобках, ${{Пол}}, никуда не печатается: движок видит его, идёт за значением и ставит значение на это место. Всё, что вокруг скобок, для движка просто буквы.

Зелёным - то, что подставил движок. Красным - то, что набрали вы, и оно вышло ровно там, где стояло
Зелёным - то, что подставил движок. Красным - то, что набрали вы, и оно вышло ровно там, где стояло

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

Проверить это легко. Я не трогаю описание данных вообще, а правлю единственную строку с текстом:

<line><data>${{Имя}} (${{Пол}}) -- диагноз: ${{Диагноз}}</data></line>
Владимир (Мужской) -- диагноз: Простатит
Александр (Мужской) -- диагноз: Хламидиоз
Наталья (Женский) -- диагноз: Хронический артрит

Значения не изменились и не могли: сид (стартовое число случайности, с ним один и тот же конфиг всегда даёт одни и те же данные) и описания полей прежние, Владимир так и остался Владимиром с простатитом. Изменилась только строка текста вывода.

Три тега, и ни в одном нет слова “формат”

Вывод описывается в том же файле, что и сами данные. Основной текст записи строится из трёх тегов, вложенных друг в друга, а вокруг записей есть ещё несколько обёрток - о них чуть ниже.

Запись состоит из строк, строка - из кусков текста. Кусков в одной строке может быть несколько
Запись состоит из строк, строка - из кусков текста. Кусков в одной строке может быть несколько

<block> - это одна запись: одна карточка, один документ, одна строчка таблицы. Движок повторяет его столько раз, сколько записей вы заказали, и каждый раз подставляет свежие значения.

<line> - одна строка вывода. После неё движок ставит перевод строки.

<data> - кусок текста внутри строки. Всё, что стоит между открывающим и закрывающим тегом, уходит на выход как есть. Единственное исключение - подстановка ${{Имя}}.

“Как есть” тут значит “буквально”. Пробелы - это тоже текст, ничего не обрезается и не схлопывается, поэтому отступ в выводе вы делаете отступом внутри <data>.

У вывода обычно есть ещё начало и конец, и на них тоже есть теги. <before> и <after> печатаются по одному разу на весь вывод - это шапка и хвост. В них удобны служебные имена, которые движок подставляет сам: ${{_total}} - сколько всего записей, ${{_count}} - номер текущей. <before_block> и <after_block> печатаются вокруг каждой записи.

Обёрток на самом деле больше: есть, например, <delimiter_block>, который печатается между записями и не печатается после последней. В этой статье понадобятся только эти четыре.

Формат, под который экспортёра не найдётся

До этого места всё выглядит как обычный шаблонизатор, и это справедливое подозрение. Поэтому проверю идею на нестандартном формате. Показывать это на CSV или JSON я не буду: их соберёт кто угодно и чем угодно, тут ничего не докажешь. Интереснее случай, когда у формата вообще нет названия. Такое встречается чаще, чем кажется, поэтому давайте его придумаем.

Представьте себе склад. Товар приходит партиями, и приёмку обрабатывает проприетарная программа, которую когда-то написали для этого склада и больше ни для кого. Файл она ждёт такой:

  • первая строка - шапка: сколько всего документов в файле;

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

  • под заголовком - от одной до трёх строк с позициями, то есть с тем, что именно привезли;

  • потом строка-терминатор, которая говорит “документ кончился”;

  • в самом конце - строка “файл кончился”.

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

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

Конфиг целиком: 36 строк с комментариями. Читать его подряд не нужно - ниже я разбираю только три места из него
<tdc>
    <env count="4" seed="wh-2026" local="ru">

        <!-- Номер документа: буквы ПР, дефис и ровно шесть цифр.
             Генератор regex собирает значение по описанию его формы. -->
        <sequence name="Док"><gen type="regex" value="ПР-[0-9]{6}"/></sequence>

        <!-- Перевозчик: готовый список транспортных компаний из пакета данных -->
        <sequence name="Перевозчик"><gen type="template" value="commerce.carrier"/></sequence>

        <!-- Что за груз: четверть поставок замороженные, остальные обычные.
             Доли точные: на 4 записи будет ровно одна замороженная -->
        <sequence name="Груз"><gen type="text" value="FROZEN,DRY" percent="25,75"/></sequence>

        <!-- Позиции. repeat="1..3" кладёт в это поле не одно значение, а СПИСОК
             из одного, двух или трёх артикулов, склеенных знаком ~ -->
        <sequence name="Поз"><gen type="regex" value="46[0-9]{6}\*[0-9]{1,2}" repeat="1..3" separator="~"/></sequence>

        <!-- Шапка и хвост всего вывода: печатаются по одному разу -->
        <before><line><data>#WBIN/3 ${{_total}}</data></line></before>
        <after><line><data>#END</data></line></after>

        <!-- Обёртки вокруг КАЖДОЙ записи -->
        <before_block>
            <!-- Два куска текста в одной строке. Второй печатается только у заморозки -->
            <line><data>@${{Док}}|${{Перевозчик | upper}}</data><data if="Груз.FROZEN">|T</data></line>
        </before_block>
        <after_block><line><data>/@</data></line></after_block>

    </env>
    <block>
        <!-- each разворачивает список Поз: одна строка на каждый его элемент,
             а _item подставляет порядковый номер элемента внутри записи -->
        <line each="Поз"><data>  .${{_item}} ${{Поз}}</data></line>
    </block>
</tdc>

Ниже можно увидеть выхлоп:

#WBIN/3 4
@ПР-091445|ПЭК
  .1 46358891*79
/@
@ПР-232375|5POST|T
  .1 46539422*8
/@
@ПР-559080|ПОЧТА РОССИИ
  .1 46525120*74
  .2 46377845*4
/@
@ПР-454181|5POST
  .1 46843807*2
  .2 46544385*0
  .3 46203736*23
/@
#END

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

Шапка и хвост печатаются по разу на весь вывод, а обёртки записи повторяются вместе с ней
Шапка и хвост печатаются по разу на весь вывод, а обёртки записи повторяются вместе с ней

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

Строка позиции в конфиге одна, а печатается разное число раз. У первого документа под заголовком одна строка, у последнего три. Тут работают два слова, и они делают разное. Давайте их разберём.

Сначала repeat="1..3" на генераторе. Он говорит: положи в это поле не одно значение, а список из одного, двух или трёх значений. Все они лежат в одном поле Поз, склеенные знаком ~ - этот знак я выбрал сам атрибутом separator.

А потом each="Поз" на строке вывода. Он разворачивает этот список обратно: печатает строку по одному разу на каждый элемент. Внутри такой строки появляется ${{_item}} - номер элемента по порядку.

И там же ${{Поз}} начинает означать не весь список, а текущий элемент. Это не отдельное правило, а то, как работает each: он проходит по списку и на каждом шаге подставляет в строку тот элемент, до которого дошёл. Снаружи each то же самое ${{Поз}} вернуло бы всё поле целиком, вместе с разделителями.

Коротко: repeat отвечает на вопрос “сколько значений положить в поле”, а each - на вопрос “сколько строк из этого поля напечатать”. Первое стоит на генераторе, второе на строке вывода.

repeat собирает список внутри одного поля, each раскладывает его обратно по строкам
repeat собирает список внутри одного поля, each раскладывает его обратно по строкам

Пометка заморозки - это второй <data> на той же строке. У него своё условие: печатайся, только если груз замороженный. У обычных документов условие не выполнилось, и этот кусок текста просто не напечатался, а соседний напечатался как всегда. Пустого места вместо него не осталось - ячеек тут нет, есть символы подряд.

Условие стоит на одном куске строки. Не выполнилось - кусок не напечатался, соседний напечатался как всегда
Условие стоит на одном куске строки. Не выполнилось - кусок не напечатался, соседний напечатался как всегда

И | upper в заголовке. Вертикальная черта с именем после неё - это фильтр: он меняет значение прямо на выходе, уже после того, как оно сгенерировано. Программе на складе нужен перевозчик заглавными буквами, она их получает, а само значение в данных остаётся обычным. Ниже это пригодится.

Тот же конфиг, другой вывод

Теперь представьте, что на складе есть и вторая программа - та, что ведёт остатки. Приёмку она не обрабатывает, но про приходящие поставки знать должна, и формат у неё свой: плоские секции “ключ = значение”. Описание данных я не трогаю совсем - те же четыре поля Док, Перевозчик, Груз, Поз и тот же сид. Убираю обёртки и переписываю только <block>.

<block>
    <line><data>[recv/${{_count}}]</data></line>
    <line><data>doc  = ${{Док}}</data></line>
    <line><data>carr = ${{Перевозчик}}</data></line>
    <line if="Груз.FROZEN"><data>frozen = 1</data></line>
    <line><data>pos  = ${{Поз | replace:~,,}}</data></line>
    <line if="!_last"><data></data></line>
</block>

Ниже можем увидеть новый вывод:

[recv/1]
doc  = ПР-091445
carr = ПЭК
pos  = 46358891*79

[recv/2]
doc  = ПР-232375
carr = 5Post
frozen = 1
pos  = 46539422*8

[recv/3]
doc  = ПР-559080
carr = Почта России
pos  = 46525120*74,46377845*4

[recv/4]
doc  = ПР-454181
carr = 5Post
pos  = 46843807*2,46544385*0,46203736*23

Сравните два вывода. Документ ПР-091445 везёт ПЭК и там, и там. ПР-232375 везёт 5Post. ПР-559080 - Почта России. Позиций по документам 1, 1, 2 и 3 в обоих случаях. В итоге получаем: описание данных и сид те же самые, поменялся только набранный текст.

Значения одни и те же. Отличается только то, что набрано между тегами вывода
Значения одни и те же. Отличается только то, что набрано между тегами вывода

Заодно тут видны три мелочи. if стоит на всей <line>, поэтому убирает строку целиком, а не кусок внутри неё. | replace:~,, - ещё один фильтр, и две запятые подряд тут не опечатка. Пишется он как replace:что,на что: меняю тильду на запятую, вот вторая запятая и оказывается рядом с первой. А перевозчик без | upper печатается обычными буквами: “Почта России”.

Чего движок за вас не сделает

Сразу оговорюсь про обратную сторону, чтобы не создавать неверного впечатления. Раз движок не знает, что вы собираете, он не поставит за вас ни кавычку, ни запятую. И лишнюю не уберёт.

Чаще всего на этом спотыкаются там, где разделитель живёт внутри самого значения. Пример проще некуда. Представьте интернет-магазин: покупатель вернул товар и указал причину возврата. Причина может быть не одна - и “долгая доставка”, и “прислали не тот товар” сразу. Поэтому причины лежат в одном поле списком:

<sequence name="Причина">
    <!-- одна, две или три причины в одном поле, склеенные запятой -->
    <gen type="template" value="commerce.returnReason" repeat="1..3" separator=","/>
</sequence>

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

1,Прибыл повреждённым,Заказал по ошибке
2,Нашёл дешевле
3,Нашёл дешевле
4,Не соответствует описанию,Больше не нужен,Качество ниже ожидаемого

Глазами человека всё нормально. А теперь посмотрите на это глазами программы, которая будет файл читать. Она не знает, что такое “причина возврата”. Она знает одно правило: запятая делит строку на колонки. И честно делит по каждой запятой, которую видит.

Программа считает колонки по запятым. Запятая внутри значения ничем от разделителя не отличается, пока её не закрыть кавычками
Программа считает колонки по запятым. Запятая внутри значения ничем от разделителя не отличается, пока её не закрыть кавычками

Получается, что колонок в строках три, две, две и четыре - у каждой своё число. Такой файл либо не откроется, либо, что хуже, откроется криво и молча.

Исправляется это одним фильтром: дописываю к имени | csv, то есть меняю ${{Причина}} на ${{Причина | csv}}.

1,"Прибыл повреждённым,Заказал по ошибке"
2,"Нашёл дешевле"
3,"Нашёл дешевле"
4,"Не соответствует описанию,Больше не нужен,Качество ниже ожидаемого"

Теперь во всех строках ровно две колонки. Кавычки для читающей программы означают: всё, что внутри, - одно значение целиком, запятые внутри не считать.

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

Его брат | sql удваивает одинарную кавычку, чтобы фамилия вроде О’Коннор не оборвала строку в SQL-дампе.

И тот и другой применяются к одному конкретному полю, а не переключают что-то во всём выводе. Никакого “режима CSV” в движке нет, как нет и самого CSV: он, JSON, SQL-дамп, YAML и NDJSON - частные случаи текста, который вы набрали.

Значения и их согласованность - на движке. Все знаки препинания в выводе - на вас
Значения и их согласованность - на движке. Все знаки препинания в выводе - на вас

Одна проверка, которую я всё же оставил

Правило “печатай как есть” работает для обычного текста. Но ${{...}} - уже не обычный текст, а указание движку сходить за значением. Поэтому слово Позз в строке - это просто слово, а ${{Позз}} - то же указание, но с опечаткой в имени, и печатать его молча нельзя.

Одно и то же слово: в тексте оно напечатается, внутри фигурных скобок - остановит прогон
Одно и то же слово: в тексте оно напечатается, внутри фигурных скобок - остановит прогон

Вот этого я допускать не стал. Меняю в рабочем конфиге ${{Поз}} на ${{Позз}} и запускаю:

error[TDC193]: "Позз" is not a declared sequence — it would be printed literally
 --> priyom.tdc:34:26
   |
34 |         <line each="Поз"><data>  .${{_item}} ${{Позз}}</data></line>
   |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
help: did you mean "Поз"?
note: Declare it in <env>, or set a different inject= pattern if you really want the text ${{…}} in the output.

aborted: 1 error

Ни одной строки на выходе, код возврата 1. Прогон останавливается раньше, чем напечаталась первая строка.

Если бы движок промолчал, он напечатал бы буквально ${{Позз}}. Это худший исход, потому что ошибка при этом не пропадает, а маскируется под данные. В текстовом выводе такое ещё можно поймать глазами.

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

Когда вывод вообще не нужен

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

Файл конфига один и тот же. Текстовый путь читает <block> и обёртки, объектный их пропускает
Файл конфига один и тот же. Текстовый путь читает <block> и обёртки, объектный их пропускает
import { TDC } from 'tdcv2';

const tdc = new TDC({ configFile: './priyom.tdc' });
const rows = tdc.toArray();

console.log(rows[0]);
console.log(rows[2].Перевозчик);
{
  'Док': 'ПР-091445',
  'Перевозчик': 'ПЭК',
  'Груз': 'DRY',
  'Поз': '46358891*79'
}
Почта России

Конфиг в примере я не менял вообще: это тот же складской файл, вместе со всеми обёртками и <block>. Объектный вывод их просто не читает: он берёт только описания полей, поэтому rows[2].Перевозчик можно проверять напрямую. Значения при этом те же, что ушли бы в текст: ПР-091445 у ПЭК, третья запись у Почты России.

Одно место тут обманывает ожидание. Поле Поз, которому repeat положил список, приходит в объект склеенной строкой, а не массивом: у записи с тремя позициями это будет "46843807*2~46544385*0~46203736*23". Разбирать её, если нужен массив, придётся самостоятельно.

Обратите внимание на кавычки в этом выводе: они одинарные. Это не JSON, а то, как Node показывает обычный объект. Оборачивать результат во что-то ещё не нужно, JSON.stringify тут не при чём: массив объектов уже готов, и обращаться к полю можно сразу. JSON.stringify понадобится только если вам нужен именно JSON-текст.

Одна особенность этих объектов укусит в первом же тесте: все значения приходят строками. Число выглядит как "52", а не 52, дата - как "17.02.2026". Это не недосмотр: движок печатает то, что ушло бы в текст, а типы у полей вы знаете лучше него.

Приводить к нужному типу поэтому приходится самому, и делается это обычными средствами языка. Число - через Number(x) или короткое +x. С датой чуть интереснее: "17.02.2026" стандартный new Date() не разберёт, он ждёт год первым. Либо разбирайте строку сами, либо попросите нужный вид сразу в конфиге - format="YYYY-MM-DD" на генераторе даты даст "2026-02-17", и new Date() с ним справится.

И забирать весь массив нужно не всегда. Если тот же конфиг раскрутить до 200 000 записей, getAt(199999) достанет двухсоттысячную, не считая предыдущие.

Что из этого следует

Когда в следующий раз понадобится вывод под чужую программу, экспортёр можно не искать. И просить добавить его тоже не нужно. Достаточно открыть описание того, что эта программа ждёт, и набрать его текстом между тегами, а на местах значений написать имена полей в фигурных скобках.

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

Инструмент называется TDCV2, лежит под MIT.

А больше всего мне сейчас не хватает чужого опыта. Расскажите, как вы делаете тестовые данные: на чём спотыкались, что пришлось городить руками, чем в итоге закрыли. Отдельно интересны форматы, на которые эта механика не ложится. Читаю всё. Если увижу, что чего-то в генераторе не хватает - попробую сделать.

До встречи в следующей статье!