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

39 KiB
Raw Permalink Blame History

#482 — Доводка экспорта пространства в PDF

  • Issue: https://github.com/Matysh/houseplan-card/issues/482
  • Тип / приоритет: bug + polish / P1
  • Трек: полный; меняются геометрия размеров, PDF-примитивы, компоновка страницы, адаптивность View-диалога и golden-эталон
  • Оценка: пользовательская ценность 8/10; ценность для разработки 7/10; сложность 7/10; риск 7/10
  • Связано: #52, #53; docs/specs/052-view-dimensions.md, docs/specs/053-pdf-export.md, docs/PDF-EXPORT.md, docs/TOUCH-SUPPORT.md

1. Сценарий

Персона — Home admin из docs/SCOPE.md. Поверхность — полная карточка House Plan в режиме View на desktop либо touch-устройстве. Момент — пользователь открывает «Сохранение в PDF», выбирает состав архитектурного чертежа и сохраняет текущее пространство для печати, передачи строителю или обсуждения планировки.

В исходном плане могут одновременно присутствовать внешние и внутренние стены, проёмы, перегородки, колонны, ступенчатые и вогнутые комнаты, декор, растровая подложка и заданное направление севера.

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

До: узкий диалог может получить горизонтальный scroll; противоположные стороны дублируют одинаковые размеры; почти совпадающие вершины иногда создают ложную диагональ; подписи прилипают к стенам; стены не похожи на стены плана; чертёж плохо заполняет лист; север показан упрощённой стрелкой; внизу печатается техническая легенда wall · door · window.

После: диалог помещается от 320 CSS px, размерные цепочки остаются только у реальных осевых граней и не повторяют локальную противоположную пару, подписи расположены равномерно и не пересекают стены, физические стены имеют фон #7f7f7f и штриховку, полный чертёж оптимально вписан и центрирован, север обозначен векторным компасом, а технической легенды нет.

3. Подтверждённые причины

  1. compactRing() удаляет коллинеарные точки до схлопывания соседних точных и почти точных дублей. В ступенчатом контуре это соединяет несмежные стороны хордой. На приложенном примере ложные 3.39 m и 3.93 m совпадают с диагоналями между реальными ортогональными участками; стены под углом 72° в исходном плане нет.
  2. Размеры формируются для каждой пригодной стороны независимо: понятия локальной пары противоположных граней нет.
  3. Внутренние подписи используют фиксированные 2 mm без учёта bbox текста и тела стены; fallback сдвигает только текст вдоль стены. Внешняя shelf также может двигаться отдельно от своей размерной линии.
  4. Ориентация выбирается по сырому bbox архитектуры, до построения размеров и выносок. Дополнительно всегда резервируются приближённые 30/48 mm, после чего центрируется только архитектура. Поэтому масштаб и свободные поля не соответствуют фактической печатной сцене.
  5. PDF writer умеет заливать и обводить path, но не умеет even-odd clipping для штриховки и заполненный составной vector path для нового компаса.
  6. Общий двухзнаковый formatter PDF превратит 127 / 255 в 0.5, то есть в #808080. Для точного #7f7f7f цвету нужна отдельная точность.
  7. В CSS диалога padding прибавляется к min-width, flex-подписи не могут сжиматься, а footer запрещает перенос.

4. Изменяемый контракт

Это ТЗ заменяет только противоречащие ему части #53:

  • §7.1: цвет и вид физических стен;
  • §7.3: правило «все четыре стороны» и размещение размеров;
  • §7.5: упрощённую стрелку севера и архитектурную легенду;
  • §8.1–8.2: выбор ориентации, масштаба и центрирование;
  • связанные AC4, AC6 и golden-ожидания.

Остальные гарантии #53 сохраняются: один лист A4, стандартный масштаб, генерация на клиенте, Unicode-шрифт, детерминированные байты, отсутствие маркеров и live-state, опциональные названия/размеры/декор/подложка и ленивый PDF chunk.

