Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/feature-specs/program-certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,15 @@
drag/resize/границы/клавиатура; длинная кириллица в A4 PDF; сохранение и 409;
расписание/пауза/повтор; фильтры и пустые/ошибочные состояния; мобильная ширина;
защита несохраненных изменений; сборка Angular и проверки backend.

## Формат листа

Редактор поддерживает альбомный А4 (297 × 210 мм) и вертикальный А4 (210 × 297 мм).
Компактное поле «Ориентация А4» находится рядом с настройками фона. При загрузке
фона ориентация выбирается по его пропорциям; квадратный фон сохраняет текущий
выбор. Ориентацию можно изменить вручную, отменить и повторить вместе с другими
изменениями шаблона. Фон целиком вписывается в лист без обрезки и растяжения.
Положение элементов задано относительно выбранного листа. Подгонка текста,
размер подписи, масштаб и PDF используют одинаковые размеры листа. Старые
шаблоны без `orientation` считаются альбомными. Изменение шаблона не меняет
ранее выданные PDF. Для выкладки сначала требуется API с поддержкой ориентации.
52 changes: 52 additions & 0 deletions docs/qa/certificates-a4-orientation-20261025.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!-- @format -->

# 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.
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface CertificateAsset {
height: number;
}
export interface CertificateDraft {
orientation?: "landscape" | "portrait";
backgroundId: number | null;
signatureId: number | null;
director: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()"
Expand Down Expand Up @@ -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)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
pointer-events: none;
}

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
CertificateElement,
} from "@domain/program/program-certificates.model";
import {
certificatePage,
fitCertificateText,
hiddenCertificateElements,
moveCertificateBox,
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -196,6 +209,7 @@ export class CertificateCanvasComponent {
dx,
dy,
drag.key === "signature" ? this.signatureRatio() : undefined,
this.draft().orientation,
)
: moveCertificateBox(drag.box, dx, dy),
);
Expand Down Expand Up @@ -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),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ <h2>Сертификаты</h2>
<header class="certificates__editor-heading">
<div>
<h3>Шаблон сертификата</h3>
<p class="certificates__muted">А4 · альбомный · 297 × 210 мм</p>
<p class="certificates__muted">{{ formatLabel() }}</p>
</div>
<div class="certificates__actions">
<button
Expand Down Expand Up @@ -117,6 +117,16 @@ <h3>Шаблон сертификата</h3>
</div>
</div>
<aside class="certificates__settings">
<section class="certificates__format-settings">
<label class="certificates__format">
<span>Ориентация А4</span>
<select appField [ngModel]="d.orientation" (ngModelChange)="setOrientation($event)">
<option value="landscape">Альбомная</option>
<option value="portrait">Вертикальная</option>
</select>
</label>
<p class="certificates__muted">Подбирается по фону. Можно изменить вручную.</p>
</section>
<section>
<h4>Фон сертификата</h4>
<p class="certificates__filename">{{ assets().background?.name || "Фон не загружен" }}</p>
Expand Down Expand Up @@ -643,7 +653,7 @@ <h3>Когда выдать</h3>
<header class="certificates__editor-heading">
<div>
<h2 id="certificate-editor-heading">Шаблон сертификата</h2>
<p class="certificates__muted">А4 · альбомный</p>
<p class="certificates__muted">{{ formatLabel() }}</p>
</div>
<div class="certificates__actions">
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,33 @@ button {
}

