/
Rstycl
/
PixsoChekerTypo
Обзор
Документация
Войти
/
Rstycl
/
PixsoChekerTypo
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
code.js
843 строки
52 KB
Павленко Александр Сергеевич
feat: объединение правил сокращений единиц измерения в одну карточку
06 авг 2026, 13:00
06 авг 2026, 13:00
8a73b47
Код
Авторство
О чём код?
// ============================================================================ // Pixso Editorial Checker — движок правил редакционной проверки. // История изменений: см. CHANGELOG.md в корне репозитория. // Общее описание архитектуры (RULES/CHECKS/AUTOFIXES/PREVIEWS): см. README.md. // Тесты правил: node test-rules.js (фикстуры — example_bad/example_good правил). // Ограничение: правила рассчитаны на русскоязычные интерфейсные тексты. // ============================================================================ // defaultOn: false у правила — выключено на первом запуске (механизм для // экспериментальных правил; сейчас все правила включены по умолчанию). // Единый список единиц измерения для правил «узкий/обычный пробел перед единицей» — // раньше дублировался как два похожих, но независимо редактируемых regex-alternation. var UNITS_ALT = 'Гбит|Мбит|Бит|ТБ|ГБ|МБ|КБ|Б|мкг|мг|кг|г|т|мкм|мм|км|см|м|нс|мкс|мс|мин|сут|мес|ч|с|л|мл|шт|₽|\\$|€|£|¥'; // Таблица правил «неправильное сокращение единицы»: из неё генерируются и объекты // RULES, и функции AUTOFIXES — вместо восьми почти одинаковых копий. // wrong — regex-альтернация неправильных форм, correct — форма по гайду. var UNIT_RULES = [ { id: 'wrong-gb', short: 'ГБ', description: 'Неправильное сокращение гигабайт (Гб, GB)', wrong: 'Гб|гб|GB|Gb', correct: 'ГБ', example_bad: 'Гб', example_good: 'ГБ' }, { id: 'wrong-mb', short: 'МБ', description: 'Неправильное сокращение мегабайт (Мб, MB)', wrong: 'Мб|мб|MB|Mb', correct: 'МБ', example_bad: 'Мб', example_good: 'МБ' }, { id: 'wrong-kb', short: 'КБ', description: 'Неправильное сокращение килобайт (Кб, KB)', wrong: 'Кб|кб|KB|Kb', correct: 'КБ', example_bad: 'Кб', example_good: 'КБ' }, { id: 'wrong-tb', short: 'ТБ', description: 'Неправильное сокращение терабайт (Тб, TB)', wrong: 'Тб|тб|TB|Tb', correct: 'ТБ', example_bad: 'Тб', example_good: 'ТБ' }, { id: 'wrong-sec', short: 'Сокращения', description: 'Сокращение секунды — по гайду «с», не «сек»', wrong: 'сек\\.?|sec\\.?', correct: 'с', example_bad: 'сек', example_good: 'с' }, { id: 'wrong-ms', short: 'Мс', description: 'Неправильное сокращение миллисекунды (msec, ms)', wrong: 'msec|ms', correct: 'мс', example_bad: 'msec', example_good: 'мс' }, { id: 'wrong-min', short: 'Минуты', description: 'Сокращение минуты — по гайду «мин» без точки', wrong: 'мин\\.|min', correct: 'мин', example_bad: 'мин.', example_good: 'мин', lookahead: '(?![-\\.a-zA-Z])' }, { id: 'wrong-hour', short: 'Часы', description: 'Сокращение часа — по гайду «ч», не «час»', wrong: 'час\\.?', correct: 'ч', example_bad: 'час.', example_good: 'ч' } ]; function unitRuleObjects() { return UNIT_RULES.map(function (u) { return { id: u.id, category: 'Единицы измерения', short: u.short, description: u.description, example_bad: u.example_bad, example_good: u.example_good, pattern: '\\d?[\\s\\u202f\\u2009]?(' + u.wrong + ')' + (u.lookahead || '(?![а-яёА-ЯЁa-zA-Z])'), fix: 'Использовать «' + u.correct + '»', severity: 'error', groupId: 'units' }; }); } var RULES = [ { id: "dot-at-end", category: "Точки", short: "Точка в конце", description: "Точка в конце последнего предложения в интерфейсном элементе", example_bad: "…должен иметь значение.", example_good: "…должен иметь значение", check: "dotAtEnd", fix: "Убрать точку в конце", severity: "warning" }, { id: "no-space-before-unit", category: "Узкий пробел", short: "Пробел перед единицей", description: "Нет узкого пробела между числом и единицей измерения", example_bad: "100МБ, 30с", example_good: "100 МБ, 30 с", pattern: "\\d(" + UNITS_ALT + ")(?![а-яёА-ЯЁa-zA-Z])", protectedSkip: true, fix: "Добавить узкий пробел (U+202F). В Pixso его нет — задача для разработки", severity: "warning" }, { id: "regular-space-before-unit", category: "Узкий пробел", short: "Обычный пробел у единицы", description: "Обычный пробел вместо узкого перед единицей измерения", example_bad: "100 МБ (обычный пробел)", example_good: "100 МБ (узкий)", pattern: "\\d (" + UNITS_ALT + ")(?![а-яёА-ЯЁa-zA-Z])", protectedSkip: true, fix: "Узкий пробел (U+202F). В Pixso недоступен — для разработки", severity: "warning" }, { id: "no-space-after-numero", category: "Узкий пробел", short: "Пробел после №", description: "Нет узкого пробела между № и числом", example_bad: "№102", example_good: "№ 102", pattern: "№\\d", fix: "Узкий пробел между № и числом (для разработки)", severity: "warning" }, { id: "no-space-in-large-number", category: "Узкий пробел", short: "Пробел в большом числе", description: "Нет узкого пробела в числе из 4+ цифр", example_bad: "9999", example_good: "9 999", check: "largeNumber", fix: "Узкий пробел после каждых 3 цифр (для разработки)", severity: "warning" }, { id: "no-space-before-percent", category: "Узкий пробел", short: "Пробел перед %", description: "Нет узкого пробела между числом и %", example_bad: "35%", example_good: "35 %", pattern: "\\d%", fix: "Узкий пробел перед % (для разработки)", severity: "warning" }, { id: "hyphen-as-minus", category: "Минус", short: "Дефис вместо минуса", description: "Дефис вместо знака минуса", example_bad: "-35", example_good: "−35", pattern: "(^|[=:\\s(,])-(\\d)", protectedSkip: true, fix: "Заменить дефис на минус «−» (U+2212), пробел не ставим", severity: "error" }, { id: "space-before-minus", category: "Минус", short: "Пробел у минуса", description: "Пробел между минусом и числом", example_bad: "− 35", example_good: "−35", pattern: "− \\d", fix: "Убрать пробел между минусом и числом", severity: "error" }, { id: "hyphen-as-em-dash", category: "Тире", short: "Дефис вместо тире", description: "Дефис вместо длинного тире в предложении", example_bad: "Я - директор", example_good: "Я — директор", check: "emDash", fix: "Заменить « - » на « — » (U+2014)", severity: "warning" }, { id: "hyphen-as-en-dash-numbers", category: "Тире", short: "Дефис в диапазоне чисел", description: "Дефис вместо среднего тире в диапазоне чисел/дат", example_bad: "50-100", example_good: "50–100", check: "enDashNumbers", fix: "Среднее тире «–» (U+2013), без пробелов", severity: "error" }, { id: "hyphen-as-en-dash-dates", category: "Тире", short: "Дефис в диапазоне дат", description: "Дефис вместо среднего тире в диапазоне дат", example_bad: "25.09-30.09", example_good: "25.09–30.09", pattern: "\\d{2}\\.\\d{2}(?:\\.\\d{4})?\\s*-\\s*\\d{2}\\.\\d{2}(?:\\.\\d{4})?", fix: "Среднее тире «–» (U+2013)", severity: "error", groupId: "dates" }, { id: "en-dash-weekdays-no-space", category: "Тире", short: "Тире между днями недели", description: "Тире между днями недели без узкого пробела (или дефис вместо тире)", example_bad: "понедельник–пятница", example_good: "понедельник – пятница", check: "enDashWeekdays", fix: "Среднее тире с узким пробелом с обеих сторон", severity: "warning" }, { id: "straight-double-quotes", category: "Кавычки", short: "Двойные кавычки", description: "Прямые двойные кавычки вместо ёлочек", example_bad: "\"текст\"", example_good: "«текст»", pattern: "(?<!\\d)\"[^\"\\n]+\"", fix: "Заменить на «текст». Названия кнопок/разделов — без кавычек, жирным/ссылкой", severity: "warning", groupId: "quotes" }, { id: "straight-single-quotes", category: "Кавычки", short: "Одиночные кавычки", description: "Одиночные прямые кавычки", example_bad: "'текст'", example_good: "«текст»", pattern: "(?<![a-zA-Zа-яёА-ЯЁ\\d])'[^'\\n]+'(?![a-zA-Zа-яёА-ЯЁ])", fix: "Заменить на «текст» или убрать, если это название кнопки/раздела", severity: "warning", groupId: "quotes" }, { id: "button-in-quotes", category: "Кавычки", short: "Кнопка в кавычках", description: "Название кнопки в кавычках («Сохранить»)", example_bad: "кнопку «Сохранить»", example_good: "кнопку Сохранить", pattern: "(кнопк[а-яё]*|нажмите|нажать)\\s*[«\"][^»\"]+[»\"]", fix: "Убрать кавычки, с заглавной, полужирным", severity: "error", groupId: "quotes" }, { id: "section-in-quotes", category: "Кавычки", short: "Раздел в кавычках", description: "Название раздела/вкладки в кавычках («настройки»)", example_bad: "раздел «настройки»", example_good: "раздел Настройки", pattern: "(раздел[а-яё]*|вкладк[а-яё]*|меню|экран[а-яё]*)\\s+[«\"][^»\"]+[»\"]", fix: "Убрать кавычки, с заглавной, ссылкой", severity: "error", groupId: "quotes" }, { id: "colon-after-label-above-field", category: "Двоеточие", short: "Двоеточие после лейбла", description: "Двоеточие после лейбла (над полем / рядом с текстом)", example_bad: "Справочник:", example_good: "Справочник", check: "colonLabel", fix: "Убрать двоеточие после лейбла", severity: "warning" }, { id: "exclamation-mark", category: "Знаки", short: "Восклицательный знак", description: "Восклицательный знак — не используем в интерфейсе", example_bad: "Готово!", example_good: "Готово", pattern: "!+(?!=)", protectedSkip: true, fix: "Точно нужен? В интерфейсе не используем", severity: "warning" }, { id: "caps-lock", category: "Регистр", short: "Капслок", description: "Капслок в обычном тексте (не аббревиатура)", example_bad: "НЕ КРИЧИМ", example_good: "Не кричим", check: "capsLock", fix: "Убрать капслок, для акцента — полужирное", severity: "warning" }, { id: "dot-as-decimal-separator", category: "Числа", short: "Точка вместо запятой", description: "Точка вместо запятой в дробном числе", example_bad: "15.8", example_good: "15,8", check: "decimal", fix: "Заменить точку на запятую", severity: "error" }, { id: "units", category: "Единицы измерения", short: "Сокращения", description: "Все правила сокращений единиц измерения (ГБ, МБ, КБ, ТБ, сек, мс, мин, ч)", check: function() { return false; }, fix: "—", severity: "error", groupId: "units" } ].concat(unitRuleObjects()).concat([ { id: "extension-uppercase", category: "Файлы", short: "Расширение заглавными", description: "Расширение файла заглавными буквами", example_bad: ".PDF", example_good: ".pdf", check: "extension", fix: "Расширение — с точкой и строчными (.pdf)", severity: "error" }, { id: "format-lowercase", category: "Файлы", short: "Формат строчными", description: "Формат файла строчными (без точки)", example_bad: "открыть в pdf", example_good: "открыть в PDF", pattern: "(файл[еа]?|формат[еа]?|открыть|сохранить|загрузить|скачать|экспортировать?|импортировать?|прикрепить|конвертировать?)(?:\\s+в)?\\s+(yaml|yml|pdf|csv|json|xml|svg|jpeg|jpg|png|gif|webp|zip|rar|7z|docx|xlsx|xls|pptx|ppt|txt|mp4|mp3)\\b", fix: "Формат — без точки, заглавными (PDF)", severity: "error" }, { id: "wrong-date-mask-lowercase", category: "Дата и время", short: "Маска даты", description: "Маска даты/времени строчными буквами (дд.мм.гггг)", example_bad: "дд.мм.гггг", example_good: "ДД.ММ.ГГГГ", pattern: "(дд\\.мм\\.гггг|дд\\.мм\\.гг|чч:мм:сс|чч:мм|дд\\.мм|dd\\.mm\\.yyyy|dd\\.mm\\.yy|dd\\.mm|hh:mm:ss|hh:mm|MM\\/DD\\/YYYY|mm\\/dd\\/yyyy|mm\\/dd\\/yy)", fix: "Маска в UI — заглавными: ДД.ММ.ГГГГ, ЧЧ:ММ:СС", severity: "error", groupId: "dates" }, { id: "iso-date-format", category: "Дата и время", short: "ISO-формат даты", description: "Дата в ISO формате (2026-06-08) вместо ДД.ММ.ГГГГ", example_bad: "2026-06-08", example_good: "08.06.2026", pattern: "\\b\\d{4}-\\d{2}-\\d{2}\\b", fix: "Формат ДД.ММ.ГГГГ", severity: "warning", groupId: "dates" }, { id: "us-date-format", category: "Дата и время", short: "US-формат даты", description: "Дата в US-формате (12/12/2026) вместо ДД.ММ.ГГГГ", example_bad: "12/12/2026", example_good: "12.12.2026", pattern: "\\b\\d{1,2}/\\d{1,2}/\\d{4}\\b", fix: "Формат ДД.ММ.ГГГГ", severity: "warning", groupId: "dates" }, { id: "leading-zero-in-day", category: "Дата и время", short: "Ведущий ноль в дне", description: "Ведущий ноль в дне при словесной дате (01 апреля)", example_bad: "01 апреля", example_good: "1 апреля", pattern: "(?<!\\d)0(\\d) ([Яя]нваря|[Фф]евраля|[Мм]арта|[Аа]преля|[Мм]ая|[Ии]юня|[Ии]юля|[Аа]вгуста|[Сс]ентября|[Оо]ктября|[Нн]оября|[Дд]екабря)", fix: "Убрать ведущий ноль (1 апреля)", severity: "error", groupId: "dates" }, { id: "malformed-date-format", category: "Дата и время", short: "Неверный формат даты", description: "Неверный формат числовой даты: пропущен ноль или недопустимый день/месяц", example_bad: "02.2.2025, 2.02.2025, 02.92.2025", example_good: "02.02.2025", check: "malformedDate", fix: "Формат ДД.ММ.ГГГГ — день по календарю месяца, месяц 01–12", severity: "error", groupId: "dates" }, { id: "hyphen-as-en-dash-dates", category: "Тире", short: "Дефис в диапазоне дат", description: "Дефис вместо тире в диапазоне дат (25.09-30.09)", example_bad: "25.09-30.09", example_good: "25.09–30.09", pattern: "\\d{2}\\.\\d{2}(?:\\.\\d{4})?\\s*-\\s*\\d{2}\\.\\d{2}(?:\\.\\d{4})?", fix: "Среднее тире «–» (U+2013)", severity: "error", groupId: "dates" }, { id: "multiple-spaces", category: "Прочее", short: "Двойной пробел", description: "Двойной пробел", example_bad: "Сохранить файл", example_good: "Сохранить файл", pattern: " {2,}", fix: "Заменить на одинарный пробел", severity: "error" }, { id: "space-before-punctuation", category: "Прочее", short: "Пробел перед знаком", description: "Пробел перед знаком препинания", example_bad: "Сохранить ?", example_good: "Сохранить?", // [,;](?!\)) и [!?](?!\)) — не трогаем смайлы :) ;) // \.(?!\.) — «текст ...» отдаётся правилу three-dots // :(?![()D]) — не трогаем смайлы :) :( :D pattern: " (?:[,;](?!\\))|\\.(?!\\.)|:(?![()D])|[!?](?!\\)))", fix: "Убрать пробел перед знаком препинания", severity: "error" }, { id: "three-dots", category: "Прочее", short: "Три точки вместо многоточия", description: "Три точки подряд вместо символа многоточия", example_bad: "Загрузка...", example_good: "Загрузка…", pattern: "\\.{3,}", protectedSkip: true, fix: "Заменить на символ многоточия «…» (U+2026)", severity: "warning" }, { id: "repeated-punctuation", category: "Прочее", short: "Повтор знака препинания", description: "Повторяющийся знак препинания (для «!» — своё правило)", example_bad: "Что случилось??", example_good: "Что случилось?", pattern: "([?,;:])\\1+", protectedSkip: true, fix: "Оставить один знак", severity: "error" }, { id: "yo-letter", category: "Прочее", short: "Буква Ё", description: "Буква Ё там, где нет риска неправильного прочтения", example_bad: "Всё", example_good: "Все (если смысл не меняется)", pattern: "[ёЁ]", fix: "Проверьте смысл: «всё» ≠ «все», «её» ≠ «ее». Если ок — замените на Е", severity: "warning" }, { id: "no-nbsp-after-preposition", category: "Неразрывный пробел", short: "Неразрывный пробел после предлога", description: "Обычный пробел после предлога или союза", example_bad: "в системе", example_good: "в системе (nbsp)", pattern: "(^|\\s)([Вв]|[Сс]|[Кк]|[Ии]з|[Пп]о|[Зз]а|[Дд]о|[Нн]а|[Оо]т|[Оо]б|[Нн]е|[Нн]и|[Нн]о) [а-яёА-ЯЁ]", fix: "Неразрывный пробел (U+00A0). В Pixso нет — для разработки", severity: "warning", defaultOn: false } ]); // ─── Защитный слой: «технические» куски, которые нельзя трогать ─────────────── var EXTENSIONS = ['pdf','yaml','yml','csv','json','xml','jpeg','jpg','png','svg','gif','webp', 'zip','rar','doc','docx','xls','xlsx','ppt','pptx','mp4','mp3','txt','html','exe','dmg']; var VERSION_CTX = /(верси|сборк|build|ревизи|релиз|код\s+версии|v\.?\s*$|v\d|release|patch|update|ios|macos|android|windows)\s*$/i; function protectedRanges(t) { var rs = []; function add(re) { var m; while ((m = re.exec(t)) !== null) { rs.push([m.index, m.index + m[0].length]); if (m[0].length === 0) re.lastIndex++; } } add(/\b\d{1,2}:\d{2}(?::\d{2})?\b/g); // время 12:00:00 add(/\b\d+(?:\.\d+){2,}\b/g); // версии/ip: 2.4.1961, 10.0.5.1 add(/\b\d{3,4}\s*[xх×]\s*\d{3,4}\b/g); // разрешения: 1920x1080 add(/\b\d{3,4}(?:p|i|k|fps|hz|гц)\b/gi); // 1080p, 60fps, 144hz // Телефоны: только явные форматы (+7…, 8 XXX XXX-XX-XX). Старый паттерн // «любые 8–20 цифр с разделителями» съедал большие числа и диапазоны. add(/\+\d[\d\s\-()]{8,18}\d/g); add(/\b8[\s]?\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{2}[\s-]?\d{2}\b/g); // Технические коды: обязательна цифра или латинская многодефисная структура — // обычные дефисные слова («во-первых», «какой-то») больше не «защищаются». add(/\b[A-Za-z][\w-]*\d[\w-]*\b/g); // dp01-pf, v2-beta, MP3 add(/\b[A-Za-z]+(?:-[A-Za-z0-9]+){2,}\b/g); // da-dp-pf add(/\b[А-Яа-яЁёA-Za-z]{1,4}-\d+\b/g); // АБ-1234, СН-500 add(/\b[ДМГЧСDMYHS]{2,4}(?:[\s.:\/]+[ДМГЧСDMYHS]{2,4})*\b/g); // маски: ДД.ММ.ГГГГ ЧЧ:ММ:СС add(/https?:\/\/[^\s]+/g); // URL целиком return rs; } function overlaps(idx, len, rs) { var a = idx, b = idx + len; return rs.some(function (r) { return a < r[1] && b > r[0]; }); } // ─── Спец-проверки (там, где чистого regex мало) ───────────────────────────── var ABBREV_END = /(?:т\.\s?д|т\.\s?п|т\.\s?е|и\.\s?о|и\s?др|и\s?пр|руб|коп|стр|рис|табл|см|кв|ул|пр-т|пер|напр|прим|п\.с|p\.s|etc|fig|г|гг|шт|кг|мг|км|мм|дм|мл|ккал|квт|вт|кб|мб|гб|тб|кбит|мбит|гбит|гц|мгц|ггц|дб|пф|нф|мкф|мкс|нс|мс|пс|экз|чел|мин)\.$/i; // Сокращения дней — только как отдельные слова: без границ они матчились // внутри «автор» (вт), «все» (вс), «срок» (ср) и глушили правило тире. var WEEKDAY = /понедельник|вторник|сред[аыуе]|четверг|пятниц|суббот|воскресень|(?<![а-яёА-ЯЁ])(?:пн|вт|ср|чт|пт|сб|вс)(?![а-яёА-ЯЁ])/i; // Allowlist кириллических аббревиатур для caps-lock var ABBREV_RU = /^(ИНН|ОГРН|КПП|СНИЛС|ОКВЭД|ОКПО|НДФЛ|НДС|ФНС|ФСС|ПФР|МФЦ|МВД|ФСБ|ФАС|ЦБ|ФИО|ИП|ООО|ОАО|ЗАО|АО|ПАО|НКО|ВВП|МЧС|СМИ|ЕГЭ|ОГЭ|РФ|ЧС|ФТС|РЖД|ОМС|ДМС|ЕГРЮЛ|ЕГРИП|ОКТМО|ОКАТО|БИК|КБК|СБП|УИН)$/; // Глаголы-инструкции для colonLabel — строки с ними не являются лейблами var VERB_INSTRUCT = /^(выберите|укажите|введите|нажмите|загрузите|проверьте|добавьте|удалите|создайте|настройте|включите|выключите|перейдите|обратите|убедитесь|отметьте|заполните|подтвердите|скачайте|установите)/i; // Контекст «слева — код/номер документа»: допускает двоеточие и падежные // окончания («Заказ: 123-456», «заказа 123-456»). Общий для enDashNumbers // и largeNumber. var CODE_CTX = /(артикул|код|№|тел|phone|чек|заказ|карт|сч[её]т|инн|кпп|огрн|снилс|окпо|индекс|паспорт)[а-яё]*\s*[:№#]*\s*$/i; function daysInMonth(month, year) { var leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; return [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; } var CHECKS = { // dot-at-end: только последняя точка всего нода, не аббревиатура/не расширение/не число // Пропускает многопредложный текст (там точка в конце нужна) dotAtEnd: function (t) { var tr = t.replace(/\s+$/, ''); if (!/[а-яёА-ЯЁa-zA-Z0-9]\.$/.test(tr)) return []; if (ABBREV_END.test(tr)) return []; // Многопредложный текст: внутри строки (до последней точки) есть конец предложения if (/[.!?]\s+[А-ЯЁA-Z]/.test(tr.slice(0, -1))) return []; var lastTok = (tr.split(/\s/).pop() || ''); if (/\d\.$/.test(tr) || /\.[A-Za-z]{2,5}\.$/.test(lastTok)) return []; // 1.5. / file.pdf. return [{ index: tr.length - 1, str: '.' }]; }, // colonLabel: нод целиком = "Слово(а):" в одну строку, не инструкция, не время colonLabel: function (t) { var tr = t.trim(); if (tr.indexOf('\n') !== -1) return []; // многострочный — это не лейбл if (!/^[А-ЯЁA-Za-zа-яё][А-ЯЁа-яёA-Za-z \-]+:$/.test(tr)) return []; if (/\d:$/.test(tr)) return []; // не время/счёт if (VERB_INSTRUCT.test(tr)) return []; // инструкция, а не лейбл var i = t.lastIndexOf(':'); return [{ index: i, str: ':' }]; }, // emDash: не диапазон чисел (→ enDashNumbers), не дни недели (→ enDashWeekdays), // не «хлебные крошки» (3+ разделителя ≈ навигационная цепочка) emDash: function (t) { if ((t.match(/ - /g) || []).length >= 3) return []; var out = [], re = / - /g, m; while ((m = re.exec(t)) !== null) { var before = t[m.index - 1] || '', after = t[m.index + 3] || ''; var around = t.slice(Math.max(0, m.index - 12), m.index + 15); if (/\d/.test(before) || /\d/.test(after)) continue; // диапазон → enDashNumbers if (WEEKDAY.test(around)) continue; // дни недели → enDashWeekdays out.push({ index: m.index, str: ' - ' }); } return out; }, // enDashNumbers: диапазоны и без пробелов (50-100), и с пробелами (50 - 100); // исключает ISO-даты (2024-06-18), коды/номера документов (CODE_CTX) enDashNumbers: function (t, rs) { var isoBounds = []; var isoRe = /\b\d{4}-\d{2}-\d{2}\b/g, isoM; while ((isoM = isoRe.exec(t)) !== null) { isoBounds.push([isoM.index, isoM.index + isoM[0].length]); } var out = [], re = /(?<![\d-])\d{1,7}\s*-\s*\d{1,7}(?![-\d])/g, m; while ((m = re.exec(t)) !== null) { if (overlaps(m.index, m[0].length, rs)) continue; if (CODE_CTX.test(t.slice(0, m.index))) continue; if (isoBounds.some(function (b) { return m.index >= b[0] && m.index + m[0].length <= b[1]; })) continue; out.push({ index: m.index, str: m[0] }); } return out; }, // enDashWeekdays: полные названия (все падежи) и сокращения пн–пт. // Флагается: день–день вплотную (en dash без пробелов) и день-день через дефис // (с пробелами или без). Вариант «день – день» с пробелами не флагается. enDashWeekdays: function (t) { var FULL = '(?:понедельник[а-яё]*|вторник[а-яё]*|сред[аыуе][а-яё]*|четверг[а-яё]*|пятниц[а-яё]+|суббот[а-яё]+|воскресень[а-яё]+)'; var ABBR = '(?:пн|вт|ср|чт|пт|сб|вс)'; var DAY = '(?:' + FULL + '|' + ABBR + ')'; var re = new RegExp('(?<![а-яё])' + DAY + '(?:–|\\s*-\\s*)' + DAY + '(?![а-яё])', 'gi'), m, out = []; while ((m = re.exec(t)) !== null) { out.push({ index: m.index, str: m[0] }); } return out; }, // malformedDate: DD.MM.YYYY — без ведущего нуля, месяц вне 01–12 или день // вне календаря месяца (учитывая високосный год). // Намеренно игнорирует rs — иначе protectedRanges скрывает подобные строки как «версии» malformedDate: function (t) { var out = [], re = /\b(\d{1,2})\.(\d{1,2})\.((?:19|20)\d{2})\b/g, m; while ((m = re.exec(t)) !== null) { var day = m[1], month = m[2], year = +m[3]; var d = +day, mo = +month; var bad = false; if (day.length === 1 || month.length === 1) bad = true; else if (mo < 1 || mo > 12 || d < 1) bad = true; else if (d > daysInMonth(mo, year)) bad = true; if (bad) out.push({ index: m.index, str: m[0] }); } return out; }, decimal: function (t) { var out = [], re = /(?<![.\d])\d+\.\d+(?![.\d])/g, m; while ((m = re.exec(t)) !== null) { var prefix = t.slice(Math.max(0, m.index - 20), m.index); if (VERSION_CTX.test(prefix)) continue; // «Код версии … 849.1», «iOS 17.2» // «п. 2.1», «гл. 3.2» — нумерация пунктов, не дробь if (/(?:^|\s)(?:п|пп|гл|разд|пункт[а-яё]*|глав[а-яё]*|раздел[а-яё]*)\.?\s*$/i.test(prefix)) continue; // Нумерация в начале строки: «2.1 Введение» if (/(?:^|\n)\s*$/.test(t.slice(0, m.index)) && /^[ ][A-Za-zА-Яа-яЁё]/.test(t.slice(m.index + m[0].length))) continue; // Дата без года «25.09»: дробная часть из двух цифр в диапазоне месяцев var dp = m[0].match(/^(\d{1,2})\.(\d{2})$/); if (dp && +dp[1] >= 1 && +dp[1] <= 31 && +dp[2] >= 1 && +dp[2] <= 12) continue; out.push({ index: m.index, str: m[0] }); } return out; }, // extension: ловит и « .PDF» после пробела/в начале строки — привязка к // предыдущему символу убрана (мешала собственному example_bad) extension: function (t) { var out = [], re = /\.[A-Za-z]{2,5}\b/g, m; while ((m = re.exec(t)) !== null) { var ext = m[0].slice(1); if (ext === ext.toLowerCase()) continue; // .pdf — правильно, пропустить if (EXTENSIONS.indexOf(ext.toLowerCase()) === -1) continue; // не известное расширение out.push({ index: m.index, str: m[0] }); } return out; }, // capsLock: allowlist кириллических аббревиатур; одиночное слово — от 5 букв // (2–4 буквы капсом — почти всегда аббревиатура) capsLock: function (t, rs) { var re = /[А-ЯЁ]{2,}(?:[\s\-][А-ЯЁ]{2,})+|[А-ЯЁ]{5,}/g, m, out = []; while ((m = re.exec(t)) !== null) { if (overlaps(m.index, m[0].length, rs)) continue; var tokens = m[0].split(/[\s\-]+/); if (tokens.every(function (tok) { return ABBREV_RU.test(tok); })) continue; out.push({ index: m.index, str: m[0] }); } return out; }, // largeNumber: исключает годы (18xx–21xx), дробные части (после запятой) // и контекст документов/кодов largeNumber: function (t, rs) { var out = [], re = /(?<![\d.,\-])(\d{4,})(?![\d.\-])/g, m; while ((m = re.exec(t)) !== null) { if (overlaps(m.index, m[0].length, rs)) continue; if (/^(?:18|19|20|21)\d{2}$/.test(m[0])) continue; // годы 1800–2199 if (CODE_CTX.test(t.slice(0, m.index))) continue; out.push({ index: m.index, str: m[0] }); } return out; } }; // ─── Авто-исправления ──────────────────────────────────────────────────────── // Фикс работает на уровне фрагмента: (match) => замена. Применение к тексту // нода — по индексу вхождения (applyFixToText), а не заменой подстроки по всему // тексту: иначе клик «исправить» по одному вхождению чинил бы все одинаковые // сразу, а UI продолжал бы показывать «фантомные» нарушения. var AUTOFIXES = { 'straight-double-quotes': function(m) { return '«' + m.slice(1, -1) + '»'; }, 'straight-single-quotes': function(m) { return '«' + m.slice(1, -1) + '»'; }, 'hyphen-as-em-dash': function() { return ' — '; }, 'hyphen-as-en-dash-numbers': function(m) { return m.replace(/\s*-\s*/, '–'); }, 'hyphen-as-en-dash-dates': function(m) { return m.replace(/\s*-\s*/, '–'); }, 'hyphen-as-minus': function(m) { return m.replace('-', '−'); }, 'multiple-spaces': function() { return ' '; }, 'space-before-punctuation': function(m) { return m.trim(); }, 'leading-zero-in-day': function(m) { return m.replace(/^0/, ''); }, 'dot-as-decimal-separator': function(m) { return m.replace('.', ','); }, 'three-dots': function() { return '…'; }, 'repeated-punctuation': function(m) { return m.charAt(0); }, 'space-before-minus': function(m) { return m.replace(/ /, ''); }, 'dot-at-end': function() { return ''; }, 'colon-after-label-above-field': function() { return ''; }, 'exclamation-mark': function() { return ''; }, 'button-in-quotes': function(m) { return unquoteLabel(m); }, 'section-in-quotes': function(m) { return unquoteLabel(m); }, 'extension-uppercase': function(m) { return '.' + m.slice(1).toLowerCase(); }, 'format-lowercase': function(m) { return m.replace(/([a-zA-Z]+)$/, function(s) { return s.toUpperCase(); }); }, 'wrong-date-mask-lowercase': function(m) { return m.toUpperCase(); }, 'iso-date-format': function(m) { var p = m.split('-'); return p[2] + '.' + p[1] + '.' + p[0]; }, 'us-date-format': function(m) { var p = m.split('/'); return p[1] + '.' + p[0] + '.' + p[2]; }, 'yo-letter': function(m) { return m.replace(/ё/g, 'е').replace(/Ё/g, 'Е'); }, // Pixso поддерживает узкий неразрывный пробел U+202F (NARROW NO-BREAK // SPACE) — ставим его же в фиксах, чтобы результат «Исправить» совпадал с превью. 'no-space-before-unit': function(m) { return m.replace(/(\d)(\D+)/, '$1 $2'); }, 'regular-space-before-unit': function(m) { return m.replace(/^(\d+) /, '$1 '); }, 'no-space-after-numero': function(m) { return m.replace(/№(\d)/, '№ $1'); }, 'no-space-in-large-number': function(m) { return groupThousands(m); }, 'en-dash-weekdays-no-space': function(m) { return m.replace(/\s*[-–]\s*/, ' – '); }, 'no-space-before-percent': function(m) { return m.replace(/(\d)%/, '$1 %'); } }; UNIT_RULES.forEach(function (u) { AUTOFIXES[u.id] = function (m) { return m.replace(new RegExp(u.wrong), u.correct); }; }); // Применяет фикс к тексту по индексу вхождения. Если текст успел измениться // (индекс не подтверждается), ищет единственное вхождение; при неоднозначности // возвращает null — лучше не исправить, чем исправить не то. function applyFixToText(text, match, index, fixFn) { var at = index; if (typeof at !== 'number' || at < 0 || text.substr(at, match.length) !== match) { var first = text.indexOf(match); if (first === -1 || text.indexOf(match, first + 1) !== -1) return null; at = first; } var repl = fixFn(match); if (repl == null || repl === match) return null; return text.slice(0, at) + repl + text.slice(at + match.length); } // ─── Превью «после» для правил без безопасного автофикса (для UI, без кнопки) ─ function capitalizeFirst(s) { return s.charAt(0).toUpperCase() + s.slice(1); } function groupThousands(numStr) { return numStr.replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } function unquoteLabel(m) { return m.replace(/[«"]([^»"]+)[»"]/, function(_, inner) { return capitalizeFirst(inner.trim()); }); } // Контракт: preview-функция бросает исключение, если для данного совпадения // нельзя однозначно построить превью (см. malformed-date-format) — вызывающий // код (runRules) перехватывает это и просто не показывает превью «после». var PREVIEWS = { // NBSP U+00A0 после предлога — фикса нет (Pixso не гарантирует поддержку), только превью 'no-nbsp-after-preposition': function(m) { return m.replace(/ ([а-яёА-ЯЁ])$/, ' ' + String.fromCharCode(0xA0) + '$1'); }, 'caps-lock': function(m) { var first = true; return m.split(/([\s\-]+)/).map(function(tok) { if (!tok || /^[\s\-]+$/.test(tok)) return tok; if (ABBREV_RU.test(tok)) { first = false; return tok; } var lower = tok.toLowerCase(); var res = first ? capitalizeFirst(lower) : lower; first = false; return res; }).join(''); }, 'malformed-date-format': function(m) { var parts = m.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/); if (!parts) throw new Error('unsupported'); var day = +parts[1], month = +parts[2], year = +parts[3]; if (month > 12 || month < 1 || day < 1) throw new Error('ambiguous'); if (day > daysInMonth(month, year)) throw new Error('ambiguous'); var pad = function(n) { return n < 10 ? '0' + n : '' + n; }; return pad(day) + '.' + pad(month) + '.' + parts[3]; } }; // ─── Компиляция (флаг 'g' БЕЗ 'm'!) ────────────────────────────────────────── var COMPILED = RULES.map(function (r) { if (r.check) { if (typeof CHECKS[r.check] !== 'function') { console.error('[checker] Rule "' + r.id + '" references unknown check "' + r.check + '" — rule skipped'); return null; } return r; // спец-проверка, regex не нужен } try { return Object.assign({}, r, { re: new RegExp(r.pattern, 'g') }); } catch (e) { console.error('[checker] Bad pattern "' + r.id + '":', e.message); return null; } }).filter(Boolean); function runRules(text, enabledIds) { if (!text) return []; var enabled = {}; (enabledIds || []).forEach(function (id) { enabled[id] = true; }); var results = [], rs = protectedRanges(text); COMPILED.forEach(function (r) { if (!enabled[r.id]) return; var hits = []; if (r.check) { hits = CHECKS[r.check](text, rs); } else { var re = r.re; // прекомпилирован в COMPILED re.lastIndex = 0; var m; while ((m = re.exec(text)) !== null) { if (!(r.protectedSkip && overlaps(m.index, m[0].length, rs))) hits.push({ str: m[0], index: m.index }); if (m[0].length === 0) re.lastIndex++; } } hits.forEach(function (hit) { var mt = hit.str; var fixFn = AUTOFIXES[r.id]; var previewFn = fixFn || PREVIEWS[r.id]; var afterMatch = null; if (previewFn) { try { afterMatch = previewFn(mt); } catch (e) { afterMatch = null; } } results.push({ ruleId: r.id, category: r.category, description: r.description, short: r.short || r.description, match: mt, matchIndex: hit.index, fix: r.fix, severity: r.severity, canFix: !!fixFn, afterMatch: afterMatch }); }); }); return results; } // ─── Контекст вокруг найденного фрагмента (для UI) ─────────────────────────── // Расширяем до границ слова (буквы/цифры + немного соединительной пунктуации), // а не на фиксированное число символов — так видно всё слово с ошибкой, а не обрубок. var WORD_CHAR = /[a-zA-Zа-яёА-ЯЁ0-9%№\-.:]/; // Слева — короткий отступ: строка обрезается CSS-эллипсисом СПРАВА, поэтому // длинный «pre» рискует вытолкнуть саму подсветку (mark) за пределы видимой // области. Справа отступ можно оставить длиннее — обрежется безопасно. var MAX_PAD_LEFT = 10; var MAX_PAD_RIGHT = 24; function buildContext(text, match, index) { var idx = typeof index === 'number' && index >= 0 ? index : text.indexOf(match); if (idx === -1) return null; var start = idx; while (start > 0 && idx - start < MAX_PAD_LEFT && WORD_CHAR.test(text[start - 1])) start--; var leftTruncated = start > 0 && WORD_CHAR.test(text[start - 1]); var matchEnd = idx + match.length; var end = matchEnd; while (end < text.length && end - matchEnd < MAX_PAD_RIGHT && WORD_CHAR.test(text[end])) end++; var rightTruncated = end < text.length && WORD_CHAR.test(text[end]); return { pre: (leftTruncated ? '…' : '') + text.slice(start, idx), hit: match, post: text.slice(matchEnd, end) + (rightTruncated ? '…' : '') }; } // ─── Сбор текстовых нодов: пропускаем скрытые ─────────────────────────────── function collectTextNodes(node) { if (node.visible === false) return []; // скрытые состояния/варианты — мимо if (node.type === 'TEXT') return [node]; if (node.children) { return node.children.reduce(function (acc, child) { return acc.concat(collectTextNodes(child)); }, []); } return []; } var DEFAULT_ENABLED = RULES.filter(function (r) { return r.defaultOn !== false; }).map(function (r) { return r.id; }); pixso.showUI(__html__, { width: 440, height: 580 }); function sendScope() { pixso.ui.postMessage({ type: 'SCOPE', count: pixso.currentPage.selection.length }); } pixso.on('selectionchange', sendScope); // ─── Обработчики сообщений от UI ───────────────────────────────────────────── function onInitRequest() { sendScope(); pixso.clientStorage.getAsync('enabled-rules').then(function (saved) { var enabledIds; var allCurrentIds = RULES.map(function (r) { return r.id; }); if (saved && typeof saved === 'object' && !Array.isArray(saved) && saved.v === 1) { // Формат v1: {v, enabled, allIds} — добавляем новые default-on правила var knownIds = Array.isArray(saved.allIds) ? saved.allIds : []; var storedEnabled = Array.isArray(saved.enabled) ? saved.enabled : []; // Фильтруем: исключаем правила с defaultOn: false, которые были в старом сохранении storedEnabled = storedEnabled.filter(function (id) { var rule = RULES.find(function (r) { return r.id === id; }); return !rule || rule.defaultOn !== false; }); var merged = storedEnabled.slice(); // Добавляем новые правила с defaultOn !== false, исключая уже сохранённые DEFAULT_ENABLED.forEach(function (id) { if (knownIds.indexOf(id) === -1 && merged.indexOf(id) === -1) { merged.push(id); } }); // Убираем ID удалённых правил (на случай даунгрейда) enabledIds = merged.filter(function (id) { return allCurrentIds.indexOf(id) !== -1; }); } else if (Array.isArray(saved)) { // Старый формат (просто массив) — оставляем как есть, сразу мигрируем enabledIds = saved.filter(function (id) { return allCurrentIds.indexOf(id) !== -1; }); } else { enabledIds = DEFAULT_ENABLED; } // Финальная фильтрация: исключаем правила с defaultOn: false enabledIds = enabledIds.filter(function (id) { var rule = RULES.find(function (r) { return r.id === id; }); return !rule || rule.defaultOn !== false; }); pixso.ui.postMessage({ type: 'INIT', rules: RULES, enabledIds: enabledIds }); pixso.clientStorage.setAsync('enabled-rules', { v: 1, enabled: enabledIds, allIds: allCurrentIds }); }); // Свёрнутые правила в результатах сканирования — запоминаем выбор между сканами pixso.clientStorage.getAsync('collapsed-rules').then(function (saved) { var collapsed = []; if (saved && typeof saved === 'object' && !Array.isArray(saved) && saved.v === 1 && Array.isArray(saved.collapsed)) { collapsed = saved.collapsed.filter(function (id) { return RULES.some(function (r) { return r.id === id; }); }); } pixso.ui.postMessage({ type: 'COLLAPSED_RULES', collapsed: collapsed }); }); } function onScan(msg) { try { var enabledIds = Array.isArray(msg.enabledIds) && msg.enabledIds.length ? msg.enabledIds : DEFAULT_ENABLED; var sel = pixso.currentPage.selection; var roots = sel.length > 0 ? Array.from(sel) : Array.from(pixso.currentPage.children); var textNodes = roots.reduce(function (acc, node) { return acc.concat(collectTextNodes(node)); }, []); var raw = textNodes.reduce(function (acc, node) { var nv = runRules(node.characters, enabledIds).map(function (v) { return Object.assign({}, v, { nodeId: node.id, nodeName: node.name, context: buildContext(node.characters, v.match, v.matchIndex) }); }); return acc.concat(nv); }, []); // Дедуп: одно и то же СЛОВО из повторяющихся инстансов → одна строка + счётчик. // Ключ — по слову-контексту (pre+hit+post), а не по подстроке match: два разных // слова с одинаковой короткой ошибкой (напр. «всё» и «её» — оба содержат «ё») // не должны схлопываться в одно нарушение. Каждый инстанс несёт matchIndex — // он нужен автофиксу для точечной замены. var seen = {}, violations = []; raw.forEach(function (v) { var wordKey = v.context ? (v.context.pre + v.context.hit + v.context.post) : v.match; var key = v.ruleId + '|' + wordKey; var inst = { nodeId: v.nodeId, nodeName: v.nodeName, matchIndex: v.matchIndex }; if (seen[key]) { seen[key].count++; seen[key].instances.push(inst); return; } v.count = 1; v.instances = [inst]; seen[key] = v; violations.push(v); }); pixso.ui.postMessage({ type: 'VIOLATIONS', items: violations, totalNodes: textNodes.length }); } catch (e) { console.error('[checker] SCAN error:', e); pixso.ui.postMessage({ type: 'SCAN_ERROR', error: String((e && e.message) || e) }); } } function onSaveSettings(msg) { if (Array.isArray(msg.enabledIds)) { pixso.clientStorage.setAsync('enabled-rules', { v: 1, enabled: msg.enabledIds, allIds: RULES.map(function (r) { return r.id; }) }); } } function onSaveCollapsedRules(msg) { if (!Array.isArray(msg.collapsed)) return; pixso.clientStorage.setAsync('collapsed-rules', { v: 1, collapsed: msg.collapsed.filter(function (id) { return RULES.some(function (r) { return r.id === id; }); }) }); } // Применяет автофикс к набору целей {nodeId, matchIndex}. Возвращает список // реально исправленных целей. Индексы в одном ноде применяются с конца к началу, // чтобы более ранние не сдвигались после замены. function applyTargets(ruleId, match, targets) { var fixFn = AUTOFIXES[ruleId]; if (!fixFn || !match) return []; var byNode = {}; (targets || []).forEach(function (tg) { if (!tg || tg.nodeId == null) return; (byNode[tg.nodeId] = byNode[tg.nodeId] || []).push(tg.matchIndex); }); var fixed = []; Object.keys(byNode).forEach(function (nodeId) { var node = pixso.getNodeById(nodeId); if (!node || node.type !== 'TEXT') return; var text = node.characters; var idxs = byNode[nodeId].slice().sort(function (a, b) { return b - a; }); var applied = []; idxs.forEach(function (idx) { try { var next = applyFixToText(text, match, idx, fixFn); if (next == null) return; text = next; applied.push(idx); } catch (e) { console.error('[checker] AUTOFIX error', nodeId, e); } }); if (applied.length && text !== node.characters) { node.characters = text; applied.forEach(function (idx) { fixed.push({ nodeId: nodeId, matchIndex: idx }); }); } }); return fixed; } function onAutofix(msg) { var fixed = applyTargets(msg.ruleId, msg.match, msg.targets); pixso.ui.postMessage({ type: 'AUTOFIX_DONE', ruleId: msg.ruleId, match: msg.match, targets: fixed, fixed: fixed.length }); } function onAutofixAll(msg) { var items = Array.isArray(msg.items) ? msg.items : []; var fixedItems = []; items.forEach(function (item) { var fixed = applyTargets(msg.ruleId, item.match, item.targets); if (fixed.length) fixedItems.push({ match: item.match, targets: fixed }); }); var total = fixedItems.reduce(function (sum, it) { return sum + it.targets.length; }, 0); pixso.ui.postMessage({ type: 'AUTOFIX_ALL_DONE', ruleId: msg.ruleId, items: fixedItems, fixed: total }); } function onFocus(msg) { var ids = msg.nodeIds || (msg.nodeId ? [msg.nodeId] : []); if (!ids.length) return; var nodes = ids.map(function (id) { return pixso.getNodeById(id); }).filter(Boolean); if (!nodes.length) { console.error('[checker] FOCUS: nodes not found', ids); return; } try { pixso.currentPage.selection = nodes; pixso.viewport.scrollAndZoomIntoView(nodes); } catch (e) { console.error('[checker] FOCUS error:', e); } } var MESSAGE_HANDLERS = { 'INIT_REQUEST': onInitRequest, 'SCAN': onScan, 'SAVE_SETTINGS': onSaveSettings, 'SAVE_RULE_COLLAPSED': onSaveCollapsedRules, 'AUTOFIX': onAutofix, 'AUTOFIX_ALL': onAutofixAll, 'FOCUS': onFocus }; pixso.ui.onmessage = function (msg) { var handler = msg && MESSAGE_HANDLERS[msg.type]; if (handler) handler(msg); };