Сортировка текстов, сортировка групп абзацев

Поступил вопрос с мест по поводу автоматической сортировки абзацев в тексте. Примерный смысл вопроса следующий:

Большинство скриптов сортировки, в том числе и скрипт paraTrooper.js, написанный мной в далеком 2004 году, умеют сортировать абзацы поштучно. А что делать, если нужно отсортировать по неким признакам группы абзацев, сохранив при этом взаимное расположение абзацев внутри группы?

Задача интересная не только с точки зрения разработки, но и как пример, на котором можно наглядно продемонстрировать некоторые приемы работы с текстом в скриптах для InDesign.

Постановка задачи

Формально задача поставлена следующим образом. Имеется текст, отформатированный тремя стилями: header (заголовок), subheader (подзаголовок) и text (обычный текст). Текст, начинающийся от абзаца со стилем header до следующего абзаца с таким же стилем (не включая этот абзац) считается группой. Нужно отсортировать такие группы по определенному признаку, в данном случае по номеру в тексте абзаца-заголовка.

screenshot_55

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

В тексте, соответственно, девять произвольно расположенных групп абзацев.

Что должен сделать скрипт:

  1. найти и сформировать группы,
  2. сортировать группы,
  3. переместить абзацы согласно сортировке.

И все это быстро и надежно.

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

#target indesign

var langStrings = {
	nodoc: 		localize( { en: 'No documents are open!', ru: 'Нет открытых документов!' } ),
	notext: 	localize( { en: 'No text are selected!', ru: 'Не выбран текст!' } ),
	nostyle: 	localize( { en: 'Can\'t find predefined paragraph style!', ru: 'Не найден предопределенный стиль абзаца!' } ),
}

var iniStart = 'header';

function numsort(a, b) {

	var anum = parseInt(a[0].contents.toString().replace(/^[^\d]+/gim, ''));
	var bnum = parseInt(b[0].contents.toString().replace(/^[^\d]+/gim, ''));

	return anum - bnum;

}

function main() {

	try {
		var doc = app.activeDocument;
	} catch (error) {
		alert(langStrings.nodoc);
		return false;
	}

	try {
		var story = app.selection[0].parentStory;
	} catch (error) {
		alert(langStrings.notext);
		return false;
	}

	var style = doc.paragraphStyles.itemByName(iniStart);
	if (!style.isValid) {
		alert(langStrings.nostyle);
		return false;
	}

	var regions = [];
	var current = null;

	for (var i = 0; i < story.paragraphs.length; i++) {
		var para = story.paragraphs[i];
		if (para.appliedParagraphStyle == style) {
			if (current != null) {
				regions.push(current);
			}
			var current = [];
			current.push(para);
		} else {
			if (current != null) {
				current.push(para);
			}
		}
	}

	if (current != null) {
		regions.push(current);
	}

	var beg = regions[0];
	var end = regions[regions.length - 1];

	regions.sort(numsort);
	
	var temptf = doc.pages[0].textFrames.add();
	var ts = temptf.parentStory;

	for (var i = 0; i < regions.length; i++) {
		var reg = story.paragraphs.itemByRange(regions[i][0], regions[i][regions[i].length - 1]);
		reg.duplicate(LocationOptions.AT_END, ts);
	}
	
	var prevtext = story.paragraphs.itemByRange(beg[0], end[end.length - 1]).getElements()[0];
	if (prevtext.characters.lastItem().contents != '\u000D') {
		ts.insertionPoints.firstItem().contents = '\u000D';
	}
	var newtext = ts.paragraphs.itemByRange(ts.paragraphs.firstItem(), ts.paragraphs.lastItem());
	newtext.move(LocationOptions.AFTER, prevtext);
	prevtext.paragraphs.everyItem().remove();

	temptf.remove(); 
}

main();

Разбор кода

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

var iniStart = 'header';

Затем объявляются две функции: numsort  —  для сортировки групп (подробно рассмотрим позже) и main  —  основная функция, где и будет происходить основная работа.

В функции main сначала проверяются на наличие объекты и объявляются переменные doc (рабочий документ), story (текст для обработки) и style (стиль абзаца-заголовка, отмечающего начало групп). Если нужный объект не обнаружен, то выводится сообщение и функция возвращает false. Все довольно стандартно, разве что получение и проверка стиля абзаца имеют некоторую специфику, на которую стоит обратить внимание. При желании можно написать диалоговое окно, в котором пользователь может выбрать стиль для абзаца-заголовка. Это намного удобнее для пользователя, но мы такой диалог писать, конечно, не будем.

Следующий кусок кода отвечает непосредственно за группировку абзацев.