5. Скоуп

  1. Сделать диалог PDF безопасным на узкой View-поверхности.
  2. Исправить нормализацию контуров до генерации размеров.
  3. Ограничить размеры горизонтальными и вертикальными гранями с каноническим near-axis допуском.
  4. Убирать только одну из локальной пары геометрически противоположных равных граней.
  5. Перестроить размещение внутренних и внешних размеров в согласованные полосы.
  6. Добавить физическим стенам, перегородкам и колоннам точный фон и штриховку с clipping по их реальной геометрии и проёмам.
  7. Выбирать ориентацию и стандартный масштаб по bbox фактически построенной сцены и центрировать эту сцену в печатном поле.
  8. Заменить стрелку севера на переданный векторный компас.
  9. Удалить архитектурную легенду, сохранив дату, версию и масштабную линейку.
  10. Обновить тесты, golden, документацию и лицензионное уведомление.

6. Не входит

  • изменение модели пространства, wall segments, openings, decor или PDF настроек в localStorage;
  • новые настройки экспорта либо изменение значений существующих галочек;
  • размеры диагональных стен, дуг, мебели, проёмов, перегородок или колонн;
  • многостраничный PDF, другой формат бумаги или ручной выбор масштаба;
  • ручное перетаскивание размеров и автоматические строительные размерные цепи;
  • изменение вида стен на интерактивном плане;
  • изменение семантики north_deg либо способа задания севера;
  • печать устройств, их состояний или подключение внешних PDF-сервисов;
  • общий рефакторинг writer за пределами нужных заполненных vector path, clipping и точной сериализации цвета.

Если исправление потребует нового config-поля, изменения пользовательских галочек или поддержки размеров настоящих диагональных стен, задача возвращается в S3-spec.

7. Диалог на узкой поверхности

  • Поддерживаемая минимальная ширина viewport — 320 CSS px.
  • Surface, body и footer используют box-sizing:border-box, min-width:0 и не шире доступной области. Padding входит в вычисленную ширину.
  • Текст внутри label может переноситься; checkbox/radio/input не сжимается и не отделяется от начала своей подписи.
  • При ширине, на которой две footer-кнопки с локализованными подписями не помещаются, footer становится одной колонкой: «Отмена» сверху, основное действие «Сохранить PDF» снизу; обе кнопки занимают доступную ширину.
  • На большей ширине сохраняется действующий горизонтальный footer.
  • Горизонтальный scroll запрещён для документа, dialog surface, body и footer. Вертикальный scroll допустим; все элементы остаются доступны.
  • Интерактивные цели сохраняют минимум 44×44 CSS px, focus ring и существующий keyboard contract.
  • Контракт проверяется во всех четырёх локалях EN/RU/DE/FR, а не только на самой короткой строке.

8. Нормализация контуров и допустимые грани

8.1 Порядок очистки ring

Перед поиском коллинеарных вершин каждый конечный ring нормализуется так:

  1. удалить повтор последней точки, если он замыкает первую;
  2. схлопнуть соседние точные либо близкие точки, включая пару на seam «последняя → первая»;
  3. только после этого итеративно удалять промежуточную точку действительно прямого участка;
  4. повторять шаги 2–3 до fixed point либо пока проход ничего не изменил.

Близость точек — не более 1 mm в физических координатах плана, переведённых в render coordinates через действующий cell_cm. Это только фильтр печатной геометрии: исходный config не мутируется. Точки с нечисловыми координатами не создают размер.

Точка считается промежуточной только когда оба соседних вектора ненулевые, коллинеарны в пределах канонического допуска и их скалярное произведение положительно. Поэтому разворот, настоящий угол и короткая грань между разными точками не превращаются в хорду. Результат не содержит нулевых рёбер.

8.2 Только осевые размеры

Размер допускается только для:

  • строго горизонтальной/вертикальной грани; либо
  • near-axis грани, отклонённой не более чем на канонические 0.25° из src/near-axis.ts (NEAR_AXIS_MAX_DEGREES / NEAR_AXIS_MAX_SLOPE).

Для near-axis шума печатная размерная грань выравнивается по major axis через её midpoint и использует длину major projection. Исходная геометрия не изменяется. Грань за пределом допуска полностью исключается: запрещено строить проекцию, хорду или соединять её соседние концы.

