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
10 changes: 10 additions & 0 deletions docs/feature-specs/program-certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,13 @@ drag/resize/границы/клавиатура; длинная кириллиц
размер подписи, масштаб и PDF используют одинаковые размеры листа. Старые
шаблоны без `orientation` считаются альбомными. Изменение шаблона не меняет
ранее выданные PDF. Для выкладки сначала требуется API с поддержкой ориентации.

## Шрифты текста

В панели выбранного текстового элемента доступно компактное поле «Шрифт»:
Mont, PT Serif и Lobster. У каждого элемента свой выбор; подпись остается
изображением. Шрифт сохраняется вместе с геометрией, участвует в истории и
подставляется в пробный и выдаваемый PDF. Старые шаблоны используют Mont.
Файлы хранятся локально, PDF встраивает выбранный шрифт, подгонка длинного ФИО
использует его метрики. Семейство не меняет элементы управления и подписи
редактора. Для выкладки требуется сначала парный API с `layout.*.font_family`.
51 changes: 51 additions & 0 deletions docs/qa/certificates-fonts-20261025.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!-- @format -->

# Certificate text fonts — verification 2026-10-25

Scope: a per-element font selector in the existing certificate canvas. The rest
of the product keeps Mont and the established management controls. Choices are
Mont, PT Serif and Lobster, with bundled Cyrillic faces and no font CDN requests.
The selector is 132 × 32px with 12px type; it wraps with the existing tools on mobile.

## Behavior and compatibility

- Each text box stores `fontFamily`; image signatures cannot use a text font.
- Changing a family preserves geometry and font size and participates in undo/redo.
- Font choice survives save/reload, text moves, resizes and orientation changes.
- Font loading completes before the final text-fit measurement. A load failure is
shown explicitly. Selection labels and placeholders retain Mont.
- Existing drafts and snapshots use Mont. The API preserves each saved family when
an older client omits the field and rejects unknown values and arbitrary font URLs.
- Browser WOFF2 files are container conversions of the exact TTFs used for PDF
measurements and embedding. PT Serif uses 400/700, Lobster its actual 400 face
without synthetic bold, and Mont the existing 400/600 faces. Font provenance and
attribution files accompany the assets in both repositories.

## Verification

- 32 Angular component/layout tests pass, including initial selected font, switching,
history, save payloads, geometry preservation, image and preview restrictions.
- 35 backend certificate tests: 33 pass, 2 PostgreSQL locking tests skip on local
SQLite. Tests cover per-element round trip, old clients, legacy defaults, rejected
families, long Cyrillic fitting in all three fonts and both A4 orientations, and
actual embedded font resources in native WeasyPrint PDF output.
- Angular build, targeted ESLint, Stylelint, Prettier, Black, flake8 and diff checks.
- Actual Angular build and Django API on disposable localhost program 1: switched
all three families, downloaded preview PDFs, saved/reloaded Lobster and used
undo/redo. Long-name preview and compact controls checked at desktop and 390/320.
- Six native long-name PDFs (three fonts × two orientations) rendered with Poppler
and visually inspected. Each has one A4 page, an embedded selected font, intact
extracted Cyrillic name and no clipped glyphs.

A browser integration check found that `[value]` plus dynamically created options
displayed Mont after reload even though the saved font was Lobster. The selector
now uses the existing Angular forms value accessor and the initial saved selection
has a regression assertion. This was fixed before PR creation.

Evidence is outside Git in `design-artifacts/program-certificate-fonts-20261025/`:
browser/PDF checks, preview PDFs, six long-name renders, desktop/mobile screenshots
and command logs. No real dev data or participant email was changed. Native touch,
other browser engines and delivery activation were not exercised.

