fix(pdf): le texte persan s'affichait en points d'interrogation
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s

Cause racine (reproduite et verrouillee par test) : l'insertion PDF
demandait la police integree Helvetica tout en fournissant un fichier
de police — PyMuPDF ignore alors le fichier, et Helvetica n'a aucun
glyphe arabe : chaque lettre persane devenait un '?', et ces '?'
debordaient les boites d'origine (les nombreux « [translation
overflow] »). Defaut present dans toutes les versions deployees.

- le nom de police est desormais Personnalise quand un fichier de
  police est fourni : le fichier est reellement integre
- reproduction complete avant/apres : 0 '?', 0 debordement, persan
  rendu avec la police resolue
- test de non-regression : la page traduite ne contient PAS Helvetica
  et le texte extrait n'a aucun '?'
- bouton « Reconstruire » de la relecture : une infobulle explique
  desormais pourquoi il est grise (aucun segment approuve ni modifie),
  dans les 13 langues
This commit is contained in:
2026-09-01 21:32:40 +02:00
parent 5f3d57b4f7
commit f93b13b53f
16 changed files with 76 additions and 22 deletions

View File

@@ -346,7 +346,16 @@ export default function ReviewPage() {
if (f) importXliff(f); if (f) importXliff(f);
}} }}
/> />
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}> <Button
size="sm"
onClick={rebuild}
disabled={isRebuilding || counts.approved + counts.edited === 0}
title={
counts.approved + counts.edited === 0
? t('reviews.rebuildDisabledHint')
: undefined
}
>
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />} {isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
{t('reviews.rebuild')} {t('reviews.rebuild')}
</Button> </Button>

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "لا توجد أجزاء لمراجعتها في هذه المهمة", "reviews.empty.title": "لا توجد أجزاء لمراجعتها في هذه المهمة",
"reviews.empty.desc": "لا تحتوي هذه الترجمة على أجزاء محفوظة: إما أنها فشلت قبل ترجمة النص، أو لم يعد الخادم يحتفظ بها. يمكنك إزالتها من قائمة الترجمات الأخيرة." "reviews.empty.desc": "لا تحتوي هذه الترجمة على أجزاء محفوظة: إما أنها فشلت قبل ترجمة النص، أو لم يعد الخادم يحتفظ بها. يمكنك إزالتها من قائمة الترجمات الأخيرة.",
"reviews.rebuildDisabledHint": "وافق على جزء واحد على الأقل أو عدّله أولاً — تعيد إعادة البناء تطبيق مراجعتك."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Für diesen Auftrag gibt es nichts zu prüfen", "reviews.empty.title": "Für diesen Auftrag gibt es nichts zu prüfen",
"reviews.empty.desc": "Diese Übersetzung enthält keine gespeicherten Segmente: Entweder ist sie vor der Textübersetzung fehlgeschlagen, oder der Server bewahrt sie nicht mehr auf. Sie können sie aus der Liste der letzten Übersetzungen entfernen." "reviews.empty.desc": "Diese Übersetzung enthält keine gespeicherten Segmente: Entweder ist sie vor der Textübersetzung fehlgeschlagen, oder der Server bewahrt sie nicht mehr auf. Sie können sie aus der Liste der letzten Übersetzungen entfernen.",
"reviews.rebuildDisabledHint": "Genehmigen oder bearbeiten Sie zuerst mindestens ein Segment — der Neuaufbau übernimmt Ihre Prüfung."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nothing to review for this job", "reviews.empty.title": "Nothing to review for this job",
"reviews.empty.desc": "This translation has no stored segments: either it failed before any text was translated, or it is no longer retained by the server. You can remove it from your recent translations list." "reviews.empty.desc": "This translation has no stored segments: either it failed before any text was translated, or it is no longer retained by the server. You can remove it from your recent translations list.",
"reviews.rebuildDisabledHint": "Approve or edit at least one segment first — the rebuild applies your reviewed wording."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nada que revisar en este trabajo", "reviews.empty.title": "Nada que revisar en este trabajo",
"reviews.empty.desc": "Esta traducción no tiene segmentos guardados: o falló antes de traducir el texto, o el servidor ya no la conserva. Puede quitarla de la lista de traducciones recientes." "reviews.empty.desc": "Esta traducción no tiene segmentos guardados: o falló antes de traducir el texto, o el servidor ya no la conserva. Puede quitarla de la lista de traducciones recientes.",
"reviews.rebuildDisabledHint": "Primero aprueba o edita al menos un segmento: la reconstrucción aplica tu revisión."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "بخشی برای بازبینی در این کار وجود ندارد", "reviews.empty.title": "بخشی برای بازبینی در این کار وجود ندارد",
"reviews.empty.desc": "این ترجمه بخش ذخیره‌شده‌ای ندارد: یا پیش از ترجمهٔ متن ناموفق بوده، یا سرور دیگر آن را نگه نمی‌دارد. می‌توانید آن را از فهرست ترجمه‌های اخیر حذف کنید." "reviews.empty.desc": "این ترجمه بخش ذخیره‌شده‌ای ندارد: یا پیش از ترجمهٔ متن ناموفق بوده، یا سرور دیگر آن را نگه نمی‌دارد. می‌توانید آن را از فهرست ترجمه‌های اخیر حذف کنید.",
"reviews.rebuildDisabledHint": "ابتدا حداقل یک بخش را تأیید یا ویرایش کنید — بازسازی، بازبینی شما را اعمال می‌کند."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approbation ({current}/{total})…", "reviews.approvingProgress": "Approbation ({current}/{total})…",
"reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre.", "reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre.",
"reviews.empty.title": "Rien à relire pour ce travail", "reviews.empty.title": "Rien à relire pour ce travail",
"reviews.empty.desc": "Cette traduction n'a aucun segment enregistré : soit elle a échoué avant la traduction du texte, soit elle n'est plus conservée par le serveur. Vous pouvez la retirer de la liste des traductions récentes." "reviews.empty.desc": "Cette traduction n'a aucun segment enregistré : soit elle a échoué avant la traduction du texte, soit elle n'est plus conservée par le serveur. Vous pouvez la retirer de la liste des traductions récentes.",
"reviews.rebuildDisabledHint": "Approuvez ou modifiez d'abord au moins un segment — la reconstruction applique votre relecture."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Niente da rivedere per questo lavoro", "reviews.empty.title": "Niente da rivedere per questo lavoro",
"reviews.empty.desc": "Questa traduzione non ha segmenti salvati: o è fallita prima di tradurre il testo, o il server non la conserva più. Puoi rimuoverla dall'elenco delle traduzioni recenti." "reviews.empty.desc": "Questa traduzione non ha segmenti salvati: o è fallita prima di tradurre il testo, o il server non la conserva più. Puoi rimuoverla dall'elenco delle traduzioni recenti.",
"reviews.rebuildDisabledHint": "Prima approva o modifica almeno un segmento: la ricostruzione applica la tua revisione."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "このジョブに校正するセグメントはありません", "reviews.empty.title": "このジョブに校正するセグメントはありません",
"reviews.empty.desc": "この翻訳には保存されたセグメントがありません。テキストの翻訳前に失敗したか、サーバーに保持されていません。最近の翻訳リストから削除できます。" "reviews.empty.desc": "この翻訳には保存されたセグメントがありません。テキストの翻訳前に失敗したか、サーバーに保持されていません。最近の翻訳リストから削除できます。",
"reviews.rebuildDisabledHint": "まず少なくとも1つのセグメントを承認または編集してください。再構築には校正内容が必要です。"
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "이 작업에 검토할 세그먼트가 없습니다", "reviews.empty.title": "이 작업에 검토할 세그먼트가 없습니다",
"reviews.empty.desc": "이 번역에는 저장된 세그먼트가 없습니다. 텍스트 번역 전에 실패했거나 서버에 더 이상 보관되지 않습니다. 최근 번역 목록에서 제거할 수 있습니다." "reviews.empty.desc": "이 번역에는 저장된 세그먼트가 없습니다. 텍스트 번역 전에 실패했거나 서버에 더 이상 보관되지 않습니다. 최근 번역 목록에서 제거할 수 있습니다.",
"reviews.rebuildDisabledHint": "먼저 세그먼트를 하나 이상 승인하거나 수정하세요 — 재구성에는 검토 내용이 필요합니다."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Niets te controleren voor deze opdracht", "reviews.empty.title": "Niets te controleren voor deze opdracht",
"reviews.empty.desc": "Deze vertaling heeft geen opgeslagen segmenten: of hij mislukte voordat er tekst werd vertaald, of de server bewaart hem niet meer. U kunt hem verwijderen uit de lijst met recente vertalingen." "reviews.empty.desc": "Deze vertaling heeft geen opgeslagen segmenten: of hij mislukte voordat er tekst werd vertaald, of de server bewaart hem niet meer. U kunt hem verwijderen uit de lijst met recente vertalingen.",
"reviews.rebuildDisabledHint": "Keur eerst minstens één segment goed of bewerk het — de herbouw past uw controle toe."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nada a revisar neste trabalho", "reviews.empty.title": "Nada a revisar neste trabalho",
"reviews.empty.desc": "Esta tradução não tem segmentos armazenados: ou falhou antes de qualquer texto ser traduzido, ou o servidor já não a conserva. Pode removê-la da lista de traduções recentes." "reviews.empty.desc": "Esta tradução não tem segmentos armazenados: ou falhou antes de qualquer texto ser traduzido, ou o servidor já não a conserva. Pode removê-la da lista de traduções recentes.",
"reviews.rebuildDisabledHint": "Aprove ou edite primeiro pelo menos um segmento — a reconstrução aplica a sua revisão."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Для этого задания нет сегментов для проверки", "reviews.empty.title": "Для этого задания нет сегментов для проверки",
"reviews.empty.desc": "У этого перевода нет сохранённых сегментов: либо он завершился ошибкой до перевода текста, либо сервер его больше не хранит. Вы можете удалить его из списка недавних переводов." "reviews.empty.desc": "У этого перевода нет сохранённых сегментов: либо он завершился ошибкой до перевода текста, либо сервер его больше не хранит. Вы можете удалить его из списка недавних переводов.",
"reviews.rebuildDisabledHint": "Сначала утвердите или измените хотя бы один сегмент — пересборка применит вашу правку."
} }

View File

@@ -49,5 +49,6 @@
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter.", "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "此任务没有可校对的段落", "reviews.empty.title": "此任务没有可校对的段落",
"reviews.empty.desc": "此翻译没有已存储的段落:可能是在翻译文本之前失败,或服务器已不再保留。您可以将其从最近翻译列表中移除。" "reviews.empty.desc": "此翻译没有已存储的段落:可能是在翻译文本之前失败,或服务器已不再保留。您可以将其从最近翻译列表中移除。",
"reviews.rebuildDisabledHint": "请先批准或修改至少一个段落——重建会应用您的校对内容。"
} }

View File

@@ -1151,3 +1151,35 @@ class TestLtrNormalization:
assert out_para is not None assert out_para is not None
out_pPr = out_para._p.find(f"{{{A_NS}}}pPr") out_pPr = out_para._p.find(f"{{{A_NS}}}pPr")
assert out_pPr is None or out_pPr.get("rtl") != "1" assert out_pPr is None or out_pPr.get("rtl") != "1"
@pytest.mark.skipif(_host_rtl_font() is None, reason="no Arabic-capable font on this host")
def test_layout_mode_uses_the_resolved_font_file(self, tmp_path):
"""Regression guard: PyMuPDF ignores the fontfile when fontname is a
base-14 name ("helv") — every Persian/Arabic glyph used to render as
'?' in Helvetica, with mass "[translation overflow]" markers."""
import fitz
src = tmp_path / "src.pdf"
doc = fitz.open()
page = doc.new_page()
page.insert_textbox(
fitz.Rect(72, 72, 400, 96), "Bonjour le monde",
fontsize=11, fontname="helv",
)
doc.save(str(src))
doc.close()
translator = PDFTranslator(
provider=MockTranslationProvider({"Bonjour le monde": "سلام دنیا"})
)
out = tmp_path / "out.pdf"
translator.translate_file(src, out, "fa")
doc2 = fitz.open(str(out))
fonts = [f[3] for f in doc2[0].get_fonts()]
text = doc2[0].get_text()
doc2.close()
assert "Helvetica" not in fonts, fonts
assert "?" not in text
assert any(0xFB50 <= ord(c) <= 0xFEFF for c in text)

