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
11 changes: 11 additions & 0 deletions docs/modules/program-certificates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
не пересоздаются. Миграция базы не требуется.
44 changes: 35 additions & 9 deletions partner_programs/services/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def default_draft():
(60, 70, 25, 8, 16),
]
return {
"orientation": "landscape",
"background_id": None,
"signature_id": None,
"director": "",
Expand Down Expand Up @@ -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)],
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 = "<br>".join(escape(line) for line in lines)
elements.append(
f'<div class="element {key}" style="{style}font-size:{size}px"><span>{content}</span></div>'
Expand All @@ -400,13 +422,17 @@ def src(asset):
"""<html><head><meta charset="utf-8"><style>
@font-face{font-family:Mont;src:url('certificate-font:regular')}
@font-face{font-family:Mont;src:url('certificate-font:bold');font-weight:600}
@page{size:297mm 210mm;margin:0}*{box-sizing:border-box}body{margin:0;font-family:Mont;color:#353535}
.sheet{position:relative;width:297mm;height:210mm;overflow:hidden;background:white}
.background{position:absolute;width:100%;height:100%;object-fit:cover}
@page{size:PAGE_WIDTHmm PAGE_HEIGHTmm;margin:0}*{box-sizing:border-box}body{margin:0;font-family:Mont;color:#353535}
.sheet{position:relative;width:PAGE_WIDTHmm;height:PAGE_HEIGHTmm;overflow:hidden;background:white}
.background{position:absolute;width:100%;height:100%;object-fit:contain}
.element{position:absolute;text-align:center;line-height:1.3;overflow:hidden;padding:1px}
.element span{position:absolute;left:1px;right:1px;top:50%;transform:translateY(-50%);white-space:nowrap}
.name,.title{font-weight:600}.signature{object-fit:contain}
</style></head><body><div class="sheet">"""
</style></head><body><div class="sheet">""".replace(
"PAGE_WIDTH", str(page_width)
).replace(
"PAGE_HEIGHT", str(page_height)
)
+ "".join(elements)
+ "</div></body></html>"
)
Expand Down
95 changes: 95 additions & 0 deletions partner_programs/tests/test_certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading