Browse Source

fix(file-manager): harden the merged previews - PDF.js legacy build, server PDF thumbnails, STEP progress (issue #2976)

Follow-ups after merging PR #3128 (with the #2990 preview work):

- PDF preview failed on every browser without
  Map.prototype.getOrInsertComputed (Chrome < 145, Firefox < 144,
  older Safari): "This file cannot be previewed" for any PDF. Load
  pdf.js's legacy build, which bundles the polyfills for page and
  worker, and bundle the worker through Vite (?worker&url) so
  build.target lowers its class static block for Safari 16.0-16.3.
  The browser-baseline check only scanned .js output and never saw
  the copied .mjs worker; it scans .mjs too.
- PDF grid thumbnails only existed after someone opened the preview.
  Page one is now rendered server-side with PDFium (pypdfium2, new
  dependency, prebuilt wheels for every shipped platform) on upload,
  ZIP extraction and external-folder scans; Generate Thumbnails
  backfills PDFs as well. Renders are serialised behind a lock
  (PDFium is not thread-safe), run off the event loop, and scale
  from the page size so a huge MediaBox cannot allocate a huge
  bitmap. Unreadable PDFs land without a thumbnail and keep the
  browser fallback.
- A large STEP file takes over a minute to mesh in the browser and
  showed only a spinner. STEP loads now show "Converting STEP
  model... N s" and a note that large files can take a minute or
  more, in all 15 locales.
- Drop the two occt-import-js "externalized for browser
  compatibility" build warnings (path/crypto are only required in
  its Node branch); any other externalization still shows.
maziggy 2 days ago
parent
commit
86bd9e1bd4
42 changed files with 585 additions and 63 deletions
  1. 1 0
      CHANGELOG.md
  2. 45 21
      backend/app/api/routes/library.py
  3. 72 0
      backend/app/services/pdf_thumbnail.py
  4. 157 0
      backend/tests/integration/test_library_pdf_thumbnail.py
  5. 84 0
      backend/tests/unit/test_pdf_thumbnail.py
  6. 5 2
      frontend/scripts/check-browser-baseline.mjs
  7. 89 0
      frontend/src/__tests__/components/ModelViewerStepLoading.test.tsx
  8. 2 2
      frontend/src/__tests__/components/PdfPreviewModal.test.tsx
  9. 1 1
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  10. 25 1
      frontend/src/components/ModelViewer.tsx
  11. 13 3
      frontend/src/components/PdfPreviewModal.tsx
  12. 4 2
      frontend/src/i18n/locales/de.ts
  13. 4 2
      frontend/src/i18n/locales/en.ts
  14. 4 2
      frontend/src/i18n/locales/es.ts
  15. 4 2
      frontend/src/i18n/locales/fr.ts
  16. 4 2
      frontend/src/i18n/locales/it.ts
  17. 4 2
      frontend/src/i18n/locales/ja.ts
  18. 4 2
      frontend/src/i18n/locales/ko.ts
  19. 4 2
      frontend/src/i18n/locales/nl.ts
  20. 4 2
      frontend/src/i18n/locales/pt-BR.ts
  21. 4 2
      frontend/src/i18n/locales/ru.ts
  22. 4 2
      frontend/src/i18n/locales/sv.ts
  23. 4 2
      frontend/src/i18n/locales/tr.ts
  24. 4 2
      frontend/src/i18n/locales/uk.ts
  25. 4 2
      frontend/src/i18n/locales/zh-CN.ts
  26. 4 2
      frontend/src/i18n/locales/zh-TW.ts
  27. 18 0
      frontend/vite.config.ts
  28. 5 0
      requirements.txt
  29. 1 0
      static/assets/PdfPreviewModal-CJNoidne.js
  30. 1 0
      static/assets/SpreadsheetPreviewModal-CbNPic2J.js
  31. 1 0
      static/assets/chunk-aKtaBQYM.js
  32. 1 0
      static/assets/index-BCr8craX.css
  33. 0 1
      static/assets/index-ChscM3lF.css
  34. 0 0
      static/assets/index-wE5DdboK.js
  35. BIN
      static/assets/occt-import-js-BhHfLpto.wasm
  36. 0 0
      static/assets/papaparse.min-CR43Mv99.js
  37. 0 0
      static/assets/pdf-ciypm8Kl.js
  38. 0 0
      static/assets/pdf.worker.min-0idpi3xP.js
  39. 1 0
      static/assets/pdf.worker.min-DpOxPwGP.js
  40. 0 0
      static/assets/stepPreview.worker-QYcQPnWg.js
  41. 0 0
      static/assets/xlsx-CKkngM-o.js
  42. 3 2
      static/index.html

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 45 - 21
backend/app/api/routes/library.py

@@ -76,6 +76,7 @@ from backend.app.services.design_settings import (
     overrides_from_config,
 )
 from backend.app.services.filament_requirements import annotate_rack_groups