var regions = [];
var current = null;
for (var i = 0; i < story.paragraphs.length; i++) {
	var para = story.paragraphs[i];
	if (para.appliedParagraphStyle == style) {
		if (current != null) {
			regions.push(current);
		}
		var current = [];
		current.push(para);
	} else {
		if (current != null) {
			current.push(para);
		}
	}
}
if (current != null) {
	regions.push(current);
}

Что в нем происходит.

Сначала объявляются две переменные: regions (массив для хранения групп абзацев) и current (переменная для формирования отдельной группы). Поскольку ни одна группа еще не сформирована, переменной current нужно присвоить значение null.

Затем в цикле перебираются все абзацы выбранного текста story. Если стиль, примененный к очередному абзацу будет стилем заголовка, то предыдущая группа (переменная current) будет добавлена к массиву групп (если, конечно, current не равна null), а переменная current будет заново инициализирована как новый массив, куда будет добавлен абзац заголовка. Если же стиль абзаца не является стилем заголовка, то очередной абзац будет добавлен к текущей группе current (опять же, если  current не равна null).

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

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

Следующие две строки

var beg = regions[0];
var end = regions[regions.length - 1];

объявляют две переменных, которые нам пригодятся в дальнейшем. По сути это первая и последняя группа абзацев в массиве regions.

Следующая строка

regions.sort(numsort);

сортирует массив групп.

Если кто не знает: JavaScript позволяет использовать для сортировки массивов произвольные функции, передав имя функции сортировки в качестве  параметра функции sort().

Рассмотрим, как происходит сортировка в функции numsort.

function numsort(a, b) {

	var anum = parseInt(a[0].contents.toString().replace(/^[^\d]+/gim, ''));
	var bnum = parseInt(b[0].contents.toString().replace(/^[^\d]+/gim, ''));

	return anum - bnum;

}

Любая функция сортировки имеет два параметра, в которые при вызове передаются два сравниваемых элемента массива. В нашем случае элементы массива  —  это группы абзацев, причем в этих группах первый элемент является абзацем-заголовком, у которого есть номер, по каковому номеру эти группы и нужно сортировать. В общем, как всегда: игла в яйце, яйцо в утке, утка в зайце и так далее. Отличие от народной сказки только в том, что вытащить нужное значение довольно просто: используем встроенную функцию parseInt(), куда передадим текст заголовка, предварительно очистив от лишних знаков в начале текста. «Заголовок 1» преобразуется в «1»; строковое значение преобразуется в числовое. Функция возвращает разницу между номером из первого параметра и номером из второго, а массив волшебным образом сортируется по возрастанию.

Следующие две строки

var temptf = doc.pages[0].textFrames.add();
var ts = temptf.parentStory;

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

Что, собственно, и происходит в следующем цикле.

for (var i = 0; i < regions.length; i++) {
	var reg = story.paragraphs.itemByRange(regions[i][0], regions[i][regions[i].length - 1]);
	reg.duplicate(LocationOptions.AT_END, ts);
}

Из каждой группы абзацев в отсортированном массиве regions формируется последовательность, которая дублируется в конец созданной story. Текст отсортирован.

Следующая задача  —  заменить несортированный текст готовым отсортированным. Слабые духом могут использовать app.copy() и app.paste(), но этот путь не для нас. Использования стандартного буфера обмена следует избегать до последнего, поскольку это потенциальная дыра для крайне тяжело отслеживаемых ошибок. Например, если в процессе работы скрипта содержимое буфера заменит любая другая программа.

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

Вот здесь нам и пригодятся переменные beg и end. Поскольку мы их взяли из еще не сортированного массива групп абзацев, то через них можно получить начало и конец несортированного текста, который нужно заменить. Получаем последовательность:

var prevtext = story.paragraphs.itemByRange(beg[0], end[end.length - 1]).getElements()[0];

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

if (prevtext.characters.lastItem().contents != '\u000D') {
	ts.insertionPoints.firstItem().contents = '\u000D';
}

Если этого не сделать, то первый абзац нового текста объединится с последним абзацем старого текста в один абзац, после чего этот самый составной абзац успешно будет удален через один шаг с криком: «Доктор, мы его теряем!».

Получаем новый текст в виде последовательности абзацев:

var newtext = ts.paragraphs.itemByRange(ts.paragraphs.firstItem(), ts.paragraphs.lastItem());

Переносим новый текст на позицию сразу после исходного несортированного текста:

newtext.move(LocationOptions.AFTER, prevtext);

Удаляем исходный несортированный текст:

prevtext.paragraphs.everyItem().remove();

Обратите внимание, удаляем «поабзацно», а не целиком:

prevtext.remove();

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

Последним действием удаляем временный текстовый фрейм.

temptf.remove();

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

screenshot_59

Файлы

Скрипт: ParaRegSort.zip

Текст, использованный для тестирования: pgs.zip

Оставьте комментарий