Каждый кандидат сохраняет стабильную ссылку на исходные ring и edge index до компактации. Стабильность нужна для дедупликации, layout и детерминированного порядка, но не экспортируется в PDF.

9. Локальная дедупликация противоположных размеров

Равный отформатированный текст сам по себе никогда не является основанием для удаления. Дедупликация выполняется отдельно:

  • внутри одного room inner contour; либо
  • внутри одного связного external outer ring.

Два кандидата образуют пару только если одновременно:

  1. они имеют одну ось и противоположные нормали;
  2. их tangent projection intervals совпадают с допуском 1 mm;
  3. их projected lengths совпадают с допуском 1 mm;
  4. midpoint соединяющего их перпендикуляра лежит внутри соответствующего простого ring;
  5. обе грани остаются допустимыми после §8.

Для external ring проверяется внутренняя область самого ring; внутренние holes не связывают несвязанные здания. Один кандидат участвует максимум в одной паре. Если возможны несколько пар, выбирается ближайшая по перпендикуляру, затем по стабильному edge key.

Из пары остаётся сторона с лучшим placement score: меньше hard collisions с телом архитектуры/границей печатного поля/уже размещёнными более приоритетными аннотациями, затем больше свободного пространства по нормали. Полная ничья разрешается минимальным стабильным ключом (axis, tangentStart, normalSign, ringIndex, sourceEdgeIndex).

Следствия:

  • прямоугольная комната получает один горизонтальный и один вертикальный внутренний размер, а не четыре;
  • одинаковые значения у разных комнат, разных connected rings либо на разных осях сохраняются;
  • равные, но не противоположные участки L/ступенчатого контура сохраняются;
  • общий горизонтальный и вертикальный габарит одинаковой длины не конфликтуют.

10. Размещение размеров

Все вычисления clearance выполняются в mm страницы после выбора масштаба и по фактическому rotated bbox текста.

10.1 Общие правила

  • Центр текста проецируется в midpoint измеряемой грани.
  • Между bbox текста и ближайшей гранью тела стены остаётся минимум 1 mm.
  • Текст, основная размерная линия и shelf не пересекают тело стены. Extension внешнего размера может начинаться на самой внешней грани стены, но не входит внутрь её тела.
  • Для вогнутого ring внутренняя/внешняя нормаль определяется пробами point-in-ring по обе стороны грани, а не только направлением к centroid.
  • Нельзя исправлять collision индивидуальным tangent-jitter текста.

10.2 Внутренние размеры

Внутренний размер сохраняет действующий лаконичный text-only вид. Его базовая полоса находится внутрь комнаты на расстоянии, равном половине normal extent текста плюс 1 mm от реальной внутренней грани стены.

Размеры одного room contour с общей стороной/осью/нормалью относятся к одной полосе. Если любой элемент полосы конфликтует с архитектурой либо уже принятым более приоритетным элементом, вся полоса смещается внутрь шагами 3 mm до первого валидного положения. Если валидного положения нет, сохраняется существующая выноска для непрямоугольной комнаты; произвольный сдвиг текста вдоль стены не допускается.

10.3 Внешние размеры

Базовая размерная линия проходит на 6 mm наружу от внешней грани тела стены. Текст расположен снаружи линии с зазором 1 mm. Extension lines начинаются на грани тела стены и заканчиваются на своей размерной линии.

Коллинеарные внешние размеры с общей осью и внешней нормалью образуют одну полосу. При collision вся полоса — line, extensions, shelf и text — смещается наружу шагами 4 mm. Shelf используется только когда текст шире доступного участка и остаётся частью той же полосы.

Базовое расстояние одинаково для всех размеров своего класса. Дополнительные полосы разрешены только как детерминированный ответ на collision; отдельная подпись не может «прилипнуть» к стене или уехать независимо.

11. Вид физических стен

В PDF следующие объекты получают одинаковый material:

  • физические room/external/shared стены ненулевой толщины;
  • перегородки ненулевой толщины;
  • квадратные и круглые колонны ненулевого размера.

Material состоит из:

  1. фон RGB 127 127 127, то есть точный #7f7f7f;
  2. одна семья диагональных линий 45° к странице, шаг 3 mm, толщина 0.18 mm, цвет основной печатной краски;
  3. существующий контур толщиной 0.25 mm поверх fill и hatch.