.certificates {
&__format-settings {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 1px solid var(--medium-grey-for-outline);
}

&__format {
display: grid;
gap: 8px;

> span {
font-weight: 600;
}

.ui-field {
width: 164px;
max-width: 100%;
min-height: 32px;
padding: 5px 28px 5px 8px;
font-size: 12px;
line-height: 20px;
background-position: right 8px center;
background-size: 12px;
border-radius: 8px;
}
}

&__heading {
margin-bottom: 20px;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,46 @@ async function setup(data = settings()) {
return { fixture, component: fixture.componentInstance, repository };
}
describe("Program certificates", () => {
it("defaults legacy templates to landscape and saves portrait with undo and redo", async () => {
const { fixture, component, repository } = await setup();
expect(component.draft()!.orientation).toBe("landscape");
expect(component.dirty()).toBe(false);
component.setOrientation("portrait");
fixture.detectChanges();
expect(component.formatLabel()).toContain("210 × 297");
expect(fixture.nativeElement.querySelector(".canvas").style.aspectRatio).toBe("210 / 297");
component.undo();
expect(component.draft()!.orientation).toBe("landscape");
component.redo();
expect(component.draft()!.orientation).toBe("portrait");
await component.save();
expect(repository.save).toHaveBeenCalledWith(
9,
1,
expect.objectContaining({ orientation: "portrait" }),
);
expect(component.dirty()).toBe(false);
fixture.destroy();
});
it("chooses orientation from a portrait background and undoes the file and orientation together", async () => {
const { fixture, component, repository } = await setup();
repository.upload.mockReturnValue(
of({ id: 12, name: "portrait.png", width: 2480, height: 3508 }),
);
repository.asset.mockReturnValue(of(new Blob(["image"])));
await component.upload(
{ target: { files: [new File(["image"], "portrait.png")] } } as unknown as Event,
"background",
);
expect(component.draft()!.orientation).toBe("portrait");
expect(component.draft()!.backgroundId).toBe(12);
component.undo();
expect(component.draft()!.orientation).toBe("landscape");
expect(component.draft()!.backgroundId).toBeNull();
component.redo();
expect(component.draft()!.orientation).toBe("portrait");
fixture.destroy();
});
it("allows only the name to remain, restores removed elements and preserves geometry", async () => {
const { fixture, component } = await setup();
const layout = structuredClone(component.draft()!.layout);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
CERTIFICATE_LABELS,
} from "@domain/program/program-certificates.model";
import {
certificatePage,
hiddenCertificateElements,
normalizeCertificateDraft,
resizeCertificateBox,
Expand Down Expand Up @@ -94,6 +95,11 @@ export class ManagementCertificatesComponent {
readonly editorPreview = signal(false);
readonly keys = CERTIFICATE_ELEMENTS;
readonly elementLabels = CERTIFICATE_LABELS;
readonly formatLabel = computed(() =>
this.draft()?.orientation === "portrait"
? "А4 · вертикальный · 210 × 297 мм"
: "А4 · альбомный · 297 × 210 мм",
);
readonly undoStack = signal<CertificateEditState[]>([]);
readonly redoStack = signal<CertificateEditState[]>([]);
readonly visibleElements = computed(() =>
Expand Down Expand Up @@ -296,6 +302,9 @@ export class ManagementCertificatesComponent {
if (remember) this.remember();
this.draft.update(d => (d ? { ...d, ...values } : d));
}
setOrientation(orientation: CertificateDraft["orientation"]): void {
if (orientation && orientation !== this.draft()?.orientation) this.patch({ orientation });
}
schedule(value: string): void {
this.patch({ scheduledAt: value ? fromMoscowInput(value) : null });
}
Expand Down Expand Up @@ -358,19 +367,30 @@ export class ManagementCertificatesComponent {
this.remember();
this.assets.update(assets => ({ ...assets, [kind]: asset }));
this.patch(
kind === "background" ? { backgroundId: asset.id } : { signatureId: asset.id },
kind === "background"
? {
backgroundId: asset.id,
orientation:
asset.width === asset.height
? this.draft()!.orientation
: asset.height > asset.width
? "portrait"
: "landscape",
}
: { signatureId: asset.id },
false,
);
if (kind === "signature") {
const draft = this.draft()!;
const box = draft.layout.signature;
const ratio = asset.width / asset.height;
const width = Math.min(box.width, (box.height * ratio) / (297 / 210));
const page = certificatePage(draft.orientation);
const width = Math.min(box.width, (box.height * ratio) / (page.widthMm / page.heightMm));
this.patch(
{
layout: {
...draft.layout,
signature: resizeCertificateBox({ ...box, width }, 0, 0, ratio),
signature: resizeCertificateBox({ ...box, width }, 0, 0, ratio, draft.orientation),
},
},
false,
Expand Down
19 changes: 19 additions & 0 deletions projects/social_platform/src/app/utils/certificate-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ describe("Certificate layout geometry", () => {
expect((resized.width * (297 / 210)) / resized.height).toBeCloseTo(3);
expect(resized.height + resized.y).toBeLessThanOrEqual(100);
});
it("preserves signature proportions and page bounds on portrait A4", () => {
const resized = resizeCertificateBox(box, 20, 80, 3, "portrait");
expect((resized.width * (210 / 297)) / resized.height).toBeCloseTo(3);
expect(resized.height + resized.y).toBeLessThanOrEqual(100);
expect(resized.width + resized.x).toBeLessThanOrEqual(100);
});
it("fits a long name into portrait A4 using the portrait dimensions", () => {
const value = "Александра Константиновна Добровольская";
const fitted = fitCertificateText(
value,
{ ...box, width: 70, height: 12, fontSize: 40 },
text => Array.from(text).length * 550,
"portrait",
);
expect(fitted.lines.join(" ")).toBe(value);
expect(fitted.lines.length * fitted.fontSize * 1.3).toBeLessThanOrEqual(12 * 11.22 - 2);
for (const line of fitted.lines)
expect(line.length * 0.55 * fitted.fontSize * 1.02).toBeLessThanOrEqual(70 * 7.937 - 2);
});
it("limits a tall signature by remaining height without stretching", () => {
const resized = resizeCertificateBox({ ...box, y: 80 }, 100, 0, 0.5);
expect(resized.height).toBe(20);
Expand Down
Loading