From bd52ebea1acbdf338aa1ad4cf11fce067b1dae66 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 25 Oct 2026 16:14:54 +0300 Subject: [PATCH] feat(certificates): support portrait and landscape A4 PDFs --- docs/modules/program-certificates.md | 11 +++ partner_programs/services/certificates.py | 44 ++++++++-- partner_programs/tests/test_certificates.py | 95 +++++++++++++++++++++ 3 files changed, 141 insertions(+), 9 deletions(-) diff --git a/docs/modules/program-certificates.md b/docs/modules/program-certificates.md index 2b3db324..aeac0ada 100644 --- a/docs/modules/program-certificates.md +++ b/docs/modules/program-certificates.md @@ -75,3 +75,14 @@ PDF не изменяются, миграция БД не требуется. П 6. Фильтры Все/Отправлено/Ошибки с серверным поиском и пагинацией. 7. Повтор только ошибок, тот же PDF, отправленные письма не дублируются. 8. Свой PDF в программе и ссылка в сервисном письме; защита чужих документов. + +## Ориентация А4 + +`draft.orientation` принимает `landscape` (297 × 210 мм) или `portrait` +(210 × 297 мм). Новые и старые шаблоны без этого поля используют `landscape`. +При сохранении старым клиентом без поля API сохраняет предыдущую ориентацию. +Неизвестные значения отклоняются без изменения ревизии. Ориентация входит в +снимок выдачи; preview и генерация PDF используют его физические размеры, +включая подгонку текста. Фон вписывается целиком без обрезки; вертикальные +изображения сохраняют разрешение до 2480 × 3508. Ранее сформированные PDF +не пересоздаются. Миграция базы не требуется. diff --git a/partner_programs/services/certificates.py b/partner_programs/services/certificates.py index 907c1461..ea743391 100644 --- a/partner_programs/services/certificates.py +++ b/partner_programs/services/certificates.py @@ -53,6 +53,7 @@ def default_draft(): (60, 70, 25, 8, 16), ] return { + "orientation": "landscape", "background_id": None, "signature_id": None, "director": "", @@ -84,6 +85,7 @@ def hidden_elements(draft): def normalized_draft(draft): return { **draft, + "orientation": draft.get("orientation", "landscape"), "hidden_elements": [key for key in ELEMENTS if key in hidden_elements(draft)], } @@ -193,7 +195,10 @@ def upload_asset(program, kind, upload): from PIL import ImageOps image = ImageOps.exif_transpose(image) - image.thumbnail((3508, 2480) if kind == "background" else (2000, 2000)) + limit = (2000, 2000) + if kind == "background": + limit = (2480, 3508) if image.height > image.width else (3508, 2480) + image.thumbnail(limit) buffer = io.BytesIO() image.save(buffer, format="PNG") if buffer.tell() > 20 * 1024 * 1024: @@ -229,6 +234,9 @@ class Element(serializers.Serializer): font_size = serializers.FloatField(min_value=8, max_value=72) class Draft(serializers.Serializer): + orientation = serializers.ChoiceField( + choices=["landscape", "portrait"], required=False + ) background_id = serializers.IntegerField(allow_null=True) signature_id = serializers.IntegerField(allow_null=True) director = serializers.CharField(max_length=250, allow_blank=True) @@ -251,6 +259,9 @@ class Draft(serializers.Serializer): serializer = Draft(data=draft) serializer.is_valid(raise_exception=True) data = serializer.data + if "orientation" not in data: + # Older clients must not silently rotate a portrait template when saving. + data["orientation"] = (previous or {}).get("orientation", "landscape") if "hidden_elements" not in data: # Старый клиент не должен восстанавливать удаленные новым редактором элементы. hidden = hidden_elements(previous or data) @@ -298,11 +309,18 @@ def certificate_font(bold): ) -def fit_certificate_text(value, box, bold=False): +def page_dimensions(orientation="landscape"): + return (210, 297) if orientation == "portrait" else (297, 210) + + +def fit_certificate_text(value, box, bold=False, orientation="landscape"): """Подгоняем реальные строки Mont по обеим сторонам бокса, без обрезки ФИО.""" font = certificate_font(bold) - width = max(1, box["width"] * 11.22 - 2) - height = max(1, box["height"] * 7.937 - 2) + page_width, page_height = ( + (793.7, 1122) if orientation == "portrait" else (1122, 793.7) + ) + width = max(1, box["width"] * page_width / 100 - 2) + height = max(1, box["height"] * page_height / 100 - 2) def lines_at(size): def fits(text): @@ -344,6 +362,8 @@ def render_pdf(snapshot, assets, name, program_name, number, issued_at): 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": @@ -391,7 +411,9 @@ def src(asset): ) else: value = values[key] - size, lines = fit_certificate_text(value, box, key in ("name", "title")) + size, lines = fit_certificate_text( + value, box, key in ("name", "title"), orientation + ) content = "
".join(escape(line) for line in lines) elements.append( f'
{content}
' @@ -400,13 +422,17 @@ def src(asset): """
""" +
""".replace( + "PAGE_WIDTH", str(page_width) + ).replace( + "PAGE_HEIGHT", str(page_height) + ) + "".join(elements) + "
" ) diff --git a/partner_programs/tests/test_certificates.py b/partner_programs/tests/test_certificates.py index 06ef949e..2bf000ac 100644 --- a/partner_programs/tests/test_certificates.py +++ b/partner_programs/tests/test_certificates.py @@ -168,6 +168,101 @@ def test_new_template_has_only_required_name_centered(self): for upper, lower in zip(rows, rows[1:]): self.assertLessEqual(upper["y"] + upper["height"], lower["y"]) + def test_portrait_orientation_round_trip_and_older_client_save(self): + draft = service.default_draft() + draft["orientation"] = "portrait" + response = self.client.put( + self.root, {"revision": 1, "draft": draft}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + self.client.get(self.root).data["draft"]["orientation"], "portrait" + ) + draft.pop("orientation") + draft["director"] = "Обновленный директор" + 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"]["orientation"], "portrait") + for invalid in ("square", "", None): + draft["orientation"] = 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) + self.assertEqual(self.config.draft["orientation"], "portrait") + + def test_legacy_orientation_defaults_to_landscape(self): + draft = deepcopy(self.config.draft) + draft.pop("orientation") + self.config.draft = draft + self.config.save() + self.assertEqual( + self.client.get(self.root).data["draft"]["orientation"], "landscape" + ) + self.assertEqual( + service.validate_draft(self.program, draft)["orientation"], "landscape" + ) + + def test_portrait_background_keeps_a4_print_resolution(self): + buffer = io.BytesIO() + Image.new("RGB", (2480, 3508), "white").save(buffer, format="PNG") + asset = service.upload_asset( + self.program, + "background", + SimpleUploadedFile("portrait.png", buffer.getvalue()), + ) + self.assertEqual((asset.width, asset.height), (2480, 3508)) + + def test_portrait_name_fit_uses_portrait_width_and_height(self): + box = {"width": 70, "height": 12, "font_size": 40} + value = "Александра Константиновна Добровольская" + size, lines = service.fit_certificate_text(value, box, True, "portrait") + self.assertEqual(" ".join(lines), value) + self.assertLessEqual(len(lines) * size * 1.3, box["height"] * 11.22 - 2) + for line in lines: + self.assertLessEqual( + service.certificate_font(True).getlength(line) / 1000 * size * 1.02, + box["width"] * 7.937 - 2, + ) + + @tag("native_pdf") + def test_native_pdf_uses_saved_a4_orientation_and_legacy_default(self): + from weasyprint.document import Document + + original_write = Document.write_pdf + pages = [] + + def capture(document, *args, **kwargs): + pages.append([(page.width, page.height) for page in document.pages]) + return original_write(document, *args, **kwargs) + + for orientation, expected in ( + ("portrait", (210, 297)), + ("landscape", (297, 210)), + (None, (297, 210)), + ): + with self.subTest(orientation=orientation): + draft = service.default_draft() + if orientation is None: + draft.pop("orientation") + else: + draft["orientation"] = orientation + with patch.object( + Document, "write_pdf", autospec=True, side_effect=capture + ): + pdf = service.render_pdf( + draft, {}, "Анна Крылова", "Программа", "1", timezone.now() + ) + self.assertTrue(pdf.startswith(b"%PDF")) + self.assertEqual(len(pages[-1]), 1) + width, height = pages[-1][0] + self.assertAlmostEqual(width * 25.4 / 96, expected[0], places=5) + self.assertAlmostEqual(height * 25.4 / 96, expected[1], places=5) + def test_optional_elements_can_be_hidden_independently_but_name_cannot(self): for key in service.ELEMENTS: draft = service.default_draft()