Фаза штриховки привязана к началу страницы, поэтому соседние стены не начинают узор заново и не дают заметных швов. Hatch строится в paper coordinates и обрезается even-odd clip path (W* n или эквивалентом) фактического тела каждого объекта. Проёмы остаются чистыми holes; hatch/fill не попадает в tunnel.

Нулевая стена сохраняет действующий line contract и не получает ни серый фон, ни hatch. Decor и растровая подложка не меняются.

Цвет сериализуется отдельным formatter с достаточной точностью (минимум пять знаков после точки для 127/255). Координатный formatter writer не меняется: это сохраняет детерминизм и размер файла.

12. Компоновка листа

12.1 Кандидаты

Для обеих ориентаций A4 — portrait и landscape — строится фактическая сцена с:

  • архитектурой, включёнными decor/подложкой;
  • реально оставшимися dimension lines, extensions, shelves и text;
  • фактически понадобившимися выносками;
  • line widths и rotated text bbox.

Приближённые безусловные резервы 30/48 mm удаляются. Header и footer не входят в plan scene и заранее вычитаются из доступного поля.

Для каждой ориентации проверяется действующий стандартный ряд масштабов от наиболее крупного к мелкому. Кандидат валиден, только если полный scene bbox с допуском 0.5 mm находится внутри печатного поля.

Для экстремального плана после 1:500 denominator продолжается с шагом 50 через конечный bracket/refinement. Заведомо невместимая фиксированная аннотация завершает экспорт pdf.failed; обрезанный best-effort PDF не возвращается.

12.2 Выбор и центрирование

Побеждает:

  1. минимальный denominator, то есть наиболее крупный стандартный масштаб;
  2. при равном масштабе — большая доля площади печатного поля, занятая полным scene bbox;
  3. при полной ничьей — portrait.

После выбора переводится весь scene целиком: paths, raster, размеры, текст и выноски. Центр полного bbox совпадает с центром доступного plan field с допуском 0.5 mm. Ни один элемент не пересекает header/footer или поля страницы.

Подпись масштаба и scale bar вычисляются из выбранного denominator; нельзя визуально увеличить чертёж, оставив старое значение масштаба.

13. Векторный компас

Если у пространства задан north_deg, существующее место северного указателя занимает compass-line из VMware Clarity Assets (viewBox 0 0 36 36) — обе канонические filled path, без растрирования и без внешних ссылок.

Provenance проверен перед реализацией по неизменяемым первичным материалам:

Переданный владельцем файл имеет SHA-256 461EBD20E65DF8F017CADCCFEF4F58E7A7E5CB3A8877B4797E6C81C011E41F0B. Он не совпадает с upstream побайтово: SVG Repo добавил XML/comment и заменил display size 36×36 на 800×800. При этом viewBox и значения обеих d path совпадают с точным upstream blob. Реализация вендорит path из указанного upstream commit, а не оболочку SVG Repo, и сохраняет полный MIT license и copyright рядом с ассетом либо в third-party notices.

Компас:

  • сохраняет квадратный aspect ratio и основной печатный цвет;
  • остаётся vector content PDF;
  • вращается по прежней семантике: 0° — северная стрелка вверх страницы, 90° — вправо, 180° — вниз, 270° — влево;
  • использует действующий resolver north_deg и подпись pdf.north;
  • отсутствует, если направление севера не задано.

PDF writer получает минимально необходимый filled vector primitive с явно заданными fill rule/fill/stroke. Он не становится универсальным SVG renderer.

Архитектурная легенда полностью удаляется: в PDF нет wall · door · window, её переводов либо разделителей. Неиспользуемые pdf.legend.* удаляются из EN/RU/ DE/FR словарей.

В footer сохраняются локализованная дата, House Plan v…, действующие разделители и масштабная линейка. Новых пользовательских строк нет. Изменённые описания в документации обновляются на EN/RU; существующие строки диалога проверяются во всех четырёх локалях.

15. Модель, совместимость и rollback

Config schema, config_version, модель пространства и ключи сохранённых PDF опций не меняются. Миграции и downgrade converter не нужны. Экспорт читает исходную модель, создаёт нормализованную временную геометрию и не записывает её обратно.

Rollback — возврат frontend/PDF lazy chunk к предыдущей версии. Сохранённые планы и пользовательские настройки остаются совместимы в обе стороны.

16. Touch, доступность, безопасность и производительность

  • PDF-диалог относится к View, поэтому полностью поддерживается на touch, а не по best-effort правилу редакторов. На 320 px он остаётся читаемым и usable.
  • Клавиатура, Escape, focus trap, focus return и screen-reader names действуют как в #53; перестройка footer не меняет DOM-порядок действий.
  • Генерация остаётся полностью локальной. Новый ассет вендорится, runtime network request, пользовательский HTML и новые данные HA не появляются.
  • Lazy boundary сохраняется: до открытия экспорта PDF writer, compass и шрифт не загружаются в initial bundle.
  • Детерминированность байтов при одинаковых now, config и options сохраняется.
  • Бюджет построения сцены из теста #53 остаётся <200 ms для его large-house fixture на референсной Node-среде. Проверка двух ориентаций не должна выполнять повторный разбор модели или декодирование растра.

17. Acceptance criteria и доказательства

AC Критерий Обязательное доказательство
AC1 При viewport 320×760 в EN/RU/DE/FR ни document, ни surface/body/footer диалога не имеют горизонтального overflow; кнопки полностью доступны и не меньше 44×44 px targeted browser smoke с scrollWidth <= clientWidth, bbox кнопок и фактическим export
AC2 Точные/почти точные соседние вершины схлопываются до удаления коллинеарных точек; каждый реальный поворот сохраняется; нулевых рёбер и ложных 3.39/3.93 нет unit fixture регрессии + mutation witness, отключающий duplicate normalization
AC3 Размер получают только H/V и near-axis ≤0.25°; грань за порогом не создаёт проекцию/хорду boundary unit на обе стороны допуска + scene test + mutation witness без axis filter
AC4 Удаляется одна локальная противоположная пара, но сохраняются одинаковые значения разных комнат/rings/осей и непарные равные грани L-контура unit topology matrix + scene counts + mutation witness глобальной дедупликации
AC5 Подпись центрирована, clearance ≥1 mm, базовая полоса едина; collision перемещает целиком полосу, включая line/extensions/shelf unit bbox/concave tests + focused golden + mutation witness старого tangent-jitter
AC6 Физические стены/перегородки/обе колонны имеют точный #7f7f7f и page-anchored hatch; openings чисты; zero wall не заштрихована writer operators/color unit + PDF raster semantic probes/golden + mutation witness без even-odd clip
AC7 Обе A4 orientation оцениваются по полному фактическому bbox; выбран максимальный стандартный масштаб, сцена целиком внутри поля и центрирована ≤0.5 mm scene unit с portrait/landscape и реальными callouts + focused golden + mutation witness старого raw-aspect выбора
AC8 Компас содержит обе канонические path, остаётся vector и верно ориентирован при 0/90/180/270°; без north отсутствует; license сохранена parser/unit + writer PDF parse + raster probes + license check + mutation witness старой стрелки
AC9 Архитектурная легенда и pdf.legend.* отсутствуют, дата/версия/масштаб сохраняются extracted-text unit/smoke и i18n consistency + mutation witness возврата легенды
AC10 Сохраняются один A4, стандартный scale, все четыре content-toggle, Unicode, отсутствие devices/live-state, deterministic bytes и lazy loading актуализированные существующие unit/smoke тесты #53
AC11 Построение large-house scene укладывается в <200 ms; initial bundle не получает PDF-код perf unit + bundle/lazy manifest check
AC12 Пользовательская и техническая документация соответствует новому результату, а golden/docs fingerprints актуальны check-docs, Linux golden verify и полный Linux docs artifact review

18. Тест-план