Deploy the paired API first, then frontend. No database migration or regeneration
of previously issued PDF files is needed. Custom font-file upload is outside this change.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export interface CertificateBox {
width: number;
height: number;
fontSize: number;
fontFamily?: "mont" | "pt-serif" | "lobster";
}
export const CERTIFICATE_FONTS = [
{ key: "mont", label: "Mont", family: "Mont", boldWeight: 600 },
{ key: "pt-serif", label: "PT Serif", family: "Certificate PT Serif", boldWeight: 700 },
{ key: "lobster", label: "Lobster", family: "Certificate Lobster", boldWeight: 400 },
] as const;
export function certificateFont(key: CertificateBox["fontFamily"]) {
return CERTIFICATE_FONTS.find(font => font.key === key) ?? CERTIFICATE_FONTS[0];
}
export interface CertificateAsset {
id: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
<!-- @format -->
<div class="canvas__selection-tools" aria-label="Инструменты выбранного элемента">
<label class="canvas__family">
<span>Шрифт</span>
<select
appField
[ngModel]="font(selected()).key"
[disabled]="selected() === 'signature' || preview() || disabled() || fontLoadError()"
(ngModelChange)="familyChanged($event)"
>
@for (font of fonts; track font.key) {
<option [value]="font.key">{{ font.label }}</option>
}
</select>
</label>
<label class="canvas__font"
>Размер текста
<input
Expand Down Expand Up @@ -32,6 +45,9 @@
</button>
}
</div>
@if (fontLoadError()) {
<p class="canvas__font-error" role="alert">Не удалось загрузить шрифты. Обновите страницу.</p>
}
<div class="canvas__viewport" #viewport>
<div
class="canvas"
Expand Down Expand Up @@ -71,7 +87,8 @@
[tabindex]="preview() ? -1 : 0"
[attr.aria-label]="labels[key] + ': переместить стрелками'"
[class.canvas__element--selected]="selected() === key"
[class.canvas__element--bold]="key === 'name' || key === 'title'"
[style.font-family]="font(key).family"
[style.font-weight]="fontWeight(key)"
[style.left.%]="draft().layout[key].x"
[style.top.%]="draft().layout[key].y"
[style.width.%]="draft().layout[key].width"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@
@use "styles/ui-foundation";
@use "styles/program-management-controls";

@font-face {
font-family: "Certificate PT Serif";
font-style: normal;
font-weight: 400;
src: url("/assets/font/certificates/PTSerif-Regular.woff2") format("woff2");
font-display: swap;
}

@font-face {
font-family: "Certificate PT Serif";
font-style: normal;
font-weight: 700;
src: url("/assets/font/certificates/PTSerif-Bold.woff2") format("woff2");
font-display: swap;
}

@font-face {
font-family: "Certificate Lobster";
font-style: normal;
font-weight: 400;
src: url("/assets/font/certificates/Lobster-Regular.woff2") format("woff2");
font-display: swap;
}

:host {
display: block;
min-width: 0;
Expand Down Expand Up @@ -65,6 +89,7 @@
line-height: 1.3;
color: var(--black);
text-align: center;
font-synthesis: none;
touch-action: none;
cursor: grab;
user-select: none;
Expand All @@ -84,10 +109,6 @@
outline-width: 2px;
}

&--bold {
font-weight: 600;
}

&--selected {
z-index: 2;
}
Expand All @@ -107,6 +128,7 @@
}

&__placeholder {
font-family: Mont, sans-serif;
font-size: 1.5cqw;
color: var(--grey-for-text);
}
Expand All @@ -116,6 +138,7 @@
top: -22px;
left: -1px;
padding: 2px 6px;
font-family: Mont, sans-serif;
font-size: 10px;
font-weight: 400;
line-height: 18px;
Expand Down Expand Up @@ -203,6 +226,26 @@
}
}

&__family {
display: flex;
gap: 8px;
align-items: center;
min-height: 32px;

select {
width: 132px;
padding-right: 28px;
font-size: 12px;
background-position: right 8px center;
background-size: 12px;
}
}

&__font-error {
margin: 0 0 12px;
color: var(--ui-error-text);
}

&__alignment {
display: flex;
gap: 4px;
Expand Down Expand Up @@ -260,6 +303,7 @@
}

