Files
houseplan-card/docs/specs/053-pdf-export.md
T
Matysh 15b060ac38 fix: polish PDF export
Issue: #482
User-Visible: yes
2026-09-07 11:06:28 +03:00

406 lines
41 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #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 как единственный (без жирного): заголовок — тот же
шрифт большим кеглем.
- Изометрическая проекция на печать не влияет: всегда плоский план.