mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
406 lines
41 KiB
Markdown
Executable File
406 lines
41 KiB
Markdown
Executable File
# ТЗ #53 — Экспорт пространства в PDF: чистый архитектурный план
|
||
|
||
> **Частично переопределено #482.** Актуальный контракт цвета и штриховки
|
||
> стен, отбора/дедупликации/размещения размеров, выбора листа и масштаба,
|
||
> компаса и отсутствия легенды находится в
|
||
> [`482-pdf-export-polish.md`](482-pdf-export-polish.md). При противоречии он
|
||
> имеет приоритет; остальные требования этого ТЗ сохраняются.
|
||
|
||
- **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` (§ Площади — контракт площадей; § Длины на бумаге не применяется, см. §7.3), `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. Размеры и площади (галочка, по умолчанию включена)
|
||
|
||
Решение владельца 2026-09-07: **внутренние размеры каждой комнаты,
|
||
достаточные, чтобы начертить её независимо от остальной планировки, и
|
||
внешние размеры каждой наружной грани планировки.** Осевые пролёты стен из
|
||
`052-view-dimensions.md` § Длины на бумаге не используются; из #52
|
||
переиспользуется только § Площади.
|
||
|
||
**Площадь** — чистый пол через `geometryArea` (тот же резолвер, что у
|
||
карточки комнаты), `formatArea`, единицы из HA. Печатается под названием
|
||
комнаты кеглем на 1 pt меньше; без названия — одна строка площади в той же
|
||
точке.
|
||
|
||
**Внутренние размеры комнаты.** Контур — внутренние грани стен комнаты
|
||
(чистый пол, тот же полигон, что у площади), после компакции коллинеарных
|
||
рёбер. Каждое ребро контура получает длину `formatLength(segmentCm)` —
|
||
расстояние от грани до грани, а не по оси стены. Подпись — внутри комнаты,
|
||
параллельно ребру, отступ 2 мм от грани, читается слева направо или снизу
|
||
вверх. Для комнаты-прямоугольника печатаются все четыре ребра; если при
|
||
коллизии места не хватает, второе ребро той же длины и того же направления
|
||
может быть опущено — комната остаётся вычерчиваемой (по одной длине на
|
||
направление). Для непрямоугольных контуров опускать нельзя: каждое ребро —
|
||
свой размер. Рёбра короче 30 см на бумаге не подписываются, но их длину
|
||
можно восстановить из соседних — такие рёбра помечаются короткой засечкой
|
||
без числа. Проёмы на длину ребра не влияют (ребро — грань стены целиком).
|
||
|
||
**Внешние размеры планировки.** Внешний контур — объединение физических
|
||
стен пространства по наружным граням (тот же силуэт, что у контура
|
||
«вписать всё»), после компакции. Каждая прямая наружная грань получает
|
||
размерную линию *снаружи* контура на расстоянии 6 мм: выносные линии
|
||
перпендикулярно грани, засечки, число над линией. Грани одной прямой
|
||
(сдвиг по наружной стене из-за выступа) — отдельные размеры, каждая своя;
|
||
суммарной цепочки нет (её даёт сумма). Несколько несвязных наружных
|
||
контуров (пристройка, отдельное строение в пространстве) — каждый со
|
||
своими внешними размерами. Виртуальные (нулевые) границы наружного
|
||
контура длину не получают. Порог 30 см для внутренних рёбер здесь не
|
||
применяется: каждая физическая наружная грань, включая грань короче 30 см,
|
||
получает числовое значение. Если число не помещается над размерной линией,
|
||
оно выносится наружу на детерминированную полку с линией-указателем и не
|
||
скрывается.
|
||
|
||
**Единицы и точность** — `formatLength`/`formatArea`, metric/imperial из
|
||
HA, масштаб сетки `cell_cm`; числа совпадают с живой линейкой ресайза на
|
||
том же ребре и с площадью в карточке комнаты.
|
||
|
||
**Коллизии** — детерминированно: приоритет внешние размеры → площади →
|
||
внутренние рёбра длинные → короткие; подпись, чей прямоугольник
|
||
пересекает уже размещённый или выходит за поле листа, скрывается только в
|
||
разрешённых выше случаях: дубликат направления у прямоугольной комнаты или
|
||
короткое (< 30 см) внутреннее ребро с засечкой. У непрямоугольного контура
|
||
числовое значение ребра из-за коллизии не исчезает: сначала кегль уменьшается
|
||
до 6 pt и подпись детерминированно сдвигается вдоль ребра внутрь комнаты; если
|
||
свободного места всё равно нет, у ребра ставится компактная метка `R<n>` с
|
||
выносной линией, а полное значение печатается в нумерованном блоке
|
||
«Внутренние размеры» в свободном поле листа. Нумерация стабильна: комнаты —
|
||
по `room.id`, рёбра — от лексикографически минимальной вершины по часовой
|
||
стрелке. Блок входит в bounding box печатного содержимого при выборе масштаба
|
||
§7.5. Никакая подпись не печатается вверх ногами.
|
||
|
||
### 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.internal_dimensions`, `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-raster.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` — только кнопка, exact-build loader, открытие
|
||
диалога и передача уже рассчитанной геометрии; после реализации 13 643 строки
|
||
при потолке 13 659. Writer, сцена, шрифт и UI диалога остаются вне ядра;
|
||
- `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` (узкое
|
||
read-only исключение #53, принятое 2026-08-15 и суженное 2026-09-07),
|
||
`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 | Внутренние размеры: каждое ребро чистого контура комнаты (грань-в-грань) имеет числовую подпись, нумерованную выноску либо разрешённую для ребра < 30 см засечку; значения не исчезают из-за коллизий, совпадают с `formatLength` живой линейки на том же ребре, площадь — с карточкой комнаты; по этим данным комната вычерчивается автономно (тест восстанавливает контур и сверяет с моделью) | unit (фикстуры: прямоугольник, L, комната с выступом, плотный непрямоугольный контур с выноской, общая толстая стена, виртуальная граница, партиция внутри комнаты) |
|
||
| AC6 | Внешние размеры: у каждой прямой физической наружной грани планировки, включая грань < 30 см, своя размерная линия и числовое значение снаружи контура; несвязные контуры — каждый свои; виртуальные границы без размера; текст не вверх ногами; тесные значения не скрываются, а детерминированно выносятся | 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 растёт только на кнопку, exact-build loader и передачу контекста (≤ 1 200 Б gzip; замер реализации относительно `origin/dev` — 983 Б); отказ загрузки чанка → тост, карточка жива; чужая сборка → терминальный отказ без повтора | `bundle:budget` + smoke с перехватом чанка (образец #474) |
|
||
| AC12 | Скачивание работает в мобильном приложении HA (Android) — файл появляется в загрузках | вручную владельцем на даче до закрытия, запись в issue |
|
||
| AC13 | Растровая подложка встроена под геометрией с прозрачностью, лимит 25 МБ даёт `pdf.too_large` без файла | unit + smoke |
|
||
| AC14 | Диалог модален и центрирован после переподключения HA (контракт #463) | `smoke_pdf_export` повторяет reconnect-проверку из `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` — пролёты и площади на общих фикстурах,
|
||
раскладка, коллизии, нумерованные выноски, порог 30 см только для
|
||
внутренних рёбер, короткая внешняя грань с числом, «не вверх ногами».
|
||
- `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-room-edge-dropped` (у непрямоугольной комнаты пропущено ребро — контур невосстановим), `pdf-outer-face-inside` (внешний размер нарисован внутри контура), `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 мс CPU для одного текущего
|
||
пространства `large-house`: 20 комнат и относящиеся к этому пространству
|
||
перегородки, колонны и декор; проверяется в unit как порог) плюс асинхронная
|
||
растеризация подложки. Ничего не уходит в сеть, кроме загрузки чанка и
|
||
получения подложки тем же путём, что и на экране. PDF не содержит ссылок,
|
||
скриптов, метаданных HA (только название пространства, дома, дата, версия).
|
||
Шрифт — лицензия Apache 2.0, файл лицензии в репозитории.
|
||
|
||
## 16. Риски и меры
|
||
|
||
| Риск | Мера |
|
||
|---|---|
|
||
| Собственный writer даёт PDF, который открывается не везде | тест парсером `pdfjs-dist` + ручная проверка в Acrobat Reader, Chrome, macOS Preview, Android (AC12); простой PDF 1.4 без потоков-объектов и без сжатия контент-потока |
|
||
| Размерные подписи налезают друг на друга на плотных планах (по 4+ числа на комнату) | детерминированная коллизия §7.3: внешние → площади → длинные рёбра; у прямоугольников дубликаты опускаются первыми, обязательные значения переходят в выноски; проверено на текущем 20-комнатном пространстве `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 как единственный (без жирного): заголовок — тот же
|
||
шрифт большим кеглем.
|
||
- Изометрическая проекция на печать не влияет: всегда плоский план.
|