.canvas__font,
.canvas__family,
.canvas__check,
.canvas__zoom {
min-height: 44px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import {
viewChild,
} from "@angular/core";
import { ButtonDirective, ChoiceDirective, FieldDirective } from "@uilib";
import { FormsModule } from "@angular/forms";
import {
CERTIFICATE_ELEMENTS,
CERTIFICATE_LABELS,
CERTIFICATE_FONTS,
certificateFont,
CertificateBox,
CertificateDraft,
CertificateElement,
Expand All @@ -30,7 +33,7 @@ import {

@Component({
selector: "app-certificate-canvas",
imports: [ButtonDirective, ChoiceDirective, FieldDirective],
imports: [FormsModule, ButtonDirective, ChoiceDirective, FieldDirective],
templateUrl: "./certificate-canvas.component.html",
styleUrl: "./certificate-canvas.component.scss",
changeDetection: ChangeDetectionStrategy.OnPush,
Expand All @@ -53,6 +56,8 @@ export class CertificateCanvasComponent {
readonly removeElement = output<CertificateElement>();
readonly keys = CERTIFICATE_ELEMENTS;
readonly labels = CERTIFICATE_LABELS;
readonly fonts = CERTIFICATE_FONTS;
readonly fontLoadError = signal(false);
readonly zoom = signal(1);
readonly dragging = signal(false);
readonly viewport = viewChild<ElementRef<HTMLElement>>("viewport");
Expand Down Expand Up @@ -81,11 +86,12 @@ export class CertificateCanvasComponent {
const result = {} as Record<CertificateElement, { fontSize: number; text: string }>;
const cache = new Map<string, number>();
for (const key of this.keys) {
const weight = key === "name" || key === "title" ? 600 : 400;
const font = this.font(key);
const weight = this.fontWeight(key);
const measure = (value: string) => {
const id = `${weight}:${value}`;
const id = `${font.key}:${weight}:${value}`;
if (!cache.has(id)) {
if (this.context) this.context.font = `${weight} 1000px Mont`;
if (this.context) this.context.font = `${weight} 1000px "${font.family}"`;
cache.set(id, this.context?.measureText(value).width ?? Array.from(value).length * 550);
}
return cache.get(id)!;
Expand Down Expand Up @@ -133,9 +139,24 @@ export class CertificateCanvasComponent {
if (this.viewport()) observer?.observe(this.viewport()!.nativeElement);
window.addEventListener("resize", measure);
measure();
void document.fonts?.ready.then(() => {
if (!this.destroy.destroyed) this.fontsReady.set(true);
});
if (document.fonts) {
void document.fonts.ready
.then(() =>
Promise.all(
this.fonts.flatMap(font =>
[...new Set([400, font.boldWeight])].map(weight =>
document.fonts.load(`${weight} 1000px "${font.family}"`),
),
),
),
)
.then(() => {
if (!this.destroy.destroyed) this.fontsReady.set(true);
})
.catch(() => {
if (!this.destroy.destroyed) this.fontLoadError.set(true);
});
}
this.destroy.onDestroy(() => {
observer?.disconnect();
window.removeEventListener("resize", measure);
Expand All @@ -145,6 +166,19 @@ export class CertificateCanvasComponent {
box(): CertificateBox {
return this.draft().layout[this.selected()];
}
font(key: CertificateElement) {
return certificateFont(this.draft().layout[key].fontFamily);
}
fontWeight(key: CertificateElement): number {
return key === "name" || key === "title" ? this.font(key).boldWeight : 400;
}
familyChanged(value: string): void {
if (this.disabled() || this.preview() || this.selected() === "signature") return;
const font = this.fonts.find(font => font.key === value);
if (!font || font.key === certificateFont(this.box().fontFamily).key) return;
this.interactionStart.emit();
this.update(this.selected(), { ...this.box(), fontFamily: font.key });
}
visible(key: CertificateElement): boolean {
return !hiddenCertificateElements(this.draft()).includes(
key as Exclude<CertificateElement, "name">,
Expand Down
Loading