diff --git a/docs/feature-specs/program-certificates.md b/docs/feature-specs/program-certificates.md index 2514e80aa..f91759e20 100644 --- a/docs/feature-specs/program-certificates.md +++ b/docs/feature-specs/program-certificates.md @@ -61,3 +61,13 @@ drag/resize/границы/клавиатура; длинная кириллиц размер подписи, масштаб и PDF используют одинаковые размеры листа. Старые шаблоны без `orientation` считаются альбомными. Изменение шаблона не меняет ранее выданные PDF. Для выкладки сначала требуется API с поддержкой ориентации. + +## Шрифты текста + +В панели выбранного текстового элемента доступно компактное поле «Шрифт»: +Mont, PT Serif и Lobster. У каждого элемента свой выбор; подпись остается +изображением. Шрифт сохраняется вместе с геометрией, участвует в истории и +подставляется в пробный и выдаваемый PDF. Старые шаблоны используют Mont. +Файлы хранятся локально, PDF встраивает выбранный шрифт, подгонка длинного ФИО +использует его метрики. Семейство не меняет элементы управления и подписи +редактора. Для выкладки требуется сначала парный API с `layout.*.font_family`. diff --git a/docs/qa/certificates-fonts-20261025.md b/docs/qa/certificates-fonts-20261025.md new file mode 100644 index 000000000..3f95dc072 --- /dev/null +++ b/docs/qa/certificates-fonts-20261025.md @@ -0,0 +1,51 @@ + + +# 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. 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 476c75a67..ce67730b2 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 @@ -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; 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 98e5c4971..4feebad35 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 @@ -1,5 +1,18 @@
+
+@if (fontLoadError()) { + +}
(); 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>("viewport"); @@ -81,11 +86,12 @@ export class CertificateCanvasComponent { const result = {} as Record; const cache = new Map(); 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)!; @@ -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); @@ -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, diff --git a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts index b2446ca97..c784c8599 100644 --- a/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/program/management/certificates/certificates.component.spec.ts @@ -1,11 +1,13 @@ /** @format */ import { TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { of, Subject, throwError } from "rxjs"; import { describe, expect, it, vi } from "vitest"; import { ProgramCertificatesRepositoryPort } from "@domain/program/ports/program-certificates.repository.port"; import { ProgramCertificatesUseCase } from "@api/program/use-cases/program-certificates.use-case"; import { CertificateDraft, CertificateSettings } from "@domain/program/program-certificates.model"; import { ManagementCertificatesComponent } from "./certificates.component"; +import { CertificateCanvasComponent } from "./certificate-canvas.component"; const keys = [ "title", "subtitle", @@ -80,6 +82,69 @@ async function setup(data = settings()) { return { fixture, component: fixture.componentInstance, repository }; } describe("Program certificates", () => { + it("changes only the selected element font with history, saves it and keeps geometry", async () => { + const { fixture, component, repository } = await setup(); + const initial = structuredClone(component.draft()!.layout); + const select = fixture.nativeElement.querySelector( + ".canvas__family select", + ) as HTMLSelectElement; + expect(select.value).toBe("mont"); + select.value = "pt-serif"; + select.dispatchEvent(new Event("change")); + fixture.detectChanges(); + expect(component.draft()!.layout.name).toEqual({ ...initial.name, fontFamily: "pt-serif" }); + expect(component.draft()!.layout.director).toEqual(initial.director); + expect(fixture.nativeElement.querySelector(".canvas__element").style.fontFamily).toContain( + "Certificate PT Serif", + ); + component.undo(); + expect(component.draft()!.layout).toEqual(initial); + component.redo(); + expect(component.draft()!.layout.name.fontFamily).toBe("pt-serif"); + await component.save(); + expect(repository.save).toHaveBeenCalledWith( + 9, + 1, + expect.objectContaining({ + layout: expect.objectContaining({ + name: expect.objectContaining({ fontFamily: "pt-serif" }), + }), + }), + ); + expect(component.dirty()).toBe(false); + fixture.destroy(); + }); + it("preserves the saved font while centering, and blocks font changes for images and preview", async () => { + const data = settings(); + data.draft.layout.name.fontFamily = "lobster"; + const { fixture, component } = await setup(data); + expect( + (fixture.nativeElement.querySelector(".canvas__family select") as HTMLSelectElement).value, + ).toBe("lobster"); + const canvas = fixture.debugElement.query(By.directive(CertificateCanvasComponent)) + .componentInstance as CertificateCanvasComponent; + canvas.center(); + fixture.detectChanges(); + expect(component.draft()!.layout.name.fontFamily).toBe("lobster"); + expect(canvas.fontWeight("name")).toBe(400); + component.selected.set("signature"); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect( + (fixture.nativeElement.querySelector(".canvas__family select") as HTMLSelectElement).disabled, + ).toBe(true); + canvas.familyChanged("pt-serif"); + expect(component.draft()!.layout.signature.fontFamily).toBeUndefined(); + component.selected.set("name"); + component.editorPreview.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + canvas.familyChanged("pt-serif"); + expect(component.draft()!.layout.name.fontFamily).toBe("lobster"); + fixture.destroy(); + }); 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"); diff --git a/projects/social_platform/src/assets/font/certificates/Lobster-OFL.txt b/projects/social_platform/src/assets/font/certificates/Lobster-OFL.txt new file mode 100644 index 000000000..a41143009 --- /dev/null +++ b/projects/social_platform/src/assets/font/certificates/Lobster-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2010 The Lobster Project Authors (https://github.com/impallari/The-Lobster-Font), with Reserved Font Name "Lobster". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/projects/social_platform/src/assets/font/certificates/Lobster-Regular.woff2 b/projects/social_platform/src/assets/font/certificates/Lobster-Regular.woff2 new file mode 100644 index 000000000..cad21d8c5 Binary files /dev/null and b/projects/social_platform/src/assets/font/certificates/Lobster-Regular.woff2 differ diff --git a/projects/social_platform/src/assets/font/certificates/PTSerif-Bold.woff2 b/projects/social_platform/src/assets/font/certificates/PTSerif-Bold.woff2 new file mode 100644 index 000000000..8ab407ad4 Binary files /dev/null and b/projects/social_platform/src/assets/font/certificates/PTSerif-Bold.woff2 differ diff --git a/projects/social_platform/src/assets/font/certificates/PTSerif-OFL.txt b/projects/social_platform/src/assets/font/certificates/PTSerif-OFL.txt new file mode 100644 index 000000000..e9f47cb8b --- /dev/null +++ b/projects/social_platform/src/assets/font/certificates/PTSerif-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010, ParaType Ltd. (http://www.paratype.com/public), +with Reserved Font Names "PT Sans", "PT Serif" and "ParaType". + +This Font Software is licensed under the SIL Open Font License, Version 1.1 +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/projects/social_platform/src/assets/font/certificates/PTSerif-Regular.woff2 b/projects/social_platform/src/assets/font/certificates/PTSerif-Regular.woff2 new file mode 100644 index 000000000..dc3c3acd5 Binary files /dev/null and b/projects/social_platform/src/assets/font/certificates/PTSerif-Regular.woff2 differ diff --git a/projects/social_platform/src/assets/font/certificates/README.md b/projects/social_platform/src/assets/font/certificates/README.md new file mode 100644 index 000000000..bd981b23a --- /dev/null +++ b/projects/social_platform/src/assets/font/certificates/README.md @@ -0,0 +1,23 @@ + + +# Certificate fonts + +WOFF2 files are container conversions of Google Fonts TTF files from commit +[`e7db6ca603fd4862010383ff17ffd6cc4ffac1c0`](https://github.com/google/fonts/tree/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0). +The paired API bundles the original TTFs for identical text metrics and PDF embedding. +Font outlines, names, character coverage and layout tables are retained. + +| Frontend file | Upstream TTF | TTF SHA-256 | +| --------------------- | ------------------------------------ | ---------------------------------------------------------------- | +| PTSerif-Regular.woff2 | ofl/ptserif/PT_Serif-Web-Regular.ttf | a4951fade06ff8f09b7673aa81ffb65a8cd409e24d3289a6dc670bc4dda2557a | +| PTSerif-Bold.woff2 | ofl/ptserif/PT_Serif-Web-Bold.ttf | 038ba7336bd7ea14f12ad155bed51a4345cac5153275d521dec3ba04021c526e | +| Lobster-Regular.woff2 | ofl/lobster/Lobster-Regular.ttf | d6568e697fd50cedc0be04d8aae4127fe95add607e7bff954ca88604be80c205 | + +PT Serif: ParaType. [License and attribution](PTSerif-OFL.txt). +Lobster: The Lobster Project Authors. [License and attribution](Lobster-OFL.txt). +Mont continues to use the existing product assets. All font requests are served +by the application, with no Google Fonts CDN dependency. Added WOFF2 files total +297,460 bytes and are loaded when the certificate canvas opens. + +The CSS aliases `Certificate PT Serif` and `Certificate Lobster` apply only to +certificate text. Product controls and editor selection labels keep Mont.