From 66138eb3ea099c27322d351734c37c0e67292e0b Mon Sep 17 00:00:00 2001 From: DmitriyFadin <93384500+DmitriyFadin@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:15:45 +0300 Subject: [PATCH] Export training plan as one continuous PDF page --- .env.example | 4 ++ README.md | 4 +- docs/verification.md | 8 ++++ .../Pdf/ContinuousPdfLayout.cs | 20 +++++++++ .../Pdf/PdfOptions.cs | 2 + .../Pdf/PdfWeekTableBuilder.cs | 6 +++ .../Pdf/PlanPdfExporter.cs | 41 +++++++------------ src/TrainingPlanner.Web/appsettings.json | 2 + 8 files changed, 60 insertions(+), 27 deletions(-) create mode 100644 src/TrainingPlanner.Infrastructure/Pdf/ContinuousPdfLayout.cs diff --git a/.env.example b/.env.example index cc21abf..7e4408b 100644 --- a/.env.example +++ b/.env.example @@ -49,3 +49,7 @@ Pdf__CameraSizePoints=18 # Пример переопределения подсказки процентов (индекс 0 — первая строка списка). # Planner__IntensityPresets__0__Percent=100 # Planner__IntensityPresets__0__Repetitions=1 + +# Непрерывный PDF: ширина и предел высоты одной страницы в см. +Pdf__PageWidthCentimeters=29.7 +Pdf__MaxContinuousPageHeightCentimeters=500 diff --git a/README.md b/README.md index 366b497..01898c1 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ - Удаление тренировки через иконку корзины с подтверждением; после удаления последней тренировки неделю можно заполнить заново. - Автосохранение, ручное сохранение, отмена последнего удаления блока в течение 10 секунд только в исходной тренировке (настройка `.env`: `Planner__UndoDeleteDurationSeconds`) и защита от перезаписи из другой вкладки. - В начале PDF — таблица с именем, целью, режимом тренировок и состоянием здоровья клиента. Заметки тренера не экспортируются. -- Альбомный PDF: одна непрерывная таблица на неделю, цветные разделители тренировок с отдельными итогами, одна строка на упражнение, кликабельные ссылки и значки камеры. Ширина таблицы — 27,5 см; PNG камеры встроен в сборку, выровнен по центру ячейки. Размер иконки задаётся `Pdf__CameraSizePoints`. +- PDF одной длинной страницей: высота подбирается по содержимому без уменьшения шрифта, недели идут подряд. Одна таблица на неделю, цветные разделители тренировок с отдельными итогами, одна строка на упражнение, кликабельные ссылки и значки камеры. Ширина таблицы — 27,5 см; PNG камеры встроен в сборку, выровнен по центру ячейки. Размер иконки задаётся `Pdf__CameraSizePoints`. Интерфейс первой версии рассчитан на компьютер. Полная адаптация под iPhone 15 отложена. Клиентского кабинета, загрузки видео, регистрации и работы без интернета нет. Blazor Interactive Server требует соединения с сервером. @@ -115,3 +115,5 @@ dotnet ef migrations add MigrationName --project src/TrainingPlanner.Web --outpu ### Обновление старых программ Перед применением миграции ExercisePrescription сделайте резервную копию БД. Общие вес и повторения берутся из первого подхода, старые сеты разделяются на одиночные упражнения, отдых переводится из секунд в минуты. Разные веса последующих подходов больше не используются. Для возврата к прежней модели нужны резервная копия и прежняя версия приложения. Переменные `PLANNER_DEFAULT_REST_SECONDS` и `PLANNER_DEFAULT_ROUNDS` заменены на `PLANNER_DEFAULT_REST_MINUTES` и `PLANNER_DEFAULT_SETS_COUNT`. + +Непрерывный PDF: `Pdf__PageWidthCentimeters=29.7`, `Pdf__MaxContinuousPageHeightCentimeters=500`. При превышении предела экспорт сообщает об ошибке, не обрезая программу. diff --git a/docs/verification.md b/docs/verification.md index f3aa89d..b8fe87e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -121,3 +121,11 @@ - Поле процента заменено компактным списком из девяти значений с ориентиром повторений. Значения вынесены в Planner:IntensityPresets. Сохранённые нестандартные проценты отображаются отдельным пунктом. - Локальная сборка и Docker-обновление выполнены. Без скриншотов проверены все девять значений, значение по умолчанию 80, сохранение 85 после перезагрузки и неизменность поля повторений. Собственные тестовые данные удалены. - По уточнению пользователя убран title списка (подсказка при наведении). Сервер и его данные не затрагивались, миграций нет. + +## Непрерывная страница PDF — 14 октября 2026 + +- Все недели экспортируются на одну страницу с высотой по содержимому, без уменьшения текста. Цветные заголовки, таблица клиента и отступ перед первой неделей сохранены. +- Сборка успешна, 16 тестов прошли, локальное Docker-приложение обновлено. +- Проверены два экспорта: короткий с четырьмя пустыми неделями и длинный с семью неделями. Оба содержат одну страницу шириной 29,7 см; высота соответственно 493 и 2019 pt. Текст находится внутри границ страницы, после последней таблицы остаётся обычный нижний отступ. +- В длинном экспорте сохранены все 36 гиперссылок «Видео» с исходными адресами и 12 изображений камеры. Проверка выполнена по структуре, тексту и координатам PDF без скриншотов. Собственные тестовые данные удалены. +- Сервер не затрагивался; изменений БД и миграций нет. diff --git a/src/TrainingPlanner.Infrastructure/Pdf/ContinuousPdfLayout.cs b/src/TrainingPlanner.Infrastructure/Pdf/ContinuousPdfLayout.cs new file mode 100644 index 0000000..c20ad74 --- /dev/null +++ b/src/TrainingPlanner.Infrastructure/Pdf/ContinuousPdfLayout.cs @@ -0,0 +1,20 @@ +using MigraDoc.DocumentObjectModel; +using MigraDoc.Rendering; + +namespace TrainingPlanner.Infrastructure.Pdf; + +public class ContinuousPdfLayout(PdfOptions options) +{ + public void Fit(Document document) + { + var measurement = new DocumentRenderer(document.Clone()); + measurement.PrepareDocument(); + if (measurement.FormattedDocument?.PageCount != 1) + throw new InvalidOperationException("Программа слишком длинная для одной страницы PDF. Сократите её объём."); + var elements = measurement.GetRenderInfoFromPage(1) + ?? throw new InvalidOperationException("Не удалось рассчитать высоту PDF."); + var contentBottom = elements.Max(info => info.LayoutInfo.ContentArea.Y.Point + info.LayoutInfo.ContentArea.Height.Point); + document.Sections[0].PageSetup.PageHeight = Unit.FromPoint( + Math.Ceiling(contentBottom + Unit.FromCentimeter(options.MarginCentimeters).Point)); + } +} diff --git a/src/TrainingPlanner.Infrastructure/Pdf/PdfOptions.cs b/src/TrainingPlanner.Infrastructure/Pdf/PdfOptions.cs index d0cf282..ab2a714 100644 --- a/src/TrainingPlanner.Infrastructure/Pdf/PdfOptions.cs +++ b/src/TrainingPlanner.Infrastructure/Pdf/PdfOptions.cs @@ -8,6 +8,8 @@ public class PdfOptions public double FontSize { get; set; } public double CameraSizePoints { get; set; } public double HeadingSize { get; set; } + public double PageWidthCentimeters { get; set; } + public double MaxContinuousPageHeightCentimeters { get; set; } public double MarginCentimeters { get; set; } public double ClientLabelWidthCentimeters { get; set; } public int ClientDetailsGapLines { get; set; } diff --git a/src/TrainingPlanner.Infrastructure/Pdf/PdfWeekTableBuilder.cs b/src/TrainingPlanner.Infrastructure/Pdf/PdfWeekTableBuilder.cs index 126edab..0ca882e 100644 --- a/src/TrainingPlanner.Infrastructure/Pdf/PdfWeekTableBuilder.cs +++ b/src/TrainingPlanner.Infrastructure/Pdf/PdfWeekTableBuilder.cs @@ -33,6 +33,12 @@ public class PdfWeekTableBuilder(PdfOptions options) "Отдых, мин", "Съёмка", "Время тренировки" }; for (var index = 0; index < labels.Length; index++) header.Cells[index].AddParagraph(labels[index]); + if (week.Workouts.Count == 0) + { + var empty = table.AddRow(); + empty.Cells[0].MergeRight = table.Columns.Count - 1; + empty.Cells[0].AddParagraph("Тренировки не назначены."); + } foreach (var workout in week.Workouts) AddWorkout(table, workout); for (var rowIndex = 0; rowIndex < table.Rows.Count; rowIndex++) diff --git a/src/TrainingPlanner.Infrastructure/Pdf/PlanPdfExporter.cs b/src/TrainingPlanner.Infrastructure/Pdf/PlanPdfExporter.cs index 12693bb..6782445 100644 --- a/src/TrainingPlanner.Infrastructure/Pdf/PlanPdfExporter.cs +++ b/src/TrainingPlanner.Infrastructure/Pdf/PlanPdfExporter.cs @@ -26,34 +26,23 @@ public class PlanPdfExporter : IPlanPdfExporter normal.Font.Name = options.FontFamily; normal.Font.Size = options.FontSize; normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(options.FontSize / 2); - var firstWeek = true; - foreach (var week in plan.Weeks) + var section = document.AddSection(); + section.PageSetup.PageWidth = Unit.FromCentimeter(options.PageWidthCentimeters); + section.PageSetup.PageHeight = Unit.FromCentimeter(options.MaxContinuousPageHeightCentimeters); + section.PageSetup.TopMargin = section.PageSetup.BottomMargin = Unit.FromCentimeter(options.MarginCentimeters); + section.PageSetup.LeftMargin = section.PageSetup.RightMargin = Unit.FromCentimeter(options.MarginCentimeters); + new PdfClientTableBuilder(options).Add(section, client); + for (var line = 0; line < options.ClientDetailsGapLines; line++) { - var section = document.AddSection(); - section.PageSetup.PageFormat = PageFormat.A4; - section.PageSetup.Orientation = Orientation.Landscape; - section.PageSetup.TopMargin = section.PageSetup.BottomMargin = Unit.FromCentimeter(options.MarginCentimeters); - section.PageSetup.LeftMargin = section.PageSetup.RightMargin = Unit.FromCentimeter(options.MarginCentimeters); - if (firstWeek) - { - new PdfClientTableBuilder(options).Add(section, client); - for (var line = 0; line < options.ClientDetailsGapLines; line++) - { - var spacer = section.AddParagraph(); - spacer.Format.SpaceBefore = Unit.FromPoint(0); - spacer.Format.SpaceAfter = Unit.FromPoint(0); - spacer.Format.LineSpacingRule = LineSpacingRule.Single; - } - firstWeek = false; - } - var footer = section.Footers.Primary.AddParagraph(); - footer.Format.Alignment = ParagraphAlignment.Right; - footer.AddPageField(); - if (week.Workouts.Count == 0) - section.AddParagraph($"Неделя {week.Position + 1}. Тренировки не назначены."); - else - tables.Add(section, week); + var spacer = section.AddParagraph(); + spacer.Format.SpaceBefore = Unit.FromPoint(0); + spacer.Format.SpaceAfter = Unit.FromPoint(0); + spacer.Format.LineSpacingRule = LineSpacingRule.Single; } + foreach (var week in plan.Weeks) + tables.Add(section, week); + + new ContinuousPdfLayout(options).Fit(document); var renderer = new PdfDocumentRenderer { Document = document }; renderer.RenderDocument(); using var stream = new MemoryStream(); diff --git a/src/TrainingPlanner.Web/appsettings.json b/src/TrainingPlanner.Web/appsettings.json index 537e3cd..83b844f 100644 --- a/src/TrainingPlanner.Web/appsettings.json +++ b/src/TrainingPlanner.Web/appsettings.json @@ -68,6 +68,8 @@ "MaximumReceiveMessageBytes": 1048576 }, "Pdf": { + "PageWidthCentimeters": 29.7, + "MaxContinuousPageHeightCentimeters": 500, "ClientDetailsGapLines": 5, "ClientLabelWidthCentimeters": 4.5, "FontPath": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",