View File

@@ -1065,15 +1065,15 @@ class PDFTranslator:
color = self._int_to_rgb(block["color"]) color = self._int_to_rgb(block["color"])
align = fitz.TEXT_ALIGN_RIGHT if rtl_target else fitz.TEXT_ALIGN_LEFT align = fitz.TEXT_ALIGN_RIGHT if rtl_target else fitz.TEXT_ALIGN_LEFT
# PyMuPDF bug: fontname=None raises AttributeError. Default to 'helv'. # PyMuPDF: fontname must be a CUSTOM name when a fontfile is given —
# If a custom font file is available, use it via fontfile (fontname ignored). # a base-14 name ("helv"…) makes the fontfile SILENTLY IGNORED and
# Base-14 font variant honouring the block's bold/italic flags — # every non-Latin glyph renders as "?". This is exactly how Persian
# previously every block (headings included) rendered in regular. # PDFs used to come out full of question marks.
# When a custom Unicode fontfile is used we keep it: we have no # Without a font file, pick the base-14 variant honouring the
# bold/italic variant of that file, and glyph coverage matters more # block's bold/italic flags. When a custom Unicode fontfile is used
# than weight. # we keep one regular face: glyph coverage matters more than weight.
if font_path: if font_path:
fontname = "helv" fontname = "wordly-unicode"
fontfile = font_path fontfile = font_path
else: else:
fontname = ( fontname = (