+from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.process_overrides import apply_process_overrides
 from backend.app.services.slice_output_check import (
@@ -2008,6 +2009,14 @@ async def scan_external_folder(
                 if thumbnail_path_str:
                     thumbnail_path = to_relative_path(Path(thumbnail_path_str))
 
+            # Render page one of a PDF so it has a thumbnail before anyone opens it
+            if file_type == "pdf" and thumbnail_path is None:
+                thumbnail_path_str = await asyncio.to_thread(
+                    generate_pdf_thumbnail, filepath, get_library_thumbnails_dir()
+                )
+                if thumbnail_path_str:
+                    thumbnail_path = to_relative_path(Path(thumbnail_path_str))
+
             db_file = LibraryFile(
                 folder_id=target_folder_id,
                 is_external=True,
@@ -2402,6 +2411,11 @@ async def upload_file(
             # For image files, create a thumbnail from the image itself
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
+        elif ext.lower() == ".pdf":
+            # Page one, rendered server-side; the browser preview's own
+            # render (POST /preview-thumbnail) remains the fallback.
+            thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
+
         elif ext == ".stl":
             # Generate STL thumbnail if enabled. Same MIN_USABLE_STL_BYTES
             # pre-skip as extract_zip_file — stubs / placeholders below this
@@ -2674,6 +2688,9 @@ async def extract_zip_file(
                     elif ext.lower() in IMAGE_EXTENSIONS:
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
+                    elif ext.lower() == ".pdf":
+                        thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
+
                     elif ext == ".stl":
                         # Generate STL thumbnail if enabled. Pre-skip files
                         # below MIN_USABLE_STL_BYTES — they can't contain
@@ -2746,27 +2763,31 @@ async def batch_generate_stl_thumbnails(
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ):
-    """Generate thumbnails for STL files in batch.
+    """Generate thumbnails for STL and PDF files in batch.
 
     Note: Requires library:update_all permission since this is a batch operation
     that may affect files owned by different users.
 
+    PDFs are included so the ones added before server-side PDF thumbnails
+    existed can be backfilled without opening each preview. The route keeps
+    its name for API compatibility.
+
     Can generate thumbnails for:
     - Specific file IDs (file_ids)
-    - All STL files in a folder (folder_id)
-    - All STL files missing thumbnails (all_missing=True)
+    - All STL/PDF files in a folder (folder_id)
+    - All STL/PDF files missing thumbnails (all_missing=True)
     """
     thumbnails_dir = get_library_thumbnails_dir()
     results: list[BatchThumbnailResult] = []
 
     # Build query based on request
-    query = LibraryFile.active().where(LibraryFile.file_type == "stl")
+    query = LibraryFile.active().where(LibraryFile.file_type.in_(("stl", "pdf")))
 
     if request.file_ids:
         # Specific files
         query = query.where(LibraryFile.id.in_(request.file_ids))
     elif request.folder_id is not None:
-        # All STL files in a specific folder
+        # All STL/PDF files in a specific folder
         query = query.where(LibraryFile.folder_id == request.folder_id)
         if not request.all_missing:
             # If not specifically asking for missing thumbnails, get all
@@ -2774,7 +2795,7 @@ async def batch_generate_stl_thumbnails(
         else:
             query = query.where(LibraryFile.thumbnail_path.is_(None))
     elif request.all_missing:
-        # All STL files without thumbnails
+        # All STL/PDF files without thumbnails
         query = query.where(LibraryFile.thumbnail_path.is_(None))
     else:
         # No criteria specified - return empty
@@ -2786,19 +2807,19 @@ async def batch_generate_stl_thumbnails(
         )
 
     result = await db.execute(query)
-    stl_files = result.scalars().all()
+    target_files = result.scalars().all()
 
     succeeded = 0
     failed = 0
 
-    for stl_file in stl_files:
-        file_path = to_absolute_path(stl_file.file_path)
+    for target_file in target_files:
+        file_path = to_absolute_path(target_file.file_path)
 
         if not file_path or not file_path.exists():
             results.append(
                 BatchThumbnailResult(
-                    file_id=stl_file.id,
-                    filename=stl_file.filename,
+                    file_id=target_file.id,
+                    filename=target_file.filename,
                     success=False,
                     error="File not found on disk",
                 )
@@ -2807,16 +2828,19 @@ async def batch_generate_stl_thumbnails(
             continue
 
         try:
-            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+            if target_file.file_type == "pdf":
+                thumbnail_path = await asyncio.to_thread(generate_pdf_thumbnail, file_path, thumbnails_dir)
+            else:
+                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
             if thumbnail_path:
                 # Update database with relative path
-                stl_file.thumbnail_path = to_relative_path(thumbnail_path)
+                target_file.thumbnail_path = to_relative_path(thumbnail_path)
                 await db.flush()
                 results.append(
                     BatchThumbnailResult(
-                        file_id=stl_file.id,
-                        filename=stl_file.filename,
+                        file_id=target_file.id,
+                        filename=target_file.filename,
                         success=True,
                     )
                 )
@@ -2824,19 +2848,19 @@ async def batch_generate_stl_thumbnails(
             else:
                 results.append(
                     BatchThumbnailResult(
-                        file_id=stl_file.id,
-                        filename=stl_file.filename,
+                        file_id=target_file.id,
+                        filename=target_file.filename,
                         success=False,
                         error="Thumbnail generation failed",
                     )
                 )
                 failed += 1
         except Exception as e:
-            logger.error("Failed to generate thumbnail for %s: %s", stl_file.filename, e)
+            logger.error("Failed to generate thumbnail for %s: %s", target_file.filename, e)
             results.append(
                 BatchThumbnailResult(
-                    file_id=stl_file.id,
-                    filename=stl_file.filename,
+                    file_id=target_file.id,
+                    filename=target_file.filename,
                     success=False,
                     error=str(e),
                 )
@@ -2846,7 +2870,7 @@ async def batch_generate_stl_thumbnails(
     await db.commit()
 
     return BatchThumbnailResponse(
-        processed=len(stl_files),
+        processed=len(target_files),
         succeeded=succeeded,
         failed=failed,
         results=results,

+ 72 - 0
backend/app/services/pdf_thumbnail.py

@@ -0,0 +1,72 @@
+"""PDF Thumbnail Generation Service.
+
+Renders the first page of a PDF with PDFium (pypdfium2) so a library PDF gets
+its grid thumbnail at upload/scan time. Before this, PDF thumbnails only
+existed once somebody had opened the in-browser preview, which posted its
+first render back (#2976); that route stays as the fallback for a PDF this
+renderer cannot read.
+"""
+
+import logging
+import threading
+import uuid
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# PDFium is not thread-safe, and callers run this through asyncio.to_thread,
+# so two uploads landing together would otherwise enter it concurrently.
+_PDFIUM_LOCK = threading.Lock()
+
+# Rendered at twice the output size and downsampled, so small print on the
+# page stays legible instead of breaking up into aliased specks.
+_SUPERSAMPLE = 2
+
+
+def generate_pdf_thumbnail(pdf_path: Path, thumbnails_dir: Path, size: int = 256) -> str | None:
+    """Render page one of a PDF into a square, white-backed PNG thumbnail.
+
+    Same shape as the thumbnail the browser preview posts back (a ``size``
+    square with the page centred on white), so a grid mixing both kinds
+    looks uniform. Returns the thumbnail path, or None if the file cannot be
+    rendered - an encrypted, empty or damaged PDF simply gets no thumbnail.
+    """
+    try:
+        import pypdfium2 as pdfium
+        from PIL import Image
+    except ImportError:
+        logger.warning("pypdfium2 not installed, skipping PDF thumbnail for %s", pdf_path.name)
+        return None
+
+    try:
+        with _PDFIUM_LOCK:
+            pdf = pdfium.PdfDocument(str(pdf_path))
+            try:
+                if len(pdf) == 0:
+                    return None
+                page = pdf[0]
+                try:
+                    width, height = page.get_size()
+                    if width <= 0 or height <= 0:
+                        return None
+                    # Scale from the page's own size, so the bitmap is bounded
+                    # by the output size whatever MediaBox the file declares.
+                    scale = (size * _SUPERSAMPLE) / max(width, height)
+                    rendered = page.render(scale=scale).to_pil()
+                finally:
+                    page.close()
+            finally:
+                pdf.close()
+
+        rendered = rendered.convert("RGB")
+        rendered.thumbnail((size, size), Image.Resampling.LANCZOS)
+        thumbnail = Image.new("RGB", (size, size), (255, 255, 255))
+        thumbnail.paste(rendered, ((size - rendered.width) // 2, (size - rendered.height) // 2))
+
+        thumb_filename = f"{uuid.uuid4().hex}.png"
+        thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
+        thumbnail.save(thumb_path, "PNG", optimize=True)
+        return str(thumb_path)
+    except Exception as e:
+        logger.warning("Failed to create PDF thumbnail for %s: %s", pdf_path.name, e)
+        return None

+ 157 - 0
backend/tests/integration/test_library_pdf_thumbnail.py

@@ -0,0 +1,157 @@
+"""Integration tests for server-side PDF thumbnails (#2976).
+
+A PDF gets its grid thumbnail when it enters the library - upload, ZIP
+extraction, external-folder scan - instead of only after somebody has opened
+the browser preview. The "Generate Thumbnails" batch backfills PDFs added
+before that.
+"""
+
+import io
+import zipfile
+
+import pytest
+from httpx import AsyncClient
+from PIL import Image
+from reportlab.lib.pagesizes import A4
+from reportlab.pdfgen import canvas
+from sqlalchemy import select
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.library import LibraryFile
+
+
+def _pdf_bytes() -> bytes:
+    buf = io.BytesIO()
+    c = canvas.Canvas(buf, pagesize=A4)
+    c.rect(0, A4[1] - 200, 200, 200, fill=1, stroke=0)
+    c.drawString(72, 72, "Bambuddy")
+    c.showPage()
+    c.save()
+    return buf.getvalue()
+
+
+@pytest.fixture
+def isolated_storage(monkeypatch, tmp_path):
+    """Point library and thumbnail storage at a throwaway directory.
+
+    A subdirectory, so an external share created beside it in ``tmp_path``
+    is not inside the Bambuddy-managed tree (which the mount refuses).
+    """
+    base = tmp_path / "data"
+    base.mkdir()
+    monkeypatch.setattr(app_settings, "base_dir", base)
+    monkeypatch.setattr(app_settings, "archive_dir", base / "archive")
+    return base
+
+
+def _assert_png_thumbnail(base_dir, thumbnail_path):
+    assert thumbnail_path
+    stored = base_dir / thumbnail_path
+    assert stored.exists()
+    with Image.open(stored) as img:
+        assert img.format == "PNG"
+        assert img.size == (256, 256)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_upload_renders_pdf_thumbnail(async_client: AsyncClient, isolated_storage):
+    response = await async_client.post(
+        "/api/v1/library/files",
+        files={"file": ("manual.pdf", _pdf_bytes(), "application/pdf")},
+    )
+    assert response.status_code == 200
+    body = response.json()
+    assert body["file_type"] == "pdf"
+    _assert_png_thumbnail(isolated_storage, body["thumbnail_path"])
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_upload_of_unreadable_pdf_still_lands_without_thumbnail(async_client: AsyncClient, isolated_storage):
+    response = await async_client.post(
+        "/api/v1/library/files",
+        files={"file": ("broken.pdf", b"%PDF-1.4 not really", "application/pdf")},
+    )
+    assert response.status_code == 200
+    body = response.json()
+    assert body["file_type"] == "pdf"
+    # The browser preview can still post its own render later.
+    assert body["thumbnail_path"] is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_zip_extraction_renders_pdf_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr("docs/manual.pdf", _pdf_bytes())
+    response = await async_client.post(
+        "/api/v1/library/files/extract-zip",
+        files={"file": ("bundle.zip", buf.getvalue(), "application/zip")},
+    )
+    assert response.status_code == 200
+
+    row = (await db_session.execute(select(LibraryFile).where(LibraryFile.filename == "manual.pdf"))).scalar_one()
+    _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_external_scan_renders_pdf_thumbnail(
+    async_client: AsyncClient, db_session, isolated_storage, tmp_path, monkeypatch
+):
+    monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(tmp_path.parent))
+    share = tmp_path / "nas_share"
+    share.mkdir()
+    (share / "manual.pdf").write_bytes(_pdf_bytes())
+
+    created = await async_client.post(
+        "/api/v1/library/folders/external",
+        json={"name": "NAS", "external_path": str(share), "readonly": True, "show_hidden": False},
+    )
+    assert created.status_code == 200, created.text
+    folder = created.json()
+    response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
+    assert response.status_code == 200
+
+    files = (await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")).json()
+    pdf = next(f for f in files if f["filename"] == "manual.pdf")
+    row = await db_session.get(LibraryFile, pdf["id"])
+    _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_batch_generate_backfills_pdf_without_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
+    pdf_path = isolated_storage / "archive" / "library" / "files" / "old.pdf"
+    pdf_path.parent.mkdir(parents=True, exist_ok=True)
+    pdf_path.write_bytes(_pdf_bytes())
+    old = LibraryFile(
+        filename="old.pdf",
+        file_path="archive/library/files/old.pdf",
+        file_type="pdf",
+        file_size=pdf_path.stat().st_size,
+    )
+    # A spreadsheet is a client-thumbnail type too, but the server has no
+    # renderer for it - the batch must leave it alone.
+    sheet = LibraryFile(
+        filename="parts.csv",
+        file_path="archive/library/files/parts.csv",
+        file_type="csv",
+        file_size=10,
+    )
+    db_session.add_all([old, sheet])
+    await db_session.commit()
+
+    response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json={"all_missing": True})
+    assert response.status_code == 200
+    result = response.json()
+    assert result["processed"] == 1
+    assert result["succeeded"] == 1
+    assert result["results"][0]["file_id"] == old.id
+
+    await db_session.refresh(old)
+    await db_session.refresh(sheet)
+    _assert_png_thumbnail(isolated_storage, old.thumbnail_path)
+    assert sheet.thumbnail_path is None

+ 84 - 0
backend/tests/unit/test_pdf_thumbnail.py

@@ -0,0 +1,84 @@
+"""Unit tests for server-side PDF thumbnails (#2976).
+
+generate_pdf_thumbnail renders page one with PDFium into the same square,
+white-backed PNG the browser preview posts back, and returns None rather than
+raising for anything it cannot render.
+"""
+
+from pathlib import Path
+
+import pytest
+from PIL import Image
+from reportlab.lib.pagesizes import A4, landscape
+from reportlab.pdfgen import canvas
+
+from backend.app.services.pdf_thumbnail import generate_pdf_thumbnail
+
+
+def _make_pdf(path: Path, pagesize=A4, pages: int = 1) -> Path:
+    c = canvas.Canvas(str(path), pagesize=pagesize)
+    for n in range(pages):
+        # A solid black block in the top-left corner of each page, so the
+        # render can be told apart from a blank white square.
+        c.setFillColorRGB(0, 0, 0)
+        c.rect(0, pagesize[1] - 200, 200, 200, fill=1, stroke=0)
+        c.drawString(72, 72, f"page {n + 1}")
+        c.showPage()
+    c.save()
+    return path
+
+
+def test_renders_page_one_as_square_png(tmp_path):
+    pdf = _make_pdf(tmp_path / "doc.pdf", pages=3)
+    thumb = generate_pdf_thumbnail(pdf, tmp_path)
+
+    assert thumb is not None
+    with Image.open(thumb) as img:
+        assert img.format == "PNG"
+        assert img.size == (256, 256)
+        assert img.mode == "RGB"
+        # Something was drawn: not a blank white square.
+        assert img.getextrema() != ((255, 255), (255, 255), (255, 255))
+
+
+def test_portrait_page_is_centred_on_white(tmp_path):
+    pdf = _make_pdf(tmp_path / "portrait.pdf")
+    thumb = generate_pdf_thumbnail(pdf, tmp_path)
+
+    with Image.open(thumb) as img:
+        # A4 portrait is narrower than tall: the side margins are padding.
+        assert img.getpixel((2, 128)) == (255, 255, 255)
+        assert img.getpixel((253, 128)) == (255, 255, 255)
+
+
+def test_landscape_page_is_centred_on_white(tmp_path):
+    pdf = _make_pdf(tmp_path / "landscape.pdf", pagesize=landscape(A4))
+    thumb = generate_pdf_thumbnail(pdf, tmp_path)
+
+    with Image.open(thumb) as img:
+        assert img.size == (256, 256)
+        assert img.getpixel((128, 2)) == (255, 255, 255)
+        assert img.getpixel((128, 253)) == (255, 255, 255)
+
+
+def test_huge_mediabox_still_yields_bounded_thumbnail(tmp_path):
+    # 5 m x 5 m: the render scale comes from the page size, so this must not
+    # allocate a page-sized bitmap.
+    pdf = _make_pdf(tmp_path / "huge.pdf", pagesize=(14400, 14400))
+    thumb = generate_pdf_thumbnail(pdf, tmp_path)
+
+    with Image.open(thumb) as img:
+        assert img.size == (256, 256)
+
+
+@pytest.mark.parametrize("content", [b"", b"not a pdf at all", b"%PDF-1.4\n%truncated"])
+def test_unreadable_pdf_returns_none(tmp_path, content):
+    bad = tmp_path / "bad.pdf"
+    bad.write_bytes(content)
+
+    assert generate_pdf_thumbnail(bad, tmp_path) is None
+    assert list(tmp_path.glob("*.png")) == []
+
+
+def test_missing_file_returns_none(tmp_path):
+    assert generate_pdf_thumbnail(tmp_path / "missing.pdf", tmp_path) is None

+ 5 - 2
frontend/scripts/check-browser-baseline.mjs

@@ -62,14 +62,17 @@ const FORBIDDEN = [
 
 let bundles;
 try {
-  bundles = readdirSync(ASSETS).filter((f) => f.endsWith('.js'));
+  // .mjs too: a dependency's worker imported with `?url` is copied verbatim
+  // under its own extension, bypasses `build.target`, and was invisible here -
+  // the pdf.js worker shipped a class static block that way (#2976).
+  bundles = readdirSync(ASSETS).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
 } catch {
   console.error(`check-browser-baseline: no build output at ${ASSETS} - run \`vite build\` first.`);
   process.exit(1);
 }
 
 if (bundles.length === 0) {
-  console.error(`check-browser-baseline: no .js files in ${ASSETS} - did the build succeed?`);
+  console.error(`check-browser-baseline: no .js or .mjs files in ${ASSETS} - did the build succeed?`);
   process.exit(1);
 }
 

+ 89 - 0
frontend/src/__tests__/components/ModelViewerStepLoading.test.tsx

@@ -0,0 +1,89 @@
+/**
+ * STEP loading state (#2976).
+ *
+ * A large STEP export can take over a minute in the OpenCascade worker. With
+ * only a spinner on screen that read as a preview that never opens, so a
+ * STEP load says what it is doing and counts the seconds. Other formats load
+ * in a moment and keep the bare spinner.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+
+// Only the GL context is faked (jsdom has none); the rest of three.js runs.
+vi.mock('three', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('three')>();
+  class FakeWebGLRenderer {
+    domElement = document.createElement('canvas');
+    shadowMap = { enabled: false, type: 0 };
+    toneMapping = 0;
+    toneMappingExposure = 1;
+    setSize() {}
+    setPixelRatio() {}
+    render() {}
+    dispose() {}
+  }
+  class FakePMREMGenerator {
+    fromScene() {
+      return { texture: new actual.Texture(), dispose() {} };
+    }
+    dispose() {}
+  }
+  return { ...actual, WebGLRenderer: FakeWebGLRenderer, PMREMGenerator: FakePMREMGenerator };
+});
+
+vi.mock('three/examples/jsm/controls/OrbitControls.js', () => ({
+  OrbitControls: class {
+    enableDamping = false;
+    dampingFactor = 0;
+    target = { set() {}, copy() {} };
+    update() {}
+    dispose() {}
+  },
+}));
+
+import { ModelViewer } from '../../components/ModelViewer';
+
+describe('ModelViewer STEP loading state', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    // Never resolves: the viewer stays in its loading state for the test.
+    vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
+    vi.stubGlobal(
+      'ResizeObserver',
+      class {
+        observe() {}
+        unobserve() {}
+        disconnect() {}
+      },
+    );
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    vi.unstubAllGlobals();
+  });
+
+  it('says a STEP file is converting and counts the seconds', () => {
+    render(<ModelViewer url="/api/v1/library/files/7/download" fileType="stp" />);
+
+    expect(screen.getByText('Converting STEP model… 0 s')).toBeInTheDocument();
+    expect(screen.getByText('Large STEP files can take a minute or more.')).toBeInTheDocument();
+
+    act(() => {
+      vi.advanceTimersByTime(3000);
+    });
+    expect(screen.getByText('Converting STEP model… 3 s')).toBeInTheDocument();
+  });
+
+  it('recognises a .step URL without an explicit file type', () => {
+    render(<ModelViewer url="/files/housing.step" />);
+    expect(screen.getByText('Converting STEP model… 0 s')).toBeInTheDocument();
+  });
+
+  it('keeps the bare spinner for formats that load quickly', () => {
+    render(<ModelViewer url="/api/v1/library/files/8/download" fileType="stl" />);
+    expect(screen.queryByText(/Converting STEP model/)).not.toBeInTheDocument();
+    expect(screen.queryByText('Large STEP files can take a minute or more.')).not.toBeInTheDocument();
+  });
+});

+ 2 - 2
frontend/src/__tests__/components/PdfPreviewModal.test.tsx

@@ -24,12 +24,12 @@ const pdfjsMocks = vi.hoisted(() => {
   return { render, getPage, getDocument };
 });
 
-vi.mock('pdfjs-dist', () => ({
+vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
   GlobalWorkerOptions: { workerSrc: '' },
   getDocument: pdfjsMocks.getDocument,
 }));
 
-vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: 'pdf.worker.min.mjs' }));
+vi.mock('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url', () => ({ default: 'pdf.worker.min.mjs' }));
 
 vi.mock('../../api/client', () => ({
   api: {

+ 1 - 1
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -557,7 +557,7 @@ describe('FileManagerPage', () => {
       render(<FileManagerPage />);
 
       await waitFor(() => {
-        const button = screen.getByTitle('Generate thumbnails for STL files missing them');
+        const button = screen.getByTitle('Generate thumbnails for STL and PDF files missing them');
         expect(button).toBeInTheDocument();
       });
     });

+ 25 - 1
frontend/src/components/ModelViewer.tsx

@@ -791,6 +791,22 @@ export function ModelViewer({
   const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
   const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
   const [stepMeshes, setStepMeshes] = useState<StepMeshData[] | null>(null);
+  // Seconds spent converting a STEP file. A large export can take over a
+  // minute in the OpenCascade worker, and a bare spinner for that long reads
+  // as "nothing is happening" - so STEP loads say what they are doing and
+  // show that time is passing.
+  const [stepLoadSeconds, setStepLoadSeconds] = useState(0);
+  const loadingType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
+  const isStepFile = loadingType === 'step' || loadingType === 'stp';
+  useEffect(() => {
+    if (!loading || !isStepFile) return;
+    setStepLoadSeconds(0);
+    const startedAt = Date.now();
+    const timer = window.setInterval(() => {
+      setStepLoadSeconds(Math.floor((Date.now() - startedAt) / 1000));
+    }, 1000);
+    return () => window.clearInterval(timer);
+  }, [loading, isStepFile]);
   // Snapshot is a one-shot per loaded url; the callback lives in a ref so its
   // identity never retriggers the (expensive) scene effects.
   const snapshotSentRef = useRef(false);
@@ -1186,8 +1202,16 @@ export function ModelViewer({
       <div ref={containerRef} className="w-full h-full min-h-[400px]" />
 
       {loading && (
-        <div className="absolute inset-0 flex items-center justify-center bg-bambu-dark/80">
+        <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-bambu-dark/80 px-6 text-center">
           <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+          {isStepFile && (
+            <div role="status" className="space-y-1">
+              <p className="text-sm text-white tabular-nums">
+                {t('modelViewer.stepConverting', { seconds: stepLoadSeconds })}
+              </p>
+              <p className="text-xs text-bambu-gray">{t('modelViewer.stepSlowHint')}</p>
+            </div>
+          )}
         </div>
       )}
 

+ 13 - 3
frontend/src/components/PdfPreviewModal.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react';
-import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
+import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist/legacy/build/pdf.mjs';
 import { api, getAuthToken } from '../api/client';
 import { formatFileSize } from '../utils/file';
 
@@ -84,9 +84,19 @@ export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, on
       const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
       if (!res.ok) throw new Error(`HTTP ${res.status}`);
       const buffer = await res.arrayBuffer();
-      const pdfjs = await import('pdfjs-dist');
+      // The legacy build, not the standard one: pdf.js 6 calls
+      // Map.prototype.getOrInsertComputed (Chrome 145, Firefox 144) and
+      // other recent APIs directly, so on any older browser every PDF failed
+      // with "cannot be previewed". The legacy build bundles polyfills for
+      // them in both the page and the worker. The worker goes through Vite
+      // (`?worker&url`) rather than being copied as-is (`?url`), so
+      // `build.target` lowers its class static block for Safari 16.0-16.3
+      // like the rest of the app, and the baseline check can see it.
+      const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
       if (!pdfjs.GlobalWorkerOptions.workerSrc) {
-        pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
+        pdfjs.GlobalWorkerOptions.workerSrc = (
+          await import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?worker&url')
+        ).default;
       }
       loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
       const loaded = await loadingTask.promise;

+ 4 - 2
frontend/src/i18n/locales/de.ts

@@ -3885,7 +3885,7 @@ export default {
     linkToProjectOrArchive: 'Mit Projekt oder Archiv verknüpfen',
     generateThumbnail: 'Vorschaubild generieren',
     generateThumbnails: 'Vorschaubilder generieren',
-    generateThumbnailsForMissing: 'Vorschaubilder für STL-Dateien ohne Vorschau generieren',
+    generateThumbnailsForMissing: 'Vorschaubilder für STL- und PDF-Dateien ohne Vorschau generieren',
     gridView: 'Rasteransicht',
     listView: 'Listenansicht',
     lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',
@@ -4075,7 +4075,7 @@ export default {
       folderRenamed: 'Ordner umbenannt',
       thumbnailsGenerated: '{{count}} Vorschaubild(er) generiert',
       thumbnailsGeneratedPartial: '{{succeeded}} Vorschaubild(er) generiert, {{failed}} fehlgeschlagen',
-      noStlMissingThumbnails: 'Keine STL-Dateien ohne Vorschaubild',
+      noStlMissingThumbnails: 'Keine STL- oder PDF-Dateien ohne Vorschaubild',
       failedToGenerateThumbnails: 'Fehler beim Generieren der Vorschaubilder: {{error}}',
       thumbnailGenerated: 'Vorschaubild generiert',
       failedToGenerateThumbnail: 'Fehler beim Generieren des Vorschaubildes: {{error}}',
@@ -5776,6 +5776,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'STEP-Modell wird konvertiert … {{seconds}} s',
+    stepSlowHint: 'Große STEP-Dateien können eine Minute oder länger dauern.',
     openInSlicer: 'Im Slicer öffnen',
     openInSlicerWith: 'In {{slicer}} öffnen',
     moreSlicerOptions: 'Weitere Slicer-Optionen',

+ 4 - 2
frontend/src/i18n/locales/en.ts

@@ -3915,7 +3915,7 @@ export default {
     linkToProjectOrArchive: 'Link to project or archive',
     generateThumbnail: 'Generate Thumbnail',
     generateThumbnails: 'Generate Thumbnails',
-    generateThumbnailsForMissing: 'Generate thumbnails for STL files missing them',
+    generateThumbnailsForMissing: 'Generate thumbnails for STL and PDF files missing them',
     gridView: 'Grid view',
     listView: 'List view',
     lowDiskSpaceWarning: 'Low disk space warning',
@@ -4105,7 +4105,7 @@ export default {
       folderRenamed: 'Folder renamed',
       thumbnailsGenerated: 'Generated {{count}} thumbnail(s)',
       thumbnailsGeneratedPartial: 'Generated {{succeeded}} thumbnail(s), {{failed}} failed',
-      noStlMissingThumbnails: 'No STL files missing thumbnails',
+      noStlMissingThumbnails: 'No STL or PDF files missing thumbnails',
       failedToGenerateThumbnails: 'Failed to generate thumbnails: {{error}}',
       thumbnailGenerated: 'Thumbnail generated',
       failedToGenerateThumbnail: 'Failed to generate thumbnail: {{error}}',
@@ -5827,6 +5827,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'Converting STEP model… {{seconds}} s',
+    stepSlowHint: 'Large STEP files can take a minute or more.',
     openInSlicer: 'Open in Slicer',
     openInSlicerWith: 'Open in {{slicer}}',
     moreSlicerOptions: 'More slicer options',

+ 4 - 2
frontend/src/i18n/locales/es.ts

@@ -3887,7 +3887,7 @@ export default {
     linkToProjectOrArchive: 'Vincular a un proyecto o archivo',
     generateThumbnail: 'Generar miniatura',
     generateThumbnails: 'Generar miniaturas',
-    generateThumbnailsForMissing: 'Generar miniaturas para los archivos STL que no las tienen',
+    generateThumbnailsForMissing: 'Generar miniaturas para los archivos STL y PDF que no las tienen',
     gridView: 'Vista de cuadrícula',
     listView: 'Vista de lista',
     lowDiskSpaceWarning: 'Advertencia de poco espacio en disco',
@@ -4077,7 +4077,7 @@ export default {
       folderRenamed: 'Carpeta renombrada',
       thumbnailsGenerated: 'Se generaron {{count}} miniatura(s)',
       thumbnailsGeneratedPartial: 'Se generaron {{succeeded}} miniatura(s), {{failed}} fallidas',
-      noStlMissingThumbnails: 'No hay archivos STL sin miniaturas',
+      noStlMissingThumbnails: 'No hay archivos STL ni PDF sin miniaturas',
       failedToGenerateThumbnails: 'Error al generar las miniaturas: {{error}}',
       thumbnailGenerated: 'Miniatura generada',
       failedToGenerateThumbnail: 'Error al generar la miniatura: {{error}}',
@@ -5784,6 +5784,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'Convirtiendo el modelo STEP… {{seconds}} s',
+    stepSlowHint: 'Los archivos STEP grandes pueden tardar un minuto o más.',
     openInSlicer: 'Abrir en el laminador',
     openInSlicerWith: 'Abrir en {{slicer}}',
     moreSlicerOptions: 'Más opciones de laminador',

+ 4 - 2
frontend/src/i18n/locales/fr.ts

@@ -3874,7 +3874,7 @@ export default {
     linkToProjectOrArchive: 'Lier à projet ou archive',
     generateThumbnail: 'Générer vignette',
     generateThumbnails: 'Générer vignettes',
-    generateThumbnailsForMissing: 'Vignettes STL manquantes',
+    generateThumbnailsForMissing: 'Vignettes STL et PDF manquantes',
     gridView: 'Grille',
     listView: 'Liste',
     lowDiskSpaceWarning: 'Espace disque faible',
@@ -4064,7 +4064,7 @@ export default {
       folderRenamed: 'Dossier renommé',
       thumbnailsGenerated: '{{count}} vignette(s) générée(s)',
       thumbnailsGeneratedPartial: '{{succeeded}} succès, {{failed}} échecs',
-      noStlMissingThumbnails: 'Aucun STL sans vignette',
+      noStlMissingThumbnails: 'Aucun STL ni PDF sans vignette',
       failedToGenerateThumbnails: 'Échec vignettes : {{error}}',
       thumbnailGenerated: 'Vignette générée',
       failedToGenerateThumbnail: 'Échec vignette : {{error}}',
@@ -5766,6 +5766,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'Conversion du modèle STEP… {{seconds}} s',
+    stepSlowHint: 'Les gros fichiers STEP peuvent prendre une minute ou plus.',
     openInSlicer: 'Ouvrir dans le Slicer',
     openInSlicerWith: 'Ouvrir dans {{slicer}}',
     moreSlicerOptions: "Plus d'options de slicer",

+ 4 - 2
frontend/src/i18n/locales/it.ts

@@ -3873,7 +3873,7 @@ export default {
     linkToProjectOrArchive: 'Collega a progetto o archivio',
     generateThumbnail: 'Genera miniatura',
     generateThumbnails: 'Genera miniature',
-    generateThumbnailsForMissing: 'Genera miniature per STL senza miniatura',
+    generateThumbnailsForMissing: 'Genera miniature per STL e PDF senza miniatura',
     gridView: 'Vista griglia',
     listView: 'Vista elenco',
     lowDiskSpaceWarning: 'Avviso spazio disco basso',
@@ -4063,7 +4063,7 @@ export default {
       folderRenamed: 'Cartella rinominata',
       thumbnailsGenerated: 'Generate {{count}} miniature',
       thumbnailsGeneratedPartial: 'Generate {{succeeded}} miniature, {{failed}} fallite',
-      noStlMissingThumbnails: 'Nessun file STL senza miniature',
+      noStlMissingThumbnails: 'Nessun file STL o PDF senza miniature',
       failedToGenerateThumbnails: 'Generazione miniature fallita: {{error}}',
       thumbnailGenerated: 'Miniatura generata',
       failedToGenerateThumbnail: 'Generazione miniatura fallita: {{error}}',
@@ -5765,6 +5765,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'Conversione del modello STEP… {{seconds}} s',
+    stepSlowHint: 'I file STEP di grandi dimensioni possono richiedere un minuto o più.',
     openInSlicer: 'Apri nello slicer',
     openInSlicerWith: 'Apri in {{slicer}}',
     moreSlicerOptions: 'Altre opzioni dello slicer',

+ 4 - 2
frontend/src/i18n/locales/ja.ts

@@ -3885,7 +3885,7 @@ export default {
     linkToProjectOrArchive: 'プロジェクトまたはアーカイブにリンク',
     generateThumbnail: 'サムネイルを生成',
     generateThumbnails: 'サムネイルを生成',
-    generateThumbnailsForMissing: 'サムネイルのないSTLファイルのサムネイルを生成',
+    generateThumbnailsForMissing: 'サムネイルのないSTL・PDFファイルのサムネイルを生成',
     gridView: 'グリッド表示',
     listView: 'リスト表示',
     lowDiskSpaceWarning: 'ディスク容量不足の警告',
@@ -4075,7 +4075,7 @@ export default {
       folderRenamed: 'フォルダ名を変更しました',
       thumbnailsGenerated: '{{count}}件のサムネイルを生成しました',
       thumbnailsGeneratedPartial: '{{succeeded}}件生成、{{failed}}件失敗',
-      noStlMissingThumbnails: 'サムネイルのないSTLファイルはありません',
+      noStlMissingThumbnails: 'サムネイルのないSTL・PDFファイルはありません',
       failedToGenerateThumbnails: 'サムネイルの生成に失敗: {{error}}',
       thumbnailGenerated: 'サムネイルを生成しました',
       failedToGenerateThumbnail: 'サムネイルの生成に失敗: {{error}}',
@@ -5777,6 +5777,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'STEPモデルを変換中… {{seconds}}秒',
+    stepSlowHint: 'サイズの大きいSTEPファイルは1分以上かかることがあります。',
     openInSlicer: 'スライサーで開く',
     openInSlicerWith: '{{slicer}}で開く',
     moreSlicerOptions: 'その他のスライサーオプション',

+ 4 - 2
frontend/src/i18n/locales/ko.ts

@@ -3695,7 +3695,7 @@ export default {
     linkToProjectOrArchive: '프로젝트 또는 아카이브에 연결',
     generateThumbnail: '썸네일 생성',
     generateThumbnails: '썸네일 생성',
-    generateThumbnailsForMissing: '썸네일이 없는 STL 파일의 썸네일 생성',
+    generateThumbnailsForMissing: '썸네일이 없는 STL 및 PDF 파일의 썸네일 생성',
     gridView: '격자 보기',
     listView: '목록 보기',
     lowDiskSpaceWarning: '디스크 공간 부족 경고',
@@ -3884,7 +3884,7 @@ export default {
       folderRenamed: '폴더 이름 변경됨',
       thumbnailsGenerated: '{{count}}개 썸네일 생성됨',
       thumbnailsGeneratedPartial: '{{succeeded}}개 썸네일 생성됨, {{failed}}개 실패',
-      noStlMissingThumbnails: '썸네일이 없는 STL 파일 없음',
+      noStlMissingThumbnails: '썸네일이 없는 STL 또는 PDF 파일 없음',
       failedToGenerateThumbnails: '썸네일 생성 실패: {{error}}',
       thumbnailGenerated: '썸네일 생성됨',
       failedToGenerateThumbnail: '썸네일 생성 실패: {{error}}'
@@ -5510,6 +5510,8 @@ export default {
     }
   },
   modelViewer: {
+    stepConverting: 'STEP 모델 변환 중… {{seconds}}초',
+    stepSlowHint: '큰 STEP 파일은 1분 이상 걸릴 수 있습니다.',
     openInSlicer: '슬라이서에서 열기',
     openInSlicerWith: '{{slicer}}에서 열기',
     moreSlicerOptions: '슬라이서 옵션 더 보기',

+ 4 - 2
frontend/src/i18n/locales/nl.ts

@@ -3915,7 +3915,7 @@ export default {
     linkToProjectOrArchive: 'Koppelen aan project of archief',
     generateThumbnail: 'Miniatuur genereren',
     generateThumbnails: 'Miniaturen genereren',
-    generateThumbnailsForMissing: 'Miniaturen genereren voor STL-bestanden die ze missen',
+    generateThumbnailsForMissing: 'Miniaturen genereren voor STL- en PDF-bestanden die ze missen',
     gridView: 'Rasterweergave',
     listView: 'Lijstweergave',
     lowDiskSpaceWarning: 'Waarschuwing weinig schijfruimte',
@@ -4105,7 +4105,7 @@ export default {
       folderRenamed: 'Map hernoemd',
       thumbnailsGenerated: '{{count}} miniatuur/miniaturen gegenereerd',
       thumbnailsGeneratedPartial: '{{succeeded}} miniatuur/miniaturen gegenereerd, {{failed}} mislukt',
-      noStlMissingThumbnails: 'Geen STL-bestanden zonder miniaturen',
+      noStlMissingThumbnails: 'Geen STL- of PDF-bestanden zonder miniaturen',
       failedToGenerateThumbnails: 'Miniaturen genereren mislukt: {{error}}',
       thumbnailGenerated: 'Miniatuur gegenereerd',
       failedToGenerateThumbnail: 'Miniatuur genereren mislukt: {{error}}',
@@ -5827,6 +5827,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'STEP-model wordt geconverteerd… {{seconds}} s',
+    stepSlowHint: 'Grote STEP-bestanden kunnen een minuut of langer duren.',
     openInSlicer: 'Openen in slicer',
     openInSlicerWith: 'Openen in {{slicer}}',
     moreSlicerOptions: 'Meer sliceropties',

+ 4 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -3873,7 +3873,7 @@ export default {
     linkToProjectOrArchive: 'Vincular a projeto ou arquivo',
     generateThumbnail: 'Gerar miniatura',
     generateThumbnails: 'Gerar miniaturas',
-    generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL que não possuem',
+    generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL e PDF que não possuem',
     gridView: 'Visualização em grade',
     listView: 'Visualização em lista',
     lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
@@ -4063,7 +4063,7 @@ export default {
       folderRenamed: 'Pasta renomeada',
       thumbnailsGenerated: 'Geradas {{count}} miniatura(s)',
       thumbnailsGeneratedPartial: 'Geradas {{succeeded}} miniatura(s), {{failed}} falharam',
-      noStlMissingThumbnails: 'Nenhum arquivo STL sem miniatura',
+      noStlMissingThumbnails: 'Nenhum arquivo STL ou PDF sem miniatura',
       failedToGenerateThumbnails: 'Falha ao gerar miniaturas: {{error}}',
       thumbnailGenerated: 'Miniatura gerada',
       failedToGenerateThumbnail: 'Falha ao gerar miniatura: {{error}}',
@@ -5765,6 +5765,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'Convertendo o modelo STEP… {{seconds}} s',
+    stepSlowHint: 'Arquivos STEP grandes podem levar um minuto ou mais.',
     openInSlicer: 'Abrir no Slicer',
     openInSlicerWith: 'Abrir em {{slicer}}',
     moreSlicerOptions: 'Mais opções de fatiador',

+ 4 - 2
frontend/src/i18n/locales/ru.ts

@@ -3687,7 +3687,7 @@ export default {
     linkToProjectOrArchive: "Связать с проектом или архивом",
     generateThumbnail: "Создать миниатюру",
     generateThumbnails: "Создать миниатюры",
-    generateThumbnailsForMissing: "Создать отсутствующие миниатюры для STL-файлов",
+    generateThumbnailsForMissing: "Создать отсутствующие миниатюры для STL- и PDF-файлов",
     gridView: "Плитка",
     listView: "Список",
     lowDiskSpaceWarning: "Мало свободного места",
@@ -3876,7 +3876,7 @@ export default {
       folderRenamed: "Папка переименована",
       thumbnailsGenerated: "Создано миниатюр: {{count}}",
       thumbnailsGeneratedPartial: "Создано миниатюр: {{succeeded}}, ошибок: {{failed}}",
-      noStlMissingThumbnails: "Нет STL-файлов без миниатюр",
+      noStlMissingThumbnails: "Нет STL- или PDF-файлов без миниатюр",
       failedToGenerateThumbnails: "Не удалось создать миниатюры: {{error}}",
       thumbnailGenerated: "Миниатюра создана",
       failedToGenerateThumbnail: "Не удалось создать миниатюру: {{error}}",
@@ -5498,6 +5498,8 @@ export default {
     },
   },
   modelViewer: {
+    stepConverting: "Преобразование STEP-модели… {{seconds}} с",
+    stepSlowHint: "Большие STEP-файлы могут обрабатываться минуту и дольше.",
     openInSlicer: "Открыть в слайсере",
     openInSlicerWith: "Открыть в {{slicer}}",
     moreSlicerOptions: "Другие варианты слайсера",

+ 4 - 2
frontend/src/i18n/locales/sv.ts

@@ -3915,7 +3915,7 @@ errors: {
     linkToProjectOrArchive: 'Länka till projekt eller arkiv',
     generateThumbnail: 'Generera minibild',
     generateThumbnails: 'Generera minibilder',
-    generateThumbnailsForMissing: 'Generera minibilder för STL-filer som saknar dem',
+    generateThumbnailsForMissing: 'Generera minibilder för STL- och PDF-filer som saknar dem',
     gridView: 'Rutnätsvy',
     listView: 'Listvy',
     lowDiskSpaceWarning: 'Varning för lågt diskutrymme',
@@ -4105,7 +4105,7 @@ errors: {
       folderRenamed: 'Mapp omdöpt',
       thumbnailsGenerated: 'Genererade {{count}} minibild(er)',
       thumbnailsGeneratedPartial: 'Genererade {{succeeded}} minibild(er), {{failed}} misslyckades',
-      noStlMissingThumbnails: 'Inga STL-filer saknar minibilder',
+      noStlMissingThumbnails: 'Inga STL- eller PDF-filer saknar minibilder',
       failedToGenerateThumbnails: 'Kunde inte generera minibilder: {{error}}',
       thumbnailGenerated: 'Minibild genererad',
       failedToGenerateThumbnail: 'Kunde inte generera minibild: {{error}}',
@@ -5827,6 +5827,8 @@ errors: {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: 'Konverterar STEP-modellen… {{seconds}} s',
+    stepSlowHint: 'Stora STEP-filer kan ta en minut eller mer.',
     openInSlicer: 'Öppna i slicer',
     openInSlicerWith: 'Öppna i {{slicer}}',
     moreSlicerOptions: 'Fler slicer-alternativ',

+ 4 - 2
frontend/src/i18n/locales/tr.ts

@@ -3880,7 +3880,7 @@ export default {
     linkToProjectOrArchive: 'Projeye veya arşive bağla',
     generateThumbnail: 'Küçük Resim Oluştur',
     generateThumbnails: 'Küçük Resimler Oluştur',
-    generateThumbnailsForMissing: 'Eksik olan STL dosyaları için küçük resimler oluştur',
+    generateThumbnailsForMissing: 'Eksik olan STL ve PDF dosyaları için küçük resimler oluştur',
     gridView: 'Izgara görünümü',
     listView: 'Liste görünümü',
     lowDiskSpaceWarning: 'Düşük disk alanı uyarısı',
@@ -4070,7 +4070,7 @@ export default {
       folderRenamed: 'Klasör yeniden adlandırıldı',
       thumbnailsGenerated: '{{count}} küçük resim oluşturuldu',
       thumbnailsGeneratedPartial: '{{succeeded}} küçük resim oluşturuldu, {{failed}} başarısız',
-      noStlMissingThumbnails: 'Küçük resmi eksik STL dosyası yok',
+      noStlMissingThumbnails: 'Küçük resmi eksik STL veya PDF dosyası yok',
       failedToGenerateThumbnails: 'Küçük resimler oluşturulamadı: {{error}}',
       thumbnailGenerated: 'Küçük resim oluşturuldu',
       failedToGenerateThumbnail: 'Küçük resim oluşturulamadı: {{error}}',
@@ -5740,6 +5740,8 @@ export default {
 
   // Model Görüntüleyici
   modelViewer: {
+    stepConverting: 'STEP modeli dönüştürülüyor… {{seconds}} sn',
+    stepSlowHint: 'Büyük STEP dosyaları bir dakika veya daha uzun sürebilir.',
     openInSlicer: 'Dilimleyicide Aç',
     openInSlicerWith: '{{slicer}} ile aç',
     moreSlicerOptions: 'Diğer dilimleyici seçenekleri',

+ 4 - 2
frontend/src/i18n/locales/uk.ts

@@ -3913,7 +3913,7 @@ export default {
     linkToProjectOrArchive: "Посилання на проєкт або архів",
     generateThumbnail: "Створити мініатюру",
     generateThumbnails: "Створити мініатюри",
-    generateThumbnailsForMissing: "Створити відсутні мініатюри файлів STL",
+    generateThumbnailsForMissing: "Створити відсутні мініатюри файлів STL і PDF",
     gridView: "Вигляд сітки",
     listView: "Перегляд списку",
     lowDiskSpaceWarning: "Попередження про брак місця на диску",
@@ -4103,7 +4103,7 @@ export default {
       folderRenamed: "Папку перейменовано",
       thumbnailsGenerated: "Створено мініатюри {{count}}",
       thumbnailsGeneratedPartial: "Створено мініатюри {{succeeded}}, {{failed}} не вдалося",
-      noStlMissingThumbnails: "Немає файлів STL без мініатюр",
+      noStlMissingThumbnails: "Немає файлів STL або PDF без мініатюр",
       failedToGenerateThumbnails: "Не вдалося створити мініатюри: {{error}}",
       thumbnailGenerated: "Створено мініатюру",
       failedToGenerateThumbnail: "Не вдалося створити мініатюру: {{error}}",
@@ -5819,6 +5819,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: "Перетворення STEP-моделі… {{seconds}} с",
+    stepSlowHint: "Великі файли STEP можуть оброблятися хвилину й довше.",
     openInSlicer: "Відкрити у слайсері",
     openInSlicerWith: "Відкрити у {{slicer}}",
     moreSlicerOptions: "Більше варіантів слайсера",

+ 4 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -3873,7 +3873,7 @@ export default {
     linkToProjectOrArchive: '链接到项目或归档',
     generateThumbnail: '生成缩略图',
     generateThumbnails: '生成缩略图',
-    generateThumbnailsForMissing: '为缺少缩略图的 STL 文件生成缩略图',
+    generateThumbnailsForMissing: '为缺少缩略图的 STL 和 PDF 文件生成缩略图',
     gridView: '网格视图',
     listView: '列表视图',
     lowDiskSpaceWarning: '磁盘空间不足警告',
@@ -4063,7 +4063,7 @@ export default {
       folderRenamed: '文件夹已重命名',
       thumbnailsGenerated: '已生成 {{count}} 个缩略图',
       thumbnailsGeneratedPartial: '已生成 {{succeeded}} 个缩略图,{{failed}} 个失败',
-      noStlMissingThumbnails: '没有缺少缩略图的 STL 文件',
+      noStlMissingThumbnails: '没有缺少缩略图的 STL 或 PDF 文件',
       failedToGenerateThumbnails: '生成缩略图失败:{{error}}',
       thumbnailGenerated: '缩略图已生成',
       failedToGenerateThumbnail: '生成缩略图失败:{{error}}',
@@ -5765,6 +5765,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: '正在转换 STEP 模型… {{seconds}} 秒',
+    stepSlowHint: '较大的 STEP 文件可能需要一分钟或更长时间。',
     openInSlicer: '在切片软件中打开',
     openInSlicerWith: '用{{slicer}}打开',
     moreSlicerOptions: '更多切片软件选项',

+ 4 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -3873,7 +3873,7 @@ export default {
     linkToProjectOrArchive: '連結到專案或歸檔',
     generateThumbnail: '產生縮圖',
     generateThumbnails: '產生縮圖',
-    generateThumbnailsForMissing: '為缺少縮圖的 STL 檔案產生縮圖',
+    generateThumbnailsForMissing: '為缺少縮圖的 STL 和 PDF 檔案產生縮圖',
     gridView: '網格檢視',
     listView: '列表檢視',
     lowDiskSpaceWarning: '磁碟空間不足警告',
@@ -4063,7 +4063,7 @@ export default {
       folderRenamed: '資料夾已重新命名',
       thumbnailsGenerated: '已產生 {{count}} 個縮圖',
       thumbnailsGeneratedPartial: '已產生 {{succeeded}} 個縮圖,{{failed}} 個失敗',
-      noStlMissingThumbnails: '沒有缺少縮圖的 STL 檔案',
+      noStlMissingThumbnails: '沒有缺少縮圖的 STL 或 PDF 檔案',
       failedToGenerateThumbnails: '產生縮圖失敗:{{error}}',
       thumbnailGenerated: '縮圖已產生',
       failedToGenerateThumbnail: '產生縮圖失敗:{{error}}',
@@ -5765,6 +5765,8 @@ export default {
 
   // Model Viewer
   modelViewer: {
+    stepConverting: '正在轉換 STEP 模型… {{seconds}} 秒',
+    stepSlowHint: '較大的 STEP 檔案可能需要一分鐘或更長時間。',
     openInSlicer: '在切片軟體中開啟',
     openInSlicerWith: '用{{slicer}}開啟',
     moreSlicerOptions: '更多切片軟體選項',

+ 18 - 0
frontend/vite.config.ts

@@ -30,6 +30,24 @@ export default defineConfig({
     // regex features are NOT lowered, which is why the baseline check script
     // still exists alongside this setting.
     target: 'safari16',
+    rolldownOptions: {
+      // occt-import-js (STEP previews, #2976) is an Emscripten build that
+      // requires `path` and `crypto`, but only inside its ENVIRONMENT_IS_NODE
+      // branches; in the browser it takes the fetch/getRandomValues paths.
+      // Vite externalizes both and warns on every build. Drop just those two
+      // warnings for that one package so a new externalization still shows.
+      onLog(level, log, defaultHandler) {
+        if (
+          level === 'warn' &&
+          /Module "(path|crypto)" has been externalized for browser compatibility, imported by "[^"]*\/node_modules\/occt-import-js\//.test(
+            log.message,
+          )
+        ) {
+          return
+        }
+        defaultHandler(level, log)
+      },
+    },
   },
   server: {
     host: '0.0.0.0',

+ 5 - 0
requirements.txt

@@ -78,6 +78,11 @@ qrcode[pil]>=7.4.0
 # PDF generation (spool label printing — #809)
 reportlab>=4.0.0
 
+# PDF thumbnails for the file manager (#2976): renders page one with PDFium.
+# Wheels bundle the PDFium binary for every platform we ship (Linux x86_64 /
+# aarch64 / armv7, macOS, Windows), so no system package is needed.
+pypdfium2>=5.0.0
+
 # STL Thumbnail Generation
 trimesh>=4.0.0
 matplotlib>=3.8.0

File diff suppressed because it is too large
+ 1 - 0
static/assets/PdfPreviewModal-CJNoidne.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/SpreadsheetPreviewModal-CbNPic2J.js


+ 1 - 0
static/assets/chunk-aKtaBQYM.js

@@ -0,0 +1 @@
+var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});export{l as i,s as n,u as r,o as t};

File diff suppressed because it is too large
+ 1 - 0
static/assets/index-BCr8craX.css


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-ChscM3lF.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-wE5DdboK.js


BIN
static/assets/occt-import-js-BhHfLpto.wasm


File diff suppressed because it is too large
+ 0 - 0
static/assets/papaparse.min-CR43Mv99.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-ciypm8Kl.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf.worker.min-0idpi3xP.js


+ 1 - 0
static/assets/pdf.worker.min-DpOxPwGP.js

@@ -0,0 +1 @@
+var e=`/assets/pdf.worker.min-0idpi3xP.js`;export{e as default};

File diff suppressed because it is too large
+ 0 - 0
static/assets/stepPreview.worker-QYcQPnWg.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/xlsx-CKkngM-o.js


+ 3 - 2
static/index.html

@@ -26,8 +26,9 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CCYi0XHi.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
+    <script type="module" crossorigin src="/assets/index-wE5DdboK.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-BCr8craX.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff