Перейти к содержимому страницы
Открыть меню
Переключить меню настроек
Открыть персональное меню
Вы не представились системе
Ваш IP-адрес будет виден всем, если вы внесёте какие-либо изменения.

Модуль:Sports table/sub

Материал из вики «Вече»

Для документации этого модуля может быть создана страница Модуль:Sports table/sub/doc

-- Общий подмодуль семейства спортивных модулей:
-- данные и функции, которыми пользуются сразу несколько модулей семейства.

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

-- Подключается из [[Модуль:Sports table]], [[Модуль:Sports results]], [[Модуль:Sports group]], из
-- подмодулей стиля турнирной таблицы, из [[Модуль:Sports table/argcheck]],
-- [[Модуль:Sports table/auto]], [[Модуль:Sports table/totalscheck]],
-- [[Модуль:Sports table/source]], из [[Модуль:Football box/base]] (через него — из карточек матча)
-- и из [[Модуль:Football event]]. Через #invoke вызывается только print_styles.

-- Документация — [[Модуль:Sports table/doc]] и [[Модуль:Sports results/doc]];
-- о палитре и таблицах стилей — раздел «Палитра и таблицы стилей» первой из них.
local ppp = {}

-- Генератор случайных чисел засевается при загрузке подмодуля. Из него вызывающие модули берут
-- значение rand_val (math.random()), которое дописывают к идентификаторам сносок: без этого сноски
-- двух таблиц на одной странице получили бы одинаковые идентификаторы и слились бы.
-- require загружает модуль один раз за вызов, поэтому засев срабатывает однажды,
-- сколько бы модулей семейства в вызове ни подключали подмодуль.
math.randomseed(os.time() + (os.clock() * 10 ^ 6))

-- Служебные функции ---------------------------------------------------------------------

-- Возвращает обёртку функции одного аргумента, которая запоминает её результаты — все возвращаемые значения —
-- на время вызова. Ею обёрнуты разбор цветов, флагов, счёта и дат и построение якорей:
-- одно и то же значение за вызов разбирается многократно — для каждой встречи команды, для ширины столбца,
-- для якорей, — а разбор идёт через mw.ustring, который заметно медленнее встроенных строковых функций.
-- Аргумент nil не запоминается: ключом таблицы он быть не может.
local function memoize(fn)
	local cache = {}
	return function(s)
		if s == nil then
			return fn(s)
		end
		local hit = cache[s]
		if hit == nil then
			hit = {fn(s)}
			cache[s] = hit
		end
		return unpack(hit)
	end
end

-- Обрезает по краям пробелы и прямые кавычки;
-- возвращает nil, если после обрезки ничего не осталось.
local function trim(s)
	if s == nil or s:match('^%s*(.-)%s*$') == '' then
		return nil
	end
	s = s:match('^[\'"%s]*(.-)[\'"%s]*$')
	return (s ~= '') and s or nil
end

-- Та же обрезка для других модулей:
-- [[Модуль:Football box/base]] обрезает ею значения карточек матча.
ppp.trim = trim

-- Палитра и цвета -----------------------------------------------------------------------

-- Палитра фоновых цветов, общая для семейства: ею заливаются строки турнирной таблицы,
-- клетки шахматки и карточки матча. Цифра в имени — уровень оттенка: 1 — самый насыщенный,
-- 4 — самый светлый. white1 и пустое имя дают «inherit», то есть отсутствие собственной заливки.
-- Значения для тёмной темы — в ppp.colors_dark.
ppp.colors = {
	gold1 = '#f7f6a8', silver1 = '#dce5e5', bronze1 = '#ffdab9',
	green1 = '#bbf3bb', green2 = '#ccf9cc', green3 = '#ddfcdd', green4 = '#eeffee',
	blue1 = '#bbf3ff', blue2 = '#ccf9ff', blue3 = '#ddfcff', blue4 = '#eeffff',
	yellow1 = '#ffffbb', yellow2 = '#ffffcc', yellow3 = '#ffffdd', yellow4 = '#ffffee',
	violet1 = '#bbbbff', violet2 = '#ccccff', violet3 = '#ddddff', violet4 = '#eeeeff',
	red1 = '#ffbbbb', red2 = '#ffcccc', red3 = '#ffdddd', red4 = '#ffeeee',
	black1 = '#bbbbbb', black2 = '#cccccc', black3 = '#dddddd', black4 = '#eeeeee',
	orange1 = '#feead5', orange2 = '#fff5e8',
	white1 = 'inherit',
	[''] = 'inherit',
	-- Два цвета вне четырёхуровневой схемы: они приняты в рувики как стандартные
	accent = '#eaf3ff', -- голубой для выделения карточки
	neutral = '#f8f9fa'}

-- Палитра для тёмной темы: имена те же, что в ppp.colors, тон тот же, светлота понижена.
-- Светлая заливка на тёмном фоне была бы самым светлым пятном на странице — ярче текста;
-- с пониженной светлотой смысл цвета читается тот же, а заливка не слепит.
-- Верхняя граница выбрана по ссылке: выше неё контраст ссылки с заливкой падает ниже 4,5.

-- Сам модуль эти значения не выводит: они нужны таблицам стилей, а страница стилей не проходит
-- через вики-разметку. Туда их переносит блок CSS от ppp.print_styles, а здесь они записаны,
-- чтобы у палитры был один источник.
ppp.colors_dark = {
	gold1 = '#4c3d00', silver1 = '#3f3f3f', bronze1 = '#5c361f',
	green1 = '#194319', green2 = '#153715', green3 = '#112c11', green4 = '#0d210d',
	blue1 = '#193b43', blue2 = '#153337', blue3 = '#112a2c', blue4 = '#0d2121',
	yellow1 = '#414118', yellow2 = '#373715', yellow3 = '#2c2c11', yellow4 = '#21210d',
	violet1 = '#191943', violet2 = '#151537', violet3 = '#11112c', violet4 = '#0d0d21',
	red1 = '#431919', red2 = '#371515', red3 = '#2c1111', red4 = '#210d0d',
	black1 = '#2e2e2e', black2 = '#262626', black3 = '#1f1f1f', black4 = '#171717',
	orange1 = '#432e19', orange2 = '#342614',
	white1 = 'inherit',
	[''] = 'inherit',
	-- Цвета вне четырёхуровневой схемы — см. ppp.colors.
	accent = '#192b43', -- голубой для выделения карточки
	neutral = '#222425'} -- нейтральный серый оформления MediaWiki

-- Цвет текста, который вызывающие модули задают вместе с любым непрозрачным фоном из ppp.colors,
-- выведенным встроенным стилем. В тёмной теме встроенный стиль не меняется: фон остался бы светлым,
-- а унаследованный цвет текста посветлел бы — светлый текст на светлом фоне.
-- Значение — стандартный цвет основного текста Википедии (см. [[ВП:Цветовой контраст]]).
ppp.text_color = '#202122'

-- Цвет полосы между группами позиций (splitN в [[Модуль:Sports table]]) — для светлой и тёмной темы.
-- Это цвет разделительных линий оформления MediaWiki (border-color-subtle): полоса светлее рамки таблицы,
-- но видна и на заливке строк, и на фоне таблицы. Серые палитры для неё не годятся:
-- они подобраны как заливка и в тёмной теме почти сливаются с фоном. Выводит его ppp.print_styles.
ppp.split_colors = {light = '#c8ccd1', dark = '#54595d'}

-- Цвет линии между кругами в шахматке ([[Модуль:Sports results]]). Это стандартный цвет рамок таблиц MediaWiki —
-- по той же причине, по которой в палитре есть accent и neutral: линии семейства не отличаются от линий остального оформления.
ppp.border_color = '#a2a9b1'

-- Имена палитры, которым назначен смысл в оформлении таблиц и карточек матча.
-- Для них ppp.palette_color возвращает происхождение «named», для всех прочих значений —
-- «custom», и такие страницы вызывающие модули заносят в отслеживающую категорию.
ppp.assigned_colors = {
	gold1 = true, silver1 = true, bronze1 = true,
	green1 = true, green2 = true, green3 = true, green4 = true,
	blue1 = true, blue2 = true, blue3 = true, blue4 = true,
	yellow1 = true, yellow2 = true, yellow3 = true, yellow4 = true,
	violet1 = true, violet2 = true, violet3 = true, violet4 = true,
	red1 = true, red2 = true, red3 = true, red4 = true,
	black1 = true, black2 = true, black3 = true, black4 = true,
	orange1 = true, orange2 = true,
	white1 = true, accent = true, neutral = true
}

-- Имена оттенков без номера уровня: «gold» читается как «gold1». Так пишут по привычке к названиям цветов CSS,
-- и разумнее понять это как первый уровень оттенка, чем выводить ярко-жёлтый #ffd700,
-- на котором текст почти не читается. Происхождение у такого значения — «custom».
local bare_hues = {
	green = 'green1', blue = 'blue1', yellow = 'yellow1', red = 'red1',
	black = 'black1', gold = 'gold1', violet = 'violet1', orange = 'orange1',
	silver = 'silver1', bronze = 'bronze1', white = 'white1'
}

-- Разбирает значение цвета без учёта регистра и пробелов по краям.
-- Возвращает цвет и его происхождение:
--   цвет, «named»  — имя из ppp.assigned_colors; так и следует писать;
--   цвет, «custom» — имя оттенка без номера (bare_hues), шестнадцатеричный код из трёх или шести
--                    цифр с решёткой или без неё, слово из латинских букв (название цвета CSS);
--   nil, «custom»  — значение задано, но цветом не является;
--   nil, nil       — значение не задано или пустое.

-- Всё, что может быть цветом, применяется, а не отбрасывается: в англовики шестнадцатеричный код —
-- документированный способ задать фон, разметку оттуда постоянно копируют, и отказ означал бы
-- пропавшую заливку на тысячах страниц. Вместо этого вызывающие модули заносят в отслеживающую
-- категорию всё, кроме «named». Название цвета CSS пропускается как есть:
-- свойство и место подстановки задаёт модуль, а недопустимое значение браузер просто не применит.
function ppp.palette_color(name)
	if name == nil then
		return nil, nil
	end
	local key = mw.ustring.lower(mw.ustring.gsub(tostring(name), '^%s*(.-)%s*$', '%1'))
	if key == '' then
		return nil, nil
	end
	if ppp.assigned_colors[key] then
		return ppp.colors[key], 'named'
	end
	local bare = bare_hues[key]
	if bare then
		return ppp.colors[bare], 'custom'
	end
	-- Шестнадцатеричный цвет, записанный с решёткой или без неё.
	local hex = mw.ustring.match(key, '^#?(%x%x%x)$') or mw.ustring.match(key, '^#?(%x%x%x%x%x%x)$')
	if hex then
		return '#' .. hex, 'custom'
	end
	-- Название цвета CSS: только латинские буквы.
	if mw.ustring.match(key, '^[a-z]+$') then
		return key, 'custom'
	end
	-- Значение задано, но цветом не является: опечатка в имени палитры, посторонний текст.
	-- Заливки не будет, но происхождение возвращается —
	-- иначе такой параметр не оставлял бы следа, и найти его было бы нечем.
	return nil, 'custom'
end
ppp.palette_color = memoize(ppp.palette_color)

-- Логические значения -------------------------------------------------------------------

-- Канонические написания логических параметров: yes и no, принятые в вики-разметке, и да и нет,
-- естественные для русского раздела; регистр не важен (см. ppp.is_canonical_boolean).
-- [[Модуль:Yesno]] понимает и другие варианты — y/n, true/false, д/н, +/− и 1/0, — и разметка с
-- ними работает, но вызывающие модули собирают их, как и нераспознанные значения, в отслеживающую
-- категорию: по её наполнению видно, что стоит привести к единому виду, а что действительно сломано.

-- Набор общий для таблиц и карточек матча ([[Модуль:Sports table/argcheck]],
-- [[Модуль:Football box/base]]): значение вида «aet=1» отмечается одинаково,
-- в каком бы модуле оно ни встретилось.
ppp.canonical_boolean = {
	['yes'] = true, ['no'] = true,
	['да'] = true, ['нет'] = true
}

-- Возвращает true, если значение записано в каноническом виде. nil каноническим не считается;
-- пустые значения вызывающие модули отсеивают сами.
function ppp.is_canonical_boolean(v)
	return v ~= nil and ppp.canonical_boolean[mw.ustring.lower(tostring(v))] == true
end

-- Предпросмотр, отслеживающие категории, страница вызова --------------------------------

-- Ответ ppp.in_preview, запомненный на время вызова.
local preview_state

-- Возвращает true, если страница показывается в предпросмотре: у несохранённой правки
-- ещё нет номера версии. Ответ в пределах вызова не меняется, поэтому вычисляется один раз.
function ppp.in_preview(frame)
	if preview_state == nil then
		preview_state = frame:preprocess('{{REVISIONID}}') == ''
	end
	return preview_state
end

-- Строит сообщение для предпросмотра; показывать ли его, решает вызывающий модуль
-- (см. ppp.in_preview). Все модули семейства выводят сообщения через эту функцию,
-- чтобы их вид не расходился. Уровень level:
--   'error' — красное «Предупреждение: …», точно установленная проблема: опечатка в имени параметра, ссылка на необъявленную команду;
--   'info'  — серое сообщение без приставки, сведения, а не неполадка;
--   иной    — обычно 'warning': зелёное «Возможная проблема: …», эвристика, которая может ошибаться:
--             расхождение сумм, подозрительная корректировка очков.

-- При inline сообщение выводится элементом span, а не div — для значков внутри строки текста
-- ([[Модуль:Football event]]), где блок разорвал бы строку.
function ppp.preview_note(level, text, inline)
	local tag = inline and 'span' or 'div'
	if level == 'info' then
		return tostring(mw.html.create(tag)
			:css('color', '#54595d')
			:wikitext(text))
	end
	local is_error = level == 'error'
	return tostring(mw.html.create(tag)
		:css('color', is_error and 'red' or 'green')
		:wikitext((is_error and 'Предупреждение: ' or 'Возможная проблема: ') .. text))
end

-- Переводит ссылку на отслеживающую категорию в текст для предпросмотра:
-- «[[Категория:Страницы, использующие спортивные таблицы с неизвестными параметрами|foo]]»
-- становится «неизвестными параметрами = foo». Сама ссылка на категорию в тексте невидима,
-- и без перевода сообщение осталось бы пустым. Строка, которая не является ссылкой на категорию,
-- возвращается как есть.
function ppp.warning_text(link)
	local name, key = mw.ustring.match(link, '^%[%[Категория:([^|%]]*)|?(.-)%]%]$')
	if not name then
		return link
	end
	name = mw.ustring.gsub(name, '^Страницы, использующие спортивные таблицы с ', '')
	name = mw.ustring.gsub(name, '^Страницы с ', '')
	return key ~= '' and name .. ' = ' .. key or name
end

-- Окончания имён песочниц и тестовых страниц, русские и английские.
local excluded_subpages = {'/песочница', '/тесты', '/sandbox', '/testcases'}

