Задача: рахунок друкується, замість «Рахунок-фактура» — прямокутники або латиниця з дірками там, де мала бути «ї» і «є». Перше, що хочеться зробити — лізти в код звіту. Не туди. Odoo взагалі не малює текст у PDF — це робить wkhtmltopdf, точніше вбудований у нього WebKit, а який гліф намалювати замість якого символу вирішує вже шрифт і система, на якій усе це стоїть. У статті про QWeb-звіти (qweb-reports-132) я цю тему свідомо викинув — не знайшов підтвердження в коді Odoo, а писати «здається, це через шрифти» не хотілось. Зараз розберу по кроках, з опорою на код і на конкретну машину.
Де Odoo взагалі призначає шрифт
Дефолтний шрифт для будь-якого PDF-звіту заданий однією змінною:
// addons/web/static/src/webclient/actions/reports/report.scss
$o-default-report-font: 'Lato' !default;
...
body {
color: $o-default-report-primary-color;
word-wrap: break-word;
font-family: $o-default-report-font;
}
Це body, тобто буквально весь текст звіту, якщо шаблон не перевизначив шрифт сам. Жоден із наших друкованих документів цього не робить: я перевірив kw_invoice_rahf, kw_invoice_akt, kw_stock_ttn, kw_invoice_doc_base — жодного font-family у їхніх templates.xml. Всі спираються на web.basic_layout, а той — на 'Lato' за замовчуванням.
Куди дівається кирилиця
Ось тут цікаво. Odoo сама знає, що 'Lato' і решта вбудованих веб-шрифтів не покривають кирилицю (і не тільки її), і тримає для цього окремий CDN-фолбек:
// addons/web/static/fonts/fonts.scss
@font-face {
font-family: 'Odoo Unicode Support Noto';
src: url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.woff2') format('woff2'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.woff') format('woff'),
url('https://fonts.odoocdn.com/fonts/noto/NotoSans-#{$type}.ttf') format('truetype');
font-weight: $weight;
font-style: $style;
unicode-range: U+0400-04FF, U+0500-052F; // Cyrillic
}
Коментар до функції, яка розкладає цей фолбек по font-стеках (utils.scss), прямо каже: «Odoo defines a limited Noto font-family for a small variety of unicode characters that are not necessary defined in the user system or even defined but not properly readable». Тобто це офіційно визнана милиця, не мій здогад.
Важливо! Українські літери «ї», «є», «і», «ґ» лежать у тому самому діапазоні U+0400–04FF, що й основна кирилиця — окремого фолбеку саме під українську немає і не треба, він і так покриває.
Але ця милиця — зовнішній HTTP-запит. Я перевірив, що адреса жива й реально віддає шрифт:
$ curl -sI https://fonts.odoocdn.com/fonts/noto/NotoSans-Reg.woff2
— відповідь HTTP/2 200, content-length: 158840 (≈155 КБ). Але це перевірено з моєї мережі, не з мережі вашого Odoo-контейнера. А $o-default-report-font: 'Lato' у report.scss — це голе значення 'Lato', без фолбек-списку. Якщо в конкретному гліфі Lato немає (а в бандл-версії з addons/web/static/fonts/lato/ це старий webfont-generator-набір, не повний Lato з Google Fonts), CSS-фолбек на 'Odoo Unicode Support Noto' тут просто не спрацює — його немає в цьому конкретному списку. Рендерер мовчки падає на generic sans-serif, а це вже питання не Odoo, а fontconfig на вашій машині.
Про кодування — окремо, бо це не сюди
Класична порада з форумів — «додайте wkhtmltopdf --encoding utf-8». Перевірив у _build_wkhtmltopdf_args (ir_actions_report.py, Odoo 18 і 19) — цього прапорця там немає взагалі:
command_args = ['--disable-local-file-access']
...
command_args.extend(['--quiet'])
Кодування Odoo не передає прапорцем — воно прописане прямо в HTML, який іде на вхід wkhtmltopdf:
head_file.write(header.encode()) # utf-8 за замовчуванням у Python
і в самому шаблоні (report_templates.xml, тричі): <meta charset="utf-8"/>. Тобто якщо у вас «крякозябри» замість тексту (а не квадратики) — це не шрифт, це хтось десь у ланцюжку перекодував рядок неправильно ще до того, як HTML дійшов до wkhtmltopdf. Шрифтова проблема виглядає інакше — квадратики, «tofu», або тихо пропущені символи.
Розглянемо діагностику на живому сервері
Команди для хоста чи контейнера, де реально стоїть Odoo (не для машини, з якої ви пишете статтю — я саме тому не вигадую вивід, а перевіряю кожен пункт окремо на своїй):
# 1. Яка збірка wkhtmltopdf і чи патчений Qt
dpkg -s wkhtmltopdf | grep -E 'Version|patched'
wkhtmltopdf --version
# 2. Чи є в системі шрифти з кирилицею взагалі
fc-list :lang=uk | head
# 3. Що fontconfig підставить замість Lato для українського тексту
fc-match "Lato:lang=uk"
# 4. Чи дотягнеться контейнер до CDN-фолбека Odoo
curl -sI https://fonts.odoocdn.com/fonts/noto/NotoSans-Reg.woff2 | head -1
На своїй машині я підняв тільки перший і другий пункт по-справжньому: wkhtmltopdf --version дав wkhtmltopdf 0.12.6, dpkg -s wkhtmltopdf — Version: 0.12.6-2build2, і опис пакунка прямо каже — «It is not built against a forked version of Qt hence some options are not supported». Тобто це збірка з репозиторію Ubuntu, без патченого Qt. У Odoo 19 саме цю ситуацію ловить _wkhtml() в ir_actions_report.py — пошуком підрядка (with patched qt) у виводі --version, і якщо його нема, забороняє мерджити кілька документів в один виклик wkhtmltopdf (UserError). В Odoo 18 такого детектора немає взагалі — там лише перевірка номера версії (>=0.12.0), без розпізнавання patched Qt і без цього UserError. До шрифтів прямого стосунку це не має, але якщо на Odoo 19 ви бачите цю помилку поруч зі зламаною кирилицею — це два різні симптоми однієї причини: збірка wkhtmltopdf з дистрибутива, не офіційна статична з wkhtmltopdf.org.
Шрифтів з кирилицею на цій машині вистачає — fc-list знайшов DejaVu Sans, Liberation Sans/Serif/Mono, набір Noto Sans*. Це підтверджено не тільки за назвою: fc-query по DejaVuSans.ttf і LiberationSans-Regular.ttf показує uk у полі lang: — покриття кирилиці по cmap, а не за репутацією шрифту. Але яким саме шрифтом fontconfig підставить відсутню кирилицю в Lato — вирішує вже конкретний fontconfig-конфіг, а не сам факт наявності шрифту в системі. На цій машині fc-match "Lato:lang=uk" підставив не DejaVu і не Liberation, а Noto Sans — він теж встановлений і, вочевидь, має вищий пріоритет у поточній конфігурації. І тут виявилось цікаве: /etc/fonts/conf.d/ фактично порожній, там лежить лише README, самі правила (60-latin.conf з дефолтним sans-serif → DejaVu Sans) є в /usr/share/fontconfig/conf.avail/, але не увімкнені симлінком. Це не універсальний факт про всі системи — це те, що я реально побачив тут, і це ілюструє головну думку: набір шрифтів у файлах ще не означає, що fontconfig ними скористається, і навіть яким саме шрифтом воно скористається — питання конкретного хоста. Перевіряйте fc-match на своєму, не вірте на слово що «шрифти ж є».
Docker і LXC — де це найчастіше стріляє
Офіційний Odoo-образ і мінімальні базові образи (debian-slim, урізані LXC-темплейти) шрифтів практично не мають — це загальновідома поведінка мінімальних образів, не щось специфічне для Odoo, і я тут нічого не заміряв на прод-контейнері KitWorks, тому не видаю це за наш замір. Але наслідок з коду вище прямий: якщо в образі немає жодного шрифту з кирилицею, а fonts.odoocdn.com недоступний (закритий egress, corporate proxy, повністю offline-контур) — рендерити кирилицю просто нічим. wkhtmltopdf не впаде з помилкою, він видасть PDF з порожніми прямокутниками замість літер, і в логах Odoo про це не буде ні слова.
Неправильно — сподіватись, що CDN-фолбек Odoo сам вирішить проблему в закритому контурі, і панічно шукати неіснуючий прапорець кодування.
Правильно — поставити в образ конкретний шрифт з підтвердженою кирилицею (fonts-dejavu-core або fonts-liberation2 з apt) і прописати його явно в CSS звіту, не покладаючись на дефолтний 'Lato' і на зовнішній HTTP-запит під час генерації PDF.
Останній крок — явно задати шрифт
Ну і найпростіше, що реально працює: в <style>-блок конкретного шаблону (у наших kw_invoice_rahf і kw_stock_ttn він вже є, просто без цього правила) додається:
<style>
body {
font-family: 'DejaVu Sans', 'Liberation Sans', sans-serif !important;
}
</style>
!important тут не проти конкретного правила з report.scss (body { font-family: $o-default-report-font; }) — за структурою документа воно й так не мало б виграти: обидва правила мають однакову специфічність (голий селектор body), а власний <style> шаблону опиняється в <body>, куди Odoo вставляє вміст документа через <t t-out="body"/> (report_templates.xml:271) — структурно пізніше за <head>, де рендериться бандл web.report_assets_common з тим самим report.scss. За правилом «останнє за порядком у документі перемагає» інлайн-стиль мав би виграти і без !important. Але покладатись на порядок рендерингу й точну специфічність селекторів у бандлі, який Odoo будь-коли може змінити, — погана ідея: !important тут не рятує від існуючої проблеми, а страхує від майбутньої, якщо в бандлі раптом з'явиться більш специфічний селектор. Після цього генеруєте PDF і перевіряєте візуально: якщо замість квадратиків — українські літери, значить у контейнері реально є вказаний шрифт і fontconfig його бачить.
Тема за матеріалом власного дослідження, без зовнішнього джерела. Код і поведінку перевірено на Odoo 18.0 і 19.0.