diff --git a/docs/feature-specs/program-certificates.md b/docs/feature-specs/program-certificates.md index be2519591..2514e80aa 100644 --- a/docs/feature-specs/program-certificates.md +++ b/docs/feature-specs/program-certificates.md @@ -49,3 +49,15 @@ drag/resize/границы/клавиатура; длинная кириллица в A4 PDF; сохранение и 409; расписание/пауза/повтор; фильтры и пустые/ошибочные состояния; мобильная ширина; защита несохраненных изменений; сборка Angular и проверки backend. + +## Формат листа + +Редактор поддерживает альбомный А4 (297 × 210 мм) и вертикальный А4 (210 × 297 мм). +Компактное поле «Ориентация А4» находится рядом с настройками фона. При загрузке +фона ориентация выбирается по его пропорциям; квадратный фон сохраняет текущий +выбор. Ориентацию можно изменить вручную, отменить и повторить вместе с другими +изменениями шаблона. Фон целиком вписывается в лист без обрезки и растяжения. +Положение элементов задано относительно выбранного листа. Подгонка текста, +размер подписи, масштаб и PDF используют одинаковые размеры листа. Старые +шаблоны без `orientation` считаются альбомными. Изменение шаблона не меняет +ранее выданные PDF. Для выкладки сначала требуется API с поддержкой ориентации. diff --git a/docs/qa/certificates-a4-orientation-20261025.md b/docs/qa/certificates-a4-orientation-20261025.md new file mode 100644 index 000000000..63c8f7fb2 --- /dev/null +++ b/docs/qa/certificates-a4-orientation-20261025.md @@ -0,0 +1,52 @@ + + +# Certificate A4 orientation — verification 2026-10-25 + +Scope: portrait and landscape A4 in the existing certificate editor. Product +Mont, compact 32px controls and the existing management layout are retained. +The orientation select is 164 × 32px, matching the scale control. It lives with +background settings and is separated from upload controls by the existing divider. + +## Behavior + +- Manual orientation changes update the physical size label and canvas aspect ratio. +- Uploading a portrait background switches a landscape draft to portrait. Undo and + redo restore both the file and orientation. Square images retain the current choice. +- Orientation is saved in the draft and issuance snapshot. Missing legacy values + default to landscape. Older clients cannot overwrite a saved portrait orientation + merely by omitting the field. +- Text fitting, proportional signature resizing, zoom and PDF use the selected page + dimensions. Percent-based element placement is retained when changing orientation. +- Backgrounds use contain, preserving every edge; images with another aspect ratio + can leave white margins. Portrait upload normalization preserves 2480 × 3508 images. +- Expanded portrait preview has a 360px target minimum height, constrained by width, + to avoid a tiny page on a 720px-high desktop. The existing modal scroll and sticky + save footer keep the remaining tools accessible. + +## Verification + +- 30 targeted Angular component/layout tests pass, including orientation save/history, + automatic background orientation, portrait text fit and signature proportions. +- Angular development build succeeds. Targeted ESLint, Stylelint, Prettier and + `git diff --check` pass. +- Backend certificate suite: 31 tests, 29 pass and 2 PostgreSQL locking tests skip on + isolated local SQLite. Native WeasyPrint tests verify one-page A4 portrait, + landscape and legacy snapshots; field validation and old-client saves are covered. +- Actual Angular build + Django API on disposable localhost program 1: saved portrait + and reloaded, downloaded both preview orientations, uploaded portrait from landscape, + used undo/redo, inspected expanded editor and 390/320 layouts. Document width stays + within the viewport and the long preview name remains inside its box. +- Downloaded PDF MediaBoxes are 210 × 297mm and 297 × 210mm, one page each. Poppler + renders were inspected: all four background corner marks remain visible. + +Evidence is outside Git in +`design-artifacts/program-certificate-orientation-20261025/`: browser/PDF JSON, +desktop/mobile screenshots, downloaded previews and rendered PNGs. + +Local Windows native rendering needed an isolated GTK DLL search path because the +bundled Poppler provides a different Fontconfig DLL. No application/deployment +environment change is required; the backend uses its existing native PDF stack. + +No real dev program was modified, no issuance was activated, and no participant +email was sent. Native touch and other browser engines were not exercised. Deploy +the paired API PR before the frontend PR; no database migration is required. diff --git a/projects/social_platform/src/app/domain/program/program-certificates.model.ts b/projects/social_platform/src/app/domain/program/program-certificates.model.ts index 86274252b..476c75a67 100644 --- a/projects/social_platform/src/app/domain/program/program-certificates.model.ts +++ b/projects/social_platform/src/app/domain/program/program-certificates.model.ts @@ -45,6 +45,7 @@ export interface CertificateAsset { height: number; } export interface CertificateDraft { + orientation?: "landscape" | "portrait"; backgroundId: number | null; signatureId: number | null; director: string; diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html index 0bfa7267e..98e5c4971 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.html @@ -37,6 +37,8 @@ class="canvas" [class.canvas--preview]="preview()" [style.width.px]="paperWidth() || null" + [style.aspect-ratio]="page().widthMm + ' / ' + page().heightMm" + [style.--canvas-padding]="100 / page().widthPx + 'cqw'" aria-label="Редактор сертификата. Перемещайте элементы мышью или стрелками клавиатуры." (pointermove)="move($event)" (pointerup)="end()" @@ -74,7 +76,7 @@ [style.top.%]="draft().layout[key].y" [style.width.%]="draft().layout[key].width" [style.height.%]="draft().layout[key].height" - [style.font-size]="fits()[key].fontSize / 11.22 + 'cqw'" + [style.font-size]="fits()[key].fontSize / (page().widthPx / 100) + 'cqw'" (focus)="select(key)" (keydown)="keydown($event, key)" (pointerdown)="start($event, key)" diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss index 22f1d1d6b..1c4d5db8b 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.scss @@ -51,7 +51,7 @@ inset: 0; width: 100%; height: 100%; - object-fit: cover; + object-fit: contain; pointer-events: none; } @@ -61,7 +61,7 @@ display: flex; align-items: center; justify-content: center; - padding: 0.0891cqw; + padding: var(--canvas-padding, 0.0891cqw); line-height: 1.3; color: var(--black); text-align: center; diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts index 287ce45f5..1e5d43535 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificate-canvas.component.ts @@ -21,6 +21,7 @@ import { CertificateElement, } from "@domain/program/program-certificates.model"; import { + certificatePage, fitCertificateText, hiddenCertificateElements, moveCertificateBox, @@ -60,12 +61,19 @@ export class CertificateCanvasComponent { private readonly fontsReady = signal(false); private context: CanvasRenderingContext2D | null = null; private readonly destroy = inject(DestroyRef); + readonly page = computed(() => certificatePage(this.draft().orientation)); readonly paperWidth = computed(() => { const width = this.availableWidth(); + const page = this.page(); // Leave room for tools, the PDF action and the sticky save footer in the dialog. - const fit = this.expanded() - ? Math.min(width, (Math.max(200, this.viewportHeight() - 480) * 297) / 210) - : width; + const height = Math.max( + this.draft().orientation === "portrait" ? 360 : 200, + this.viewportHeight() - (this.expanded() ? 480 : 320), + ); + const fit = + this.expanded() || this.draft().orientation === "portrait" + ? Math.min(width, (height * page.widthMm) / page.heightMm) + : width; return fit * this.zoom(); }); readonly fits = computed(() => { @@ -82,7 +90,12 @@ export class CertificateCanvasComponent { } return cache.get(id)!; }; - const fit = fitCertificateText(this.text(key), this.draft().layout[key], measure); + const fit = fitCertificateText( + this.text(key), + this.draft().layout[key], + measure, + this.draft().orientation, + ); result[key] = { fontSize: fit.fontSize, text: fit.lines.join("\n") }; } return result; @@ -196,6 +209,7 @@ export class CertificateCanvasComponent { dx, dy, drag.key === "signature" ? this.signatureRatio() : undefined, + this.draft().orientation, ) : moveCertificateBox(drag.box, dx, dy), ); @@ -233,6 +247,7 @@ export class CertificateCanvasComponent { vector[0] * amount, vector[1] * amount, key === "signature" ? this.signatureRatio() : undefined, + this.draft().orientation, ) : moveCertificateBox(box, vector[0] * amount, vector[1] * amount, false), ); diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html index a5e615d85..e474f1c61 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.html @@ -35,7 +35,7 @@

Сертификаты

Шаблон сертификата

-

А4 · альбомный · 297 × 210 мм

+

{{ formatLabel() }}