-- Возвращает true, если на странице title (по умолчанию — текущей) отслеживающие категории
-- не ставятся: в личном пространстве, в [[Википедия:Песочница]] с её подстраницами и на подстраницах
-- из excluded_subpages проверяют разметку, а не ведут данные, и категории там засоряли бы списки
-- для исправления. Правило одно для всего семейства — таблиц, шахматок, карточек матча и значков событий.
function ppp.tracking_excluded(title)
	title = title or mw.title.getCurrentTitle()
	if title.namespace == 2 then
		return true
	end
	local name = title.fullText or ''
	if name == 'Википедия:Песочница' or name:find('^Википедия:Песочница/') then
		return true
	end
	for _, sub in ipairs(excluded_subpages) do
		if name:sub(-#sub) == sub then
			return true
		end
	end
	return false
end

-- Возвращает отслеживающую категорию для статьи, где модуль вызван через #invoke напрямую, минуя шаблон, либо пустую строку.
-- В рувики так делать не рекомендуется: при изменении набора параметров править приходится каждую статью, а не один шаблон.
-- Категория собирает такие статьи для перевода на шаблоны; ключ сортировки — имя модуля. Учитываются только статьи.
function ppp.direct_invoke_category(frame)
	local current = mw.title.getCurrentTitle()
	if current.namespace ~= 0 then
		return ''
	end
	local parent = frame:getParent()
	if not parent or parent:getTitle() ~= current.fullText then
		return ''
	end
	local module_name = mw.ustring.gsub(frame:getTitle() or '', '^Модуль:', '')
	return '[[Категория:Статьи с прямым вызовом спортивных модулей|' .. module_name .. ']]'
end

-- Возвращает true на странице, где лежат данные таблицы: на странице шаблона, на его подстранице
-- /doc и в статье, где модуль вызван напрямую. Проверки по реестру сокращений (сверка сокращений,
-- подсказки о статьях про дерби) выполняются только там:
-- статьи, подключающие шаблон, реестр не загружают, а исправлять найденное нужно в шаблоне.
function ppp.data_page(frame)
	local parent = frame:getParent()
	local current = mw.title.getCurrentTitle().fullText
	return parent ~= nil and (parent:getTitle() == current or parent:getTitle() .. '/doc' == current)
end

-- Возвращает true, если вызов относится к другому разделу страницы. Шаблон с несколькими таблицами
-- можно подключить частично, указав при подключении transcludesection (или section); вызов с другим
-- значением section тогда не выводится. Проверка общая для турнирных таблиц, шахматок и карточек матча.
function ppp.skip_section(frame)
	local parent = frame:getParent()
	local tsection = parent and (parent.args['transcludesection'] or parent.args['section']) or ''
	local bsection = frame.args['section'] or ''
	return tsection ~= '' and bsection ~= '' and tsection ~= bsection
end

-- Ручное оформление ---------------------------------------------------------------------

-- Приёмы ручного оформления: название для сообщения и паттерн. Значения приходят к модулю уже развёрнутыми,
-- поэтому ищутся не имена шаблонов, а то, во что они разворачиваются: {{nobr}} даёт класс nowrap или white-space,
-- {{small}} — тег small. Одним поиском покрываются все написания сразу. Разметка обычных шаблонов —
-- флаги, сноски, надстрочники — под эти паттерны не попадает.
local manual_marks = {
	{'запрет переноса', 'white%-space%s*:%s*nowrap'},
	{'запрет переноса', 'class%s*=%s*"[^"]*nowrap'},
	{'запрет переноса', "class%s*=%s*'[^']*nowrap"},
	{'размер шрифта', '<%s*[Ss]mall[%s>]'},
	{'размер шрифта', '<%s*[Bb]ig[%s>]'},
	{'размер шрифта', 'font%-size%s*:'},
	{'ширина', '[^%-%w]width%s*:'},
	{'ширина', 'width%s*=%s*"%d'},
	{'отступы', 'padding%s*:'},
	{'выравнивание', 'text%-align%s*:'},
	{'перенос строки', '<%s*[Bb][Rr][%s/>]'}
}

-- Распознаёт ручное оформление в значении параметра: запрет переноса, размер шрифта, ширину, отступы,
-- выравнивание, принудительный перенос строки. Возвращает название первого найденного приёма
-- (см. manual_marks) или nil. При allow_breaks перенос строки не отмечается —
-- для полей-списков, где он разделяет записи.

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

-- Функция проверяет все значения вызова, поэтому сделана дешёвой: каждому приёму нужен знак «<», «:» или «=»,
-- и значение без них отсеивается сразу. Паттерны состоят из знаков ASCII, поэтому сравнение побайтовое.
function ppp.manual_format(value, allow_breaks)
	if type(value) ~= 'string' or not value:find('[<:=]') then
		return nil
	end
	for _, mark in ipairs(manual_marks) do
		if not (allow_breaks and mark[1] == 'перенос строки') and value:find(mark[2]) then
			return mark[1]
		end
	end
	return nil
end

-- Приводит размер шрифта в процентах к допустимому по [[ВП:РТ]]: значение меньше 85% поднимается до 85%.
-- Возвращает значение и признак того, что его пришлось поднять. Значение не в процентах возвращается без изменений.
function ppp.clamp_font_size(value)
	local size = value and mw.ustring.match(value, '^%s*(%d+%.?%d*)%s*%%%s*$')
	if not size then
		return value, false
	end
	if tonumber(size) < 85 then
		return '85%', true
	end
	return value, false
end

-- Названия команд и флаги ---------------------------------------------------------------

-- Части паттернов для ppp.team_name. В Lua нет регулярных выражений, только паттерны, поэтому
-- варианты записи перебираются по списку: флаг стоит до названия или после, выведен шаблоном
-- {{флаг}} (span с классом flagicon) или прямой ссылкой на файл и отделён пробелом или &nbsp;.
-- Само название бывает ссылкой с подписью, ссылкой без подписи или простым текстом,
-- за которым может следовать уточнение в скобках.
local span_begin = '<span class="flagicon">'
local span_end = '</span>'
local any_file = '%[%[File:.+%]%]'
local any_file_ru = '%[%[Файл:.+%]%]'
local any_space = '%s*'
local nbsp = '&nbsp;'
local flagicon = span_begin .. '%[%[Файл:.+%]%]' .. span_end
local clarification = '%b()'
local wikilink_text = '%[%[.+|([^%]]+)%]%]'
local wikilink_same = '%[%[([^%]]+)%]%]'
local nolink = '([^%[%]]+)'

-- Виды разделителя между флагом и названием и виды самого названия.
-- В каждом виде названия одна группа захвата — само название.
local spaces = {any_space, nbsp}
local targets = {
	wikilink_text, wikilink_text .. any_space .. clarification,
	wikilink_same, wikilink_same .. any_space .. clarification,
	nolink, nolink .. any_space .. clarification}

-- Возвращает название по первому подошедшему паттерну, обрезанное trim, либо исходную строку,
-- если не подошёл ни один. Сравнение побайтовое: все разделители в паттернах — знаки ASCII,
-- и захват не может начаться или закончиться посреди многобайтовой буквы. mw.ustring здесь
-- многократно медленнее: паттернов почти восемьдесят, и каждый разбирался бы заново.
local function check_any_pattern(s, patterns)
	for _, pattern in ipairs(patterns) do
		local m = s:match(pattern)
		if m then
			return trim(m)
		end
	end
	return s
end

-- Добавляет в list паттерны для всех сочетаний «вид названия × вид разделителя»;
-- fun собирает из них паттерн с флагом. Каждый паттерн привязан к концу строки.
local function pattern_maker(list, fun)
	for _, target in ipairs(targets) do
		for _, space in ipairs(spaces) do
			table.insert(list, fun(space, target) .. '$')
		end
	end
end

-- Все варианты записи названия. Первыми проверяются варианты с шаблоном {{флаг}}, чтобы в название
-- не попал открывающий span; последними — названия без флага, без привязки к концу строки.
local team_name_variants = {}
pattern_maker(team_name_variants, function(space, target) return flagicon .. space .. target end)
pattern_maker(team_name_variants, function(space, target) return target .. space .. flagicon end)
pattern_maker(team_name_variants, function(space, target) return any_file .. space .. target end)
pattern_maker(team_name_variants, function(space, target) return target .. space .. any_file end)
pattern_maker(team_name_variants, function(space, target) return any_file_ru .. space .. target end)
pattern_maker(team_name_variants, function(space, target) return target .. space .. any_file_ru end)
for _, target in ipairs(targets) do
	table.insert(team_name_variants, target)
end

-- Кэш ppp.team_name на время вызова; false — в значении не нашлось названия.
local team_name_cache = {}

-- Возвращает отображаемое название команды: без флага до или после него и без разметки ссылки
-- (у ссылки берётся подпись). nil и пустая строка возвращаются как есть; nil возвращается и тогда,
-- когда после обрезки от названия ничего не остаётся.
function ppp.team_name(s)
	if s == nil or s == '' then
		return s
	end
	local cached = team_name_cache[s]
	if cached == nil then
		cached = check_any_pattern(s, team_name_variants) or false
		team_name_cache[s] = cached
	end
	return cached or nil
end

-- Написания пространства имён файлов для паттернов, без учёта регистра.
-- Открыты для [[Модуль:Sports results]].
ppp.file_prefixes = {'[Ff][Ii][Ll][Ee]', '[Фф][Аа][Йй][Лл]'}

-- Паттерны флага: шаблон {{флаг}} (span с классом flagicon вокруг картинки) и прямая ссылка на файл,
-- в обоих написаниях пространства имён. Собираются один раз, при загрузке.
local flag_patterns = {}
for _, prefix in ipairs(ppp.file_prefixes) do
	flag_patterns[#flag_patterns + 1] = '<span[^<>]*flagicon[^<>]*>%s*%[%[' .. prefix .. ':[^%[%]]*%]%]%s*</span>'
end
for _, prefix in ipairs(ppp.file_prefixes) do
	flag_patterns[#flag_patterns + 1] = '%[%[' .. prefix .. ':[^%[%]]*%]%]'
end

-- Возвращает флаг, которым начинается значение, либо nil. Нужна для ширины столбца названий
-- (ppp.measure_team_width), для проверки места флага в карточке матча ([[Модуль:Football box/base]])
-- и для переноса флага хозяев в конец названия ([[Модуль:Sports group]]).
function ppp.leading_flag(s)
	if s == nil or s == '' then
		return nil
	end
	for _, pattern in ipairs(flag_patterns) do
		local flag = mw.ustring.match(s, '^%s*(' .. pattern .. ')')
		if flag then
			return flag
		end
	end
	return nil
end
ppp.leading_flag = memoize(ppp.leading_flag)

-- Возвращает флаг, которым заканчивается значение, либо nil.
-- Признаки флага те же, что у ppp.leading_flag.
function ppp.trailing_flag(s)
	if s == nil or s == '' then
		return nil
	end
	for _, pattern in ipairs(flag_patterns) do
		local flag = mw.ustring.match(s, '(' .. pattern .. ')%s*$')
		if flag then
			return flag
		end
	end
	return nil
end
ppp.trailing_flag = memoize(ppp.trailing_flag)

-- Убирает из значения ссылки на файлы (обычно это флаг команды),
-- опустевшую обёртку шаблона флага и оставшийся в начале неразрывный пробел.
function ppp.strip_files(s)
	s = s or ''
	for _, prefix in ipairs(ppp.file_prefixes) do
		s = mw.ustring.gsub(s, '%[%[' .. prefix .. ':[^%[%]]*%]%]', '')
	end
	-- Обёртка шаблона флага: после удаления самого файла от неё остаётся пустой span,
	-- который в выводе не виден, но занимает место в разметке и мешает дальнейшим
	-- заменам по образцу. Снимается только опустевшая — с содержимым не трогается.
	s = mw.ustring.gsub(s, '<span class="flagicon">%s*</span>', '')
	return (mw.ustring.gsub(s, '^%s*&nbsp;%s*', ''))
end

-- Снимает разметку, не относящуюся к содержанию: файлы (ppp.strip_files), надстрочники вместе
-- с содержимым, прочие теги (содержимое остаётся), служебные метки расширений и кавычки.
-- Вики-ссылки не трогает — их разбирают вызывающие функции, каждая по-своему.
local function strip_markup(s)
	s = ppp.strip_files(s)
	s = mw.ustring.gsub(s, '<[Ss][Uu][Pp]>[^<>]*</[Ss][Uu][Pp]>', '')
	s = mw.ustring.gsub(s, '<[Aa][Bb][Bb][Rr][^>]*>([^<]*)</[Aa][Bb][Bb][Rr]>', '%1')
	s = mw.ustring.gsub(s, '</?%w+[^<>]*>', '')
	s = mw.ustring.gsub(s, '\127[^\127]*UNIQ%-%-[^\127]*%-%x+%-QINU[^\127]*\127', '')
	s = mw.ustring.gsub(s, '[\'"]', '')
	return s
end

-- Сводит &nbsp; и повторяющиеся пробелы к одиночным пробелам и обрезает края (trim);
-- вместо nil возвращает пустую строку.
local function collapse_spaces(s)
	s = mw.ustring.gsub(s, '%s*&nbsp;%s*', ' ')
	s = mw.ustring.gsub(s, '%s%s*', ' ')
	return trim(s) or ''
end

-- Строит ключ команды для идентификатора матча и реестра сокращений. В отличие от ppp.team_name
-- и ppp.makeanchor у ссылки берётся её цель, а не подпись, и уточнение в скобках сохраняется:
function ppp.team_key(s)
	s = strip_markup(s)
	s = mw.ustring.gsub(s, '%[%[([^%[%]%|]*)%|[^%[%]]*%]%]', '%1')
	s = mw.ustring.gsub(s, '%[%[([^%[%]]*)%]%]', '%1')
	return collapse_spaces(s)
end
ppp.team_key = memoize(ppp.team_key)

-- Возвращает функцию сравнения кодов команд для сортировки по алфавиту: по названию из name_КОД
-- (без флага и разметки ссылки — ppp.team_name, без учёта регистра), при равных названиях —
-- по коду. Названия разбираются один раз на каждый код. Сортируют ею автосборка ([[Модуль:Sports table/auto]])
-- и расписание ([[Модуль:Sports results/schedule]]): команды без заданного порядка у обоих идут одинаково.
function ppp.name_sorter(Args)
	local key = {}
	local function keyof(c)
		if key[c] == nil then
			key[c] = mw.ustring.lower(ppp.team_name(Args['name_' .. c] or c) or c)
		end
		return key[c]
	end
	return function(x, y)
		local kx, ky = keyof(x), keyof(y)
		if kx == ky then return x < y end
		return kx < ky
	end
end

-- Делит окончание имени параметра матча («ААА_БББ») на коды хозяев и гостей. Подчёркивание
-- может стоять и внутри кода, поэтому из всех вариантов деления выбирается тот, где известны оба кода
-- (набор codes), затем — где известен код хозяев, затем — гостей; если не известен ни один, деление идёт
-- по последнему подчёркиванию. Возвращает nil, если подчёркивания нет. Общая для автосборки и расписания:
-- матч, который читает одна, другая должна найти по тем же кодам.
function ppp.split_pair(rest, codes)
	local cands = {}
	local i = rest:find('_', 1, true)
	while i do
		local h, a = rest:sub(1, i - 1), rest:sub(i + 1)
		if h ~= '' and a ~= '' then
			cands[#cands + 1] = {h, a}
		end
		i = rest:find('_', i + 1, true)
	end
	if #cands == 0 then return nil end
	for _, c in ipairs(cands) do
		if codes[c[1]] and codes[c[2]] then return c[1], c[2] end
	end
	for _, c in ipairs(cands) do
		if codes[c[1]] then return c[1], c[2] end
	end
	for _, c in ipairs(cands) do
		if codes[c[2]] then return c[1], c[2] end
	end
	return cands[#cands][1], cands[#cands][2]
end

-- Счёт ----------------------------------------------------------------------------------

-- Приводит разделитель счёта к двоеточию. В источниках и в разметке, скопированной из англовики,
-- встречаются дефис, короткое и длинное тире, минус и мнемоники &ndash; и &mdash;; счёт при этом
-- может быть подписью ссылки — внутренней или внешней, как у шаблона {{score link}}. Заменяется
-- только разделитель между двумя числами в начале значения:
-- примечания, уточнения и пояснения после счёта остаются нетронутыми.
function ppp.format_score(s)
	s = mw.ustring.gsub(s or '', '^%s*([%d%.]+)%s*[–−—%-]%s*([%d%.]+)', '%1:%2')
	s = mw.ustring.gsub(s, '^%s*([%d%.]+)%s*&[MmNn][Dd][Aa][Ss][Hh];%s*([%d%.]+)', '%1:%2')
	s = mw.ustring.gsub(s, '^%s*(%[%[[^%[%]]*%|[%d%.]+)%s*[–−—%-]%s*([%d%.]+)', '%1:%2')
	s = mw.ustring.gsub(s, '^%s*(%[[^%[%]%s]*%s+[%d%.]+)%s*[–−—%-]%s*([%d%.]+)', '%1:%2')
	s = mw.ustring.gsub(s, '^%s*(%[%[[^%[%]]*%|[%d%.]+)%s*&[MmNn][Dd][Aa][Ss][Hh];%s*([%d%.]+)', '%1:%2')
	s = mw.ustring.gsub(s, '^%s*(%[[^%[%]%s]*%s+[%d%.]+)%s*&[MmNn][Dd][Aa][Ss][Hh];%s*([%d%.]+)', '%1:%2')
	return s
end

-- Разбирает счёт вида «2:1» на два числа. Перед разбором снимается оформление:
-- служебные метки сносок, надстрочники, теги, ссылки (остаются подписи), мнемоники тире
-- и кавычки полужирного и курсива. Разбирается только начало значения: приписка после счёта не мешает.
-- Возвращает nil, если в начале значения счёта нет: в этих полях пишут и даты, и прочерки.
function ppp.score_pair(s)
	if s == nil or s == '' then
		return nil
	end
	-- Обычный счёт без оформления разбирается сразу.
	local plain_a, plain_b = s:match('^(%d+):(%d+)$')
	if plain_a then
		return tonumber(plain_a), tonumber(plain_b)
	end
	-- Метки сносок: к моменту, когда модуль получает аргумент, тег ref уже заменён служебной меткой.
	s = mw.ustring.gsub(s, '\127[^\127]*\127', '')

	-- Надстрочник удаляется вместе с содержимым: там стоит номер примечания
	-- или пояснение, а не часть счёта. У остальных тегов содержимое сохраняется.
	s = mw.ustring.gsub(s, '<[Ss][Uu][Pp].->.-</[Ss][Uu][Pp]>', '')
	s = mw.ustring.gsub(s, '</?%w+[^<>]*>', '')

	-- Ссылки заменяются своей подписью: счёт часто оборачивают ссылкой на отчёт.
	s = mw.ustring.gsub(s, '%[%[[^%[%]%|]*%|([^%[%]]*)%]%]', '%1')
	s = mw.ustring.gsub(s, '%[%[([^%[%]]*)%]%]', '%1')
	s = mw.ustring.gsub(s, '%[%S+%s+([^%[%]]*)%]', '%1')

	-- Сущности приводятся к обычным знакам: в разметке встречаются и они.
	s = mw.ustring.gsub(s, '&[Nn][Dd][Aa][Ss][Hh];', '-')
	s = mw.ustring.gsub(s, '&[Mm][Dd][Aa][Ss][Hh];', '-')
	s = mw.ustring.gsub(s, '&minus;', '-')
	s = mw.ustring.gsub(s, '&nbsp;', ' ')

	s = mw.ustring.gsub(s, "'+", '')
	local a, b = mw.ustring.match(s, '^%s*(%d+)%s*[:%-–—−‒―‐‑]%s*(%d+)')
	if not a then
		return nil
	end
	return tonumber(a), tonumber(b)
end
ppp.score_pair = memoize(ppp.score_pair)

-- Возвращает true, если значение клетки — счёт: обычный, в ссылке или с оформлением
-- (см. ppp.score_pair), либо технический результат «+:−» или «−:+». Всё прочее —
-- дата предстоящего матча, «перенесён», пояснение — счётом не считается.
function ppp.is_score_value(s)
	if s == nil or s == '' then
		return false
	end
	if ppp.score_pair(s) then
		return true
	end
	local plain = mw.ustring.gsub(s, '\127[^\127]*\127', '')
	plain = mw.ustring.gsub(plain, '%[%[[^%[%]|]*|([^%[%]]*)%]%]', '%1')
	plain = mw.ustring.gsub(plain, '</?%w+[^<>]*>', '')
	return mw.ustring.find(plain, '^%s*[%+%-−–—]%s*:%s*[%+%-−–—]') ~= nil
end
ppp.is_score_value = memoize(ppp.is_score_value)

-- Коды исхода матча в result_ХХХ_УУУ шахматки. stage — чем матч решился:
-- 'ot' — дополнительное время или овертайм, 'pk' — серия пенальти или буллитов,
-- nil — основное время. outcome — исход для команды из левого столбца (хозяев):
-- 1 — победа, 0 — ничья, −1 — поражение. У OT, PK и SO исхода нет:
-- он виден из счёта, а код уточняет только стадию.

-- Коды с исходом те же, что у параметра result карточки матча ([[Модуль:Football box/base]]):
-- W, L, OTW, OTD, OTL, PW, PL. SO, SW и SL — английские написания, принятые наравне с PK, PW и PL;
-- PKW и PKL — написание расписаний англовики, то же, что PW и PL.

-- По этой таблице закрашивает клетки [[Модуль:Sports results]] и считает исходы автосборка
-- стилей с овертаймом ([[Модуль:Sports table/auto]]): цвет клетки и строка таблицы берут исход
-- из одного места и разойтись не могут.
ppp.result_codes = {
	W   = {outcome = 1},
	L   = {outcome = -1},
	OT  = {stage = 'ot'},
	OTW = {stage = 'ot', outcome = 1},
	OTD = {stage = 'ot', outcome = 0},
	OTL = {stage = 'ot', outcome = -1},
	PK  = {stage = 'pk'},
	SO  = {stage = 'pk'},
	PW  = {stage = 'pk', outcome = 1},
	PL  = {stage = 'pk', outcome = -1},
	SW  = {stage = 'pk', outcome = 1},
	SL  = {stage = 'pk', outcome = -1},
	PKW = {stage = 'pk', outcome = 1},
	PKL = {stage = 'pk', outcome = -1},
}

-- Код исхода с точки зрения соперника: победа становится поражением, стадия сохраняется.
-- Коды без исхода (OT, PK, SO) и ничья OTD остаются как есть. Нужен расписанию
-- ([[Модуль:Sports results/schedule]]): матч записан один раз, а виден в строках обеих команд.
ppp.inverse_result = {
	W = 'L', L = 'W', OTW = 'OTL', OTL = 'OTW',
	PW = 'PL', PL = 'PW', SW = 'SL', SL = 'SW', PKW = 'PKL', PKL = 'PKW',
}

-- Даты ----------------------------------------------------------------------------------

-- Названия месяцев в родительном и именительном падеже; значение — родительный падеж.
local month_names = {
	['января'] = 'января', ['январь'] = 'января', ['февраля'] = 'февраля', ['февраль'] = 'февраля',
	['марта'] = 'марта', ['март'] = 'марта', ['апреля'] = 'апреля', ['апрель'] = 'апреля',
	['мая'] = 'мая', ['май'] = 'мая', ['июня'] = 'июня', ['июнь'] = 'июня',
	['июля'] = 'июля', ['июль'] = 'июля', ['августа'] = 'августа', ['август'] = 'августа',
	['сентября'] = 'сентября', ['сентябрь'] = 'сентября', ['октября'] = 'октября', ['октябрь'] = 'октября',
	['ноября'] = 'ноября', ['ноябрь'] = 'ноября', ['декабря'] = 'декабря', ['декабрь'] = 'декабря'
}

-- Приводит дату к единому виду: снимает ссылки, теги и приписки «года», «год», «г.», сводит пробелы,
-- убирает ведущий ноль у числа. Ссылки на день и год в тексте статьи не приняты, поэтому снимаются.
-- Значение, которое датой не распознаётся, возвращается как есть — в поле даты встречаются и диапазоны, и пояснения.
function ppp.format_date(value)
	-- Дата начинается с числа, за которым идёт пробел, &nbsp;, тег или конец ссылки;
	-- значение без такого места датой быть не может — это счёт или текст.
	if type(value) ~= 'string' or not value:find('%d[%s&<%]|]') then
		return value
	end
	-- Дата, целиком обёрнутая в ссылку на статью без цифр в названии — так в шахматке ставят на дату
	-- предстоящего матча ссылку на статью о дерби, — сохраняет ссылку: приводится только подпись.
	local target, label = value:match('^%s*%[%[([^%[%]|]+)|([^%[%]]+)%]%]%s*$')
	if target and not target:find('%d') then
		local inner = ppp.format_date(label)
		return inner ~= label and '[[' .. target .. '|' .. inner .. ']]' or value
	end
	local s = value
	s = mw.ustring.gsub(s, '%[%[[^%[%]|]*|([^%[%]]*)%]%]', '%1')
	s = mw.ustring.gsub(s, '%[%[([^%[%]]*)%]%]', '%1')
	s = mw.ustring.gsub(s, '<[^<>]*>', '')
	s = mw.ustring.gsub(s, '&nbsp;', ' ')
	s = mw.ustring.gsub(s, '%s+', ' ')
	s = mw.text.trim(s)

	-- Приписка «года» снимается отдельным шагом, а не необязательными буквами в образце:
	-- знак «?» относился бы к последнему байту буквы, а не к букве целиком.
	s = mw.ustring.gsub(s, '%s+года$', '')
	s = mw.ustring.gsub(s, '%s+год$', '')
	s = mw.ustring.gsub(s, '%s+г%.?$', '')

	-- Название месяца берётся целым словом и узнаётся по словарю:
	-- так разбор не зависит от того, какие знаки считать буквами.
	local day, month, year = mw.ustring.match(s, '^(%d%d?)%s+(%S+)%s+(%d%d%d%d)%.?$')
	if not day then
		day, month = mw.ustring.match(s, '^(%d%d?)%s+(%S+)%.?$')
	end
	local canon = month and month_names[mw.ustring.lower(month)]
	if not canon then
		return value
	end
	return tonumber(day) .. ' ' .. canon .. (year and (' ' .. year) or '')
end
ppp.format_date = memoize(ppp.format_date)

-- Якоря и ссылки на отчёты о матчах -----------------------------------------------------

-- Приводит произвольный текст к виду, пригодному для идентификатора раздела: снимает разметку
-- (strip_markup), заменяет ссылки их подписями, сводит пробелы. У ссылки берётся подпись:
-- для заголовков и названий турниров важно то, что видит читатель. Вызывается карточками матча.
function ppp.makeanchor(s)
	s = strip_markup(s)
	s = mw.ustring.gsub(s, '%[%[[^%[%]%|]*%|([^%[%]]*)%]%]', '%1')
	s = mw.ustring.gsub(s, '%[%[([^%[%]]*)%]%]', '%1')
	return collapse_spaces(s)
end
ppp.makeanchor = memoize(ppp.makeanchor)

-- Разделитель команд в идентификаторе матча. На нём сходятся две стороны: карточка матча ставит якорь
-- ([[Модуль:Football box/base]]), шахматка строит ссылку на него ([[Модуль:Sports results]],
-- через ppp.match_anchor). Разойдись разделители — ссылки перестали бы попадать в якоря.
ppp.anchor_separator = ' — '

-- Строит основной идентификатор матча вида «Динамо (Москва) — Спартак (Москва)» из ключей команд
-- (ppp.team_key). Карточка матча ставит его якорем, шахматка — целью ссылки со счёта.
function ppp.match_anchor(team1, team2)
	return ppp.team_key(team1 or 'Team 1')
		.. ppp.anchor_separator .. ppp.team_key(team2 or 'Team 2')
end

-- Разделитель команд в идентификаторе матча, принятый в англовики.
ppp.anchor_separator_en = ' v '

-- Возвращает все написания идентификатора матча — для якорей карточки. Первое — основное
-- (как у ppp.match_anchor); остальные нужны, чтобы в якорь попадала и ссылка,
-- построенная по другому соглашению: подписи ссылок через тире и подписи через латинское «v»,
-- как в англовики. Англовики берёт подпись ссылки, этот модуль — её цель, поэтому «Динамо» и «Динамо (Москва)»
-- дают разные строки; разметку карточек массово копируют из англовики, и оба написания встречаются в статьях наравне.
-- Пустые и повторяющиеся варианты отбрасываются: у команды без уточнения подпись и цель ссылки совпадают.
function ppp.match_anchors(team1, team2)
	local key1, key2 = ppp.team_key(team1 or 'Team 1'), ppp.team_key(team2 or 'Team 2')
	local name1, name2 = ppp.team_name(team1 or 'Team 1'), ppp.team_name(team2 or 'Team 2')
	local variants = {
		key1 .. ppp.anchor_separator .. key2,
		name1 .. ppp.anchor_separator .. name2,
		name1 .. ppp.anchor_separator_en .. name2
	}
	local list, seen = {}, {}
	for _, v in ipairs(variants) do
		if v ~= '' and not seen[v] then
			seen[v] = true
			list[#list + 1] = v
		end
	end
	return list
end

-- Решает, куда вести ссылку со счёта на отчёт о матче. Возвращает функцию:
-- по якорю матча она отдаёт страницу, которую надо подставить перед «#»,
-- либо пустую строку, если отчёт лежит на текущей странице.
-- group — группа, первый позиционный параметр шаблона.
--
-- Порядок решения:
-- 1. задан baselink — страница из него (пустая строка, если это текущая страница);
-- 2. модуль вызван без шаблона, через [[Модуль:Excerpt]] или на странице самого шаблона — отчёты рядом;
-- 3. тот же вызов выводит и отчёты (show_box) — отчёты рядом;
-- 4. текущая страница — статья с тем же именем, что у шаблона, — отчёты рядом;
-- 5. на странице есть вызов того же шаблона для той же группы, выводящий отчёты:
--    целиком (show_box) — отчёты рядом; матчи одной команды (её код — второй позиционный параметр) —
--    рядом отчёты о матчах этой команды;
-- 6. иначе — статья в основном пространстве с тем же именем, что у шаблона.

-- Пятый шаг читает исходный текст текущей страницы — один раз на таблицу. Во время предпросмотра
-- там лежит ещё сохранённая версия: вызов, добавленный в этой правке, будет учтён после сохранения.
function ppp.report_link_resolver(frame, Args, group)
	local current = mw.title.getCurrentTitle()
	local here = function() return '' end

	local override = Args['baselink']
	if override and override ~= '' then
		if override == current.fullText then
			return here
		end
		return function() return override end
	end

	local parent = frame:getParent() and frame:getParent():getTitle() or ''
	if parent == '' or parent == 'Модуль:Excerpt' or parent == current.fullText then
		return here
	end
	if Args['show_box'] and Args['show_box'] ~= '' then
		return here
	end

	local ptitle = mw.title.new(parent)
	local article = ptitle and ptitle.text or parent
	if article == current.fullText then
		return here
	end

	-- Ищет на странице вызовы того же шаблона для той же группы. Имя сравнивается
	-- без пространства имён, без учёта регистра первой буквы и с пробелом вместо «_».
	local function norm(s)
		s = mw.text.trim((mw.ustring.gsub(s or '', '_', ' ')))
		s = mw.ustring.gsub(s, '^[Шш]аблон:', '')
		s = mw.ustring.gsub(s, '^[Tt]emplate:', '')
		return mw.ustring.lower(mw.ustring.sub(s, 1, 1)) .. mw.ustring.sub(s, 2)
	end
	local want, grp = norm(article), mw.text.trim(group or '')
	local all, codes = false, {}
	local src = current:getContent() or ''
	for call in src:gmatch('%b{}') do
		local inner = call:match('^{{(.*)}}$')
		if inner then
			local f = mw.text.split(inner, '|', true)
			if #f >= 2 and norm(f[1]) == want and mw.text.trim(f[2]) == grp then
				for i = 3, #f do
					local key, val = f[i]:match('^%s*([^=]-)%s*=%s*(.-)%s*$')
					if key == 'show_box' and val ~= '' then
						all = true
					elseif not key and i == 3 and mw.text.trim(f[i]) ~= '' then
						codes[#codes + 1] = mw.text.trim(f[i])
					end
				end
			end
		end
	end
	if all then
		return here
	end

	-- Отчёты отдельных команд: ссылка на матч остаётся на странице, если в нём
	-- играет одна из них. Сравнивается ключ команды — тот же, из которого собран якорь.
	local keys = {}
	for _, c in ipairs(codes) do
		keys[ppp.team_key(Args['name_' .. c] or c)] = true
	end
	return function(anchor)
		local a, b = mw.ustring.match(anchor or '', '^(.-)' .. ppp.anchor_separator .. '(.*)$')
		if (a and keys[a]) or (b and keys[b]) then
			return ''
		end
		return article
	end
end

-- Ведёт ссылки со счёта вида «[[#якорь|подпись]]» туда, где лежат отчёты: дописывает перед «#»
-- страницу, которую выбирает ppp.report_link_resolver для группы из первого параметра шаблона.
-- t — массив строк разметки таблицы, изменяется на месте. Паттерн состоит из знаков ASCII,
-- поэтому замена побайтовая: строки таблицы длинные, и mw.ustring разбирал бы каждую целиком.
function ppp.resolve_report_links(frame, Args, t)
	local resolve = ppp.report_link_resolver(frame, Args, frame:getParent() and frame:getParent().args[1])
	for k = 1, #t do
		if t[k]:find('[[#', 1, true) then
			t[k] = t[k]:gsub('%[%[#([^%[%]|]*)%|', function(anchor)
				return '[[' .. resolve(anchor) .. '#' .. anchor .. '|'
			end)
		end
	end
end

-- Строка итогов и показатели команд -----------------------------------------------------

-- Код псевдокоманды, под которым [[Модуль:Sports table]] добавляет строку итогов «Всего».
-- Командой она не является: проверки, перебирающие команды по name_ХХХ, её пропускают.
ppp.total_row_code = 'SPORTS_TABLE_TOTAL'

-- Имена показателей команды во всех стилях. По ним ppp.has_stats проверяет, заполнен ли у команды
-- хоть один показатель: незаполненный показатель модуль считает нулём, и строка из одних нулей
-- неотличима от команды, данные которой просто не внесли.
ppp.stat_prefixes = {
	'win', 'draw', 'loss', 'gf', 'ga', 'pf', 'pa', 'matches', 'rw',
	'hwin', 'hdraw', 'hloss', 'hgf', 'hga', 'hpf', 'hpa', 'hrw',
	'awin', 'adraw', 'aloss', 'agf', 'aga', 'apf', 'apa', 'arw',
	'OTwin', 'OTloss', 'PKwin', 'PKloss', 'SOwin', 'SOloss', 'tiebr',
	'adjust_points', 'startpoints'
}

-- Возвращает true, если у команды code заполнен хоть один показатель из ppp.stat_prefixes.
function ppp.has_stats(Args, code)
	for _, prefix in ipairs(ppp.stat_prefixes) do
		local v = Args[prefix .. '_' .. code]
		if v ~= nil and mw.text.trim(tostring(v)) ~= '' then
			return true
		end
	end
	return false
end

-- Прибавляет показатели команды code к строке итогов (ppp.total_row_code), записывая суммы в Args.
-- fields — имена показателей стиля; показатель с синонимом записывается парой {'gf', 'pf'}:
-- значение берётся из любого написания, а сумма всегда пишется под основным именем —
-- оно читается первым, и накопленное под синонимом заслонялось бы им.
function ppp.add_to_totals(Args, code, fields)
	local total = ppp.total_row_code
	for _, f in ipairs(fields) do
		local name, alt = f, nil
		if type(f) == 'table' then
			name, alt = f[1], f[2]
		end
		local v = Args[name .. '_' .. code] or (alt and Args[alt .. '_' .. code])
		Args[name .. '_' .. total] = (tonumber(Args[name .. '_' .. total]) or 0) + (tonumber(v) or 0)
	end
end

-- Реестр сокращений и статьи о дерби ----------------------------------------------------

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

-- Реестр — JSON-страница [[Модуль:Sports table/codes.json]]: данные чисто технические,
-- в JSON их может прочесть любой модуль, а обновить — бот. Движок не даёт сохранить неверный JSON,
-- так что сломанной страница остаться не может.
ppp.registry_page = 'Модуль:Sports table/codes.json'

-- Возвращает ключ реестра для значения названия команды — статью о команде, то есть цель ссылки
-- в названии (ppp.team_key). Ключ один во всех шаблонах, в отличие от подписи ссылки и положения
-- флага. Первая буква приводится к прописной, подчёркивания — к пробелам:
-- так имена страниц сравнивает и сам движок.
function ppp.registry_key(s)
	local key = mw.text.trim((mw.ustring.gsub(ppp.team_key(s or ''), '_', ' ')))
	return mw.ustring.upper(mw.ustring.sub(key, 1, 1)) .. mw.ustring.sub(key, 2)
end

-- Возвращает сокращение, которое видит читатель: подпись из short_ХХХ, если она задана, иначе код —
-- он и попадает в заголовок столбца шахматки. Сверяется именно видимое: латинский код
-- с кириллическим short_ХХХ читатель видит кириллицей, и расхождением это не считается. Если
-- в short_ХХХ нет букв (например, там только флаг), возвращает nil: такую команду не с чем сверять.
function ppp.visible_code(code, short)
	if short and short ~= '' then
		-- Картинка флага снимается до разбора ссылок: у неё тоже есть квадратные скобки,
		-- и без подписи она приняла бы вид сокращения.
		short = ppp.strip_files(short)
		local shown = short:match('%[%[[^%[%]|]*|([^%[%]]*)%]%]') or short
		shown = mw.text.trim((mw.ustring.gsub(shown, '<[^<>]*>', '')))
		if not mw.ustring.find(shown, '%a') then
			return nil
		end
		return shown
	end
	return code
end

-- Возвращает реестр целиком — {teams = {статья = сокращение}, pending = {статья = {варианты}},
-- derbies = {«статья|статья» = статья о дерби}} — либо nil, если страницы реестра нет.
-- Реестр загружается через mw.loadJsonData — один раз на страницу, сколько бы таблиц на ней ни было.
function ppp.code_registry_data()
	local ok, data = pcall(mw.loadJsonData, ppp.registry_page)
	return ok and data or nil
end

-- Возвращает только сокращения команд (раздел teams) либо nil.
function ppp.code_registry()
	local data = ppp.code_registry_data()
	return data and data.teams or nil
end

-- Сверяет сокращения команд таблицы (name_ХХХ, short_ХХХ) с реестром:
-- registry — раздел teams, pending — раздел pending.
-- Возвращает три отсортированных списка:
-- расхождения {code, shown, want, team} — команда есть в реестре, но записана иначе;
-- команды, которых в реестре нет; команды из раздела pending.

-- Отсутствие в реестре — не ошибка: реестр пополняется по мере надобности, и требовать заранее
-- занести в него все команды мира незачем. Команда из раздела pending отдаётся отдельно:
-- она в реестре есть, и добавлять её не нужно.
function ppp.check_codes(Args, registry, pending)
	local mismatches, missing, undecided = {}, {}, {}
	for k, v in pairs(Args) do
		local code = type(k) == 'string' and k:match('^name_(.+)$')
		if code and code ~= ppp.total_row_code then
			local key = ppp.registry_key(v)
			local shown = ppp.visible_code(code, Args['short_' .. code])
			if key ~= '' and shown then
				local want = registry[key]
				if want and want ~= shown then
					mismatches[#mismatches + 1] = {code = code, shown = shown, want = want, team = key}
				elseif not want and pending and pending[key] then
					undecided[#undecided + 1] = key
				elseif not want then
					missing[#missing + 1] = key
				end
			end
		end
	end
	table.sort(mismatches, function(a, b) return a.code < b.code end)
	table.sort(missing)
	table.sort(undecided)
	return mismatches, missing, undecided
end

-- Приводит имя страницы к виду, в котором его сравнивает движок:
-- края обрезаны, подчёркивания заменены пробелами, первая буква прописная.
local function page_key(title)
	local key = mw.text.trim((mw.ustring.gsub(title or '', '_', ' ')))
	return mw.ustring.upper(mw.ustring.sub(key, 1, 1)) .. mw.ustring.sub(key, 2)
end

-- Возвращает статью о дерби для пары команд по значениям их названий (name_ХХХ, team1/team2)
-- либо nil. derbies — раздел derbies реестра: ключ — статьи обеих команд через «|», в любом порядке.
-- Сравниваются статьи (ppp.registry_key), а не подписи: одинаковые подписи у разных клубов пару не образуют.
function ppp.derby_article(derbies, name_a, name_b)
	if not derbies then
		return nil
	end
	local ka, kb = ppp.registry_key(name_a), ppp.registry_key(name_b)
	if ka == '' or kb == '' or ka == kb then
		return nil
	end
	return derbies[ka .. '|' .. kb] or derbies[kb .. '|' .. ka]
end

-- Возвращает true, если в значении есть вики-ссылка на статью article. Цели сравниваются так же,
-- как их сравнивает движок (page_key); якорь в ссылке не учитывается.
function ppp.links_to(value, article)
	if type(value) ~= 'string' or not value:find('[[', 1, true) then
		return false
	end
	local want = page_key(article)
	for target in value:gmatch('%[%[([^%[%]|#]*)') do
		if page_key(target) == want then
			return true
		end
	end
	return false
end

-- Строит подсказки о статьях про дерби для шахматки: для пар из раздела derbies реестра, у которых
-- в клетке предстоящего матча (значение — не счёт) нет ссылки на статью о дерби. Перебираются
-- команды на позициях first..last и все legs кругов (match_ХХХ_УУУ или matchN_ХХХ_УУУ); на пару —
-- одна подсказка. Ссылка сама не ставится: решает редактор. Возвращает список строк.
function ppp.derby_hints(Args, team_list, first, last, legs, derbies)
	local out, seen = {}, {}
	if not derbies then
		return out
	end
	for i = first, last do
		for j = first, last do
			local a, b = team_list[i], team_list[j]
			if i ~= j and a and b and a ~= ppp.total_row_code and b ~= ppp.total_row_code then
				local name_a, name_b = Args['name_' .. a] or a, Args['name_' .. b] or b
				local article = ppp.derby_article(derbies, name_a, name_b)
				if article then
					for l = 1, legs do
						local m = (legs == 1) and 'match_' or 'match' .. l .. '_'
						local v = Args[m .. a .. '_' .. b]
						local pair = a < b and a .. '|' .. b or b .. '|' .. a
						if v and v ~= '' and not seen[pair] and not ppp.is_score_value(v)
							and not ppp.links_to(v, article) then
							seen[pair] = true
							out[#out + 1] = ppp.team_name(name_a) .. ' — ' .. ppp.team_name(name_b)
								.. ': есть статья [[' .. article .. ']]; ссылку на неё можно поставить'
								.. ' на дату матча — [[' .. article .. '|дата]]'
						end
					end
				end
			end
		end
	end
	return out
end

-- Ширины столбцов -----------------------------------------------------------------------

-- Подбирает ширину столбца названий команд по самому длинному названию.
-- Длина считается по отображаемому тексту (ppp.team_name без шаблонов, тегов и разметки ссылок)
-- из расчёта полширины em на знак; к ней прибавляются флаг перед названием, буквы статуса и знак сноски,
-- если они есть хоть у одной команды. Перебираются все параметры name_ХХХ, а не список выведенных команд:
-- заголовок строится раньше, чем этот список собран. Разница возникает только там, где часть строк
-- скрыта параметрами show_limit или showteam: столбец тогда окажется шире нужного, но не уже — названия не обрежутся.

-- Нижняя граница, 10em, держится под общий вид: турнир из коротких названий вроде ПСВ, АЗ и ХИК
-- получал бы столбец уже числовых, и таблица выглядела бы перекошенной. Верхняя, 14em,
-- нужна, чтобы одно длинное название не съедало таблицу: за ней название переносится, и столбец перестаёт расти.

-- Возвращает ширину строкой вида «12.50em» и вторым значением признак того, что ширина упёрлась
-- в верхнюю границу. Признак нужен шахматке: там название выводится в одну строку, и столбец при
-- нехватке места не сжимается, а прокручивается; но название длиннее потолка одной строкой не
-- поместится никогда, и перенос там разрешается.
function ppp.measure_team_width(Args)
	local longest = 0
	local with_flag = false
	local status_letters = 0
	local has_note = false

	-- Один проход по аргументам: названия (name_ХХХ), буквы статуса (status_ХХХ) и сноски (note_ХХХ).
	-- Параметров у таблицы сотни, и отдельный проход на каждый вид заметно дороже.
	for k, raw in pairs(Args) do
		if type(k) == 'string' and type(raw) == 'string' then
			local prefix = k:match('^(%a+)_.')
			if prefix == 'name' then
				if ppp.leading_flag(raw) then
					with_flag = true
				end
				local shown = ppp.team_name(raw) or raw
				shown = mw.ustring.gsub(shown, '%b{}', '')
				shown = mw.ustring.gsub(shown, '<[^<>]*>', '')
				shown = mw.ustring.gsub(shown, '%[%[[^%[%]|]*|([^%[%]]*)%]%]', '%1')
				shown = mw.ustring.gsub(shown, '%[%[([^%[%]]*)%]%]', '%1')
				local n = mw.ustring.len(mw.text.trim(shown))
				if n > longest then
					longest = n
				end
			elseif prefix == 'status' then
				local n = select(2, mw.ustring.gsub(raw, '%a', ''))
				if n > status_letters then
					status_letters = n
				end
			elseif prefix == 'note' and raw ~= '' then
				has_note = true
			end
		end
	end

	-- Буквы статуса выводятся после названия или перед ним; по ширине это сопоставимо:
	-- Берётся самая длинная запись: ширина у столбца одна, и определяет её самая широкая строка.
	local extra = status_letters > 0 and (status_letters * 2 * 0.5 + 0.6) or 0
	if has_note then
		extra = extra + 1.4
	end
	local width = longest * 0.5 + (with_flag and 1.5 or 0) + extra + 0.6

	local capped = false
	if width < 10 then
		width = 10
	elseif width > 14 then
		width = 14
		capped = true
	end
	return string.format('%.2fem', width), capped
end

-- Подбирает общую ширину числовых столбцов: 2,5em, если все значения не длиннее двух знаков,
-- и 3em, если хоть одно из них — сто и больше. Учитываются победы, ничьи, поражения,
-- забитые и пропущенные — общие, дома и в гостях (win_ХХХ, hwin_ХХХ, awin_ХХХ и т. д.).
-- Очки считает ppp.measure_points_width, соотношение мячей — ppp.measure_ratio_width;
-- разница мячей не учитывается — у неё свой знак.

-- Выравнивание достигается шириной, а не разрядностью (ppp.num_align): невидимые нули занимают
-- место, и при центрировании видимое число сдвигается влево. Нижняя граница — два знака, даже когда
-- все значения однозначные: столбцы стоят вплотную, и разница между столбцом под одну цифру
-- и под две заметнее выигранного места.
local num_width_fields = {win = true, draw = true, loss = true, gf = true, ga = true, pf = true,
	pa = true, hwin = true, hdraw = true, hloss = true, hgf = true, hga = true, awin = true,
	adraw = true, aloss = true, agf = true, aga = true}
function ppp.measure_num_width(Args)
	local longest = 2
	for k, v in pairs(Args) do
		if type(k) == 'string' and num_width_fields[k:match('^(%a+)_')] and (tonumber(v) or 0) >= 100 then
			longest = 3
			break
		end
	end
	-- Полширины цифры на третий знак.
	return string.format('%.2fem', 2.5 + (longest > 2 and 0.5 or 0))
end

-- Подбирает ширину столбца очков: 2,5em, ещё 0,5em, если у кого-то сто очков и больше,
-- и ещё 0,9em, если задано хоть одно примечание hth_ХХХ. Очки считаются по win_ХХХ, draw_ХХХ и loss_ХХХ
-- с winpoints, drawpoints и losspoints (по умолчанию 3, 1, 0, при show_draw=no — 2, 0, 1) плюс
-- adjust_points_ХХХ и startpoints_ХХХ. Считаются здесь же, а не берутся готовыми: строки таблицы
-- строятся позже заголовка, и к моменту, когда нужна ширина, посчитанных очков ещё нет.

-- Ширина своя, отдельная от прочих числовых: знак сноски о личных встречах подмодуль стиля крепит
-- к столбцу, по которому ранжирует таблицу, — обычно к очкам. Расширять из-за него все числовые
-- столбцы значило бы отдать место семи столбцам ради одного: поля стали бы заметно шире,
-- и непонятно, откуда взялась пустота.
function ppp.measure_points_width(Args)
	local show_draw = (Args['show_draw'] or 'yes') ~= 'no'
	local win_points = tonumber(Args['winpoints']) or (show_draw and 3 or 2)
	local draw_points = tonumber(Args['drawpoints']) or (show_draw and 1 or 0)
	local loss_points = tonumber(Args['losspoints']) or (show_draw and 0 or 1)

	-- Один проход: очки по win_ХХХ и наличие примечаний hth_ХХХ.
	local most, has_hth = 0, false
	for k, v in pairs(Args) do
		if type(k) == 'string' then
			local code = k:match('^win_(.+)$')
			if code then
				local p = win_points * (tonumber(v) or 0)
					+ draw_points * (tonumber(Args['draw_' .. code]) or 0)
					+ loss_points * (tonumber(Args['loss_' .. code]) or 0)
					+ (tonumber(Args['adjust_points_' .. code]) or 0)
					+ (tonumber(Args['startpoints_' .. code]) or 0)
				if p > most then
					most = p
				end
			elseif type(v) == 'string' and v ~= '' and k:match('^hth_.') then
				has_hth = true
			end
		end
	end

	local width = 2.5
	if most >= 100 then
		width = width + 0.5
	end
	if has_hth then
		width = width + 0.9
	end
	return string.format('%.2fem', width)
end

-- Возвращает ширину столбца соотношения или процента мячей. Там выводится дробь вида «1,250» или «100,0» —
-- пять знаков, заметно длиннее прочих чисел, — и подгонять под неё соседние столбцы значило бы
-- отдать им место, которое нужно одному.
function ppp.measure_ratio_width()
	-- Пять знаков с запятой плюс отступы ячейки.
	return '2.90em'
end

-- Перенос текста в столбце результатов --------------------------------------------------

-- Предел неразрывной части текста в столбце результатов, в знаках. Около пятнадцати em: столько
-- помещается на экране телефона рядом с краем прокрученной таблицы.
ppp.result_phrase_limit = 30

-- Текст не длиннее этого, в знаках, не разбивается на строки даже в ячейке на несколько строк таблицы:
-- «Стыковые матчи» и подобные короткие подписи читаются одной строкой.
ppp.result_short_text = 24

-- Предлоги, союзы и частицы, которые не отрываются от следующего слова.
local glue_words = {}
for w in ('в во на с со к ко о об обо от ото до по за из изо у без безо для при про под подо '
	.. 'над надо перед пред через между из-за из-под и а но или либо ни не'):gmatch('%S+') do
	glue_words[w] = true
end

-- Тире и дефис отдельным словом: не отрываются от предыдущего слова.
local result_dashes = {['—'] = true, ['–'] = true, ['-'] = true, ['−'] = true}

-- Видимые знаки для мнемоник, от которых зависят правила переноса;
-- прочие мнемоники считаются одним знаком.
local result_entities = {['&nbsp;'] = '\194\160', ['&mdash;'] = '—', ['&ndash;'] = '–', ['&minus;'] = '−'}

-- Разбирает текст на слова. Разделитель слов — пробел вне разметки: пробел внутри тега,
-- ссылки или шаблона словом не считается. Возвращает список слов, список разделителей после каждого слова
-- (' ' — пробел, иначе сам тег <br>) и теги <br> перед первым словом. Слово хранит свои куски разметки
-- (parts; у куска-ссылки в link — её цель и подпись), видимый текст (vis) и его длину в знаках (len).
local function result_words(text)
	local words, gaps, lead = {}, {}, ''
	local cur = {parts = {}, vis = '', len = 0}
	local function add(s, vis, len, link)
		cur.parts[#cur.parts + 1] = {s = s, link = link}
		cur.vis = cur.vis .. vis
		cur.len = cur.len + len
	end
	local function finish(gap)
		if #cur.parts > 0 then
			words[#words + 1] = cur
			gaps[#words] = gap
			cur = {parts = {}, vis = '', len = 0}
		elseif gap and gap ~= ' ' then
			if #words > 0 then
				gaps[#words] = gap
			else
				lead = lead .. gap
			end
		end
	end
	local i, n = 1, #text
	while i <= n do
		local c = text:sub(i, i)
		if c == ' ' or c == '\t' then
			finish(' ')
			i = text:find('[^ \t]', i) or n + 1
		elseif c == '\127' then
			-- Метка сноски: на экране это знак вида [1].
			local j = text:find('\127', i + 1, true) or n
			add(text:sub(i, j), '', 2)
			i = j + 1
		elseif c == '<' then
			local j = text:find('>', i, true)
			if j then
				local tag = text:sub(i, j)
				if tag:match('^<%s*[Bb][Rr][%s/>]') then
					finish(tag)
				else
					add(tag, '', 0)
				end
				i = j + 1
			else
				add(c, c, 1)
				i = i + 1
			end
		elseif text:sub(i, i + 1) == '[[' and text:find(']]', i + 2, true) then
			local j = text:find(']]', i + 2, true)
			local inner = text:sub(i + 2, j - 1)
			local target, label = inner:match('^([^|]*)|(.*)$')
			target = target or inner
			if mw.ustring.find(target, '^%s*[Ff][Ii][Ll][Ee]%s*:') or mw.ustring.find(target, '^%s*[Фф][Аа][Йй][Лл]%s*:')
				or mw.ustring.find(target, '^%s*[Ii][Mm][Aa][Gg][Ee]%s*:') then
				-- Картинка, обычно флаг: по ширине — около двух знаков.
				add(text:sub(i, j + 1), '', 2)
			else
				local shown = (label or target):gsub('<[^<>]*>', '')
				add(text:sub(i, j + 1), shown, mw.ustring.len(shown),
					{target = target, label = label or target})
			end
			i = j + 2
		elseif c == '[' and (text:find('^%[https?://', i) or text:find('^%[//', i)) and text:find(']', i, true) then
			local j = text:find(']', i, true)
			local shown = text:sub(i + 1, j - 1):match('^%S+%s+(.*)$') or ''
			add(text:sub(i, j), shown, mw.ustring.len(shown))
			i = j + 1
		elseif c == '&' and text:match('^&#?%w+;', i) then
			local ent = text:match('^&#?%w+;', i)
			add(ent, result_entities[ent] or '?', 1)
			i = i + #ent
		elseif c == '{' and text:match('^%b{}', i) then
			local tpl = text:match('^%b{}', i)
			local shown = tpl:match('|([^|{}]*)}}$') or ''
			add(tpl, shown, mw.ustring.len(shown))
			i = i + #tpl
		else
			local j = text:find('[ \t\127<%[&{]', i + 1) or n + 1
			local chunk = text:sub(i, j - 1)
			add(chunk, chunk, mw.ustring.len(chunk))
			i = j
		end
	end
	finish(nil)
	return words, gaps, lead
end

-- Определяет вид разделителя после каждого слова: 'hard' — тег <br>,
-- 'phrase' — пробел после запятой или точки с запятой,
-- 'glue' — пробел, на котором строку рвать нельзя,
-- 'word' — обычный пробел. Затем делит текст на части по разделителям 'phrase' и 'hard'.
-- Возвращает виды разделителей и части {first, last, len, hard}.
local function result_structure(words, gaps)
	local kinds, phrases = {}, {}
	for k = 1, #words - 1 do
		local w, nxt = words[k], words[k + 1]
		if gaps[k] ~= ' ' then
			kinds[k] = 'hard'
		elseif w.vis:find('[,;]$') then
			kinds[k] = 'phrase'
		elseif glue_words[mw.ustring.lower(w.vis)] or w.vis:find('^%d') or result_dashes[nxt.vis]
			or nxt.vis == '' then
			-- Слово без видимого текста — метка сноски или одиночный тег — остаётся при
			-- предыдущем слове: знак сноски в начале строки оторван от своего текста.
			kinds[k] = 'glue'
		else
			kinds[k] = 'word'
		end
	end
	local first = 1
	for k = 1, #words do
		if k == #words or kinds[k] == 'phrase' or kinds[k] == 'hard' then
			local len = 0
			for m = first, k do
				len = len + words[m].len + (m > first and 1 or 0)
			end
			phrases[#phrases + 1] = {first = first, last = k, len = len, hard = kinds[k] == 'hard'}
			first = k + 1
		end
	end
	return kinds, phrases
end

-- Разбирает текст ячейки для расчёта раскладки: слова, разделители, части, куски и natural —
-- ширину текста без переносов. tail — ширина знака сноски после текста, в знаках;
-- она прибавляется к последнему слову. Для пустого текста и текста со сложной разметкой —
-- переводом строки, блоком div, вложенной таблицей, блоком fbmulticomp —
-- возвращает nil: такой текст не трогается.
local function result_measure(text, tail)
	if text == '' or text:find('\n', 1, true) or text:find('<%s*[Dd][Ii][Vv]') or text:find('<%s*[Tt][Aa][Bb][Ll][Ee]')
		or text:find('fbmulticomp', 1, true) then
		return nil
	end
	local words, gaps, lead = result_words(text)
	if #words == 0 then
		return nil
	end
	words[#words].len = words[#words].len + (tail or 0)
	local kinds, phrases = result_structure(words, gaps)

	-- Куски — слова вместе со склеенными с ними соседями: внутри куска строку не рвут.
	-- gap — вид разделителя после куска. name — кусок начинает название: первое его слово,
	-- не считая предлогов, — ссылка или слово с прописной буквы. Перед названием строку
	-- рвать лучше, чем между другими словами: так не разрываются сочетания вроде
	-- «основной этап», а название турнира целиком уходит на следующую строку.
	local chunks = {}
	local cur
	for k, w in ipairs(words) do
		if cur then
			cur.len = cur.len + 1 + w.len
		else
			cur = {first = k, len = w.len}
		end
		if cur.name == nil and not glue_words[mw.ustring.lower(w.vis)] then
			cur.name = w.parts[1].link ~= nil or mw.ustring.find(w.vis, '^%u') ~= nil
		end
		if kinds[k] ~= 'glue' then
			cur.last, cur.gap = k, kinds[k]
			chunks[#chunks + 1] = cur
			cur = nil
		end
	end

	-- Ширина текста без переносов: самая длинная из строк между тегами <br>.
	local natural, line = 0, 0
	for _, c in ipairs(chunks) do
		line = line + (line > 0 and 1 or 0) + c.len
		if c.gap == 'hard' or c.gap == nil then
			natural = math.max(natural, line)
			line = 0
		end
	end
	return {words = words, gaps = gaps, lead = lead, kinds = kinds, phrases = phrases,
		chunks = chunks, natural = natural}
end

-- Подбирает разрывы строк для кусков chunks: не больше rows строк, каждая не шире width знаков.
-- Разрыв после запятой или тега <br> ничего не стоит, перед названием — половину, между другими словами —
-- единицу; из раскладок с наименьшей ценой берётся самая ровная (с наименьшей суммой квадратов недобора до width).
-- Возвращает цену и набор разрывов — номера кусков, после которых начинается новая строка, — либо nil, если уложиться нельзя.
local function result_breaks(chunks, width, rows)
	local n = #chunks
	-- best[i][l] — лучшая раскладка кусков 1..i ровно в l строк, где кусок i завершает строку.
	local best = {[0] = {[0] = {cost = 0, sq = 0}}}
	for i = 1, n do
		best[i] = {}
		local len = -1
		for a = i, 1, -1 do
			-- Строка из кусков a..i. Тег <br> внутри строки оказаться не может.
			if a < i and chunks[a].gap == 'hard' then
				break
			end
			len = len + 1 + chunks[a].len
			if len > width then
				break
			end
			local step = 0
			if a > 1 and chunks[a - 1].gap == 'word' then
				step = chunks[a].name and 0.5 or 1
			end
			for l, prev in pairs(best[a - 1]) do
				if l < rows then
					local cand = {cost = prev.cost + step, sq = prev.sq + (width - len) ^ 2, from = a, lines = l}
					local have = best[i][l + 1]
					if not have or cand.cost < have.cost or (cand.cost == have.cost and cand.sq < have.sq) then
						best[i][l + 1] = cand
					end
				end
			end
		end
	end
	local pick, pick_l
	for l, cand in pairs(best[n]) do
		if not pick or cand.cost < pick.cost or (cand.cost == pick.cost and cand.sq < pick.sq) then
			pick, pick_l = cand, l
		end
	end
	if not pick then
		return nil
	end
	local breaks = {}
	local i, l = n, pick_l
	while i > 0 do
		local cand = best[i][l]
		if cand.from > 1 then
			breaks[cand.from - 1] = true
		end
		i, l = cand.from - 1, cand.lines
	end
	return pick.cost, breaks
end

-- Подбирает ширину, при которой разобранный текст укладывается в rows строк. Сперва находится
-- наименьшая возможная ширина; затем среди ширин не более чем на треть больших выбирается та, при
-- которой цена разрывов (см. result_breaks) меньше всего: разрыв после запятой предпочтительнее,
-- и ради него столбец может стать немного шире — но не заметно. Если уложиться не удаётся ни при
-- какой ширине, возвращает m.natural.
local function result_fit(m, rows)
	local lo = 0
	for _, c in ipairs(m.chunks) do
		lo = math.max(lo, c.len)
	end
	local narrowest
	local pick, pick_cost
	for width = lo, m.natural do
		local cost = result_breaks(m.chunks, width, rows)
		if cost then
			narrowest = narrowest or width
			if width * 0.75 > narrowest then
				break
			end
			if not pick_cost or cost < pick_cost then
				pick, pick_cost = width, cost
			end
			if cost == 0 then
				break
			end
		end
	end
	return pick or m.natural
end

-- Рассчитывает раскладку столбца результатов. cells — ячейки столбца {text, rows, tail}, где rows —
-- число строк таблицы под ячейкой, tail — ширина знака сноски; header — текст заголовка столбца.
-- Возвращает для каждой ячейки набор разрывов строк для ppp.wrap_result_text либо nil, если текст
-- остаётся на усмотрение браузера. Ширины считаются в знаках: сравниваются тексты одного столбца
-- между собой, и переводить их в em незачем.

-- Ширину столбца задают заголовок и ячейки в одну строку таблицы: их текст в одну строку и есть
-- естественная ширина столбца на широком экране. Ячейка, объединяющая несколько строк таблицы,
-- вмещает столько же строк текста, не увеличивая высоту таблицы. Если её текст в одну строку шире
-- столбца, он распределяется по этим строкам: сперва по запятым, а между словами — лишь когда это
-- делает столбец заметно уже. Короткий текст (ppp.result_short_text) не разбивается совсем.

-- Разрывы в такой ячейке ставит модуль, а не браузер: число строк известно заранее, и модуль
-- выбирает места разрыва по правилам, а браузер переносит по мере заполнения строки и разорвал бы,
-- например, название турнира посередине.
function ppp.result_layout(cells, header)
	local base = 0
	if header and header ~= '' then
		-- Заголовок полужирный: его буквы примерно на десятую шире.
		local shown = mw.ustring.gsub(header, '<[^<>]*>', '')
		shown = mw.ustring.gsub(shown, '%[%[[^%[%]|]*|([^%[%]]*)%]%]', '%1')
		shown = mw.ustring.gsub(shown, '%[%[([^%[%]]*)%]%]', '%1')
		shown = mw.ustring.gsub(shown, '\127[^\127]*\127', '[1]')
		base = mw.ustring.len(mw.text.trim(shown)) * 1.1
	end
	local measured = {}
	for i, cell in ipairs(cells) do
		measured[i] = result_measure(cell.text or '', cell.tail)
		if measured[i] and cell.rows == 1 then
			base = math.max(base, measured[i].natural)
		end
	end

	-- Ширина столбца: наибольшая из нужных. Ячейки, которым её мало, переносятся.
	local width = base
	for i, cell in ipairs(cells) do
		local m = measured[i]
		if m and cell.rows > 1 then
			local need = m.natural
			if need > math.max(base, ppp.result_short_text) then
				need = math.min(m.natural, math.max(result_fit(m, cell.rows), ppp.result_short_text))
			end
			width = math.max(width, need)
		end
	end

	-- Текст, который шире столбца лишь немного, переносить незачем:
	-- столбец расширится на пару знаков, зато текст останется одной строкой.
	local wide = width
	for i, cell in ipairs(cells) do
		local m = measured[i]
		if m and cell.rows > 1 and m.natural <= width * 1.1 then
			wide = math.max(wide, m.natural)
		end
	end
	width = wide

	local out = {}
	for i, cell in ipairs(cells) do
		local m = measured[i]
		if m and cell.rows > 1 and m.natural > width then
			local _, breaks = result_breaks(m.chunks, width, cell.rows)
			out[i] = breaks
		end
	end
	return out
end

-- Готовит текст ячейки столбца результатов к переносу. Сам перенос выполняет браузер —
-- по той ширине, которая достаётся столбцу на экране читателя, — а модуль решает, где строку рвать можно,
-- и склеивает остальные места знаком &nbsp;. Поэтому на широком экране текст остаётся в одну строку,
-- а на узком переносится, но только в разрешённых местах. Правила:
-- * основное место переноса — пробел после запятой или точки с запятой;
-- * часть текста между запятыми не разрывается, если она не длиннее ppp.result_phrase_limit;
-- * внутри более длинной части перенос возможен между словами, но предлог, союз, частица и число
--   не отрываются от следующего слова, а тире и знак сноски — от предыдущего;
-- * подпись вики-ссылки не разрывается, если слово с ней не длиннее того же предела;
-- * слово с дефисом, тире или косой чертой внутри — «2-й», «плей-офф», «3—5», «1/8» —
--   оборачивается классом st-nobr: браузер переносит после этих знаков, как после пробела.

-- breaks — разрывы строк от ppp.result_layout: на их месте ставится <br>. Тег <br> в самом тексте
-- остаётся принудительным переносом: разметку редактора модуль не меняет, а такое значение отмечает
-- ppp.manual_format. Текст со сложной разметкой возвращается как есть.
function ppp.wrap_result_text(text, breaks)
	local m = result_measure(text or '')
	if not m then
		return text
	end
	local limit = ppp.result_phrase_limit
	local phrase_of = {}
	for _, ph in ipairs(m.phrases) do
		for k = ph.first, ph.last do
			phrase_of[k] = ph
		end
	end
	local break_after = {}
	if breaks then
		for c_i in pairs(breaks) do
			break_after[m.chunks[c_i].last] = true
		end
	end
	local out = {m.lead}
	for k, w in ipairs(m.words) do
		-- Обёртка st-nobr — только у слова без тегов:
		-- вокруг незакрытого тега она нарушила бы вложенность разметки.
		local short = mw.ustring.len(w.vis) <= limit
		local joined = short and mw.ustring.find(w.vis, '[^%s][%-‐‑–—/][^%s]')
		if joined then
			for _, part in ipairs(w.parts) do
				if part.s:sub(1, 1) == '<' then
					joined = false
				end
			end
		end
		if joined then
			out[#out + 1] = '<span class="st-nobr">'
		end
		for _, part in ipairs(w.parts) do
			local link = part.link
			if link and short and link.label:find(' ', 1, true)
				and not link.label:find('<', 1, true) then
				out[#out + 1] = '[[' .. link.target .. '|' .. link.label:gsub(' ', '&nbsp;') .. ']]'
			else
				out[#out + 1] = part.s
			end
		end
		if joined then
			out[#out + 1] = '</span>'
		end
		local kind = m.kinds[k]
		if kind == 'hard' then
			out[#out + 1] = m.gaps[k]
		elseif break_after[k] then
			out[#out + 1] = '<br>'
		elseif kind == 'phrase' then
			out[#out + 1] = ' '
		elseif kind == 'glue' or (kind == 'word' and phrase_of[k].len <= limit) then
			out[#out + 1] = '&nbsp;'
		elseif kind == 'word' then
			out[#out + 1] = ' '
		end
	end
	return table.concat(out)
end

-- Числа в ячейках -----------------------------------------------------------------------

-- Записывает число x, как принято в русском тексте: с десятичной запятой, с нулём в целой части («0,917», а не «.917»)
-- и со знаком минуса вместо дефиса. prec — число знаков после запятой: значение округляется, половина —
-- в большую по модулю сторону, и недостающие знаки дописываются нулями («1,000»). Без prec целое выводится
-- без дробной части, дробное — со всеми своими знаками («2,5»). Ноль, получившийся при округлении отрицательного значения, выводится без минуса.

-- Запись задана здесь явно: вид дробей в таблице не зависит от того, как числа форматирует язык вики.
function ppp.decimal(x, prec)
	local sign = (x < 0) and '&minus;' or ''
	x = math.abs(x)
	local s
	if prec then
		local scale = 10 ^ prec
		s = string.format('%.' .. prec .. 'f', math.floor(x * scale + 0.5) / scale)
	else
		s = tostring(x)
	end
	if tonumber(s) == 0 then
		sign = ''
	end
	return sign .. (s:gsub('%.', ','))
end

-- Параметры, числа в которых бывают дробными или отрицательными: очки за исходы
-- (winpoints, OTlosspoints, goalpoints и т. п.), поправка и начальные очки, забитое и пропущенное —
-- в том числе дома и на выезде (hgf_, aga_ и т. п.).
local DECIMAL_PARAMS = {'^%a*points$', '^adjust_points_', '^startpoints_', '^[ha]?[gp][fa]_'}

-- Приводит числа, записанные по-русски, к виду, который читает tonumber: «0,5» — к «0.5»,
-- минус знаком «−» или «&minus;» — к дефису. Иначе такое значение не прочиталось бы как число:
-- ничья за «0,5» очка молча считалась бы по умолчанию, поправка «−3» — нулём. Меняются
-- только параметры из DECIMAL_PARAMS и только значения, целиком состоящие из числа;
-- текст («—», «н/д») остаётся как есть. Args изменяется на месте.
function ppp.read_decimals(Args)
	local fixed = {}
	for k, v in pairs(Args) do
		if type(k) == 'string' and type(v) == 'string' and v:find('[,&\226]') then
			for _, pattern in ipairs(DECIMAL_PARAMS) do
				if k:find(pattern) then
					local s = mw.text.trim(v):gsub('^−%s*', '-'):gsub('^&minus;%s*', '-')
					s = s:gsub('^(%-?%d*),(%d+)$', '%1.%2')
					if tonumber(s) then
						fixed[k] = s
					end
					break
				end
			end
		end
	end
	for k, v in pairs(fixed) do
		Args[k] = v
	end
end

-- Дописывает слева невидимые нули (класс st-pad), чтобы числа разной разрядности занимали одинаковую
-- ширину и выравнивались по правому краю столбца. Итоговая разрядность на единицу больше digits:
-- при 1 — два знака, при 2 — три, при 3 — четыре. Выравнивание не применяется, если digits или num не числовые,
-- digits вне этого ряда, число отрицательное или уже нужной разрядности. Дробное число
-- (половина игры отставания, очки при ничьей за пол-очка) записывается через ppp.decimal;
-- целое и текст возвращаются как есть.

-- Отрицательное значение передаётся без знака — num_align(- points, digits), — а минус вызывающий модуль
-- приписывает к результату: с отрицательным числом проверка n >= 0 не пройдёт, и выравнивание молча не применится.
function ppp.num_align(num, digits)
	local n = tonumber(num)
	if type(num) == 'number' and num ~= math.floor(num) then
		num = ppp.decimal(num)
	end
	if n and tonumber(digits) then
		digits = tonumber(digits)
		if digits == 1 then
			if n >= 0 and n < 10 then
				return '<span class="st-pad">0</span>' .. num
			end
		elseif digits == 2 then
			if n >= 0 and n < 10 then
				return '<span class="st-pad">00</span>' .. num
			elseif n >= 10 and n < 100 then
				return '<span class="st-pad">0</span>' .. num
			end
		elseif digits == 3 then
			if n >= 0 and n < 10 then
				return '<span class="st-pad">000</span>' .. num
			elseif n >= 10 and n < 100 then
				return '<span class="st-pad">00</span>' .. num
			elseif n >= 100 and n < 1000 then
				return '<span class="st-pad">0</span>' .. num
			end
		end
	end
	return num
end

-- Возвращает разность забитого и пропущенного со знаком — «+3», 0, «&minus;2», «+1,5» —
-- либо тире, если одно из чисел не задано (передаётся как '?').
function ppp.goal_diff(gfor, gaig)
	if gfor == '?' or gaig == '?' then
		return '&mdash;'
	end
	local d = gfor - gaig
	if d > 0 then
		return '+' .. ppp.decimal(d)
	elseif d < 0 then
		return ppp.decimal(d)
	end
	return d
end

-- Подпись столбца отставания от опорной команды (show_GB).
ppp.gb_header = '<abbr title="Отставание от опорной команды">Отст.</abbr>'

-- Возвращает текст ячейки отставания: тире у самой опорной команды (gb == nil) и при нулевом
-- отставании, иначе число игр отставания или опережение со знаком плюс, выровненные ppp.num_align
function ppp.gb_text(gb, digits)
	if gb == nil or gb == 0 then
		return '&mdash;'
	elseif gb > 0 then
		return ppp.num_align(gb, digits)
	end
	return '+' .. ppp.num_align(- gb, digits)
end

-- Сноски --------------------------------------------------------------------------------

-- Строит сноску к примечанию команды (note_ХХХ) в турнирной таблице и в шахматке.
-- Примечание — либо собственный текст own_text (is_own) под ключом entry_key, либо ссылка
-- на примечание другой команды: ref_key, её позиция ref_position и текст ref_text.
-- Если сноска с этим ключом уже создана (note_id_list) или её создаст строка владельца
-- в видимом диапазоне ii_start..ii_end, ставится ссылка на неё по имени;
-- иначе сноску с текстом создаёт текущая строка. note_id_list — накопитель идентификаторов,
-- изменяется на месте; rand_val дополняет идентификаторы (см. засев генератора в начале модуля).
-- Примечания к матчам шахматка строит своей функцией по тому же образцу; так же устроена ppp.hth.
function ppp.build_note(frame, rand_val, note_id_list, ii_start, ii_end, entry_key, own_text, is_own, ref_key, ref_position, ref_text)
	local note_id, note_string
	if is_own then
		note_id = '"table_note_' .. entry_key .. rand_val .. '"'
		note_id_list[entry_key] = note_id
		note_string = frame:expandTemplate{title = 'efn',
			args = {group = 'lower-alpha', name = note_id, own_text}
		}
	else
		-- Сноска уже создана либо её создаст строка владельца в видимом диапазоне —
		--достаточно сослаться на неё по имени.
		if note_id_list[ref_key] or (ref_position and ref_position >= ii_start and ref_position <= ii_end) then
			note_id = '"table_note_' .. ref_key .. rand_val .. '"'
			note_string = frame:extensionTag{name = 'ref',
				args = {group = 'lower-alpha', name = note_id}
			}
		else
			note_id = '"table_note_' .. ref_key .. rand_val .. '"'
			note_id_list[ref_key] = note_id
			note_string = frame:expandTemplate{title = 'efn',
				args = {group = 'lower-alpha', name = note_id, ref_text}
			}
		end
	end
	return note_string
end

-- Строит сноски к примечанию о личных встречах команды team_code_ii (параметр hth_ХХХ).
-- Значение hth_local может быть:
-- а) собственным текстом примечания;
-- б) кодом другой команды с собственным примечанием hth_ — тогда обе команды ссылаются на одну сноску;
-- в) списком таких кодов через запятую — если у каждого кода из списка есть своё примечание hth_;
--    иначе весь список считается текстом примечания (вариант «а»).

-- Сноски строятся только в полной таблице (full_table). Сноску чужого примечания создаёт строка владельца,
-- если он в видимом диапазоне ii_start..ii_end, иначе текущая строка (как в ppp.build_note). hth_id_list —
-- накопитель идентификаторов, изменяется на месте; team_list — позиции команд по кодам; rand_val дополняет идентификаторы.
-- Возвращает таблицу: str — текст сносок, list — hth_id_list, notes_exist — признак «в таблице есть примечания».
function ppp.hth(frame, Args, full_table, hth_id_list, hth_local, notes_exist, team_list, team_code_ii, ii_start, ii_end, rand_val)
	local hth_string = ''

	if hth_local and full_table then
		notes_exist = true

		-- Пробует разобрать hth_local как список кодов (вариант «в»): признаётся списком,
		-- только если элементов больше одного и у каждого есть своё hth_-примечание.
		local hth_local_table = mw.text.split(hth_local, '%s*,%s*')
		local multiref = 1
		if #hth_local_table > 1 then
			for _, hth_loc in ipairs(hth_local_table) do
				multiref = multiref * (Args['hth_' .. hth_loc] and 1 or 0)
			end
		else
			multiref = 0
		end
		-- Списком не оказалось: дальше обрабатывается одно значение целиком.
		if multiref < 1 then
			hth_local_table = {hth_local}
		end

		for _, hth_loc in ipairs(hth_local_table) do
			local hth_ref_text = Args['hth_' .. hth_loc]
			local hth_id
			if not hth_ref_text then
				-- Вариант «а»: hth_loc — сам текст примечания. Создаёт сноску
				-- и запоминает её идентификатор за текущей командой.
				hth_id = '"table_hth_' .. team_code_ii .. rand_val .. '"'
				hth_id_list[team_code_ii] = hth_id
				hth_string = hth_string .. frame:expandTemplate{title = 'efn',
					args = {group = 'lower-alpha', name = hth_id, hth_loc}
				}
			else
				-- Варианты «б» и «в»: hth_loc — код команды с собственным примечанием.
				local hth_local_num = team_list[hth_loc] or -1
				if hth_id_list[hth_loc] or (hth_local_num >= ii_start and hth_local_num <= ii_end) then
					-- Сноска уже создана либо будет создана в пределах видимого диапазона —
					-- достаточно сослаться на неё по тому же идентификатору.
					hth_id = '"table_hth_' .. hth_loc .. rand_val .. '"'
					hth_string = hth_string .. frame:extensionTag{name = 'ref',
						args = {group = 'lower-alpha', name = hth_id}
					}
				else
					-- Команда-владелец примечания вне видимого диапазона: сноску создаёт
					-- текущая строка, запоминая идентификатор за кодом владельца.
					hth_id = '"table_hth_' .. hth_loc .. rand_val .. '"'
					hth_id_list[hth_loc] = hth_id
					hth_string = hth_string .. frame:expandTemplate{title = 'efn',
						args = {group = 'lower-alpha', name = hth_id, hth_ref_text}
					}
				end
			end
		end
	end
	return {str = hth_string, list = hth_id_list, notes_exist = notes_exist}
end

-- Обозначения статуса -------------------------------------------------------------------

-- Собирает обозначения статуса команд, которыми пользуются и подмодули стиля
-- (буквы рядом с названием команды), и нижний колонтитул таблицы (легенда). Возвращает:
-- code     — словарь «буква → текст статуса»: общий словарь, дополненный status_overrides
--            и текстами status_text_<буква>; буква без описания получает «?»;
-- called   — словарь «буква → использована ли»: здесь у всех букв false, отмечают их вызывающие
--            модули при построении строк, и от этого зависит состав легенды;
-- letters  — буквы в порядке вывода легенды: сначала перечисленные в status_order, затем
--            остальной латинский алфавит, без повторов;
-- position — место букв относительно названия: 'after' либо, при status_pos=before, 'before'.

-- Необязательный status_overrides — словарь {буква = текст},
-- который заменяет или дополняет отдельные буквы общего словаря.
function ppp.status(Args, status_overrides)
	-- Общий словарь обозначений, единый для всех стилей.
	local status_code = {
		A = 'Команда вышла в следующий раунд',
		C = 'Чемпион',
		D = 'Команда дисквалифицирована',
		E = 'Команда выбыла из розыгрыша',
		G = 'Гости',
		H = 'Хозяева',
		O = 'Победитель плей-офф',
		P = 'Повышение команды (выход в дивизион уровнем выше)',
		Q = 'Команда квалифицировалась в указанную стадию',
		R = 'Выбывание команды (в дивизион уровнем ниже)',
		S = 'Победитель регулярного сезона',
		T = 'Команда квалифицировалась в турнир, но его конкретная стадия пока неизвестна'}
	if type(status_overrides) == 'table' then
		for letter, text in pairs(status_overrides) do
			status_code[letter] = text
		end
	end

	-- Буквы из status_order выводятся в легенде первыми, поэтому ставятся впереди алфавита;
	-- повторы устраняются ниже, в общем проходе по буквам.
	local status_letters = (Args['status_order'] or '') .. 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

	-- Расположение букв статуса: после названия команды, если не задано иное.
	local status_position = 'after'
	if string.lower(Args['status_pos'] or '') == 'before' then
		status_position = 'before'
	end

	-- Один проход по буквам заводит для каждой признак «использована», подставляет заглушку
	-- для букв без описания, убирает повторное вхождение буквы, уже названной в status_order,
	-- и применяет текст status_text_<буква>. Буквы обычно латинские, и тогда строка перебирается побайтово:
	-- так проход обходится без mw.ustring, который на каждом шаге заново разбирал бы всю строку.
	local status_called = {}
	local ascii = not status_letters:find('[\128-\255]')
	for l in (ascii and status_letters:gmatch('.') or mw.text.gsplit(status_letters, '')) do
		status_called[l] = false
		status_code[l] = status_code[l] or '?'
		if ascii then
			status_letters = status_letters:gsub('(' .. l .. '.-)' .. l, '%1')
		else
			status_letters = mw.ustring.gsub(status_letters, '(' .. l .. '.-)' .. l, '%1')
		end
		if Args['status_text_' .. l] then
			status_code[l] = Args['status_text_' .. l]
		end
	end

	return {code = status_code, called = status_called, letters = status_letters, position = status_position}
end

-- Стили и шапка таблицы -----------------------------------------------------------------

-- Возвращает подключение таблицы стилей семейства [[Шаблон:Sports table/styles.css]].
-- Подключение держится здесь, а не у каждого вызывающего модуля: имя страницы должно быть в одном месте.
-- Вызывают [[Модуль:Sports table]] и [[Модуль:Sports results]].

-- Если за результатом идёт вики-таблица, между ними обязателен перевод строки: разметка «{|»
-- разбирается, только когда стоит в начале строки, а тег стилей это начало занимает, —
-- таблица тогда выводится как обычный текст вместе с именами классов. Перед HTML-тегом перевод строки
-- не нужен. Повторные вызовы безвредны: MediaWiki подключает один и тот же файл однократно.
function ppp.styles()
	return mw.getCurrentFrame():extensionTag{
		name = 'templatestyles', args = {src = 'Sports table/styles.css'}
	}
end

-- Добавляет в накопитель tt заголовок одного столбца. tt — таблица {count, tab_text}: число
-- столбцов и массив строк разметки; счётчик увеличивается, строка дописывается, возвращается тот же
-- tt. width подставляется в свойство width; при width == 'auto' ширина не задаётся. Число без
-- единиц измерения считается пикселями — так ширину пишут редакторы в параметрах вроде teamwidth —
-- и переводится в em из расчёта 14px = 1em; модули семейства передают ширину уже в em.

-- Необязательный extra — дополнительные свойства стиля, каждое с точкой с запятой в конце.
-- Передаётся отдельно от ширины: приписанный к ней, он сорвал бы перевод пикселей в em,
-- а без разделителя слился бы с шириной в одно неверное свойство.
function ppp.colhead(tt, width, text, extra)
	local head_string
	extra = extra or ''
	if width == 'auto' then
		head_string = '! scope="col"' .. (extra ~= '' and ' style="' .. extra .. '"' or '')
			.. ' |' .. text .. '\n'
	else
		if mw.ustring.match(width, '^[0-9]+$') then
			width = (tonumber(width) / 14) .. 'em'
		end
		head_string = '! scope="col" style="width:' .. width .. ';' .. extra .. '" |' .. text .. '\n'
	end

	tt.count = tt.count + 1
	table.insert(tt.tab_text, head_string)
	return tt
end

-- Открывает турнирную таблицу для подмодуля стиля: подбирает ширины столбцов, выводит начало разметки
-- (с классом sortable при sortable_table) и подпись из title или table_header, затем первые столбцы заголовка —
-- позицию (если задан pos_label), группу (в полной таблице при group_col) и команду с подписью team_header и ссылками VTE_text.
-- Возвращает накопитель заголовков (см. ppp.colhead) и ширины столбцов {team, num, ratio, points}.

-- Ширины вычисляются по данным каждой таблицы, поэтому задаются заголовкам встроенным стилем:
-- ширину столбца определяет его заголовок. Названия — teamwidth либо ширина по самому длинному названию
-- (ppp.measure_team_width); числовые столбцы — одна ширина на все; соотношение и процент мячей — своя, под дробь;
-- очки — pointswidth либо своя, под сноски и трёхзначные числа.
function ppp.open_table(t, Args, pos_label, group_col, VTE_text, full_table)
	local yesno = require('Модуль:Yesno')
	local w = {
		team = Args['teamwidth'] or ppp.measure_team_width(Args),
		num = ppp.measure_num_width(Args),
		ratio = ppp.measure_ratio_width(),
		points = Args['pointswidth'] or ppp.measure_points_width(Args)
	}
	local sort_text = yesno(Args['sortable_table'] or 'no') and ' sortable' or ''
	table.insert(t, '{| class="wikitable st-table' .. sort_text .. '"\n')
	if Args['title'] or Args['table_header'] then
		table.insert(t, '|+ ' .. (Args['title'] or Args['table_header']) .. '\n')
	end
	local tt = {count = 0, tab_text = t}
	if pos_label ~= nil then
		tt = ppp.colhead(tt, w.num, pos_label)
	end
	if full_table and group_col then
		tt = ppp.colhead(tt, w.num, Args['group_header'] or '<abbr title="Группа">Гр.</abbr>')
	end
	tt = ppp.colhead(tt, w.team, (Args['team_header'] or 'Команда') .. VTE_text)
	return tt, w
end

-- Дописывает в заголовок готовую ячейку столбца результатов results_header_txt —
-- только в полной таблице (full_table).
function ppp.close_header(tt, full_table, results_header_txt)
	if full_table then
		tt.count = tt.count + 1
		table.insert(tt.tab_text, results_header_txt)
	end
	return tt
end

-- Блок CSS палитры: точка входа для #invoke ---------------------------------------------

-- Порядок цветов в блоке CSS. Задан явно, а не обходом ppp.colors: порядок обхода pairs() в Lua
-- не гарантирован, и блок мог бы менять порядок от вызова к вызову — сравнить его с тем,
-- что лежит на странице стилей, стало бы нельзя. white1 и пустое имя сюда не входят:
-- собственной заливки у них нет.
local palette_order = {
	'green1', 'green2', 'green3', 'green4',
	'blue1', 'blue2', 'blue3', 'blue4',
	'yellow1', 'yellow2', 'yellow3', 'yellow4',
	'violet1', 'violet2', 'violet3', 'violet4',
	'red1', 'red2', 'red3', 'red4',
	'black1', 'black2', 'black3', 'black4',
	'orange1', 'orange2',
	'gold1', 'silver1', 'bronze1',
	'accent', 'neutral'
}

-- Заливки служебных элементов таблиц: селекторы и цвет — имя палитры либо пара значений
-- для светлой и тёмной темы. ppp.print_styles выводит их вместе с палитрой (вид table),
-- и значения не переписываются в таблицу стилей руками.
-- tr.st-split — полоса между группами позиций (splitN в [[Модуль:Sports table]]),
--               цвет ppp.split_colors;
-- td.sr-void  — служебная клетка шахматки: команда против самой себя или скрытый результат
--              ([[Модуль:Sports results]]); в самостоятельной шахматке (sr-table) и в клетках
--              результатов турнирной таблицы при show_matches=yes (st-table). Цвет — neutral,
--              тот же, которым карточка матча помечает несостоявшуюся игру.
-- Селектор с именем элемента, как у цветов палитры, а прочие свойства этих элементов таблица
-- стилей задаёт селектором без него: у двух правил одного селектора stylelint отметил бы повтор.
local service_fills = {
	{{'.st-table tr.st-split'}, ppp.split_colors},
	{{'.st-table td.sr-void', '.sr-table td.sr-void'}, 'neutral'},
}

-- Возвращает приставку классов заливки для вида kind: 'f-' у карточек матча (box, collapsible),
-- 'st-' у таблиц. У каждой части семейства свои имена классов, и соответствие между ними держится в одном месте.
function ppp.class_prefix(kind)
	return (kind == 'box' or kind == 'collapsible') and 'f-' or 'st-'
end

-- Строит блок CSS с палитрой для страницы стилей; вызывается через #invoke на странице документации.
-- Страница стилей не проходит через вики-разметку, и значения из модуля туда не подставить —
-- там должны стоять готовые. Поэтому блок собирается здесь, а редактор копирует его целиком:
-- ручной шаг сводится к замене блока, а не к набору значений.

-- Первый параметр — вид, от которого зависит, на каком элементе стоит класс заливки:
-- table       — по умолчанию: ячейки турнирной таблицы и шахматки; блок для [[Шаблон:Sports table/styles.css]];
-- box         — корневой элемент карточки матча; блок для [[Шаблон:Football box/styles.css]];
-- collapsible — внутренняя таблица сворачиваемой карточки; блок для [[Шаблон:Football box collapsible/styles.css]].

-- Для каждого цвета выводится правило светлой темы и два правила тёмной (ppp.colors_dark):
-- для тёмной темы, выбранной в настройках (html.skin-theme-clientpref-night), и для темы по настройке системы
-- (html.skin-theme-clientpref-os). Для таблиц за цветами палитры идут заливки служебных элементов (service_fills).
-- Результат — блок syntaxhighlight, готовый к копированию.
function ppp.print_styles(frame)
	local kind = mw.text.trim((frame and frame.args and frame.args[1]) or 'table')
	local roots
	if kind == 'box' then
		roots = {'.footballbox'}
	elseif kind == 'collapsible' then
		-- Класс цвета ставится внутренней таблице, а не корневому блоку: корень несёт
		-- только имя карточки, и селектор из двух классов на одном элементе не совпал бы.
		roots = {'.footballbox-collapsible .fcevent'}
	else
		-- Класс заливки стоит на ячейке, а не на строке: при show_matches в ту же строку
		-- дописываются клетки шахматки, и заливка строки просвечивала бы через них.
		-- Ячейка названия команды — заголовок строки, отсюда отдельный th.
		roots = {'.st-table td', '.st-table th', '.sr-table td'}
	end

	local function rules(prefix, theme, indent)
		local colors = (theme == 'dark') and ppp.colors_dark or ppp.colors
		local out = {}
		for _, name in ipairs(palette_order) do
			local sel = {}
			for _, root in ipairs(roots) do
				sel[#sel + 1] = indent .. prefix .. root .. '.' .. ppp.class_prefix(kind) .. name
			end
			out[#out + 1] = table.concat(sel, ',\n') .. ' {'
			out[#out + 1] = indent .. '\t' .. 'background: ' .. colors[name] .. ';'
			out[#out + 1] = indent .. '}'
			out[#out + 1] = ''
		end
		if kind ~= 'box' and kind ~= 'collapsible' then
			for _, sf in ipairs(service_fills) do
				local sel = {}
				for _, selector in ipairs(sf[1]) do
					sel[#sel + 1] = indent .. prefix .. selector
				end
				local col = sf[2]
				col = (type(col) == 'table') and col[theme] or colors[col]
				out[#out + 1] = table.concat(sel, ',\n') .. ' {'
				out[#out + 1] = indent .. '\t' .. 'background: ' .. col .. ';'
				out[#out + 1] = indent .. '}'
				out[#out + 1] = ''
			end
		end
		return table.concat(out, '\n')
	end

	local css = rules('', 'light', '')
		.. '\n@media screen {\n'
		.. rules('html.skin-theme-clientpref-night ', 'dark', '\t')
		.. '}\n\n@media screen and (prefers-color-scheme: dark) {\n'
		.. rules('html.skin-theme-clientpref-os ', 'dark', '\t')
		.. '}\n'

	return frame:extensionTag{name = 'syntaxhighlight', content = css, args = {lang = 'css'}}
end

return ppp