Unit / component

  • pdf-dimensions: exact/near duplicate, seam duplicate, turn retention, fixed-point compact, zero edge, 0.25° boundary, true diagonal, rectangle pair, L-contour false pair, equal dimensions across rooms/rings/axes.
  • pdf-scene: retained counts, midpoint, clearance, whole-lane shift, concave normal, exact full bbox, both orientations, scale bar, wall/partition/column material, opening holes, zero wall, compass cardinals, footer text and perf.
  • pdf-writer: high-precision color, filled compound vector, even-odd clip, parseability and deterministic bytes after new operators.
  • отдельный compass unit: обе path разбираются в конечные координаты, имеют квадратный bbox и детерминированно трансформируются.

Browser smoke

  • текущий полный экспорт и отказ lazy chunk;
  • 320×760 и четыре локали с измерением overflow;
  • stacked footer сохраняет cancel/save behavior и download;
  • извлечённый текст не содержит легенду.

Visual / golden

Обновить pdf-export-geometry-light и добавить focused scenario с почти квадратным планом, near-duplicate углом, локальной парой, одинаковыми размерами двух комнат, проёмом, zero wall, перегородкой, обеими колоннами и north_deg.

Semantic guard проверяет:

  • ненулевой architectural ink и минимальное заполнение plan field;
  • серый участок стены с контрастными hatch-полосами;
  • чистую область проёма;
  • vector/raster ink в области компаса;
  • отсутствие содержимого за полями и приблизительное центрирование.

На Windows разрешены unit/typecheck/build/browser smoke и диагностический golden:verify. Канонические golden и десять docs screenshots переснимаются в Linux CI/закреплённом контейнере: DirectWrite не является принимаемой заменой Linux FreeType.

Backend и HA API не меняются, поэтому backend pytest не является целевым доказательством этой задачи.

19. Документация и release artifacts

Обновить:

  • docs/PDF-EXPORT.md — размеры, hatch, layout, compass, отсутствие легенды;
  • docs/USER-GUIDE.md и docs/USER-GUIDE.ru.md — вид результата;
  • docs/TESTING.md — структурные и visual PDF gates;
  • docs/specs/053-pdf-export.md — явные ссылки на заменённый контракт #482;
  • docs/specs/README.md, docs/STATUS.md, docs/ARCHITECTURE.md при изменении перечисленных модулей;
  • screenshot диалога PDF и оба changelog.

Коммиты реализации получают User-Visible: yes. Changelog EN/RU коротко описывает исправленный экспорт и ссылается на #482. Release body по общему процессу содержит только значимое пользовательское изменение и ссылки на оба changelog.

20. Ожидаемая карта изменений

  • src/pdf/pdf-dimensions.ts — нормализация, axis filter, кандидаты и пары;
  • src/pdf/pdf-collision.ts — точные box/segment collision-предикаты для полос;
  • src/pdf/pdf-scene.ts — dimension lanes, material, full-scene candidates;
  • src/pdf/pdf-writer.ts — clipping, filled vector и точный цвет;
  • src/pdf/pdf-compass.ts и лицензионный notice — канонический compass asset;
  • src/pdf/hp-pdf-dialog.ts — narrow responsive contract;
  • src/i18n/{en,ru,de,fr}.json — удаление мёртвых legend keys;
  • targeted unit/smoke, golden matrix/guards/fixtures и документы §19.

Карта не является разрешением на сопутствующий refactoring. Любой новый runtime dependency, config migration или выход за PDF/View dialog требует возврата в S3-spec.

21. Риски

Риск Снижение
Локальная дедупликация удалит два разных размера с одинаковым числом topology pair требует общий ring, ось, interval, противоположные normals и inside probe; negative matrix AC4
Очистка дублей срежет настоящий короткий участок физический epsilon 1 mm, forward-dot и сохранение каждого turn; boundary tests AC2
Штриховка закроет проём либо даст швы page-anchored pattern + even-odd clip + raster probes AC6
#7f7f7f округлится до #808080 отдельный high-precision color formatter и operator/raster test
Более точный layout станет медленным model parse/raster decode один раз, perf budget AC11
Полный bbox всё ещё пропустит rotated text/line width bbox строится после фактических annotations и включает stroke/text extents; adversarial tests AC7
Compass нарушит лицензию или раздует runtime две вендоренные path, MIT notice, без общего SVG runtime и в lazy chunk
Narrow fix сломает focus либо действия DOM-order неизменен, browser keyboard/download smoke AC1/AC10