diff --git a/.github/workflows/impress.yml b/.github/workflows/impress.yml index 896f07efb..385aa633c 100644 --- a/.github/workflows/impress.yml +++ b/.github/workflows/impress.yml @@ -206,10 +206,11 @@ jobs: - name: Install development dependencies run: pip install --user .[dev] - - name: Install gettext (required to compile messages) + - name: Install gettext (required to compile messages) and MIME support run: | sudo apt-get update - sudo apt-get install -y gettext pandoc + sudo apt-get install -y gettext pandoc shared-mime-info + sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types - name: Generate a MO file from strings extracted from the project run: python manage.py compilemessages diff --git a/CHANGELOG.md b/CHANGELOG.md index 28e07ec46..b8e55e73c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to - 💄(frontend) add filtering to left panel #475 - ✨(frontend) new share modal ui #489 - ✨(frontend) add favorite feature #515 +- 🏷️(backend) add content-type to uploaded files #552 +- ✨(frontend) export pdf docx front side #537 +- 📝(documentation) Documentation about self-hosted installation #530 +- ✨(helm) helm versioning #530 ## Changed diff --git a/Dockerfile b/Dockerfile index f6378f9e8..501db0d53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,10 +72,11 @@ RUN apk add \ gettext \ gdk-pixbuf \ libffi-dev \ - pandoc \ pango \ shared-mime-info +RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types + # Copy entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index b217194d9..6066c7c29 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2,6 +2,7 @@ # pylint: disable=too-many-lines import logging +import mimetypes import re import uuid from urllib.parse import urlparse @@ -604,8 +605,19 @@ def attachment_upload(self, request, *args, **kwargs): extension = serializer.validated_data["expected_extension"] key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}" + # Determine the content type of the file + file = serializer.validated_data["file"] + content_type, _ = mimetypes.guess_type(file.name) + + # Fallback if MIME type cannot be determined + if not content_type: + content_type = "application/octet-stream" + # Prepare metadata for storage - extra_args = {"Metadata": {"owner": str(request.user.id)}} + extra_args = { + "Metadata": {"owner": str(request.user.id)}, + "ContentType": content_type, + } if serializer.validated_data["is_unsafe"]: extra_args["Metadata"]["is_unsafe"] = "true" @@ -936,40 +948,6 @@ def perform_create(self, serializer): role=models.RoleChoices.OWNER, ) - @drf.decorators.action( - detail=True, - methods=["post"], - url_path="generate-document", - permission_classes=[permissions.AccessPermission], - ) - # pylint: disable=unused-argument - def generate_document(self, request, pk=None): - """ - Generate and return a document for this template around the - body passed as argument. - - 2 types of body are accepted: - - HTML: body_type = "html" - - Markdown: body_type = "markdown" - - 2 types of documents can be generated: - - PDF: format = "pdf" - - Docx: format = "docx" - """ - serializer = serializers.DocumentGenerationSerializer(data=request.data) - - if not serializer.is_valid(): - return drf.response.Response( - serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST - ) - - body = serializer.validated_data["body"] - body_type = serializer.validated_data["body_type"] - export_format = serializer.validated_data["format"] - - template = self.get_object() - return template.generate_document(body, body_type, export_format) - class TemplateAccessViewSet( ResourceAccessViewsetMixin, diff --git a/src/backend/core/models.py b/src/backend/core/models.py index b7fb8e797..2bba13e42 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -4,11 +4,8 @@ import hashlib import smtplib -import tempfile -import textwrap import uuid from datetime import timedelta -from io import BytesIO from logging import getLogger from django.conf import settings @@ -20,19 +17,12 @@ from django.core.files.storage import default_storage from django.core.mail import send_mail from django.db import models -from django.http import FileResponse -from django.template.base import Template as DjangoTemplate -from django.template.context import Context from django.template.loader import render_to_string -from django.utils import html, timezone +from django.utils import timezone from django.utils.functional import cached_property, lazy from django.utils.translation import get_language, override from django.utils.translation import gettext_lazy as _ -import frontmatter -import markdown -import pypandoc -import weasyprint from botocore.exceptions import ClientError from timezone_field import TimeZoneField @@ -754,107 +744,6 @@ def get_abilities(self, user): "retrieve": can_get, } - def generate_pdf(self, body_html, metadata): - """ - Generate and return a pdf document wrapped around the current template - """ - document_html = weasyprint.HTML( - string=DjangoTemplate(self.code).render( - Context({"body": html.format_html(body_html), **metadata}) - ) - ) - css = weasyprint.CSS( - string=self.css, - font_config=weasyprint.text.fonts.FontConfiguration(), - ) - - pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1) - response = FileResponse(BytesIO(pdf_content), content_type="application/pdf") - response["Content-Disposition"] = f"attachment; filename={self.title}.pdf" - - return response - - def generate_word(self, body_html, metadata): - """ - Generate and return a docx document wrapped around the current template - """ - template_string = DjangoTemplate(self.code).render( - Context({"body": html.format_html(body_html), **metadata}) - ) - - html_string = f""" - - - - - - - {template_string} - - - """ - - reference_docx = "core/static/reference.docx" - output = BytesIO() - - # Convert the HTML to a temporary docx file - with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file: - output_path = tmp_file.name - - pypandoc.convert_text( - html_string, - "docx", - format="html", - outputfile=output_path, - extra_args=["--reference-doc", reference_docx], - ) - - # Create a BytesIO object to store the output of the temporary docx file - with open(output_path, "rb") as f: - output = BytesIO(f.read()) - - # Ensure the pointer is at the beginning - output.seek(0) - - response = FileResponse( - output, - content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ) - response["Content-Disposition"] = f"attachment; filename={self.title}.docx" - - return response - - def generate_document(self, body, body_type, export_format): - """ - Generate and return a document for this template around the - body passed as argument. - - 2 types of body are accepted: - - HTML: body_type = "html" - - Markdown: body_type = "markdown" - - 2 types of documents can be generated: - - PDF: export_format = "pdf" - - Docx: export_format = "docx" - """ - document = frontmatter.loads(body) - metadata = document.metadata - strip_body = document.content.strip() - - if body_type == "html": - body_html = strip_body - else: - body_html = ( - markdown.markdown(textwrap.dedent(strip_body)) if strip_body else "" - ) - - if export_format == "pdf": - return self.generate_pdf(body_html, metadata) - - return self.generate_word(body_html, metadata) - class TemplateAccess(BaseAccess): """Relation model to give access to a template for a user or a team with a role.""" diff --git a/src/backend/core/static/reference.docx b/src/backend/core/static/reference.docx deleted file mode 100644 index 2192455df..000000000 Binary files a/src/backend/core/static/reference.docx and /dev/null differ diff --git a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py index 1288f8ca6..3b0574fa0 100644 --- a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py +++ b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py @@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success(): assert response.status_code == 201 pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png") - match = pattern.search(response.json()["file"]) + file_path = response.json()["file"] + match = pattern.search(file_path) file_id = match.group(1) # Validate that file_id is a valid UUID uuid.UUID(file_id) + # Now, check the metadata of the uploaded file + key = file_path.replace("/media", "") + file_head = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=key + ) + + assert file_head["Metadata"] == {"owner": "None"} + assert file_head["ContentType"] == "image/png" + @pytest.mark.parametrize( "reach, role", @@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams): Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id)} + assert file_head["ContentType"] == "image/png" def test_api_documents_attachment_upload_invalid(client): @@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings): @pytest.mark.parametrize( - "name,content,extension", + "name,content,extension,content_type", [ - ("test.exe", b"text", "exe"), - ("test", b"text", "txt"), - ("test.aaaaaa", b"test", "txt"), - ("test.txt", PIXEL, "txt"), - ("test.py", b"#!/usr/bin/python", "py"), + ("test.exe", b"text", "exe", "application/x-msdownload"), + ("test", b"text", "txt", "application/octet-stream"), + ("test.aaaaaa", b"test", "txt", "application/octet-stream"), + ("test.txt", PIXEL, "txt", "text/plain"), + ("test.py", b"#!/usr/bin/python", "py", "text/x-python"), ], ) -def test_api_documents_attachment_upload_fix_extension(name, content, extension): +def test_api_documents_attachment_upload_fix_extension( + name, content, extension, content_type +): """ A file with no extension or a wrong extension is accepted and the extension is corrected in storage. @@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension) Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["ContentType"] == content_type def test_api_documents_attachment_upload_empty_file(): @@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe(): Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["ContentType"] == "application/x-msdownload" diff --git a/src/backend/core/tests/templates/test_api_templates_generate_document.py b/src/backend/core/tests/templates/test_api_templates_generate_document.py deleted file mode 100644 index 0ed8f4d39..000000000 --- a/src/backend/core/tests/templates/test_api_templates_generate_document.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -Test users API endpoints in the impress core app. -""" - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.tests.conftest import TEAM, USER, VIA - -pytestmark = pytest.mark.django_db - - -def test_api_templates_generate_document_anonymous_public(): - """Anonymous users can generate pdf document with public templates.""" - template = factories.TemplateFactory(is_public=True) - data = { - "body": "# Test markdown body", - } - - response = APIClient().post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_anonymous_not_public(): - """ - Anonymous users should not be allowed to generate pdf document with templates - that are not marked as public. - """ - template = factories.TemplateFactory(is_public=False) - data = { - "body": "# Test markdown body", - } - - response = APIClient().post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -def test_api_templates_generate_document_authenticated_public(): - """Authenticated users can generate pdf document with public templates.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "# Test markdown body"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_authenticated_not_public(): - """ - Authenticated users should not be allowed to generate pdf document with templates - that are not marked as public. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=False) - data = {"body": "# Test markdown body"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize("via", VIA) -def test_api_templates_generate_document_related(via, mock_user_teams): - """Users related to a template can generate pdf document.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - access = None - if via == USER: - access = factories.UserTemplateAccessFactory(user=user) - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - access = factories.TeamTemplateAccessFactory(team="lasuite") - - data = {"body": "# Test markdown body"} - - response = client.post( - f"/api/v1.0/templates/{access.template_id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_type_html(): - """Generate pdf document with the body type html.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "

