Files
houseplan-card/docs/specs/053-pdf-export.md
T

35 KiB
Executable File
Raw Blame History

ТЗ #53 — Экспорт пространства в PDF: чистый архитектурный план

  • Issue: https://github.com/Matysh/houseplan-card/issues/53
  • Приоритет: P3 · Тип: feature · Трек: полный (новый UX, новый ленивый чанк, новый формат вывода)
  • Оценка: пользовательская ценность 7/10 («распечатал план для электрика/страховой»), уникальность против конкурентов 9/10 (у них нет геометрии для печати); сложность 6/10; риск 4/10 (шрифты и растр в PDF, детерминизм)
  • Решения владельца: 2026-08-15 (исключение из docs/SCOPE.md, задача остаётся в очереди); 2026-09-07 (#52 объединён сюда, слой размеров во View не делается; кнопка-принтер, маленький диалог, только текущее пространство, без устройств, подложка — если есть у пространства)
  • Связанные контракты: docs/specs/052-view-dimensions.md (§ Площади, § Длины — контракт измерений), docs/WALL-THICKNESS.md, docs/BACKDROP.md, docs/FURNITURE.md, docs/SCOPE.md, #456 (образец диалога у пространства), #476 (диалог с кнопкой)

1. Сценарий

Персона — администратор дома, desktop-браузер (touch — best effort по docs/TOUCH-SUPPORT.md, но диалог обязан работать пальцем). Он открывает нужное пространство, нажимает иконку принтера в панели карточки, в маленьком диалоге оставляет или снимает три-четыре галочки и нажимает «Сохранить». Через секунду браузер скачивает houseplan-<пространство>-<дата>.pdf — один лист A4 с планом этого пространства: стены с толщиной, перегородки, виртуальные стены, проёмы, размеры и площади, названия комнат — без устройств, состояний, подсветок и цветов. Лист можно отдать электрику, страховой, подрядчику.

2. Что человек увидит до и после

До: план существует только на экране; «бумажный» вариант — скриншот с маркерами и свечением.

После: в панели карточки между шестерёнкой и знаком вопроса — иконка принтера. Диалог «Сохранение в PDF» с галочками и одной кнопкой. Файл скачивается без диалога печати браузера, одинаково на десктопе и в мобильном приложении HA. На листе — векторная штриховая графика, читаемые подписи на языке плана, масштаб и масштабная линейка в подвале.

3. Проблема

Экспорт JSON (#50) переносит план между установками, но не годится как документ для человека без Home Assistant. SVG на экране рисует всё сразу — состояния, свечение, маркеры — и не имеет ни масштаба, ни листа. Размеры стен и площади уже считаются (ресайз, карточка комнаты), но нигде не печатаются. Контракт измерений принят в #52 и не реализован; печатный лист — первое место, где он нужен целиком.

4. Решения владельца (2026-09-07)

  1. Точка входа — иконка принтера в основной панели между настройками и знаком вопроса.
  2. Диалог маленький: заголовок «Сохранение в PDF», галочки и кнопка «Сохранить».
  3. Экспортируется только текущее пространство; «все пространства» — не делаем.
  4. На листе только архитектура: стены, перегородки, виртуальные стены, проёмы, размеры. Устройства не нужны совсем — ни галочки, ни маркеров.
  5. Галочки: «Отображать размеры» (по умолчанию включена), «Отображать мебель и другой декор», «Отображать названия комнат», «Отображать подложку» — последняя есть только если у пространства задана подложка.
  6. Слой размеров на экране (View) не делается — #52 закрыт.

5. Скоуп

  • Кнопка в панели и диалог (§6).
  • Печатная сцена пространства из канонической модели (§7): геометрия, подписи, размеры по контракту #52, подложка и декор по галочкам.
  • Лист A4, автоориентация, вписывание, подвал с масштабом (§7.5).
  • Генерация PDF целиком на клиенте, ленивым чанком, без внешних сервисов (§8); скачивание файла.
  • Шрифт с кириллицей и латиницей, встроенный в PDF (§8.3).
  • i18n четырёх языков (§9), документация, тесты (§12–§13).

6. Не-скоуп

  • Устройства, маркеры, состояния, свечение, солнце, следы робота, топология Zigbee — не печатаются никогда.
  • Экспорт всех пространств одним файлом; титульный лист; выбор формата листа (A3, Letter) и масштаба вручную — возможные следующие задачи.
  • Слой размеров на экране; изометрический вид (печатается только плоский план независимо от текущей проекции).
  • Печать через window.print()/диалог печати браузера — отвергнуто в §8.1.
  • Изменение модели данных, конфига, схемы бэкенда — нет.
  • houseplan-space-card — кнопки нет (статичная карточка без панели); отдельная задача при необходимости.

7. Контракт: что на листе

7.1. Геометрия (всегда)

Источник — spaceModels(cfg) для текущего пространства, та же нормализованная модель, что у View: комнаты, wall_segments с толщиной, partitions, wall_columns, проёмы (door, window, gate, passage).

элемент изображение
стена с толщиной замкнутый контур по физическим граням, заливка серым (#555), обводка чёрная 0,25 мм; общая стена — один раз
стена нулевой толщины (виртуальная / открытая граница) штриховая линия 0,35 мм, штрих 3 мм / пробел 2 мм, без заливки
перегородка как стена, своей толщиной; тоньше 4 см на бумаге — сплошная линия 0,5 мм
колонна контур по cm (квадрат/круг), заливка серым
дверь разрыв в стене + створка и дуга открывания в сторону и с петлями, как на экране (flip_v/петли уважаются)
окно разрыв в стене с двойной линией по граням
ворота как дверь, без дуги, с диагональной штриховкой створки
проход (passage) разрыв без линий

Никаких заливок комнат, свечения, цветов стен из настроек: лист монохромный. Толщина линий — в миллиметрах бумаги, не зависит от масштаба (аналог vector-effect: non-scaling-stroke).

7.2. Названия комнат (галочка, по умолчанию — см. §18 Q1)

Название — как хранится в плане (язык пользователя), в сохранённой позиции подписи либо во внутренней точке комнаты (тот же резолвер, что у View). Кегль 9 pt; если название не помещается в комнату при масштабе листа — уменьшается до 7 pt, ниже — скрывается (tooltip'а на бумаге нет, поэтому комната остаётся без подписи, а не с обрезанной).

7.3. Размеры и площади (галочка, по умолчанию включена)

Правила измерения — дословно из 052-view-dimensions.md, § Площади и § Длины: площадь чистого пола через geometryArea; уникальные физические осевые пролёты стен из нормализованной модели, общая стена один раз, виртуальные границы без длины, проёмы не вычитаются, колонны без линейного размера, коллинеарный мусор через существующую компакцию. Значения — formatArea/formatLength, единицы из HA (metric/imperial), масштаб сетки пространства (cell_cm).

Раскладка на бумаге (в отличие от экрана — без зума и без указателя):

  • площадь — под названием комнаты тем же кеглем минус 1 pt; если названия нет или оно скрыто — в той же точке одна строка площади;
  • внешние стены — размерные линии снаружи внешнего контура: выносные линии перпендикулярно грани, размерная линия с засечками (архитектурная нотация), текст над линией, читаемый слева направо или снизу вверх, никогда вверх ногами; пролёты одной прямой грани — цепочкой на одной размерной линии;
  • внутренние стены и перегородки — подпись вдоль пролёта с той стороны, где больше свободного пола; кегль 7 pt;
  • коллизии — детерминированно: приоритет внешний контур → длинный пролёт → короткий; подпись, чей прямоугольник пересекает уже размещённый или выходит за поле листа, скрывается; пролёты короче 40 см на бумаге не подписываются.

7.4. Мебель и другой декор (галочка, по умолчанию — Q1)

Всё содержимое decor[]: мебель (арт из furniture-art-runtime, штрих чёрный), линии/прямоугольники/эллипсы (цвет игнорируется — чёрный штрих, без заливки), текстовые подписи (кегль по width_cm, минимум 6 pt), изображения (kind: 'image') — растром по правилу §7.6. Порядок слоёв — как в модели.

7.5. Лист, подвал, легенда (всегда)

  • A4 (210 × 297 мм), поля 12 мм; ориентация — по пропорциям bounding box контента (ширина > высоты → landscape).
  • Контент — геометрия ± внешние размерные линии (при включённой галочке) ± подложка/декор (при включённых) — вписывается в поле листа с сохранением пропорций; масштаб округляется вниз до стандартного ряда 1:20, 1:25, 1:50, 1:75, 1:100, 1:150, 1:200, 1:250, 1:500, чтобы линейкой на бумаге можно было мерить; при экстремальных планах — ближайший больший.
  • Шапка: название пространства (кегль 14 pt), справа — название дома (title карточки), если задано.
  • Подвал: «Масштаб 1:N», масштабная линейка 1 м (metric) / 5 ft (imperial) с делениями, стрелка севера при заданном компасе пространства или общем north_deg (тот же резолвер, что у солнца), дата (локальная, YYYY-MM-DD), «House Plan vX.Y.Z», легенда: стена · перегородка · виртуальная стена · дверь · окно · ворота (только присутствующие на плане элементы).
  • Текст шапки/подвала — на языке карточки (_t).

7.6. Подложка (галочка, есть только при заданной подложке)

Растровая подложка (PNG/JPEG/WebP) встраивается как XObject с исходными байтами (JPEG — DCTDecode без перекодирования; PNG/WebP — через canvas в JPEG q=0.85, чтобы не тащить декодер PNG в writer), в сохранённой позиции (plan_x/plan_y/plan_scale*, поворот) под геометрией, с прозрачностью 60 % — иначе штриховая геометрия тонет в скане. SVG-подложка растеризуется через canvas при 150 dpi по размеру на листе. Изображения декора — по тем же правилам. Суммарный размер встроенных растров ограничен 25 МБ: превышение — отказ с тостом pdf.too_large до генерации, галочки остаются.

Внешние URL подложки идут через тот же подписанный доступ, что и на экране; недоступная картинка — отказ с тостом pdf.asset_failed, файл не пишется (лист без обещанной подложки молча — хуже, чем честный отказ).

8. Контракт: как делается PDF

8.1. Выбор способа

вариант плюсы минусы решение
window.print() + print-CSS нет зависимостей, любой шрифт диалог печати вместо скачивания; в мобильном приложении HA (WebView) печать недоступна или калечит лист; пагинация и масштаб не под контролем отвергнут
jsPDF + svg2pdf готовые примитивы ~200 КБ gzip ленивого кода плюс шрифт всё равно нужен свой (стандартные 14 шрифтов PDF без кириллицы); конвертация SVG → PDF с потерями (маски, non-scaling-stroke) отвергнут
собственный минимальный PDF-writer вектор, прямое скачивание, ~15 КБ gzip кода, одинаково на десктопе и в приложении; рисуем из модели, а не из экрана — без потерь писать самим: страницы, контент-поток (m l c h f S), встраивание TrueType (CIDFontType2, Identity-H, ToUnicode), XObject Image, xref принят

8.2. Ленивый чанк pdf-export

src/pdf/ — новый модуль, динамически импортируемый из ядра через EditorRuntimeLoader (отпечаток сборки, повтор с нонсом, терминальный отказ для чужой сборки — образец iso-scene-render). В стартовый граф не входит ничего, кроме кнопки, обработчика и вызова загрузчика; манифест получает роль pdf и lazyPdfFiles, bundle:budget требует непустой список и отсутствие пересечения с initial (как у lazyIsometricFiles).

Состав: pdf-writer.ts (объекты, потоки, xref, шрифт, изображения), pdf-scene.ts (модель → примитивы листа: геометрия, подписи, размеры, подвал), pdf-dimensions.ts (контракт #52: пролёты, площади, раскладка), pdf-export.ts (оркестрация: опции → сцена → байты → Blob → скачивание), hp-pdf-dialog.ts (диалог).

8.3. Шрифт

Один шрифт, встроенный в PDF как FontFile2: Roboto Regular, лицензия Apache 2.0 (файл лицензии в assets/fonts/), сабсет, собираемый на этапе генерации (scripts/generate-pdf-font.mjs, devDependency subset-font): Basic Latin, Latin-1 Supplement, Latin Extended-A, Cyrillic, Cyrillic Supplement, General Punctuation, знаки ° ² ′ ″ × ≈. Результат — src/pdf/pdf-font.generated.ts (base64, ожидаемо 60–80 КБ raw), только в ленивом чанке; --check-режим генератора, как у мебели. Глиф вне сабсета → .notdef (пустой прямоугольник) — документировано; язык плана ограничен четырьмя поддерживаемыми, поэтому в практике не встречается.

Ширины глифов берутся из hmtx сабсета в момент генерации и кладутся в тот же файл — writer не парсит TrueType в браузере.

8.4. Скачивание и детерминизм

Blob → URL.createObjectURL → <a download> → revokeObjectURL. Имя: houseplan-<slug названия пространства>-<YYYY-MM-DD>.pdf. В мобильном приложении HA скачивание идёт штатным путём WebView — проверяется вручную на Android-приложении (AC12).

При одинаковых входах (модель, опции, единицы, язык, версия, дата) байты PDF идентичны: даты CreationDate/ModDate берутся из переданного now, объекты нумеруются детерминированно, растры — исходные байты. Это основа golden-проверки (§13).

8.5. Диалог и состояния

hp-pdf-dialog на базе hp-dialog (модальность и центрирование — #463). Заголовок pdf.title («Сохранение в PDF»), галочки в порядке: размеры, мебель и другой декор, названия комнат, подложка (только если space.bg); кнопка pdf.save («Сохранить»), закрытие крестиком/Esc/кликом вне — как у остальных диалогов. Состояние галочек запоминается в localStorage (hp.pdf.options, схема {v:1,...}); при отсутствии — значения по умолчанию §18 Q1.

Нажатие «Сохранить»: кнопка блокируется, текст pdf.saving («Формируем…»), загружается чанк (первый раз — до 1 с), формируется файл, диалог закрывается после начала скачивания. Отказ (чанк не загрузился, растр не удалось получить, лимит) — тост с причиной, диалог остаётся открытым с теми же галочками. Повторное нажатие во время формирования игнорируется.

Кнопка в панели: mdi:printer-outline, title/aria-label = title.export_pdf, стоит между «Общие настройки» и «Помощь и обратная связь» в том же блоке (_canEdit), в киоске не показывается, в изометрии — показывается (печатается плоский план). Минимальная зона нажатия 44×44 px как у соседей.

9. i18n

Новые ключи (en/ru/de/fr): title.export_pdf, pdf.title, pdf.dimensions, pdf.decor, pdf.room_names, pdf.backdrop, pdf.save, pdf.saving, pdf.failed, pdf.too_large, pdf.asset_failed, pdf.scale («Масштаб 1:{n}»), pdf.north, pdf.legend.wall, pdf.legend.partition, pdf.legend.virtual, pdf.legend.door, pdf.legend.window, pdf.legend.gate. Тест i18n-dead-keys требует потребителя у каждого — все используются диалогом или подвалом.

10. Модель данных, миграция, совместимость

Конфиг, схема, бэкенд — без изменений; экспорт только читает. Планы любой поддерживаемой версии модели печатаются через ту же нормализацию, что и View. localStorage — единственное новое состояние, per-браузер.

11. Затронутые файлы и модули

  • src/pdf/pdf-writer.ts, pdf-scene.ts, pdf-dimensions.ts, pdf-export.ts, hp-pdf-dialog.ts, pdf-font.generated.ts — новые;
  • scripts/generate-pdf-font.mjs, assets/fonts/Roboto-Regular.ttf + LICENSE — новые; package.json (devDependency subset-font, скрипты pdf-font:generate/pdf-font:check);
  • src/houseplan-card.ts — кнопка, загрузчик, открытие диалога: ядро на потолке 13 659 (факт 13 563 после #478 — запас 96 строк); добавление укладывается в запас, но правило «хочешь добавить — вынеси» действует: число в дифе;
  • scripts/bundle-manifest.mjs, scripts/bundle-budget.mjs — роль pdf, lazyPdfFiles, токен retry-URL;
  • src/i18n/*.json; docs/PDF-EXPORT.md (новый), docs/USER-GUIDE*.md, docs/ARCHITECTURE.md (ленивая граница), docs/SCOPE.md (исключение 2026-08-15 — уже записано, проверить формулировку), docs/CHANGELOG*;
  • тесты §13; golden-сцена; смок demo/smoke_pdf_export.mjs; scripts/mutation-gate.mjs — свидетели §13.

12. Критерии приёмки

AC Критерий Доказательство
AC1 Кнопка-принтер между шестерёнкой и «?» у _canEdit, отсутствует в киоске; открывает диалог с заголовком, галочками §8.5 и кнопкой smoke
AC2 Галочка «подложка» присутствует только у пространства с bg; состояние галочек переживает перезагрузку страницы smoke
AC3 «Сохранить» скачивает файл houseplan-<slug>-<дата>.pdf; в файле ровно одна страница A4 (MediaBox 595.28×841.89 или наоборот) с ориентацией по пропорциям smoke (перехват download) + unit на writer
AC4 Геометрия §7.1: стены с толщиной залиты, нулевые — штрих, проёмы с разрывами, общая стена один раз, ни одного маркера/состояния/цвета комнаты golden по растру PDF (§13) + unit по операторам контент-потока
AC5 Размеры: значения площадей и длин побайтно совпадают с formatArea/formatLength на тех же данных, что карточка комнаты и живая линейка (контракт #52) unit (общие фикстуры с тестами #52-контракта: прямоугольник, L, общая толстая стена, виртуальная граница, партиция)
AC6 Внешние размерные линии снаружи контура, текст не вверх ногами, коллизии детерминированы; пролёты < 40 см без подписи unit на раскладку + golden
AC7 Галочки реально управляют содержимым: без «размеров» в PDF нет ни одной размерной строки; без «названий» — ни одного названия; без «декора» — ни одного декор-объекта; без «подложки» — ни одного XObject Image unit по извлечённому тексту/операторам
AC8 Кириллица и латиница в названиях печатаются встроенным сабсетом; текст извлекается из PDF (pdfjs-dist в тесте) как Unicode — работает ToUnicode unit
AC9 Подвал: масштаб из ряда §7.5, линейка соответствует масштабу (1 м на бумаге = 1000/N мм ± 0,1 мм), стрелка севера при компасе, легенда только присутствующих элементов unit
AC10 Детерминизм: два вызова с тем же now дают идентичные байты; изменение любой галочки — другие unit
AC11 Ленивость: чанк pdf не в initial, initial View не растёт больше чем на кнопку/обработчик (≤ 400 Б gzip); отказ загрузки чанка → тост, карточка жива; чужая сборка → терминальный отказ без повтора bundle:budget + smoke с перехватом чанка (образец #474)
AC12 Скачивание работает в мобильном приложении HA (Android) — файл появляется в загрузках вручную владельцем на даче до закрытия, запись в issue
AC13 Растровая подложка встроена под геометрией с прозрачностью, лимит 25 МБ даёт pdf.too_large без файла unit + smoke
AC14 Диалог модален и центрирован после переподключения HA (контракт #463) smoke smoke_dialog_modal_recovery расширяется на hp-pdf-dialog
AC15 Свидетели §13 в реестре, каждый «поймано 1 из 1» отрицательные прогоны

13. План автотестов

  • test/pdf-writer.test.mjs — объекты/xref валидны (парсится pdfjs-dist), шрифт CIDFontType2 + ToUnicode, изображение DCTDecode, детерминизм.
  • test/pdf-dimensions.test.mjs — пролёты и площади на общих фикстурах, раскладка, коллизии, порог 40 см, «не вверх ногами».
  • test/pdf-scene.test.mjs — таблица §7.1 по операторам, галочки (AC7), подвал (AC9), ориентация и ряд масштабов.
  • demo/smoke_pdf_export.mjs — кнопка, диалог, localStorage, перехват download, MediaBox, отказ чанка, лимит растра.
  • golden: сцена pdf-export-geometry-light — PDF рендерится pdfjs-dist в canvas в браузере golden-harness и сравнивается как PNG (детерминизм §8.4 делает это устойчивым); при отклонении — обычная приёмка эталона.
  • Свидетели: pdf-shared-wall-twice (общая стена дважды), pdf-dimensions-ignore-toggle (галочка размеров не влияет), pdf-virtual-wall-solid (нулевая стена сплошная), pdf-font-no-tounicode (текст без ToUnicode — не извлекается), pdf-backdrop-over-geometry (подложка поверх геометрии), pdf-devices-leak (маркеры попадают на лист), pdf-scale-not-standard (масштаб не из ряда).

14. Release-артефакты

User-Visible: yes. Changelog en/ru: «Экспорт текущего пространства в PDF: чистый архитектурный план с размерами, площадями и названиями комнат, по желанию — мебель и подложка (#53)». docs/PDF-EXPORT.md (что печатается, правила измерений — ссылка на 052, ограничения растров, шрифт и лицензия), раздел в USER-GUIDE/USER-GUIDE.ru, скриншот диалога в документации (пересъёмка по правилу §8 PROCESS).

15. Производительность и безопасность

Генерация — синхронный расчёт сцены (< 200 мс на large-house фикстуре, 60 комнат; проверяется в unit как порог) плюс асинхронная растеризация подложки. Ничего не уходит в сеть, кроме загрузки чанка и получения подложки тем же путём, что и на экране. PDF не содержит ссылок, скриптов, метаданных HA (только название пространства, дома, дата, версия). Шрифт — лицензия Apache 2.0, файл лицензии в репозитории.

16. Риски и меры

Риск Мера
Собственный writer даёт PDF, который открывается не везде тест парсером pdfjs-dist + ручная проверка в Acrobat Reader, Chrome, macOS Preview, Android (AC12); PDF 1.4 без потоков-объектов и без сжатия текста (только FlateDecode контента при наличии CompressionStream, иначе без сжатия)
Размерные подписи налезают друг на друга на плотных планах детерминированная коллизия §7.3 с приоритетом внешнего контура; проверено на large-house фикстуре в golden
Ядро на потолке логика целиком в src/pdf/**, ядро — кнопка и вызов; счёт строк в дифе
Растры делают файл огромным лимит 25 МБ, JPEG q=0.85, тост вместо молчаливого 100-МБ файла
Мобильное приложение не скачивает Blob AC12 вручную до закрытия; при отказе — запасной путь data:-URL в новой вкладке, решение по факту
Детерминизм ломается плавающей точкой раскладки координаты округляются до 0,01 pt перед записью

17. Откат

Revert одного коммита: кнопка и чанк исчезают, конфиг и данные не затрагивались, localStorage-ключ становится бесхозным (безвреден).

18. Принятые предположения и вопросы к ревью ТЗ

  • Q1 — значения галочек по умолчанию. Владелец задал только «размеры — включена». Принято: названия комнат — включена (архитектурный план без названий комнат нечитаем), мебель и другой декор — выключена («чистый план»), подложка — включена, если есть (формулировка владельца «подложка — да, если она включена на этом пространстве»).
  • Q2 — кому видна кнопка. Принято: тем же, кому видны шестерёнка и «?» (_canEdit), поскольку владелец поместил её между ними; не-админы экспорт не получают. Альтернатива — всем, кто видит карточку.
  • Q3 — формат листа и масштаб. Принято автоматическое решение §7.5 (A4, автоориентация, стандартный ряд масштабов); выбор в диалоге — не-скоуп.
  • Шрифт Roboto Regular как единственный (без жирного): заголовок — тот же шрифт большим кеглем.
  • Изометрическая проекция на печать не влияет: всегда плоский план.