Polska odmiana rzeczowników w Hugo

tech • 1281 słów • 7 minut czytania

Po ogarnięciu polskich nazw miesięcy w Hugo przyszła pora na odmiany rzeczowników przy liczebnikach i odpowiednią formę liczby mnogiej. Poza wsparciem tego w tłumaczeniach, Hugo nie posiada żadnych innych pomocnych mechanizmów. Żeby ogarnąć poprawną formę i odmianę dla naszego rodzimego języku to trzeba trochę pokombinować…

Na początku miałem problem z przypomnieniem sobie fachowej nazwę tej reguły, aby o to zapytać wyszukiwarkę. Wiedziałem tylko, że kiedyś coś takiego widziałem przy tłumaczeniach z wykorzystaniem gettetxt-a i plików PO. W końcu trafiłem na poszukiwane informacje o “składni liczebników” na stronie Rady Języka Polskiego:

[…] liczebniki 2, 3, 4 oraz liczebniki, których ostatnim członem jest 2, 3, 4 (czyli np. 22, 23, 24, 152, 153, 154 itd.) łączą się z rzeczownikami w mianowniku liczby mnogiej, np. trzy koty, dwadzieścia cztery koty, sto pięćdziesiąt dwa koty. Liczebniki od 5 do 21 i te, które są zakończone na 5-9 (np. 25, 36, 27, 58, 69), łączą się z rzeczownikiem w dopełniaczu liczby mnogiej, np. pięć kotów, siedemnaście kotów, sto siedemdziesiąt siedem kotów […]

Niechciałem samemu przekładać tego na kod, aby nie popełnić czasem jakiegoś głupiego błędu lub nie pominąć szczególnego przypadku. Posłużyłem się znów wspomnianym gettextem. Tam w regułach Plural-Forms można znaleźć taki oto kod dla języka polskiego:

Plural-Forms: nplurals=3; \
	plural=n==1 ? 0 : \
		n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;

Tłumacząc ten warunek na bardziej czytelny zapis funkcyjny w C++ otrzymujemy:

enum PluralForms { One, Few, Many };

PluralForms GetPluralForm(int n) {
	if (n == 1)
		return One;
	if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20))
		return Few;
	return Many;
}

W takiej formie postanowiłem zasymulować to jako partial-function w szablonach Hugo.

Logikę udało mi się w miarę łatwo przetransformować pomimo ograniczonych zasobów szablonowych Go. Zawartość pliku partials/func/GetPluralForm.html przedstawia się nastepująco:

{{/*
	Get the correct plural form according to the rules of Polish language
	usage: $plural := partial "func/GetPluralForm" <num>
	return 0 /one/, 1 /few/ or 2 /many/
*/}}

{{ $ret := 2 }}
{{ $val := . }}

{{ if eq $val 1 }}
	{{ $ret = 0 }}		{{/* one */}}
{{ else }}
	{{/* val % 10 >= 2 && val % 10 <= 4 && (val % 100 < 10 || val % 100 >= 20) */}}
	{{ $mod10 := mod $val 10 }}
	{{ $mod100 := mod $val 100 }}
	{{ if and (and (ge $mod10 2) (le $mod10 4)) (or (lt $mod100 10) (ge $mod100 20)) }}
		{{ $ret = 1 }}	{{/* few */}}
	{{ end }}
	{{/* else: 2 */}}	{{/* many */}}
{{ end }}

{{ return $ret }}

Taki szablon cząstkowy zwraca identyfikator poprawnej formy odmiany dla podanej liczby - wartość 0 (one), 1 (few) i 2 (many). Wykorzystać to można w szablonie do warunkowego renderowania zawartości strony:

{{- $plural := partial "func/GetPluralForm" .ReadingTime }}
{{- if eq $plural 0 }}		{{/* one */}}
	{{ .ReadingTime }} minuta
{{- else if eq $plural 1 }}	{{/* few */}}
	{{ .ReadingTime }} minuty
{{- else }}					{{/* many */}}
	{{ .ReadingTime }} minut
{{- end }}

Powyższy kod może nie jest dobrym przykładem, ale w rozbudowanych szablonach, gdy dla różnych form przewidujemy większe zmiany w designie taka mozliwość może być przydatna. Chociaż w większości przypadków użycie będzie ograniczone do wyboru odpowiedniej wersji stringa. Wtedy można zapisać to w nieco mniej “rozlazłej” postaci:

{{- $plurals := slice "minuta" "minuty" "minut" }}
{{- $plural := partial "func/GetPluralForm" .ReadingTime }}
{{ .ReadingTime }} {{ index $plurals $plural }}

Całość można jeszcze bardziej skompresować definiując niektóre elementy inline w miejscu wywołania… ale to i tak nie spełnia moich oczekiwań. Do wyboru będę miał dwa warianty - długa i mało czytelna (na pierwszy rzut oka) linijka kodu, albo czytelny kod z dodatkowymi zmiennymi w “szablonowej” przestrzeni nazw. A ja chciałbym uniknąć obu tych opcji.

Chcę mieć krótki i zwięzły kawałek kodu w szablonie, bo w końcu będę “odmieniał” tylko kilka słówek. To wymaga napisania sobie dodatkowego helper-a. Przejmie on na siebie całą logikę i formatowanie stringa, aby w szablonie, w miejscu renderowania można było ograniczyć się do przekazania tylko niezbędnych parametrów:

{{ partial "func/PluralFormat" (slice .WordCount "%d słowo" "%d słowa" "%d słów") }}

Implementacja takiej funkcji (partial-a) jest prosta. Mój plik partials/func/PluralFormat.html:

{{/*
	Return formatted string with the correct plural form...
	usage: partial "func/PluralFormat" <slice num "one" "few" "many">
*/}}

{{ $num := index . 0 }}
{{ $plr := partial "func/GetPluralForm" $num }}
{{ $fmt := index . (add $plr 1) }}

{{ return printf $fmt $num }}

Wynikowy string formatowany jest za pomocą funkcji printf, zatem przekazywane literały muszą zawierać jedno pole formatujące liczbę - na przykład %d. Wynika to ze sposobu obsługi błędów w funkcjach formatujących IO z pakietu fmt języka Go, z których korzysta Hugo. W razie błędu do wynikowego stringa doklejone zostaną dodatkowe informacje.

Dotyczy to także niezgodności liczby przekazanych argumentów ze specyfikowanymi polami w ciągu formatującym. Nie zostaną zignorowane nadmiarowe argumenty i w przypadku takiego wywołania:

fmt.Printf("Za dużo słów...", num)		// num := 123

zostanie wyplute coś w stylu:

Za dużo słów...%!(EXTRA int=123)

Ta “drobna” niedogodność mi nie przeszkadza, bo aktualnie i tak zawsze wyświetlam liczbę wraz z poprawnie odmienionym rzeczownikiem. Ale gdyby ktoś miał inne potrzeby to należy ten przypadek jakoś oprogramować lub po prostu użyć warunkowego renderowania ;)

Teraz zasadnicze pytanie: po co się tak meczę zamiast skorzystać z i18n? Na to odpowiedź można po części znaleźć w poprzednim wpisie. Ta strona zawsze bedzie prowadzona w ojczystym języku, więc użycie jakiegokolwiek modułu do lokalizacji, aby dokonać odmiany kilku słówek w szablonie jest moim zdaniem wielkim overkill-em.

Sprawa przedstawia się zgoła inaczej, gdy ktoś prowadzi wielojęzyczną stronę lub bloga, czy to z tłumaczonym kontentem, czy tylko wielojęzykowym interfejsem, to oczywiste dla niego powinno być wykorzystanie lokalizacji.

Hugo używa go-i18n do tłumaczeń stringów, a moduł ten radzi sobie bardzo dobrze z liczbą mnogą w ponad 200 językach. Mając w polskim pliku tłumaczeń (i18n/pl.yaml) zapisane odpowiednie formy dla danego identyfikatora:

- id: words
  translation:  
    one:  "{{ .Count }} słowo"
    few:  "{{ .Count }} słowa"
    many: "{{ .Count }} słów"

w szablonie wystarczy skorzystać z funkcji i18n:

{{ i18n "words" .WordCount }}

aby w rezultacie otrzymać to samo co robią moje genialne szablony cząstkowe…

[dodano 2020-07-31 17:00]

Znalazłem mały błąd w obsłudze partial templates przy zwracaniu wartości w Hugo, który występuje przy przekazywaniu wartości 0 jako argumentu szablonu. Szczegóły w opisie zgłoszonego błędu (#7528).

Problem ten psuje przedstawione tutaj “funkcje”. Dla przypadków z liczebnikiem 0 zwracana wartość będzie nil zamiast spodziewanej 2 (dla liczby 0 poprawna forma to ta sama co dla “wiele”). Tymczasowo, do czasu naprawy błędu, można zastosować mały workaround na zwracaną wartość z GetPluralForm:

{{ $plr := partial "func/GetPluralForm" $num }}

{{/* workaround for bug #7528 */}}
{{ $plr = cond (eq $plr nil) 2 $plr }}

Mam jednak nadzieję, że szybko pojawi się fix, bo to nieco psuje moją wizję krótkiego i zwięzłego kodu ;)

[dodano 2020-09-20 18:00]

W nowej wersji Hugo (0.75) dodano zwracanie ERROR-a w stylu “error calling partial: partials that returns a value needs a non-zero argument” w sytuacji przekazania 0 do partial templates. Zatem, aby budowanie strony przebiegło pomyślnie należy zastosować zaktualizowany workaround:

{{/* workaround for bug #7528 */}}
{{ $tmp := cond (eq $num 0) 5 $num }}

{{ $plr := partial "func/GetPluralForm" $tmp }}

Po prostu podmieniamy wartość liczby z 0 na 5, aby nie przekazywać zera do funkcji. Finalnie i tak otrzymamy poprawny wynik, bo obie te liczby używają formy “wiele”.

[dodano 2021-12-30 19:30]

Wydano Hugo w wersji 0.91, w której wreszcie naprawiono błąd #7528 i nie trzeba już stosować hacka, bo przekazywane argumenty poprawnie są “interpretowane”. W kodzie bloga usunąłem już obejście tego problemu.

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/