Test body

", "body_type": "html"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_type_markdown(): - """Generate pdf document with the body type markdown.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "# Test markdown body", "body_type": "markdown"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_type_unknown(): - """Generate pdf document with the body type unknown.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "# Test markdown body", "body_type": "unknown"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 400 - assert response.json() == { - "body_type": [ - '"unknown" is not a valid choice.', - ] - } - - -def test_api_templates_generate_document_export_docx(): - """Generate pdf document with the body type html.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "

Test body

", "body_type": "html", "format": "docx"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert ( - response.headers["content-type"] - == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) diff --git a/src/backend/core/tests/test_models_templates.py b/src/backend/core/tests/test_models_templates.py index 6e7cba2c1..95f8fbdea 100644 --- a/src/backend/core/tests/test_models_templates.py +++ b/src/backend/core/tests/test_models_templates.py @@ -2,10 +2,6 @@ Unit tests for the Template model """ -import os -import time -from unittest import mock - from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ValidationError @@ -189,31 +185,3 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries): "partial_update": False, "generate_document": True, } - - -def test_models_templates__generate_word(): - """Generate word document and assert no tmp files are left in /tmp folder.""" - template = factories.TemplateFactory() - response = template.generate_word("

Test body

", {}) - - assert response.status_code == 200 - assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0 - - -@mock.patch( - "pypandoc.convert_text", - side_effect=RuntimeError("Conversion failed"), -) -def test_models_templates__generate_word__raise_error(_mock_pypandoc): - """ - Generate word document and assert no tmp files are left in /tmp folder - even when the conversion fails. - """ - template = factories.TemplateFactory() - - try: - template.generate_word("

Test body

", {}) - except RuntimeError as e: - assert str(e) == "Conversion failed" - time.sleep(0.5) - assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0 diff --git a/src/backend/demo/data/template/code.txt b/src/backend/demo/data/template/code.txt index 0ab83f606..56f6736b9 100644 --- a/src/backend/demo/data/template/code.txt +++ b/src/backend/demo/data/template/code.txt @@ -1,10 +1,2 @@ - -
- -
-
-
{{ body }}
-
-
+ +
\ No newline at end of file diff --git a/src/backend/demo/data/template/css.txt b/src/backend/demo/data/template/css.txt index 79a440ab7..e69de29bb 100644 --- a/src/backend/demo/data/template/css.txt +++ b/src/backend/demo/data/template/css.txt @@ -1,20 +0,0 @@ -body { - background: white; - font-family: arial; -} -.header img { - width: 5cm; - margin-left: -0.4cm; -} -.body{ - margin-top: 1.5rem; -} -img { - max-width: 100%; -} -[custom-style="center"] { - text-align: center; -} -[custom-style="right"] { - text-align: right; -} diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 2bc8b1a60..ae6cae167 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -50,13 +50,10 @@ dependencies = [ "openai==1.58.1", "psycopg[binary]==3.2.3", "PyJWT==2.10.1", - "pypandoc==1.14", - "python-frontmatter==1.1.0", "python-magic==0.4.27", "requests==2.32.3", "sentry-sdk==2.19.2", "url-normalize==1.4.3", - "WeasyPrint>=60.2", "whitenoise==6.8.2", "mozilla-django-oidc==4.0.1", ] diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 6e62d3add..eff9592a6 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -1,6 +1,7 @@ +import path from 'path'; + import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; -import jsdom from 'jsdom'; import pdf from 'pdf-parse'; import { createDoc, verifyDocName } from './common'; @@ -41,10 +42,8 @@ test.describe('Doc Export', () => { ).toBeVisible(); await expect(page.getByRole('button', { name: 'Download' })).toBeVisible(); }); - test('it converts the doc to pdf with a template integrated', async ({ - page, - browserName, - }) => { + + test('it exports the doc to pdf', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -77,10 +76,7 @@ test.describe('Doc Export', () => { expect(pdfText).toContain('Hello World'); // This is the doc text }); - test('it converts the doc to docx with a template integrated', async ({ - page, - browserName, - }) => { + test('it exports the doc to docx', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -111,120 +107,61 @@ test.describe('Doc Export', () => { expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`); }); - test('it converts the blocknote json in correct html for the export', async ({ - page, - browserName, - }) => { - test.setTimeout(60000); - + /** + * This test tell us that the export to pdf is working with images + * but it does not tell us if the images are beeing displayed correctly + * in the pdf. + * + * TODO: Check if the images are displayed correctly in the pdf + */ + test('it exports the docs with images', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); - let body = ''; - await page.route('**/templates/*/generate-document/', async (route) => { - const request = route.request(); - body = request.postDataJSON().body; - - await route.continue(); + const fileChooserPromise = page.waitForEvent('filechooser'); + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); }); await verifyDocName(page, randomDoc); - await page.locator('.bn-block-outer').last().fill('Hello World'); - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('Break'); - await expect(page.getByText('Break')).toBeVisible(); - - // Center the text - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="alignTextCenter"]').click(); - - // Change the background color - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="background-color-brown"]').click(); - - // Change the text color - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="text-color-orange"]').click(); + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); - // Add a list - await page.locator('.bn-block-outer').last().click(); await page.keyboard.press('Enter'); await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Bullet List').click(); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 3'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); - // Add a number list - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Numbered List').click(); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 3'); + const image = page.getByRole('img', { name: 'logo-suite-numerique.png' }); + + await expect(image).toBeVisible(); - // Add img - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page - .getByRole('option', { - name: 'Image', - }) - .click(); await page - .getByRole('tab', { - name: 'Embed', + .getByRole('button', { + name: 'download', }) .click(); + await page - .getByPlaceholder('Enter URL') - .fill('https://example.com/image.jpg'); - await page - .getByRole('button', { - name: 'Embed image', + .getByRole('combobox', { + name: 'Template', }) .click(); - // Download await page - .getByRole('button', { - name: 'download', + .getByRole('option', { + name: 'Demo Template', }) - .click(); + .click({ + delay: 100, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); await page .getByRole('button', { @@ -232,31 +169,13 @@ test.describe('Doc Export', () => { }) .click(); - // Empty paragraph should be replaced by a
- expect(body.match(/
/g)?.length).toBeGreaterThanOrEqual(2); - expect(body).toContain('style="color: orange;"'); - expect(body).toContain('custom-style="center"'); - expect(body).toContain('style="background-color: brown;"'); - - const { JSDOM } = jsdom; - const DOMParser = new JSDOM().window.DOMParser; - const parser = new DOMParser(); - const html = parser.parseFromString(body, 'text/html'); - - const ulLis = html.querySelectorAll('ul li'); - expect(ulLis.length).toBe(3); - expect(ulLis[0].textContent).toBe('Test List 1'); - expect(ulLis[1].textContent).toBe('Test List 2'); - expect(ulLis[2].textContent).toBe('Test List 3'); - - const olLis = html.querySelectorAll('ol li'); - expect(olLis.length).toBe(3); - expect(olLis[0].textContent).toBe('Test Number 1'); - expect(olLis[1].textContent).toBe('Test Number 2'); - expect(olLis[2].textContent).toBe('Test Number 3'); - - const img = html.querySelectorAll('img'); - expect(img.length).toBe(1); - expect(img[0].src).toBe('https://example.com/image.jpg'); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfExport = await pdf(pdfBuffer); + const pdfText = pdfExport.text; + + expect(pdfText).toContain('Hello World'); }); }); diff --git a/src/frontend/apps/e2e/package.json b/src/frontend/apps/e2e/package.json index 290b8808e..cefbb6bc8 100644 --- a/src/frontend/apps/e2e/package.json +++ b/src/frontend/apps/e2e/package.json @@ -13,16 +13,15 @@ }, "devDependencies": { "@playwright/test": "1.49.1", + "@types/luxon": "3.4.2", "@types/node": "*", "@types/pdf-parse": "1.1.4", "eslint-config-impress": "*", - "typescript": "*", "luxon": "3.5.0", - "@types/luxon": "3.4.2" + "typescript": "*" }, "dependencies": { "convert-stream": "1.0.2", - "jsdom": "25.0.1", "pdf-parse": "1.1.1" } } diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 1439798d0..8f06d7dc7 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -18,13 +18,17 @@ "@blocknote/core": "0.22.0", "@blocknote/mantine": "0.22.0", "@blocknote/react": "0.22.0", + "@blocknote/xl-docx-exporter": "0.22.0", + "@blocknote/xl-pdf-exporter": "0.22.0", "@gouvfr-lasuite/integration": "1.0.2", "@hocuspocus/provider": "2.15.0", "@openfun/cunningham-react": "2.9.4", + "@react-pdf/renderer": "4.1.6", "@sentry/nextjs": "8.47.0", "@tanstack/react-query": "5.62.11", "cmdk": "1.0.4", "crisp-sdk-web": "1.0.25", + "docx": "9.1.0", "i18next": "24.2.0", "i18next-browser-languagedetector": "8.0.2", "idb": "8.0.1", diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index adf3ce5c2..48a7a3a6c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -27,7 +27,7 @@ import { } from '@/features/docs/doc-versioning'; import { useResponsiveStore } from '@/stores'; -import { ModalPDF } from './ModalExport'; +import { ModalExport } from './ModalExport'; interface DocToolBoxProps { doc: Doc; @@ -43,7 +43,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const colors = colorsTokens(); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); - const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); + const [isModalExportOpen, setIsModalExportOpen] = useState(false); const selectHistoryModal = useModal(); const modalShare = useModal(); @@ -66,7 +66,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { label: t('Export'), icon: 'download', callback: () => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }, }, ] @@ -200,7 +200,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { } onClick={() => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }} size={isSmallMobile ? 'small' : 'medium'} /> @@ -230,8 +230,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { {modalShare.isOpen && ( modalShare.close()} doc={doc} /> )} - {isModalPDFOpen && ( - setIsModalPDFOpen(false)} doc={doc} /> + {isModalExportOpen && ( + setIsModalExportOpen(false)} doc={doc} /> )} {isModalRemoveOpen && ( setIsModalRemoveOpen(false)} doc={doc} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx index e08ac21dc..4e72a6eb2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx @@ -1,3 +1,11 @@ +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from '@blocknote/xl-docx-exporter'; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from '@blocknote/xl-pdf-exporter'; import { Button, Loader, @@ -7,91 +15,116 @@ import { VariantType, useToastProvider, } from '@openfun/cunningham-react'; -import { useEffect, useMemo, useState } from 'react'; +import { pdf } from '@react-pdf/renderer'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import { Box, Text } from '@/components'; import { useEditorStore } from '@/features/docs/doc-editor'; import { Doc } from '@/features/docs/doc-management'; -import { useExport } from '../api/useExport'; import { TemplatesOrdering, useTemplates } from '../api/useTemplates'; -import { adaptBlockNoteHTML, downloadFile } from '../utils'; +import { downloadFile, exportResolveFileUrl } from '../utils'; -export enum DocDownloadFormat { +enum DocDownloadFormat { PDF = 'pdf', DOCX = 'docx', } -interface ModalPDFProps { +interface ModalExportProps { onClose: () => void; doc: Doc; } -export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { +export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { t } = useTranslation(); const { data: templates } = useTemplates({ ordering: TemplatesOrdering.BY_CREATED_ON_DESC, }); const { toast } = useToastProvider(); const { editor } = useEditorStore(); - const { - mutate: createExport, - data: documentGenerated, - isSuccess, - isPending, - error, - } = useExport(); - const [templateIdSelected, setTemplateIdSelected] = useState(); + const [templateSelected, setTemplateSelected] = useState(''); + const [isExporting, setIsExporting] = useState(false); const [format, setFormat] = useState( DocDownloadFormat.PDF, ); const templateOptions = useMemo(() => { - if (!templates?.pages) { - return []; - } - - const templateOptions = templates.pages + const templateOptions = (templates?.pages || []) .map((page) => page.results.map((template) => ({ label: template.title, - value: template.id, + value: template.code, })), ) .flat(); - if (templateOptions.length) { - setTemplateIdSelected(templateOptions[0].value); - } + templateOptions.unshift({ + label: t('Empty template'), + value: '', + }); return templateOptions; - }, [templates?.pages]); + }, [t, templates?.pages]); - useEffect(() => { - if (!error) { + async function onSubmit() { + if (!editor) { + toast(t('The export failed'), VariantType.ERROR); return; } - toast(error.message, VariantType.ERROR); + setIsExporting(true); - onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error, t]); - - useEffect(() => { - if (!documentGenerated || !isSuccess) { - return; - } - - // normalize title const title = doc.title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s/g, '-'); - downloadFile(documentGenerated, `${title}.${format}`); + const html = templateSelected; + let exportDocument = editor.document; + if (html) { + const blockTemplate = await editor.tryParseHTMLToBlocks(html); + exportDocument = [...blockTemplate, ...editor.document]; + } + + let blobExport: Blob; + if (format === DocDownloadFormat.PDF) { + const defaultExporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + ); + + const exporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + const pdfDocument = await exporter.toReactPDFDocument(exportDocument); + blobExport = await pdf(pdfDocument).toBlob(); + } else { + const defaultExporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + ); + + const exporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + + blobExport = await exporter.toBlob(exportDocument); + } + + downloadFile(blobExport, `${title}.${format}`); toast( t('Your {{format}} was downloaded succesfully', { @@ -100,29 +133,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { VariantType.SUCCESS, ); - onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [documentGenerated, isSuccess, t]); - - async function onSubmit() { - if (!templateIdSelected || !format) { - return; - } + setIsExporting(false); - if (!editor) { - toast(t('No editor found'), VariantType.ERROR); - return; - } - - let body = await editor.blocksToFullHTML(editor.document); - body = adaptBlockNoteHTML(body); - - createExport({ - templateId: templateIdSelected, - body, - body_type: 'html', - format, - }); + onClose(); } return ( @@ -138,6 +151,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="secondary" fullWidth onClick={() => onClose()} + disabled={isExporting} > {t('Cancel')} @@ -146,7 +160,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="primary" fullWidth onClick={() => void onSubmit()} - disabled={isPending || !templateIdSelected} + disabled={isExporting} > {t('Download')} @@ -173,9 +187,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { clearable={false} label={t('Template')} options={templateOptions} - value={templateIdSelected} + value={templateSelected} onChange={(options) => - setTemplateIdSelected(options.target.value as string) + setTemplateSelected(options.target.value as string) } />