diff --git a/CHANGELOG.md b/CHANGELOG.md index 580792c6..008fae88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to - ✨ Import of documents #7765 - ✨(backend) add documents/all endpoint with descendants #1553 +- ✅(export) add PDF regression tests #1762 ## [4.3.0] - 2026-01-05 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/base-content-test-pdf.txt b/src/frontend/apps/e2e/__tests__/app-impress/assets/base-content-test-pdf.txt new file mode 100644 index 00000000..d030dbd9 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/assets/base-content-test-pdf.txt @@ -0,0 +1 @@ +"AtACsvXrmg0ABwEOZG9jdW1lbnQtc3RvcmUDCmJsb2NrR3JvdXAHALL165oNAAMOYmxvY2tDb250YWluZXIHALL165oNAQMJcGFyYWdyYXBoBwCy9euaDQIGBACy9euaDQMKSGVsbG8gVGV4dCgAsvXrmg0CD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oNAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDQINdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNAQJpZAF3DmluaXRpYWxCbG9ja0lkgbL165oNAQEABYey9euaDRIDDmJsb2NrQ29udGFpbmVyBwCy9euaDRgDB2hlYWRpbmcHALL165oNGQYGALL165oNGgRib2xkAnt9hLL165oNGw9IZWxsbyBIZWFkaW5nIDGGsvXrmg0qBGJvbGQEbnVsbCgAsvXrmg0ZD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oNGQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDRkNdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNGQVsZXZlbAF9ASgAsvXrmg0ZDGlzVG9nZ2xlYWJsZQF5KACy9euaDRgCaWQBdyQ1MjZhZWIwYy1mMTk1LTRjNzUtODVmZS0yNWZlN2FhYTYyYzSBsvXrmg0YAQAFh7L165oNMgMOYmxvY2tDb250YWluZXIHALL165oNOAMHaGVhZGluZwcAsvXrmg05BgYAsvXrmg06BGJvbGQCe32EsvXrmg07D0hlbGxvIEhlYWRpbmcgMoay9euaDUoEYm9sZARudWxsKACy9euaDTkPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg05CXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oNOQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg05BWxldmVsAX0CKACy9euaDTkMaXNUb2dnbGVhYmxlAXkoALL165oNOAJpZAF3JDY2Yzg1NTU5LTZlN2ItNGQxOS04MDU1LWEzYzE3YzcyNWE1ZoGy9euaDTgBAAWHsvXrmg1SAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg1YAwdoZWFkaW5nBwCy9euaDVkGBgCy9euaDVoEYm9sZAJ7fYSy9euaDVsPSGVsbG8gSGVhZGluZyAzhrL165oNagRib2xkBG51bGwoALL165oNWQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDVkJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg1ZDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDVkFbGV2ZWwBfQMoALL165oNWQxpc1RvZ2dsZWFibGUBeSgAsvXrmg1YAmlkAXckOWM1ZTFlYWYtZTVhZi00MzA3LWI0Y2YtMmUwMWJjNDUwZjhih7L165oNWAMOYmxvY2tDb250YWluZXIHALL165oNcgMNY2hlY2tMaXN0SXRlbQcAsvXrmg1zBgQAsvXrmg10EENoZWNrbGlzdCBJdGVtIDEoALL165oNcw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDXMJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg1zDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDXMHY2hlY2tlZAF5KACy9euaDXICaWQBdyQxYjQ2Y2E3Ny0wYWJhLTQyNDAtYmFiZC0xNDY5NzA3OGJkZDiHsvXrmg1yAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg2KAQMNY2hlY2tMaXN0SXRlbQcAsvXrmg2LAQYEALL165oNjAEQQ2hlY2tsaXN0IEl0ZW0gMigAsvXrmg2LAQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDYsBCXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oNiwENdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNiwEHY2hlY2tlZAF5KACy9euaDYoBAmlkAXckYjkyYjY2MGUtNjFhNi00NzE5LTgyMDctMTc3ZTQwMzBhZGZih7L165oNigEDDmJsb2NrQ29udGFpbmVyAQCy9euaDaIBAQAEKACy9euaDaIBAmlkAXckOTE0YWY3YTUtYmY4ZS00ZjkzLTg5NTItYjlkODM4NmI3ODM4h7L165oNogEDDmJsb2NrQ29udGFpbmVyBwCy9euaDakBAwVxdW90ZQcAsvXrmg2qAQYEALL165oNqwELSGVsbG8gUXVvdGUoALL165oNqgEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg2qAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDakBAmlkAXckOWE2Y2FlMjUtMjAwMi00OTViLTlmZWEtMmRjNzc4NDQ1OGNhh7L165oNqQEDDmJsb2NrQ29udGFpbmVyBwCy9euaDboBAwlwYXJhZ3JhcGgoALL165oNuwEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg27AQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDbsBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDboBAmlkAXckNDU3MGI4NWEtYjliMy00MWViLWEyMTEtMDU3MGQ4OTk5OTk4h7L165oNugEDDmJsb2NrQ29udGFpbmVyBwCy9euaDcABAw50b2dnbGVMaXN0SXRlbQcAsvXrmg3BAQYEALL165oNwgETSGVsbG8gVG9nZ2xlIExpc3QgMSgAsvXrmg3BAQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDcEBCXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oNwQENdGV4dEFsaWdubWVudAF3BGxlZnSHsvXrmg3BAQMKYmxvY2tHcm91cAcAsvXrmg3ZAQMOYmxvY2tDb250YWluZXIHALL165oN2gEDCXBhcmFncmFwaAcAsvXrmg3bAQYEALL165oN3AEbSGVsbG8gVG9nZ2xlIExpc3QgMSBDb250ZW50KACy9euaDdsBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oN2wEJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg3bAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg3aAQJpZAF3JDMwOTk5YzZjLWNlMTctNGEzOS1iNzcwLWMxZTMzZmJlMzlhMoey9euaDdoBAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg38AQMJcGFyYWdyYXBoKACy9euaDf0BD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oN/QEJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg39AQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg38AQJpZAF3JGJjMDU4ODY5LTRhYTYtNDc1MC04Nzc1LTkyOWRmNjZiNGMwMygAsvXrmg3AAQJpZAF3JDUxZjNlZGUzLTllYjQtNDNiNS05ODA1LTZhZWY2ZDNhNTRmY4ey9euaDcABAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg2DAgMQbnVtYmVyZWRMaXN0SXRlbQcAsvXrmg2EAgYEALL165oNhQIUTnVtYmVyZWQgTGlzdCBJdGVtIDEoALL165oNhAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg2EAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDYQCDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDYQCBXN0YXJ0AX8oALL165oNgwICaWQBdyRiZDQ1MzRjMy05YTNmLTQ4OWQtOGRhYS01MjNhY2U5OWU0MWGHsvXrmg2DAgMOYmxvY2tDb250YWluZXIHALL165oNnwIDEG51bWJlcmVkTGlzdEl0ZW0HALL165oNoAIGBACy9euaDaECFE51bWJlcmVkIExpc3QgSXRlbSAyKACy9euaDaACD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oNoAIJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg2gAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg2gAgVzdGFydAF/KACy9euaDZ8CAmlkAXckM2EzYTQ2ZTktMTc0OS00ZTU0LWFjNjQtMmRkZjM3MDFhZWUyh7L165oNnwIDDmJsb2NrQ29udGFpbmVyAQCy9euaDbsCAQAEKACy9euaDbsCAmlkAXckMGRiNmJkYTUtYzkzZi00OWUwLThmYmYtNjQ2ZTQzYzc0ZGE1h7L165oNuwIDDmJsb2NrQ29udGFpbmVyBwCy9euaDcICAw5idWxsZXRMaXN0SXRlbQcAsvXrmg3DAgYEALL165oNxAISQnVsbGV0IExpc3QgSXRlbSAxKACy9euaDcMCD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oNwwIJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg3DAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg3CAgJpZAF3JDZlYmZjNDQ1LTI4ZDEtNGI2OS05ZjViLTlmMTI4NmJmZTJmOIey9euaDcICAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg3bAgMOYnVsbGV0TGlzdEl0ZW0HALL165oN3AIGBACy9euaDd0CEkJ1bGxldCBMaXN0IEl0ZW0gMigAsvXrmg3cAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDdwCCXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oN3AINdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oN2wICaWQBdyQwYzNlZDljYS1hNjVjLTQ3YzUtYTljYy1hOGI1MzUxOTJlYjGHsvXrmg3bAgMOYmxvY2tDb250YWluZXIBALL165oN9AIBAAMoALL165oN9AICaWQBdyRjMmE0Yjg2OC01OGExLTRlZWMtOTgwYy04ZmYzYzI0Y2RiODeHsvXrmg30AgMOYmxvY2tDb250YWluZXIHALL165oN+gIDCWNvZGVCbG9jawcAsvXrmg37AgYGALL165oN/AIJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjQ5LCAxMTcsIDEzMSkifYSy9euaDf0CBWNvbnN0gbL165oNggMChLL165oNhAMBIIGy9euaDYUDAoSy9euaDYcDAWGBsvXrmg2IAwKEsvXrmg2KAwEggbL165oNiwMChLL165oNjQMBPYGy9euaDY4DAoSy9euaDZADASCBsvXrmg2RAwKEsvXrmg2TAwIxMIGy9euaDZUDAoSy9euaDZcDATuGsvXrmg2YAwl0ZXh0Q29sb3IEbnVsbIGy9euaDZkDAigAsvXrmg37AghsYW5ndWFnZQF3CmphdmFzY3JpcHQoALL165oN+gICaWQBdyQ3M2I0M2FkNS04ZDA5LTRiZjQtYWRlZS1lOGM2MDUwNThiNjOHsvXrmg36AgMOYmxvY2tDb250YWluZXIHALL165oNngMDB2RpdmlkZXIoALL165oNngMCaWQBdyQ1YTMzOTRiMy0wMGI3LTRmOTMtOWNkYS1jMTQ1ZjI3NWJhMTWHsvXrmg2eAwMOYmxvY2tDb250YWluZXIHALL165oNoQMDCXBhcmFncmFwaCgAsvXrmg2iAw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDaIDCXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oNogMNdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNoQMCaWQBdyRkNDEwNjM5YS02MjA4LTQ4OTYtOTRkYy1jY2VkYzkxMjMxNzeHsvXrmg2hAwMOYmxvY2tDb250YWluZXIHALL165oNpwMDCXBhZ2VCcmVhaygAsvXrmg2nAwJpZAF3JDE0NWZlMGZhLTQ0NmItNDNkYS1hNGY2LTE4ZWFjZmVkYWM1MIey9euaDacDAwpjb2x1bW5MaXN0BwCy9euaDaoDAwZjb2x1bW4HALL165oNqwMDDmJsb2NrQ29udGFpbmVyBwCy9euaDawDAwlwYXJhZ3JhcGgHALL165oNrQMGBACy9euaDa4DDUNvbHVtbiAxIFRleHQoALL165oNrQMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg2tAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDa0DDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDawDAmlkAXckNTcwM2IxOTYtMDQ1Yy00NDFhLTk0YTctODhjZTY5YzgwMTcyKACy9euaDasDAmlkAXckODNlYTNjNGItYzA3My00ZTExLThmMTEtYjNjMDllYzFlY2EwKACy9euaDasDBXdpZHRoAX0Bh7L165oNqwMDBmNvbHVtbgcAsvXrmg3CAwMOYmxvY2tDb250YWluZXIHALL165oNwwMDCXBhcmFncmFwaAcAsvXrmg3EAwYEALL165oNxQMNQ29sdW1uIDIgVGV4dCgAsvXrmg3EAw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDcQDCXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oNxAMNdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNwwMCaWQBdyQyNGJjNWM5NC05OTA0LTQxZjktYWUwNi1mZWNiMWRkZTQ2YjkoALL165oNwgMCaWQBdyQ2NTY1M2M1ZC03NzBhLTQxNGMtYmY0Yi0zMTMyMzNkZDAwNjYoALL165oNwgMFd2lkdGgBfQGHsvXrmg3CAwMGY29sdW1uBwCy9euaDdkDAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg3aAwMJcGFyYWdyYXBoBwCy9euaDdsDBgQAsvXrmg3cAw1Db2x1bW4gMyBUZXh0KACy9euaDdsDD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oN2wMJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg3bAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg3aAwJpZAF3JDQ2YzgyMzJjLTNkOTQtNDIyZi1iNTU3LWYwMWM0N2U1MWIwYoey9euaDdoDAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg3uAwMJcGFyYWdyYXBoKACy9euaDe8DD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oN7wMJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg3vAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg3uAwJpZAF3JGU5NmUzNGJkLWJmN2EtNGUyZS04MWUxLTc2ODBmNjI2Mzg3MCgAsvXrmg3ZAwJpZAF3JDU0ZjFiYjIxLTQzMzAtNDgwZS1hNmYwLThhNzM3ODcwMmE5NSgAsvXrmg3ZAwV3aWR0aAF9ASgAsvXrmg2qAwJpZAF3JGRiODIxZmQ2LTVhZGUtNDVjZi04Y2E0LTAwNjZiODFmN2Q5MYey9euaDaoDAw5ibG9ja0NvbnRhaW5lcgcAsvXrmg33AwMHY2FsbG91dCgAsvXrmg34Aw10ZXh0QWxpZ25tZW50AXcEbGVmdCgAsvXrmg34Aw9iYWNrZ3JvdW5kQ29sb3IBdwZ5ZWxsb3coALL165oN+AMFZW1vamkBdwTwn5KhKACy9euaDfcDAmlkAXckNTFkZTBmZDUtN2VmZS00MDZmLTkzMmMtNzI1YjMxMDNmZTVkh7L165oN9wMDDmJsb2NrQ29udGFpbmVyBwCy9euaDf0DAwlwYXJhZ3JhcGgoALL165oN/gMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg3+Awl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDf4DDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDf0DAmlkAXckMGEzNGU4ZDAtNjcyNi00NTZhLWE1OWEtMmViMDhmYzI3ZTA0h7L165oN/QMDDmJsb2NrQ29udGFpbmVyBwCy9euaDYMEAwV0YWJsZQcAsvXrmg2EBAMIdGFibGVSb3cHALL165oNhQQDCXRhYmxlQ2VsbAcAsvXrmg2GBAMOdGFibGVQYXJhZ3JhcGgHALL165oNhwQGBACy9euaDYgEBkNlbGwgMSgAsvXrmg2GBAl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDYYED2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oNhgQNdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNhgQHY29sc3BhbgF9ASgAsvXrmg2GBAdyb3dzcGFuAX0Bh7L165oNhgQDCXRhYmxlQ2VsbAcAsvXrmg2UBAMOdGFibGVQYXJhZ3JhcGgHALL165oNlQQGBACy9euaDZYEBkNlbGwgMigAsvXrmg2UBAl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDZQED2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oNlAQNdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNlAQHY29sc3BhbgF9ASgAsvXrmg2UBAdyb3dzcGFuAX0Bh7L165oNlAQDCXRhYmxlQ2VsbAcAsvXrmg2iBAMOdGFibGVQYXJhZ3JhcGgHALL165oNowQGBACy9euaDaQEBkNlbGwgMygAsvXrmg2iBAl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDaIED2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oNogQNdGV4dEFsaWdubWVudAF3BGxlZnQoALL165oNogQHY29sc3BhbgF9ASgAsvXrmg2iBAdyb3dzcGFuAX0Bh7L165oNhQQDCHRhYmxlUm93BwCy9euaDbAEAwl0YWJsZUNlbGwHALL165oNsQQDDnRhYmxlUGFyYWdyYXBoBwCy9euaDbIEBgQAsvXrmg2zBAZDZWxsIDQoALL165oNsQQJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg2xBA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDbEEDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDbEEB2NvbHNwYW4BfQEoALL165oNsQQHcm93c3BhbgF9AYey9euaDbEEAwl0YWJsZUNlbGwHALL165oNvwQDDnRhYmxlUGFyYWdyYXBoBwCy9euaDcAEBgQAsvXrmg3BBAZDZWxsIDUoALL165oNvwQJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg2/BA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDb8EDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDb8EB2NvbHNwYW4BfQEoALL165oNvwQHcm93c3BhbgF9AYey9euaDb8EAwl0YWJsZUNlbGwHALL165oNzQQDDnRhYmxlUGFyYWdyYXBoBwCy9euaDc4EBgQAsvXrmg3PBAZDZWxsIDaBsvXrmg3PBAIoALL165oNzQQJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg3NBA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDc0EDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDc0EB2NvbHNwYW4BfQEoALL165oNzQQHcm93c3BhbgF9ASgAsvXrmg2EBAl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDYMEAmlkAXckNTJhNmNkNDMtMDYxMy00YWQ4LWI0OTctZTY2MThjMGUwZTMwgbL165oNgwQBAAmHsvXrmg3fBAMOYmxvY2tDb250YWluZXIHALL165oN6QQDCXBhcmFncmFwaCgAsvXrmg3qBA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDeoECXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oN6gQNdGV4dEFsaWdubWVudAF3BGxlZnQhALL165oN6QQCaWQBR7L165oNowEDCXBhcmFncmFwaCgAsvXrmg3vBA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KACy9euaDe8ECXRleHRDb2xvcgF3B2RlZmF1bHQoALL165oN7wQNdGV4dEFsaWdubWVudAF3BGxlZnRHsvXrmg28AgMJcGFyYWdyYXBoKACy9euaDfMED2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoALL165oN8wQJdGV4dENvbG9yAXcHZGVmYXVsdCgAsvXrmg3zBA10ZXh0QWxpZ25tZW50AXcEbGVmdEey9euaDfUCAwlwYXJhZ3JhcGgoALL165oN9wQPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg33BAl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDfcEDXRleHRBbGlnbm1lbnQBdwRsZWZ0xrL165oNggOy9euaDYMDCXRleHRDb2xvciR7InN0cmluZ1ZhbHVlIjoicmdiKDIyNSwgMjI4LCAyMzIpIn3GsvXrmg2FA7L165oNhgMJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMTIxLCAxODQsIDI1NSkifcay9euaDYgDsvXrmg2JAwl0ZXh0Q29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigyMjUsIDIyOCwgMjMyKSJ9xrL165oNiwOy9euaDYwDCXRleHRDb2xvciR7InN0cmluZ1ZhbHVlIjoicmdiKDI0OSwgMTE3LCAxMzEpIn3GsvXrmg2OA7L165oNjwMJdGV4dENvbG9yJHsic3RyaW5nVmFsdWUiOiJyZ2IoMjI1LCAyMjgsIDIzMikifcay9euaDZEDsvXrmg2SAwl0ZXh0Q29sb3IkeyJzdHJpbmdWYWx1ZSI6InJnYigxMjEsIDE4NCwgMjU1KSJ9xrL165oNlQOy9euaDZYDCXRleHRDb2xvciR7InN0cmluZ1ZhbHVlIjoicmdiKDIyNSwgMjI4LCAyMzIpIn3HsvXrmg36ArL165oNngMDDmJsb2NrQ29udGFpbmVyBwCy9euaDYIFAwlwYXJhZ3JhcGgoALL165oNgwUPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgAsvXrmg2DBQl0ZXh0Q29sb3IBdwdkZWZhdWx0KACy9euaDYMFDXRleHRBbGlnbm1lbnQBdwRsZWZ0KACy9euaDYIFAmlkAXckY2RkNjFjMWYtNzhkMC00OWM1LTgwNjMtMGY1M2I0ODA4OGEzobL165oN7gQBqLL165oNiAUBdyQ2OGQwMDRmOS1kNzZhLTQ2YzctOWZmMy03NzA5NTFjN2EzYTOBsvXrmg3XBAHfAvnE4N4MAKiy9euaDYkFAXckOWQ2YzU2M2ItMmMwNi00OTg0LTgwMTgtZmMzYTk1MmJlNjAzx7L165oNgwSy9euaDd8EAw5ibG9ja0NvbnRhaW5lcgcA+cTg3gwBAwlwYXJhZ3JhcGgHAPnE4N4MAgYEAPnE4N4MAwEvKAD5xODeDAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gwCCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gwBAmlkAXckNjhkMDA0ZjktZDc2YS00NmM3LTlmZjMtNzcwOTUxYzdhM2Ezx/nE4N4MAbL165oN3wQDDmJsb2NrQ29udGFpbmVyBwD5xODeDAkDCXBhcmFncmFwaCgA+cTg3gwKD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MCgl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDAoNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MCQJpZAF3JDg3Yjk5ODk5LTIyYzgtNDZlMi04OWFmLTM1M2FlZDk4MmVlOcf5xODeDAmy9euaDd8EAw5ibG9ja0NvbnRhaW5lcgcA+cTg3gwPAwlwYXJhZ3JhcGgoAPnE4N4MEA9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDBAJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gwQDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDA8CaWQBdyQ4ZTk3YWQxNS0wODhmLTQ0M2YtYTJlYy0xYWU0ZmEwNzIzNmPH+cTg3gwPsvXrmg3fBAMOYmxvY2tDb250YWluZXIHAPnE4N4MFQMJcGFyYWdyYXBoKAD5xODeDBYPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gwWCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MFg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gwVAmlkAXckZjk1ZmVkZDQtODkwMy00ZjVjLWJhM2QtNjI1Y2Y5Mjg4M2Iyx/nE4N4MFbL165oN3wQDDmJsb2NrQ29udGFpbmVyBwD5xODeDBsDCXBhcmFncmFwaCgA+cTg3gwcD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MHAl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDBwNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MGwJpZAF3JGM4MzM3YmYyLTM2YTUtNDkyOS04NTI1LTQwNWRiZWQzNzk5N8f5xODeDBuy9euaDd8EAw5ibG9ja0NvbnRhaW5lcgcA+cTg3gwhAwlwYXJhZ3JhcGgoAPnE4N4MIg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDCIJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gwiDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDCECaWQBdyRlMzRjMjI2NS0xZWNjLTQ1YjAtOWU2Zi1iYmQ1YWVhMDRiNTbH+cTg3gwhsvXrmg3fBAMOYmxvY2tDb250YWluZXIHAPnE4N4MJwMJcGFyYWdyYXBoKAD5xODeDCgPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gwoCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MKA10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gwnAmlkAXckNjViZTM5OWUtMjFmOC00YWM0LWJmYjktYjFjNmI5NjgyM2Fjx/nE4N4MJ7L165oN3wQDDmJsb2NrQ29udGFpbmVyBwD5xODeDC0DCXBhcmFncmFwaCgA+cTg3gwuD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MLgl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDC4NdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MLQJpZAF3JDdhMjg0NTdiLTZhMGItNGQ0Ny04MzgzLTYzYzAxMDFlMzM4NIT5xODeDAQBL0f5xODeDAMDGGludGVybGlua2luZ1NlYXJjaElubGluZSgA+cTg3gw0B3RyaWdnZXIBdwEvKAD5xODeDDQIZGlzYWJsZWQBeaj5xODeDDYBeMf5xODeDDT5xODeDAMDFmludGVybGlua2luZ0xpbmtJbmxpbmUoAPnE4N4MOAN1cmwBdyovZG9jcy8wMjJkMDQ4Yy02ZDA3LTRlNTgtYjNmYy05YzQzYTI4NzEyZTkoAPnE4N4MOAVkb2NJZAF3JDAyMmQwNDhjLTZkMDctNGU1OC1iM2ZjLTljNDNhMjg3MTJlOSgA+cTg3gw4BXRpdGxlAXcPVGVzdCBSZWdyZXNzaW9ux/nE4N4MAfnE4N4MCQMOYmxvY2tDb250YWluZXIHAPnE4N4MPAMJcGFyYWdyYXBoKAD5xODeDD0PYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gw9CXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MPQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gw8AmlkAXckNWY1ZGRlMTgtYzg1ZC00ZmExLWIzNTctYTBlNThlNTk4Nzk0x/nE4N4MPPnE4N4MCQMOYmxvY2tDb250YWluZXIHAPnE4N4MQgMJcGFyYWdyYXBoKAD5xODeDEMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gxDCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MQw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gxCAmlkAXckODk0MTk2ZjQtOGIxNi00NWYzLTgzNzQtYjE2ZmM4NjM5NDE1BwD5xODeDEMGBAD5xODeDEgBL8f5xODeDBv5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDEoDCXBhcmFncmFwaCgA+cTg3gxLD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MSwl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDEsNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MSgJpZAF3JGU4YTdjNWVjLTI4NTYtNDMzNi1iYjE2LTlkYWE4OGRhMzc5Ncf5xODeDEr5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDFADCXBhcmFncmFwaCgA+cTg3gxRD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MUQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDFENdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MUAJpZAF3JDZiYjRlNmZiLTIyYTUtNGEyMC04YjczLWQxZjhkOWY0MjBiZMf5xODeDFD5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDFYDCXBhcmFncmFwaCgA+cTg3gxXD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MVwl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDFcNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MVgJpZAF3JDI3NmZmODE2LTY3ZWUtNDNlMi05Njg3LTdjYmFhZDMzMmRhZMf5xODeDFb5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDFwDCXBhcmFncmFwaCgA+cTg3gxdD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MXQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDF0NdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MXAJpZAF3JDE4ZmUwOWI5LWRmMmYtNGZmYS1iMWU4LTJmNDRhM2EzYmIyZMf5xODeDFz5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDGIDCXBhcmFncmFwaCgA+cTg3gxjD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MYwl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDGMNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MYgJpZAF3JGY3YTZjMWI0LTc2OGMtNDc4Yi05MTczLTliZjkxMmUxMTJhYsf5xODeDGL5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDGgDCXBhcmFncmFwaCgA+cTg3gxpD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MaQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDGkNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MaAJpZAF3JGNiNDU3MWZlLWFjNjYtNGI3Mi05MzZlLTE0MzExMmJmNGQ0Ncf5xODeDGj5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDG4DCXBhcmFncmFwaCgA+cTg3gxvD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4Mbwl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDG8NdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MbgJpZAF3JDhlNDU3MDA1LTIxODMtNDczNy1hMDA3LTk1NTk3MGQ2OTlkOMf5xODeDG75xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDHQDCXBhcmFncmFwaCgA+cTg3gx1D2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MdQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDHUNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MdAJpZAF3JGU3NjdlM2UyLTg1ODQtNDRjNC05NDU1LTU0ZThmODQ5MDY0Ncf5xODeDHT5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDHoDCXBhcmFncmFwaCgA+cTg3gx7D2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4Mewl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDHsNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MegJpZAF3JGQ1Y2E3ZGZkLWE3ZGEtNDM0NC04MDhlLWQ3ZjNhZDA3YzFkZsf5xODeDHr5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDIABAwlwYXJhZ3JhcGgoAPnE4N4MgQEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gyBAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDIEBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDIABAmlkAXckNTBmMzZiZDktYzQyYS00OTQxLWE0ZDUtYjg0ZDMzN2VhMGFhx/nE4N4MgAH5xODeDCEDDmJsb2NrQ29udGFpbmVyBwD5xODeDIYBAwlwYXJhZ3JhcGgoAPnE4N4MhwEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gyHAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDIcBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDIYBAmlkAXckNzk5N2QzMTMtY2NhZS00MTg3LWEzNjgtYWYyOTA1MTJhZGI4x/nE4N4MPPnE4N4MQgMOYmxvY2tDb250YWluZXIHAPnE4N4MjAEDCXBhcmFncmFwaCgA+cTg3gyNAQ9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDI0BCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MjQENdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MjAECaWQBdyQwOGEwYzUxNC0wODFkLTRmNDAtYTU0Mi1hNTFkZjFmYzgyNzAHAPnE4N4MjQEGBAD5xODeDJIBAi8vR/nE4N4MjQEDB2hlYWRpbmcoAPnE4N4MlQEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gyVAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDJUBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDJUBBWxldmVsAX0BKAD5xODeDJUBDGlzVG9nZ2xlYWJsZQF4BwD5xODeDJUBBgQA+cTg3gybARJIZWxsbyBUaXRsZSBUb2dnbGXH+cTg3gyVAfnE4N4MjQEDCmJsb2NrR3JvdXAHAPnE4N4MrgEDDmJsb2NrQ29udGFpbmVyBwD5xODeDK8BAwlwYXJhZ3JhcGgoAPnE4N4MsAEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gywAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDLABDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDK8BAmlkAXckNDViNTBlZWMtZDQ2MC00MWMzLTkxNGQtZmI2Y2Y3NWIyOTU1BwD5xODeDLABBgQA+cTg3gy1ARNIZWxsbyB0aXRsZSBjb250ZW50BwD5xODeDAoGBAD5xODeDMkBAS9H+cTg3gwKAwdoZWFkaW5nKAD5xODeDMsBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MywEJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gzLAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gzLAQVsZXZlbAF9BCgA+cTg3gzLAQxpc1RvZ2dsZWFibGUBeYf5xODeDK8BAw5ibG9ja0NvbnRhaW5lcgcA+cTg3gzRAQMJcGFyYWdyYXBoKAD5xODeDNIBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4M0gEJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gzSAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gzRAQJpZAF3JGY1YzQyNGY3LTI5OTMtNDk3ZC04ZDkyLTM3NDAyZDQwZTQwZMf5xODeDIwB+cTg3gxCAw5ibG9ja0NvbnRhaW5lcgcA+cTg3gzXAQMJcGFyYWdyYXBoKAD5xODeDNgBD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4M2AEJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gzYAQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gzXAQJpZAF3JGY1YzQyNGY3LTI5OTMtNDk3ZC04ZDkyLTM3NDAyZDQwZTQwZAcA+cTg3gzLAQYEAPnE4N4M3QENSGVsbG8gdGl0bGUgNMf5xODeDAn5xODeDA8DDmJsb2NrQ29udGFpbmVyBwD5xODeDOsBAwlwYXJhZ3JhcGgoAPnE4N4M7AEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gzsAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDOwBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDOsBAmlkAXckODQ3YjQxZTktNTA3Yy00YjI2LTgyOGItMmUyN2I0MDhlZjhhx/nE4N4M6wH5xODeDA8DDmJsb2NrQ29udGFpbmVyBwD5xODeDPEBAwlwYXJhZ3JhcGgoAPnE4N4M8gEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gzyAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDPIBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDPEBAmlkAXckZWNjOTYwNmQtZmFjNS00Y2FkLWI1YjUtYzFlNWViMGJiYWVmBwD5xODeDPIBBgQA+cTg3gz3AQEvR/nE4N4M8gEDB2hlYWRpbmcoAPnE4N4M+QEPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gz5AQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDPkBDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDPkBBWxldmVsAX0FKAD5xODeDPkBDGlzVG9nZ2xlYWJsZQF5BwD5xODeDPkBBgQA+cTg3gz/AQ1IZWxsbyB0aXRsZSA1x/nE4N4M8QH5xODeDA8DDmJsb2NrQ29udGFpbmVyBwD5xODeDI0CAwlwYXJhZ3JhcGgoAPnE4N4MjgIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gyOAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDI4CDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDI0CAmlkAXckMmJlZWEwMGQtYWExNS00ZGE1LWFkOWItN2YyZTk1Y2NmMTY0x/nE4N4MjQL5xODeDA8DDmJsb2NrQ29udGFpbmVyBwD5xODeDJMCAwlwYXJhZ3JhcGgoAPnE4N4MlAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gyUAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDJQCDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDJMCAmlkAXckYjcwMjk5NTUtNGIzNS00MjI5LWI0MjctNTU5M2M1MjU1ZmQxBwD5xODeDJQCBgQA+cTg3gyZAgEvR/nE4N4MlAIDB2hlYWRpbmcoAPnE4N4MmwIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gybAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDJsCDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDJsCBWxldmVsAX0GKAD5xODeDJsCDGlzVG9nZ2xlYWJsZQF5BwD5xODeDJsCBgQA+cTg3gyhAgtIZWxsbyB0aXRsZYT5xODeDKwCATbE+cTg3gysAvnE4N4MrQIBIMf5xODeDA/5xODeDBUDDmJsb2NrQ29udGFpbmVyBwD5xODeDK8CAwlwYXJhZ3JhcGgoAPnE4N4MsAIPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gywAgl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDLACDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDK8CAmlkAXckMWQ0NWMzZjktNGQ0YS00ZWVjLWI2ODEtOWRmM2NkYWY2MDc4BwD5xODeDLACBgQA+cTg3gy1AgIvOoT5xODeDLcCGfCfp5nigI3imYLvuI8gTWFnaWMgZW1vamnHsvXrmg1YsvXrmg1yAw5ibG9ja0NvbnRhaW5lcgcA+cTg3gzJAgMJcGFyYWdyYXBoKAD5xODeDMoCD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MygIJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gzKAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gzJAgJpZAF3JGUzZDcyYTNlLTFhNTUtNGJkZS04MzI3LWQ2MGQ1ZDFjZTRmNqj5xODeDAgBdyRjODgzZDRmZS00MzI2LTQzNDgtYTljNy03MGIyNzlmYjZhMjTHsvXrmg2DBPnE4N4MAQMOYmxvY2tDb250YWluZXIHAPnE4N4M0AIDCXBhcmFncmFwaCgA+cTg3gzRAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDNECCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4M0QINdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4M0AICaWQBdyQ2OGQwMDRmOS1kNzZhLTQ2YzctOWZmMy03NzA5NTFjN2EzYTMHALL165oN+AMGBAD5xODeDNYCAk15hPnE4N4M2AICwqCE+cTg3gzZAgcgQ3VzdG9thPnE4N4M4AICwqCE+cTg3gzhAgYgYmxvY2vH+cTg3gyvAvnE4N4MFQMOYmxvY2tDb250YWluZXIHAPnE4N4M6AIDCXBhcmFncmFwaCgA+cTg3gzpAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDOkCCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4M6QINdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4M6AICaWQBdyRiYWQ4YmE3Yi00ZGNjLTRiMDEtOGNjNi0xZGVkMjFmYWViNTTH+cTg3gzoAvnE4N4MFQMOYmxvY2tDb250YWluZXIHAPnE4N4M7gIDCXBhcmFncmFwaCgA+cTg3gzvAg9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDO8CCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4M7wINdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4M7gICaWQBdyRjMjIxNTY4OS1mNTdhLTQ4ZDUtOTM4Zi02OGY5MjE1MWU4MTQHAPnE4N4M7wIGBAD5xODeDPQCAS/GsvXrmg0bsvXrmg0cCXRleHRDb2xvchd7InN0cmluZ1ZhbHVlIjoiZ3JlZW4ifcay9euaDSqy9euaDSsJdGV4dENvbG9yBG51bGzGsvXrmg0b+cTg3gz2Agl0ZXh0Q29sb3IWeyJzdHJpbmdWYWx1ZSI6ImJsdWUifcay9euaDSr5xODeDPcCCXRleHRDb2xvchd7InN0cmluZ1ZhbHVlIjoiZ3JlZW4ifcay9euaDVuy9euaDVwPYmFja2dyb3VuZENvbG9yF3sic3RyaW5nVmFsdWUiOiJncmVlbiJ9xrL165oNarL165oNaw9iYWNrZ3JvdW5kQ29sb3IEbnVsbKiy9euaDaABAXiosvXrmg2CAgF3JDE0MmU5YmE2LWRlZGYtNDhlMy1iNTVhLTY2YWViNzE4NWMzMsey9euaDboBsvXrmg3AAQMOYmxvY2tDb250YWluZXIHAPnE4N4M/gIDDnRvZ2dsZUxpc3RJdGVtBwD5xODeDP8CBgQA+cTg3gyAAxNIZWxsbyBUb2dnbGUgTGlzdCAxKAD5xODeDP8CD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4M/wIJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gz/Ag10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gz+AgJpZAF3JDUxZjNlZGUzLTllYjQtNDNiNS05ODA1LTZhZWY2ZDNhNTRmY0ey9euaDcEBAwlwYXJhZ3JhcGgoAPnE4N4MmAMPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gyYAwl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDJgDDXRleHRBbGlnbm1lbnQBdwRsZWZ0qPnE4N4M/QIBdyRiYzA1ODg2OS00YWE2LTQ3NTAtODc3NS05MjlkZjY2YjRjMDPH+cTg3gz+ArL165oNwAEDDmJsb2NrQ29udGFpbmVyBwD5xODeDJ0DAwlwYXJhZ3JhcGgHAPnE4N4MngMGBAD5xODeDJ8DG0hlbGxvIFRvZ2dsZSBMaXN0IDEgQ29udGVudCgA+cTg3gyeAw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDJ4DCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MngMNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4MnQMCaWQBdyQzMDk5OWM2Yy1jZTE3LTRhMzktYjc3MC1jMWUzM2ZiZTM5YTKH+cTg3gz/AgMKYmxvY2tHcm91cAcA+cTg3gy/AwMOYmxvY2tDb250YWluZXIHAPnE4N4MwAMDCXBhcmFncmFwaAcA+cTg3gzBAwYEAPnE4N4MwgMbSGVsbG8gVG9nZ2xlIExpc3QgMSBDb250ZW50KAD5xODeDMEDD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQoAPnE4N4MwQMJdGV4dENvbG9yAXcHZGVmYXVsdCgA+cTg3gzBAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gzAAwJpZAF3JDMwOTk5YzZjLWNlMTctNGEzOS1iNzcwLWMxZTMzZmJlMzlhMsf5xODeDOgC+cTg3gzuAgMOYmxvY2tDb250YWluZXIHAPnE4N4M4gMDCXBhcmFncmFwaCgA+cTg3gzjAw9iYWNrZ3JvdW5kQ29sb3IBdwdkZWZhdWx0KAD5xODeDOMDCXRleHRDb2xvcgF3B2RlZmF1bHQoAPnE4N4M4wMNdGV4dEFsaWdubWVudAF3BGxlZnQoAPnE4N4M4gMCaWQBdyQ2MGQzYzE2MC0yOGRmLTRhNDgtYmU5NS03OTEyZjk0OGIxNDWo+cTg3gznAwF3JGI2Nzc0N2ZmLTllMDYtNGIwMi1iMTI4LTAwMzc4YTgzOTBmYUf5xODeDOMDAwdoZWFkaW5nBwD5xODeDOkDBgQA+cTg3gzqAwxjb3B5L3Bhc3RpbmcoAPnE4N4M6QMPYmFja2dyb3VuZENvbG9yAXcPcmdiKDEzLCAxNywgMjMpKAD5xODeDOkDCXRleHRDb2xvcgF3EnJnYigyNDAsIDI0NiwgMjUyKSgA+cTg3gzpAw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gzpAwVsZXZlbAF9ASgA+cTg3gzpAwxpc1RvZ2dsZWFibGUBeUf5xODeDOkDAwlwYXJhZ3JhcGgHAPnE4N4M/AMGBAD5xODeDP0DDGNvcHkvcGFzdGluZygA+cTg3gz8Aw9iYWNrZ3JvdW5kQ29sb3IBdw9yZ2IoMTMsIDE3LCAyMykoAPnE4N4M/AMJdGV4dENvbG9yAXcScmdiKDI0MCwgMjQ2LCAyNTIpKAD5xODeDPwDDXRleHRBbGlnbm1lbnQBdwRsZWZ0x/nE4N4M/AP5xODeDOkDAwdoZWFkaW5nBwD5xODeDI0EBgQA+cTg3gyOBAxjb3B5L3Bhc3RpbmcoAPnE4N4MjQQPYmFja2dyb3VuZENvbG9yAXcPcmdiKDEzLCAxNywgMjMpKAD5xODeDI0ECXRleHRDb2xvcgF3EnJnYigyNDAsIDI0NiwgMjUyKSgA+cTg3gyNBA10ZXh0QWxpZ25tZW50AXcEbGVmdCgA+cTg3gyNBAVsZXZlbAF9ASgA+cTg3gyNBAxpc1RvZ2dsZWFibGUBeYT5xODeDJoEASCE+cTg3gygBAJHdYT5xODeDKIECm91dCBvZiBkb2PH+cTg3gziA/nE4N4M7gIDDmJsb2NrQ29udGFpbmVyBwD5xODeDK0EAwlwYXJhZ3JhcGgoAPnE4N4MrgQPYmFja2dyb3VuZENvbG9yAXcHZGVmYXVsdCgA+cTg3gyuBAl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD5xODeDK4EDXRleHRBbGlnbm1lbnQBdwRsZWZ0KAD5xODeDK0EAmlkAXckYWM3ZTI0NGEtMmM5ZS00YzkxLThkNDMtNWYwMThlNmVkNmU2ArL165oNExIGMgZSBqABAaMBBcEBQrwCBfUCBIMDAoYDAokDAowDAo8DApIDApYDApoDAtYEAt8EEIgFA/nE4N4MGAABAwIIAQoEGxk2ATxQjQEEkgEDyQEC0QEG8gEE9wEClAIEmQICtgIC2QIB4QIB6AIP/QIBnQMi4wMF6QMkoQQC" \ No newline at end of file diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf b/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf new file mode 100644 index 00000000..af691844 Binary files /dev/null and b/src/frontend/apps/e2e/__tests__/app-impress/assets/doc-export-regressions.pdf differ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 612a82e7..7933931f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -604,7 +604,7 @@ test.describe('Doc Editor', () => { await verifyDocName(page, randomDoc); - const editor = await openSuggestionMenu({ page }); + const { editor } = await openSuggestionMenu({ page }); await page.getByText('Embedded file').click(); await page.getByText('Upload file').click(); 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 943ec3de..dc101095 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,18 +1,19 @@ +import fs from 'fs'; import path from 'path'; -import { expect, test } from '@playwright/test'; +import { Download, Page, expect, test } from '@playwright/test'; import cs from 'convert-stream'; import JSZip from 'jszip'; import { PDFParse } from 'pdf-parse'; import { + BrowserName, TestLanguage, createDoc, verifyDocName, waitForLanguageSwitch, } from './utils-common'; import { openSuggestionMenu, writeInEditor } from './utils-editor'; -import { createRootSubPage } from './utils-sub-pages'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -46,81 +47,14 @@ test.describe('Doc Export', () => { await expect(page.getByTestId('doc-export-download-button')).toBeVisible(); }); - test('it exports the doc with pdf line break', async ({ - page, - browserName, - }) => { - const [randomDoc] = await createDoc( - page, - 'doc-editor-line-break', - browserName, - 1, - ); - - await verifyDocName(page, randomDoc); - - const editor = await writeInEditor({ page, text: 'Hello' }); - await page.keyboard.press('Enter'); - await openSuggestionMenu({ page }); - await page.getByText('Page Break').click(); - - await expect( - editor.locator('div[data-content-type="pageBreak"]'), - ).toBeVisible(); - - await writeInEditor({ page, text: 'World' }); - - await page - .getByRole('button', { - name: 'Export the document', - }) - .click(); - - const downloadPromise = page.waitForEvent('download', (download) => { - return download.suggestedFilename().includes(`${randomDoc}.pdf`); - }); - - void page.getByTestId('doc-export-download-button').click(); - - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); - - const pdfBuffer = await cs.toBuffer(await download.createReadStream()); - const pdfParse = new PDFParse({ data: pdfBuffer }); - const pdfInfo = await pdfParse.getInfo(); - const pdfText = await pdfParse.getText(); - - expect(pdfInfo.total).toBe(2); - expect(pdfText.pages).toStrictEqual([ - { text: 'Hello', num: 1 }, - { text: 'World', num: 2 }, - ]); - expect(pdfInfo?.info.Title).toBe(randomDoc); - }); - + /** + * We override the document content to ensure that the exported DOCX + * contains various elements for testing. + * We don't check the content of the DOCX here, just that the export works + * and the file is correctly named. + */ test('it exports the doc to docx', async ({ page, browserName }) => { - const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); - - await verifyDocName(page, randomDoc); - - await page.locator('.ProseMirror.bn-editor').click(); - await page.locator('.ProseMirror.bn-editor').fill('Hello World'); - - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Resizable image with caption').click(); - - const fileChooserPromise = page.waitForEvent('filechooser'); - await page.getByText('Upload image').click(); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg')); - - const image = page - .locator('.--docs--editor-container img.bn-visual-media') - .first(); - - await expect(image).toBeVisible(); + const randomDoc = await overrideDocContent({ page, browserName }); await page .getByRole('button', { @@ -143,29 +77,14 @@ test.describe('Doc Export', () => { expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`); }); + /** + * We override the document content to ensure that the exported ODT + * contains various elements for testing. + * We don't check the content of the ODT here, just that the export works + * and the file is correctly named. + */ test('it exports the doc to odt', async ({ page, browserName }) => { - const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1); - - await verifyDocName(page, randomDoc); - - await page.locator('.ProseMirror.bn-editor').click(); - await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT'); - - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Resizable image with caption').click(); - - const fileChooserPromise = page.waitForEvent('filechooser'); - await page.getByText('Upload image').click(); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg')); - - const image = page - .locator('.--docs--editor-container img.bn-visual-media') - .first(); - - await expect(image).toBeVisible(); + const randomDoc = await overrideDocContent({ page, browserName }); await page .getByRole('button', { @@ -358,108 +277,6 @@ test.describe('Doc Export', () => { expect(pdfText.text).toContain('Hello World'); }); - test('it exports the doc with quotes', async ({ page, browserName }) => { - const [randomDoc] = await createDoc(page, 'export-quotes', browserName, 1); - - const editor = page.locator('.ProseMirror.bn-editor'); - // Trigger slash menu to show menu - await editor.click(); - await editor.fill('/'); - await page.getByText('Quote or excerpt').click(); - - await expect( - editor.locator('.bn-block-content[data-content-type="quote"]'), - ).toBeVisible(); - - await editor - .locator('.bn-block-content[data-content-type="quote"]') - .fill('Hello World'); - - await expect(editor.getByText('Hello World')).toHaveCSS( - 'font-style', - 'italic', - ); - - await page - .getByRole('button', { - name: 'Export the document', - }) - .click(); - - await expect(page.getByTestId('doc-export-download-button')).toBeVisible(); - - const downloadPromise = page.waitForEvent('download', (download) => { - return download.suggestedFilename().includes(`${randomDoc}.pdf`); - }); - - void page.getByTestId('doc-export-download-button').click(); - - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); - - const pdfBuffer = await cs.toBuffer(await download.createReadStream()); - const pdfParse = new PDFParse({ data: pdfBuffer }); - const pdfText = await pdfParse.getText(); - expect(pdfText.text).toContain('Hello World'); - }); - - test('it exports the doc with multi columns', async ({ - page, - browserName, - }) => { - const [randomDoc] = await createDoc( - page, - 'doc-multi-columns', - browserName, - 1, - ); - - await page.locator('.bn-block-outer').last().fill('/'); - - await page.getByText('Three Columns', { exact: true }).click(); - - await page.locator('.bn-block-column').first().fill('Column 1'); - await page.locator('.bn-block-column').nth(1).fill('Column 2'); - await page.locator('.bn-block-column').last().fill('Column 3'); - - expect(await page.locator('.bn-block-column').count()).toBe(3); - await expect( - page.locator('.bn-block-column[data-node-type="column"]').first(), - ).toHaveText('Column 1'); - await expect( - page.locator('.bn-block-column[data-node-type="column"]').nth(1), - ).toHaveText('Column 2'); - await expect( - page.locator('.bn-block-column[data-node-type="column"]').last(), - ).toHaveText('Column 3'); - - await page - .getByRole('button', { - name: 'Export the document', - }) - .click(); - - await expect( - page.getByTestId('doc-open-modal-download-button'), - ).toBeVisible(); - - const downloadPromise = page.waitForEvent('download', (download) => { - return download.suggestedFilename().includes(`${randomDoc}.pdf`); - }); - - void page.getByTestId('doc-export-download-button').click(); - - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); - - const pdfBuffer = await cs.toBuffer(await download.createReadStream()); - const pdfParse = new PDFParse({ data: pdfBuffer }); - const pdfText = await pdfParse.getText(); - expect(pdfText.text).toContain('Column 1'); - expect(pdfText.text).toContain('Column 2'); - expect(pdfText.text).toContain('Column 3'); - }); - test('it injects the correct language attribute into PDF export', async ({ page, browserName, @@ -506,53 +323,11 @@ test.describe('Doc Export', () => { expect(pdfString).toContain('/Lang (fr)'); }); - test('it exports the doc with interlinking', async ({ + test('it exports the doc to PDF and checks regressions', async ({ page, browserName, }) => { - const [randomDoc] = await createDoc( - page, - 'export-interlinking', - browserName, - 1, - ); - - await verifyDocName(page, randomDoc); - - const { name: docChild } = await createRootSubPage( - page, - browserName, - 'export-interlink-child', - ); - - await verifyDocName(page, docChild); - - const editor = await openSuggestionMenu({ page }); - await page.getByText('Link a doc').first().click(); - - const input = page.locator( - "span[data-inline-content-type='interlinkingSearchInline'] input", - ); - const searchContainer = page.locator('.quick-search-container'); - - await input.fill('export-interlink'); - - await expect(searchContainer).toBeVisible(); - await expect(searchContainer.getByText(randomDoc)).toBeVisible(); - - // We are in docChild, we want to create a link to randomDoc (parent) - await searchContainer.getByText(randomDoc).click(); - - // Search the interlinking link in the editor (not in the document tree) - const interlink = editor - .locator('.--docs--interlinking-link-inline-content') - .first(); - - await expect(interlink).toContainText(randomDoc); - - const downloadPromise = page.waitForEvent('download', (download) => { - return download.suggestedFilename().includes(`${docChild}.pdf`); - }); + const randomDoc = await overrideDocContent({ page, browserName }); await page .getByRole('button', { @@ -560,77 +335,155 @@ test.describe('Doc Export', () => { }) .click(); - void page.getByTestId('doc-export-download-button').click(); - - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe(`${docChild}.pdf`); - - const pdfBuffer = await cs.toBuffer(await download.createReadStream()); - const pdfParse = new PDFParse({ data: pdfBuffer }); - const pdfText = await pdfParse.getText(); - expect(pdfText.text).toContain(randomDoc); - }); - - test('it exports the doc with interlinking to odt', async ({ - page, - browserName, - }) => { - const [randomDoc] = await createDoc( - page, - 'export-interlinking-odt', - browserName, - 1, - ); - - await verifyDocName(page, randomDoc); - - const { name: docChild } = await createRootSubPage( - page, - browserName, - 'export-interlink-child-odt', - ); - - await verifyDocName(page, docChild); - - const editor = await openSuggestionMenu({ page }); - await page.getByText('Link a doc').first().click(); - - const input = page.locator( - "span[data-inline-content-type='interlinkingSearchInline'] input", - ); - const searchContainer = page.locator('.quick-search-container'); - - await input.fill('export-interlink'); - - await expect(searchContainer).toBeVisible(); - await expect(searchContainer.getByText(randomDoc)).toBeVisible(); - - // We are in docChild, we want to create a link to randomDoc (parent) - await searchContainer.getByText(randomDoc).click(); - - // Search the interlinking link in the editor (not in the document tree) - const interlink = editor - .locator('.--docs--interlinking-link-inline-content') - .first(); - - await expect(interlink).toContainText(randomDoc); - - await page - .getByRole('button', { - name: 'Export the document', - }) - .click(); - - await page.getByRole('combobox', { name: 'Format' }).click(); - await page.getByRole('option', { name: 'Odt' }).click(); + await expect( + page.getByTestId('doc-open-modal-download-button'), + ).toBeVisible(); const downloadPromise = page.waitForEvent('download', (download) => { - return download.suggestedFilename().includes(`${docChild}.odt`); + return download.suggestedFilename().includes(`${randomDoc}.pdf`); }); - void page.getByTestId('doc-export-download-button').click(); + await page.getByTestId('doc-export-download-button').click(); const download = await downloadPromise; - expect(download.suggestedFilename()).toBe(`${docChild}.odt`); + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); + + // If we need to update the PDF regression fixture, uncomment the line below + //await savePDFToAssetFolder(download); + + // Assert the generated PDF matches "assets/doc-export-regressions.pdf" + await comparePDFWithAssetFolder(download); }); }); + +export const savePDFToAssetFolder = async (download: Download) => { + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfPath = path.join(__dirname, 'assets', `doc-export-regressions.pdf`); + fs.writeFileSync(pdfPath, pdfBuffer); +}; + +export const comparePDFWithAssetFolder = async (download: Download) => { + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + + // Load reference PDF for comparison + const referencePdfPath = path.join( + __dirname, + 'assets', + 'doc-export-regressions.pdf', + ); + + const referencePdfBuffer = fs.readFileSync(referencePdfPath); + + // Parse both PDFs + const generatedPdf = new PDFParse({ data: pdfBuffer }); + const referencePdf = new PDFParse({ data: referencePdfBuffer }); + + const [generatedInfo, referenceInfo] = await Promise.all([ + generatedPdf.getInfo(), + referencePdf.getInfo(), + ]); + + const [generatedScreenshot, referenceScreenshot] = await Promise.all([ + generatedPdf.getScreenshot(), + referencePdf.getScreenshot(), + ]); + generatedScreenshot.pages[0].data; + + const [generatedText, referenceText] = await Promise.all([ + generatedPdf.getText(), + referencePdf.getText(), + ]); + + // Compare page count + expect(generatedInfo.total).toBe(referenceInfo.total); + + // Compare text content + expect(generatedText.text).toBe(referenceText.text); + + // Compare screenshots page by page + for (let i = 0; i < generatedScreenshot.pages.length; i++) { + const genPage = generatedScreenshot.pages[i]; + const refPage = referenceScreenshot.pages[i]; + + expect(genPage.width).toBe(refPage.width); + expect(genPage.height).toBe(refPage.height); + expect(genPage.data).toStrictEqual(refPage.data); + } +}; + +/** + * Override the document content API response to use a test content + * This test content contains many blocks to facilitate testing + * @param page + */ +export const overrideDocContent = async ({ + page, + browserName, +}: { + page: Page; + browserName: BrowserName; +}) => { + // Override content prop with assets/base-content-test-pdf.txt + await page.route(/\**\/documents\/\**/, async (route) => { + const request = route.request(); + if ( + request.method().includes('GET') && + !request.url().includes('page=') && + !request.url().includes('versions') && + !request.url().includes('accesses') && + !request.url().includes('invitations') + ) { + const response = await route.fetch(); + const json = await response.json(); + json.content = fs.readFileSync( + path.join(__dirname, 'assets/base-content-test-pdf.txt'), + 'utf-8', + ); + void route.fulfill({ + response, + body: JSON.stringify(json), + }); + } else { + await route.continue(); + } + }); + + const [randomDoc] = await createDoc( + page, + 'doc-export-override-content', + browserName, + 1, + ); + + await verifyDocName(page, randomDoc); + + // Add Image SVG + await page.keyboard.press('Enter'); + const { suggestionMenu } = await openSuggestionMenu({ page }); + await suggestionMenu.getByText('Resizable image with caption').click(); + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByText('Upload image').click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg')); + const image = page + .locator('.--docs--editor-container img.bn-visual-media[src$=".svg"]') + .first(); + await expect(image).toBeVisible(); + await page.keyboard.press('Enter'); + + // Add Image PNG + await openSuggestionMenu({ page }); + await suggestionMenu.getByText('Resizable image with caption').click(); + const fileChooserPNGPromise = page.waitForEvent('filechooser'); + await page.getByText('Upload image').click(); + const fileChooserPNG = await fileChooserPNGPromise; + await fileChooserPNG.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); + const imagePng = page + .locator('.--docs--editor-container img.bn-visual-media[src$=".png"]') + .first(); + await expect(imagePng).toBeVisible(); + + return randomDoc; +}; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts index 97777711..38604672 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-version.spec.ts @@ -42,8 +42,8 @@ test.describe('Doc Version', () => { // Write more await writeInEditor({ page, text: 'It will create a version' }); - await openSuggestionMenu({ page }); - await page.getByText('Add a callout block').click(); + const { suggestionMenu } = await openSuggestionMenu({ page }); + await suggestionMenu.getByText('Add a callout block').click(); const calloutBlock = page .locator('div[data-content-type="callout"]') diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index 127caeb7..454763d7 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -109,8 +109,10 @@ test.describe('Language', () => { }) => { await createDoc(page, 'doc-toolbar', browserName, 1); - const editor = await openSuggestionMenu({ page }); - await expect(page.getByText('Headings', { exact: true })).toBeVisible(); + const { editor, suggestionMenu } = await openSuggestionMenu({ page }); + await expect( + suggestionMenu.getByText('Headings', { exact: true }), + ).toBeVisible(); await editor.click(); // close the menu @@ -121,6 +123,8 @@ test.describe('Language', () => { // Trigger slash menu to show french menu await openSuggestionMenu({ page }); - await expect(page.getByText('Titres', { exact: true })).toBeVisible(); + await expect( + suggestionMenu.getByText('Titres', { exact: true }), + ).toBeVisible(); }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts index cbd11891..f5c2d7c9 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-editor.ts @@ -11,7 +11,9 @@ export const openSuggestionMenu = async ({ page }: { page: Page }) => { await editor.click(); await writeInEditor({ page, text: '/' }); - return editor; + const suggestionMenu = page.locator('.bn-suggestion-menu'); + + return { editor, suggestionMenu }; }; export const writeInEditor = async ({ diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/headingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/headingPDF.tsx index 38f0dc5d..18700f72 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/headingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/headingPDF.tsx @@ -1,3 +1,7 @@ +/** + * Derivated from Blockquote PDF mapping + * @see: https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx + */ import { Text } from '@react-pdf/renderer'; import { DocsExporterPDF } from '../types'; @@ -30,6 +34,15 @@ export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping'] const fontSizeEM = block.props.level === 1 ? 2 : block.props.level === 2 ? 1.5 : 1.17; + const levelFontSizeEM = { + 1: 2, + 2: 1.5, + 3: 1.17, + 4: 1, + 5: 0.83, + 6: 0.67, + }[block.props.level as 1 | 2 | 3 | 4 | 5 | 6]; + // Extract plain text for bookmark title const bookmarkTitle = extractTextFromBlockContent(block.content) || 'Untitled'; @@ -42,7 +55,7 @@ export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping'] title: bookmarkTitle, }} style={{ - fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT, + fontSize: levelFontSizeEM * FONT_SIZE * PIXELS_PER_POINT, fontWeight: 700, marginTop: `${fontSizeEM * MERGE_RATIO}px`, marginBottom: `${fontSizeEM * MERGE_RATIO}px`, diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx index 1a3513cb..1471e933 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imagePDF.tsx @@ -21,9 +21,6 @@ export const blockMappingImagePDF: DocsExporterPDF['mappings']['blockMapping'][' if (blob.type.includes('svg')) { const svgText = await blob.text(); - const FALLBACK_SIZE = 536; - previewWidth = previewWidth || FALLBACK_SIZE; - const result = await convertSvgToPng(svgText, previewWidth); pngConverted = result.png; dimensions = { width: result.width, height: result.height }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts index 73e09c7d..cf7cc404 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts @@ -36,7 +36,7 @@ export function downloadFile(blob: Blob, filename: string) { */ export async function convertSvgToPng( svgText: string, - width: number, + width?: number, ): Promise<{ png: string; width: number; height: number }> { // Create a canvas and render the SVG onto it const canvas = document.createElement('canvas'); @@ -54,26 +54,36 @@ export async function convertSvgToPng( const svgElement = svgDoc.documentElement; // Get viewBox or fallback to width/height attributes - let height; + let calculatedHeight: number | undefined; const svgWidth = svgElement.getAttribute?.('width'); const svgHeight = svgElement.getAttribute?.('height'); const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number); const originalWidth = svgWidth ? parseInt(svgWidth) : viewBox?.[2]; const originalHeight = svgHeight ? parseInt(svgHeight) : viewBox?.[3]; - if (originalWidth && originalHeight) { - const aspectRatio = originalHeight / originalWidth; - height = Math.round(width * aspectRatio); - } const svg = Canvg.fromString(ctx, svgText); - svg.resize(width, height, true); + + const FALLBACK_WIDTH = 536; + + // Resize if width provided, preserving aspect ratio + if (originalWidth && originalHeight && width) { + const aspectRatio = originalHeight / originalWidth; + calculatedHeight = Math.round(width * aspectRatio); + svg.resize(width, calculatedHeight, true); + } else if (!width && !originalWidth) { + svg.resize(FALLBACK_WIDTH, undefined, true); + } + await svg.render(); + const returnWidth = width || originalWidth || FALLBACK_WIDTH; + const returnHeight = calculatedHeight || returnWidth; + return { png: canvas.toDataURL('image/png'), - width, - height: height || width, + width: returnWidth, + height: returnHeight, }; }