fix(sign-pdf): surface PDF load failures instead of a blank canvas (#545)

A genuine pdf.js load failure (corrupt or password-protected file) was swallowed by the same catch that silences teardown rejections, leaving a blank canvas that looks like it is still loading. The two cases are now distinguished by the load effect's cancelled flag, and a real failure renders a clear message pointing at Unlock PDF for encrypted files. New loadFailed string in all 21 locales.

Item 4 of #478.
This commit is contained in:
SnapOtter
2026-07-17 00:36:48 +08:00
committed by GitHub
parent c8629c9d22
commit 1f4878ac4d
24 changed files with 88 additions and 3 deletions
+17 -3
View File
@@ -68,6 +68,7 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
// the first page, since setPage(0) is a no-op when page is already 0 (and
// pageCount never changes for single-page PDFs).
const [docReady, setDocReady] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
// Load the document once per file. A replaced file starts clean: drop the
// previous document's placements and stage so they don't carry over.
@@ -75,6 +76,7 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
useEffect(() => {
let cancelled = false;
setDocReady(false);
setLoadFailed(false);
for (const placed of placementsRef.current) placed.node.destroy();
placementsRef.current = [];
pageMetaRef.current.clear();
@@ -92,9 +94,11 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
setPage(0);
setDocReady(true);
})().catch(() => {
// loadingTask.destroy() in the cleanup rejects the in-flight promise;
// swallowing it is the fix for Sentry NODE-1N. A genuine load failure
// leaves docReady false, which the existing UI already handles.
// loadingTask.destroy() in the cleanup rejects the in-flight promise
// (Sentry NODE-1N); `cancelled` is already true then, so stay silent. A
// genuine load failure (corrupt or password-protected file) must surface
// instead of leaving a blank canvas that looks like it is still loading.
if (!cancelled) setLoadFailed(true);
});
return () => {
cancelled = true;
@@ -324,6 +328,16 @@ export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
[page, size],
);
if (loadFailed) {
return (
<div className="flex flex-1 items-center justify-center p-8">
<p className="max-w-md text-center text-sm text-destructive">
{t.toolSettings["sign-pdf"].loadFailed}
</p>
</div>
);
}
return (
<div className="flex flex-1 flex-col items-center gap-3 p-4">
<div className="relative" style={{ width: size.w, height: size.h }}>
+2
View File
@@ -2796,6 +2796,8 @@ export const ar: TranslationKeys = {
signingPercent: "جارٍ التوقيع… {percent}%",
downloadSigned: "تنزيل ملف PDF الموقَّع",
addFirst: "أضف توقيعًا واحدًا على الأقل قبل التنزيل.",
loadFailed:
"تعذّر عرض ملف PDF هذا. قد يكون الملف تالفًا أو محميًا بكلمة مرور؛ إذا كان مشفّرًا، فأزل كلمة المرور أولًا باستخدام أداة فتح قفل PDF.",
pad: {
title: "إنشاء توقيع",
draw: "رسم",
+2
View File
@@ -2821,6 +2821,8 @@ export const de: TranslationKeys = {
signingPercent: "Wird unterschrieben… {percent}%",
downloadSigned: "Unterschriebenes PDF herunterladen",
addFirst: "Fügen Sie vor dem Herunterladen mindestens eine Unterschrift hinzu.",
loadFailed:
"Dieses PDF konnte nicht angezeigt werden. Die Datei ist möglicherweise beschädigt oder passwortgeschützt; falls sie verschlüsselt ist, entfernen Sie das Passwort zuerst mit dem Werkzeug „PDF entsperren“.",
pad: {
title: "Unterschrift erstellen",
draw: "Zeichnen",
+2
View File
@@ -2761,6 +2761,8 @@ export const en = {
signingPercent: "Signing… {percent}%",
downloadSigned: "Download signed PDF",
addFirst: "Add at least one signature before downloading.",
loadFailed:
"Couldn't display this PDF. The file may be corrupted or password-protected; if it's encrypted, remove the password first with the Unlock PDF tool.",
pad: {
title: "Create signature",
draw: "Draw",
+2
View File
@@ -2801,6 +2801,8 @@ export const es: TranslationKeys = {
signingPercent: "Firmando… {percent}%",
downloadSigned: "Descargar PDF firmado",
addFirst: "Añade al menos una firma antes de descargar.",
loadFailed:
"No se pudo mostrar este PDF. El archivo puede estar dañado o protegido con contraseña; si está cifrado, quita primero la contraseña con la herramienta Desbloquear PDF.",
pad: {
title: "Crear firma",
draw: "Dibujar",
+2
View File
@@ -2827,6 +2827,8 @@ export const fr: TranslationKeys = {
signingPercent: "Signature en cours… {percent}%",
downloadSigned: "Télécharger le PDF signé",
addFirst: "Ajoutez au moins une signature avant de télécharger.",
loadFailed:
"Impossible d'afficher ce PDF. Le fichier est peut-être corrompu ou protégé par mot de passe ; s'il est chiffré, retirez d'abord le mot de passe avec l'outil Déverrouiller le PDF.",
pad: {
title: "Créer une signature",
draw: "Dessiner",
+2
View File
@@ -2626,6 +2626,8 @@ export const hi: TranslationKeys = {
signingPercent: "हस्ताक्षर हो रहे हैं… {percent}%",
downloadSigned: "हस्ताक्षरित PDF डाउनलोड करें",
addFirst: "डाउनलोड करने से पहले कम से कम एक हस्ताक्षर जोड़ें।",
loadFailed:
"यह PDF प्रदर्शित नहीं हो सका। फ़ाइल दूषित या पासवर्ड से सुरक्षित हो सकती है; यदि यह एन्क्रिप्टेड है, तो पहले PDF अनलॉक टूल से पासवर्ड हटाएँ।",
pad: {
title: "हस्ताक्षर बनाएं",
draw: "बनाएं",
+2
View File
@@ -2810,6 +2810,8 @@ export const id: TranslationKeys = {
signingPercent: "Menandatangani… {percent}%",
downloadSigned: "Unduh PDF yang ditandatangani",
addFirst: "Tambahkan setidaknya satu tanda tangan sebelum mengunduh.",
loadFailed:
"Tidak dapat menampilkan PDF ini. File mungkin rusak atau dilindungi kata sandi; jika terenkripsi, hapus kata sandinya terlebih dahulu dengan alat Buka Kunci PDF.",
pad: {
title: "Buat tanda tangan",
draw: "Gambar",
+2
View File
@@ -2815,6 +2815,8 @@ export const it: TranslationKeys = {
signingPercent: "Firma in corso… {percent}%",
downloadSigned: "Scarica il PDF firmato",
addFirst: "Aggiungi almeno una firma prima di scaricare.",
loadFailed:
"Impossibile visualizzare questo PDF. Il file potrebbe essere danneggiato o protetto da password; se è crittografato, rimuovi prima la password con lo strumento Sblocca PDF.",
pad: {
title: "Crea firma",
draw: "Disegna",
+2
View File
@@ -2769,6 +2769,8 @@ export const ja: TranslationKeys = {
signingPercent: "署名中… {percent}%",
downloadSigned: "署名済み PDF をダウンロード",
addFirst: "ダウンロードする前に署名を 1 つ以上追加してください。",
loadFailed:
"このPDFを表示できませんでした。ファイルが破損しているか、パスワードで保護されている可能性があります。暗号化されている場合は、先にPDFロック解除ツールでパスワードを解除してください。",
pad: {
title: "署名を作成",
draw: "手書き",
+2
View File
@@ -2749,6 +2749,8 @@ export const ko: TranslationKeys = {
signingPercent: "서명 중… {percent}%",
downloadSigned: "서명된 PDF 다운로드",
addFirst: "다운로드하기 전에 서명을 하나 이상 추가하세요.",
loadFailed:
"이 PDF를 표시할 수 없습니다. 파일이 손상되었거나 비밀번호로 보호되어 있을 수 있습니다. 암호화된 경우 먼저 PDF 잠금 해제 도구로 비밀번호를 제거하세요.",
pad: {
title: "서명 만들기",
draw: "그리기",
+2
View File
@@ -2816,6 +2816,8 @@ export const nl: TranslationKeys = {
signingPercent: "Bezig met ondertekenen… {percent}%",
downloadSigned: "Ondertekende PDF downloaden",
addFirst: "Voeg ten minste één handtekening toe voordat je downloadt.",
loadFailed:
"Deze PDF kan niet worden weergegeven. Het bestand is mogelijk beschadigd of met een wachtwoord beveiligd; als het versleuteld is, verwijder dan eerst het wachtwoord met de tool PDF ontgrendelen.",
pad: {
title: "Handtekening maken",
draw: "Tekenen",
+2
View File
@@ -2814,6 +2814,8 @@ export const pl: TranslationKeys = {
signingPercent: "Podpisywanie… {percent}%",
downloadSigned: "Pobierz podpisany PDF",
addFirst: "Dodaj co najmniej jeden podpis przed pobraniem.",
loadFailed:
"Nie udało się wyświetlić tego pliku PDF. Plik może być uszkodzony lub chroniony hasłem; jeśli jest zaszyfrowany, najpierw usuń hasło narzędziem Odblokuj PDF.",
pad: {
title: "Utwórz podpis",
draw: "Rysuj",
+2
View File
@@ -2812,6 +2812,8 @@ export const ptBR: TranslationKeys = {
signingPercent: "Assinando… {percent}%",
downloadSigned: "Baixar PDF assinado",
addFirst: "Adicione pelo menos uma assinatura antes de baixar.",
loadFailed:
"Não foi possível exibir este PDF. O arquivo pode estar corrompido ou protegido por senha; se estiver criptografado, remova a senha primeiro com a ferramenta Desbloquear PDF.",
pad: {
title: "Criar assinatura",
draw: "Desenhar",
+2
View File
@@ -2812,6 +2812,8 @@ export const ru: TranslationKeys = {
signingPercent: "Подписание… {percent}%",
downloadSigned: "Скачать подписанный PDF",
addFirst: "Добавьте хотя бы одну подпись перед скачиванием.",
loadFailed:
"Не удалось отобразить этот PDF. Файл может быть повреждён или защищён паролем; если он зашифрован, сначала удалите пароль с помощью инструмента «Разблокировать PDF».",
pad: {
title: "Создать подпись",
draw: "Нарисовать",
+2
View File
@@ -2809,6 +2809,8 @@ export const sv: TranslationKeys = {
signingPercent: "Signerar… {percent}%",
downloadSigned: "Ladda ner signerad PDF",
addFirst: "Lägg till minst en signatur innan du laddar ner.",
loadFailed:
"Det gick inte att visa denna PDF. Filen kan vara skadad eller lösenordsskyddad; om den är krypterad, ta först bort lösenordet med verktyget Lås upp PDF.",
pad: {
title: "Skapa signatur",
draw: "Rita",
+2
View File
@@ -2780,6 +2780,8 @@ export const th: TranslationKeys = {
signingPercent: "กำลังลงนาม… {percent}%",
downloadSigned: "ดาวน์โหลด PDF ที่ลงนามแล้ว",
addFirst: "เพิ่มลายเซ็นอย่างน้อยหนึ่งรายการก่อนดาวน์โหลด",
loadFailed:
"ไม่สามารถแสดง PDF นี้ได้ ไฟล์อาจเสียหายหรือมีการป้องกันด้วยรหัสผ่าน หากถูกเข้ารหัส โปรดลบรหัสผ่านด้วยเครื่องมือปลดล็อก PDF ก่อน",
pad: {
title: "สร้างลายเซ็น",
draw: "วาด",
+2
View File
@@ -2813,6 +2813,8 @@ export const tr: TranslationKeys = {
signingPercent: "İmzalanıyor… {percent}%",
downloadSigned: "İmzalı PDF'yi indir",
addFirst: "İndirmeden önce en az bir imza ekleyin.",
loadFailed:
"Bu PDF görüntülenemedi. Dosya bozuk veya parola korumalı olabilir; şifrelenmişse önce PDF Kilidini Aç aracıyla parolayı kaldırın.",
pad: {
title: "İmza oluştur",
draw: "Çiz",
+2
View File
@@ -2811,6 +2811,8 @@ export const uk: TranslationKeys = {
signingPercent: "Підписування… {percent}%",
downloadSigned: "Завантажити підписаний PDF",
addFirst: "Додайте принаймні один підпис перед завантаженням.",
loadFailed:
"Не вдалося відобразити цей PDF. Файл може бути пошкоджений або захищений паролем; якщо він зашифрований, спочатку видаліть пароль інструментом «Розблокувати PDF».",
pad: {
title: "Створити підпис",
draw: "Намалювати",
+2
View File
@@ -2811,6 +2811,8 @@ export const vi: TranslationKeys = {
signingPercent: "Đang ký… {percent}%",
downloadSigned: "Tải PDF đã ký",
addFirst: "Thêm ít nhất một chữ ký trước khi tải xuống.",
loadFailed:
"Không thể hiển thị PDF này. Tệp có thể bị hỏng hoặc được bảo vệ bằng mật khẩu; nếu tệp được mã hóa, hãy xóa mật khẩu bằng công cụ Mở khóa PDF trước.",
pad: {
title: "Tạo chữ ký",
draw: "Vẽ",
+2
View File
@@ -2568,6 +2568,8 @@ export const zhCN: TranslationKeys = {
signingPercent: "正在签名… {percent}%",
downloadSigned: "下载已签名的 PDF",
addFirst: "下载前请至少添加一个签名。",
loadFailed:
"无法显示此 PDF。文件可能已损坏或受密码保护;如果已加密,请先使用“PDF 解锁”工具移除密码。",
pad: {
title: "创建签名",
draw: "手写",
+2
View File
@@ -2568,6 +2568,8 @@ export const zhTW: TranslationKeys = {
signingPercent: "正在簽署… {percent}%",
downloadSigned: "下載已簽署的 PDF",
addFirst: "下載前請至少新增一個簽名。",
loadFailed:
"無法顯示此 PDF。檔案可能已損毀或受密碼保護;如果已加密,請先使用「解鎖 PDF」工具移除密碼。",
pad: {
title: "建立簽名",
draw: "手寫",
@@ -0,0 +1,28 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
// A genuine pdf.js load failure (corrupt or password-protected file). The
// teardown-path rejection (loadingTask.destroy() mid-flight) is a separate,
// expected path and must stay silent; see #478.
vi.mock("pdfjs-dist", () => ({
GlobalWorkerOptions: {},
getDocument: () => ({ promise: Promise.reject(new Error("Invalid PDF structure")) }),
}));
import { SignCanvas } from "@/components/tools/sign-canvas";
afterEach(cleanup);
describe("SignCanvas load failure", () => {
it("shows a visible error instead of a blank canvas when the PDF cannot load", async () => {
render(<SignCanvas fileUrl="blob:bad-pdf" />);
expect(await screen.findByText(/couldn't display this pdf/i)).toBeInTheDocument();
// The dead canvas and page controls are gone; the user is not left staring
// at an empty page that looks like it is still loading.
expect(screen.queryByTestId("sign-pdf-canvas")).not.toBeInTheDocument();
});
});
+1
View File
@@ -125,6 +125,7 @@ export default defineConfig({
"@/components/navbar": path.resolve(__dirname, "apps/landing/src/components/navbar"),
"@": path.resolve(__dirname, "apps/web/src"),
"framer-motion": path.join(landingNodeModules, "framer-motion"),
"pdfjs-dist": path.join(webNodeModules, "pdfjs-dist"),
"@snapotter/ai": path.resolve(__dirname, "packages/ai/src/index.ts"),
"@snapotter/enterprise": path.resolve(__dirname, "packages/enterprise/src/index.ts"),
"@snapotter/image-engine": path.resolve(__dirname, "packages/image-engine/src/index.ts"),