diff --git a/assets/certificates/fonts/Lobster-OFL.txt b/assets/certificates/fonts/Lobster-OFL.txt new file mode 100644 index 00000000..a4114300 --- /dev/null +++ b/assets/certificates/fonts/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/assets/certificates/fonts/Lobster-Regular.ttf b/assets/certificates/fonts/Lobster-Regular.ttf new file mode 100644 index 00000000..f30b6f66 Binary files /dev/null and b/assets/certificates/fonts/Lobster-Regular.ttf differ diff --git a/assets/certificates/fonts/PTSerif-Bold.ttf b/assets/certificates/fonts/PTSerif-Bold.ttf new file mode 100644 index 00000000..0483e59f Binary files /dev/null and b/assets/certificates/fonts/PTSerif-Bold.ttf differ diff --git a/assets/certificates/fonts/PTSerif-OFL.txt b/assets/certificates/fonts/PTSerif-OFL.txt new file mode 100644 index 00000000..e9f47cb8 --- /dev/null +++ b/assets/certificates/fonts/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/assets/certificates/fonts/PTSerif-Regular.ttf b/assets/certificates/fonts/PTSerif-Regular.ttf new file mode 100644 index 00000000..5310691a Binary files /dev/null and b/assets/certificates/fonts/PTSerif-Regular.ttf differ diff --git a/assets/certificates/fonts/README.md b/assets/certificates/fonts/README.md new file mode 100644 index 00000000..6debad47 --- /dev/null +++ b/assets/certificates/fonts/README.md @@ -0,0 +1,21 @@ +# Certificate fonts + +The renderer bundles these Google Fonts files at commit +[`e7db6ca603fd4862010383ff17ffd6cc4ffac1c0`](https://github.com/google/fonts/tree/e7db6ca603fd4862010383ff17ffd6cc4ffac1c0): + +| Local file | Upstream file | SHA-256 | +| --- | --- | --- | +| PTSerif-Regular.ttf | ofl/ptserif/PT_Serif-Web-Regular.ttf | a4951fade06ff8f09b7673aa81ffb65a8cd409e24d3289a6dc670bc4dda2557a | +| PTSerif-Bold.ttf | ofl/ptserif/PT_Serif-Web-Bold.ttf | 038ba7336bd7ea14f12ad155bed51a4345cac5153275d521dec3ba04021c526e | +| Lobster-Regular.ttf | ofl/lobster/Lobster-Regular.ttf | d6568e697fd50cedc0be04d8aae4127fe95add607e7bff954ca88604be80c205 | + +PT Serif: ParaType. See [license and attribution](PTSerif-OFL.txt). +Lobster: The Lobster Project Authors. See [license and attribution](Lobster-OFL.txt). +The upstream font names and outlines are unchanged. Mont continues to use the +existing application assets in `assets/users/user_CV/`. + +Frontend WOFF2 files are lossless container conversions of these TTF sources. +The backend uses the exact TTF files for both Pillow measurements and embedded PDF +faces. The renderer never downloads fonts during PDF generation. Lobster has one +400-weight face; it is used without synthetic bold. PT Serif uses 400/700 and Mont +uses the existing 400/600 faces. diff --git a/docs/modules/program-certificates.md b/docs/modules/program-certificates.md index aeac0ada..e9e8bd25 100644 --- a/docs/modules/program-certificates.md +++ b/docs/modules/program-certificates.md @@ -86,3 +86,15 @@ PDF не изменяются, миграция БД не требуется. П включая подгонку текста. Фон вписывается целиком без обрезки; вертикальные изображения сохраняют разрешение до 2480 × 3508. Ранее сформированные PDF не пересоздаются. Миграция базы не требуется. + +## Шрифты элементов + +`layout..font_family`: `mont`, `pt-serif` или `lobster`. Выбор хранится +отдельно для каждого элемента и входит в снимок выдачи. Старые шаблоны без поля +используют Mont. Старый клиент, опустивший поле, сохраняет предыдущий выбор +конкретного элемента; неизвестные семейства и произвольные URL отклоняются. +Pillow и PDF используют одни и те же локальные TTF, выбранный шрифт встраивается +в PDF. PT Serif использует 400/700, Mont 400/600, Lobster единственный 400 без +синтетического полужирного начертания. Шрифт изображения подписи не применяется. +Миграция и обновление ранее выданных PDF не требуются. Источники и лицензии: +`assets/certificates/fonts/README.md`. diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py index ea743391..f93ea52d 100644 --- a/partner_programs/services/certificates.py +++ b/partner_programs/services/certificates.py @@ -27,6 +27,11 @@ from projects.models import Collaborator logger = logging.getLogger(__name__) +CERTIFICATE_FONTS = { + "mont": ("Mont", "Mont-Regular.ttf", "Mont-SemiBold.ttf", 600), + "pt-serif": ("Certificate PT Serif", "PTSerif-Regular.ttf", "PTSerif-Bold.ttf", 700), + "lobster": ("Certificate Lobster", "Lobster-Regular.ttf", "Lobster-Regular.ttf", 400), +} ELEMENTS = ( "title", "subtitle", @@ -67,7 +72,9 @@ def default_draft(): "subject": "Ваш сертификат за участие в программе {program}", "message": "Здравствуйте, {name}!\nСпасибо за участие в программе «{program}».\nВаш сертификат готов.", "layout": { - key: dict(zip(("x", "y", "width", "height", "font_size"), pos)) + key: dict( + zip(("x", "y", "width", "height", "font_size"), pos), font_family="mont" + ) for key, pos in zip(ELEMENTS, positions) }, } @@ -87,6 +94,10 @@ def normalized_draft(draft): **draft, "orientation": draft.get("orientation", "landscape"), "hidden_elements": [key for key in ELEMENTS if key in hidden_elements(draft)], + "layout": { + key: {**box, "font_family": box.get("font_family", "mont")} + for key, box in draft["layout"].items() + }, } @@ -232,6 +243,9 @@ class Element(serializers.Serializer): width = serializers.FloatField(min_value=2, max_value=100) height = serializers.FloatField(min_value=1, max_value=100) font_size = serializers.FloatField(min_value=8, max_value=72) + font_family = serializers.ChoiceField( + choices=list(CERTIFICATE_FONTS), required=False + ) class Draft(serializers.Serializer): orientation = serializers.ChoiceField( @@ -275,11 +289,18 @@ class Draft(serializers.Serializer): raise ValidationError({"hidden_elements": "Элементы не должны повторяться."}) if set(data["layout"]) != set(ELEMENTS): raise ValidationError("Состав элементов шаблона изменен. Обновите страницу.") - for element in data["layout"].values(): + for key, element in data["layout"].items(): import math - if not all(math.isfinite(value) for value in element.values()): + if not all( + math.isfinite(element[field]) + for field in ("x", "y", "width", "height", "font_size") + ): raise ValidationError("Размеры и положение должны быть конечными числами.") + element.setdefault( + "font_family", + (previous or {}).get("layout", {}).get(key, {}).get("font_family", "mont"), + ) if ( element["x"] + element["width"] > 100.01 or element["y"] + element["height"] > 100.01 @@ -301,12 +322,20 @@ class Draft(serializers.Serializer): return normalized_draft(dict(data)) -@lru_cache(maxsize=4) -def certificate_font(bold): - filename = "Mont-SemiBold.ttf" if bold else "Mont-Regular.ttf" - return ImageFont.truetype( - str(Path(settings.BASE_DIR) / "assets/users/user_CV" / filename), 1000 - ) +def certificate_font_key(box): + key = box.get("font_family", "mont") + return key if key in CERTIFICATE_FONTS else "mont" + + +def certificate_font_path(key, bold): + filename = CERTIFICATE_FONTS[key][2 if bold else 1] + directory = "assets/users/user_CV" if key == "mont" else "assets/certificates/fonts" + return Path(settings.BASE_DIR) / directory / filename + + +@lru_cache(maxsize=6) +def certificate_font(bold, family="mont"): + return ImageFont.truetype(str(certificate_font_path(family, bold)), 1000) def page_dimensions(orientation="landscape"): @@ -314,8 +343,8 @@ def page_dimensions(orientation="landscape"): def fit_certificate_text(value, box, bold=False, orientation="landscape"): - """Подгоняем реальные строки Mont по обеим сторонам бокса, без обрезки ФИО.""" - font = certificate_font(bold) + """Подгоняем реальные строки выбранного шрифта без обрезки ФИО.""" + font = certificate_font(bold, certificate_font_key(box)) page_width, page_height = ( (793.7, 1122) if orientation == "portrait" else (1122, 793.7) ) @@ -361,21 +390,17 @@ def render_pdf(snapshot, assets, name, program_name, number, issued_at): from weasyprint import HTML from weasyprint.text.fonts import FontConfiguration - fonts = Path(settings.BASE_DIR) / "assets/users/user_CV" orientation = snapshot.get("orientation", "landscape") page_width, page_height = page_dimensions(orientation) def fetch(url): - if url == "certificate-font:regular": - return { - "string": (fonts / "Mont-Regular.ttf").read_bytes(), - "mime_type": "font/ttf", - } - if url == "certificate-font:bold": - return { - "string": (fonts / "Mont-SemiBold.ttf").read_bytes(), - "mime_type": "font/ttf", - } + for key in CERTIFICATE_FONTS: + for bold, weight in ((False, "regular"), (True, "bold")): + if url == f"certificate-font:{key}:{weight}": + return { + "string": certificate_font_path(key, bold).read_bytes(), + "mime_type": "font/ttf", + } if url.startswith("data:image/png;base64,"): return { "string": base64.b64decode(url.split(",", 1)[1], validate=True), @@ -411,23 +436,35 @@ def src(asset): ) else: value = values[key] + family = certificate_font_key(box) + family_name, _, _, bold_weight = CERTIFICATE_FONTS[family] + weight = bold_weight if key in ("name", "title") else 400 size, lines = fit_certificate_text( value, box, key in ("name", "title"), orientation ) content = "
".join(escape(line) for line in lines) elements.append( - f'
{content}
' + f'
{content}
' ) html = ( - """
""".replace( "PAGE_WIDTH", str(page_width) ).replace( diff --git a/partner_programs/tests/test_certificates.py b/partner_programs/tests/test_certificates.py index 2bf000ac..e6387c16 100644 --- a/partner_programs/tests/test_certificates.py +++ b/partner_programs/tests/test_certificates.py @@ -322,6 +322,124 @@ def test_text_fit_uses_real_font_and_both_dimensions(self): font.getlength(line) / 1000 * size * 1.02, 70 * 11.22 - 2 ) + def test_font_choices_round_trip_and_old_clients_preserve_each_element(self): + draft = service.default_draft() + draft["layout"]["name"]["font_family"] = "pt-serif" + draft["layout"]["director"]["font_family"] = "lobster" + response = self.client.put( + self.root, {"revision": 1, "draft": draft}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + saved = self.client.get(self.root).data["draft"] + self.assertEqual(saved["layout"]["name"]["font_family"], "pt-serif") + self.assertEqual(saved["layout"]["director"]["font_family"], "lobster") + for box in draft["layout"].values(): + box.pop("font_family") + draft["layout"]["name"]["x"] += 1 + response = self.client.put( + self.root, {"revision": 2, "draft": draft}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + response.data["draft"]["layout"]["name"]["font_family"], "pt-serif" + ) + self.assertEqual( + response.data["draft"]["layout"]["director"]["font_family"], "lobster" + ) + for invalid in ( + "unknown", + "https://example.com/font.ttf", + "Mont; color:red", + None, + ): + draft["layout"]["name"]["font_family"] = invalid + response = self.client.put( + self.root, {"revision": 3, "draft": draft}, format="json" + ) + self.assertEqual(response.status_code, 400, response.data) + self.config.refresh_from_db() + self.assertEqual(self.config.revision, 3) + + def test_legacy_elements_default_to_mont_without_rewriting_the_snapshot(self): + draft = service.default_draft() + for box in draft["layout"].values(): + box.pop("font_family") + self.config.draft = draft + self.config.save() + response = self.client.get(self.root).data["draft"] + self.assertEqual( + {box["font_family"] for box in response["layout"].values()}, {"mont"} + ) + self.config.refresh_from_db() + self.assertTrue( + all("font_family" not in box for box in self.config.draft["layout"].values()) + ) + + def test_all_certificate_fonts_fit_long_cyrillic_names_in_both_orientations(self): + value = "Александра Константиновна Добровольская" + for family in service.CERTIFICATE_FONTS: + for orientation in ("portrait", "landscape"): + with self.subTest(family=family, orientation=orientation): + box = { + "width": 65, + "height": 12, + "font_size": 40, + "font_family": family, + } + size, lines = service.fit_certificate_text( + value, box, True, orientation + ) + self.assertEqual(" ".join(lines), value) + width, height = ( + (793.7, 1122) if orientation == "portrait" else (1122, 793.7) + ) + self.assertLessEqual( + len(lines) * size * 1.3, box["height"] * height / 100 - 2 + ) + for line in lines: + self.assertLessEqual( + service.certificate_font(True, family).getlength(line) + / 1000 + * size + * 1.02, + box["width"] * width / 100 - 2, + ) + + @tag("native_pdf") + def test_selected_fonts_are_embedded_in_native_pdf(self): + import re + from weasyprint.document import Document + + original = Document.write_pdf + + def uncompressed(document, *args, **kwargs): + return original(document, *args, **{**kwargs, "uncompressed_pdf": True}) + + for family, expected in ( + ("mont", b"Mont-SemiBold"), + ("pt-serif", b"PTSerif-Bold"), + ("lobster", b"Lobster"), + ): + with self.subTest(family=family): + draft = service.default_draft() + draft["layout"]["name"]["font_family"] = family + with patch.object( + Document, "write_pdf", autospec=True, side_effect=uncompressed + ): + pdf = service.render_pdf( + draft, {}, "Анна Крылова", "Программа", "1", timezone.now() + ) + names = re.findall(rb"/BaseFont\s*/([^\s/]+)", pdf) + self.assertTrue( + any( + expected.replace(b"-", b"").lower() + in name.replace(b"-", b"").lower() + for name in names + ), + names, + ) + self.assertIn(b"/FontFile2", pdf) + @tag("native_pdf") def test_ready_template_pdf_adds_only_each_participants_name(self): from weasyprint import HTML