diff --git a/package.json b/package.json index 4250a9d1ca..14635fdeee 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "classnames": "^2.2.6", "commonmark": "^0.31.0", "counterpart": "^0.18.6", + "css-tree": "^2.3.1", "diff-dom": "^5.0.0", "diff-match-patch": "^1.0.5", "emojibase-regex": "15.3.2", @@ -167,6 +168,7 @@ "@types/commonmark": "^0.27.4", "@types/content-type": "^1.1.5", "@types/counterpart": "^0.18.1", + "@types/css-tree": "^2.3.8", "@types/diff-match-patch": "^1.0.32", "@types/escape-html": "^1.0.1", "@types/express": "^4.17.21", diff --git a/playwright/e2e/chat-export/html-export.spec.ts b/playwright/e2e/chat-export/html-export.spec.ts new file mode 100644 index 0000000000..947aa2c1bc --- /dev/null +++ b/playwright/e2e/chat-export/html-export.spec.ts @@ -0,0 +1,132 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import os from "node:os"; +import path from "node:path"; +import * as fsp from "node:fs/promises"; +import * as fs from "node:fs"; +import JSZip from "jszip"; + +import { test, expect } from "../../element-web-test"; + +// Based on https://github.com/Stuk/jszip/issues/466#issuecomment-2097061912 +async function extractZipFileToPath(file: string, outputPath: string): Promise { + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }); + } + + const data = await fsp.readFile(file); + const zip = await JSZip.loadAsync(data, { createFolders: true }); + + await new Promise((resolve, reject) => { + let entryCount = 0; + let errorOut = false; + + zip.forEach(() => { + entryCount++; + }); // there is no other way to count the number of entries within the zip file. + + zip.forEach((relativePath, zipEntry) => { + if (errorOut) { + return; + } + + const outputEntryPath = path.join(outputPath, relativePath); + if (zipEntry.dir) { + if (!fs.existsSync(outputEntryPath)) { + fs.mkdirSync(outputEntryPath, { recursive: true }); + } + + entryCount--; + + if (entryCount === 0) { + resolve(); + } + } else { + void zipEntry + .async("blob") + .then(async (content) => Buffer.from(await content.arrayBuffer())) + .then((buffer) => { + const stream = fs.createWriteStream(outputEntryPath); + stream.write(buffer, (error) => { + if (error) { + reject(error); + errorOut = true; + } + }); + stream.on("finish", () => { + entryCount--; + + if (entryCount === 0) { + resolve(); + } + }); + stream.end(); // extremely important on Windows. On Mac / Linux, not so much since those platforms allow multiple apps to read from the same file. Windows doesn't allow that. + }) + .catch((e) => { + errorOut = true; + reject(e); + }); + } + }); + }); + + return zip; +} + +test.describe("HTML Export", () => { + test.use({ + displayName: "Alice", + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ name: "Important Room" }); + await app.viewRoomByName("Important Room"); + await use({ roomId }); + }, + }); + + test("should export html successfully and match screenshot", async ({ page, app, room }) => { + // Send a bunch of messages to populate the room + for (let i = 1; i < 10; i++) { + await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" }); + } + + // Wait for all the messages to be displayed + await expect( + page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("Testing 9"), + ).toBeVisible(); + + await page.getByRole("button", { name: "Room info" }).click(); + await page.getByRole("menuitem", { name: "Export Chat" }).click(); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export", exact: true }).click(); + const download = await downloadPromise; + + const dirPath = path.join(os.tmpdir(), "html-export-test"); + const zipPath = `${dirPath}.zip`; + await download.saveAs(zipPath); + + const zip = await extractZipFileToPath(zipPath, dirPath); + await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`); + await expect(page).toMatchScreenshot("html-export.png", { + mask: [ + page.getByText("This is the start of export", { exact: false }), + page.locator(".mx_DateSeparator_dateHeading"), + page.locator(".mx_MessageTimestamp"), + ], + }); + }); +}); diff --git a/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png new file mode 100644 index 0000000000..99af1a757b Binary files /dev/null and b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png differ diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 3edf0f3cc0..67cdf8b579 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -163,81 +163,77 @@ export default class HTMLExporter extends Exporter { ${_t("export_chat|html_title")} - -
-
-
-
-
-
-
-
- ${roomAvatar} -
-
-
-
- ${safeRoomName} -
-
-
${safeTopic}
-
-
- ${previousMessagesLink} -
-
-
-
-
-
    +
    +
    +
    +
    +
    +
    +
    +
    + ${roomAvatar} +
    +
    +
    +
    - ${ - currentPage == 0 - ? `
    - ${roomAvatar} -

    ${safeRoomName}

    -

    ${safeCreatedText}

    ${safeExportedText}

    -
    -

    ${safeTopicText}

    -
    ` - : "" - } - ${content} -
+ ${safeRoomName} +
+
+
${safeTopic}
+
+
+ ${previousMessagesLink} +
+
+
+
+
+
    + ${ + currentPage == 0 + ? `
    + ${roomAvatar} +

    ${safeRoomName}

    +

    ${safeCreatedText}

    ${safeExportedText}

    +
    +

    ${safeTopicText}

    +
    ` + : "" + } + ${content} +
+
+
+
+
+
+
+
-
-
-
-
-
+ ${nextMessagesLink} +
-
- ${nextMessagesLink} -
-
`; diff --git a/src/utils/exportUtils/exportCSS.ts b/src/utils/exportUtils/exportCSS.ts index bd7ddac01b..15716ad544 100644 --- a/src/utils/exportUtils/exportCSS.ts +++ b/src/utils/exportUtils/exportCSS.ts @@ -14,74 +14,80 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { Rule, StyleSheet } from "css-tree"; + import customCSS from "!!raw-loader!./exportCustomCSS.css"; const cssSelectorTextClassesRegex = /\.[\w-]+/g; function mutateCssText(css: string): string { // replace used fonts so that we don't have to bundle Inter & Inconsalata + const sansFont = `-apple-system, BlinkMacSystemFont, avenir next, + avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`; return css - .replace( - /font-family: ?(Inter|'Inter'|"Inter")/g, - `font-family: -apple-system, BlinkMacSystemFont, avenir next, - avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`, - ) + .replace(/font-family: ?(Inter|'Inter'|"Inter")/g, `font-family: ${sansFont}`) + .replace(/--cpd-font-family-sans: ?(Inter|'Inter'|"Inter")/g, `--cpd-font-family-sans: ${sansFont}`) .replace( /font-family: ?Inconsolata/g, "font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace", ); } -function isLightTheme(sheet: CSSStyleSheet): boolean { - return (sheet.ownerNode)?.dataset.mxTheme?.toLowerCase() === "light"; -} +function includeRule(rule: Rule, usedClasses: Set): boolean { + if (rule.prelude.type === "Raw") { + // cull empty rules + if (rule.block.children.isEmpty) return false; -async function getRulesFromCssFile(path: string): Promise { - const doc = document.implementation.createHTMLDocument(""); - const styleElement = document.createElement("style"); - - const res = await fetch(path); - styleElement.textContent = await res.text(); - // the style will only be parsed once it is added to a document - doc.body.appendChild(styleElement); - - return styleElement.sheet!; + return rule.prelude.value.split(",").some((subselector) => { + const classes = subselector.trim().match(cssSelectorTextClassesRegex); + if (classes && !classes.every((c) => usedClasses.has(c.substring(1)))) { + return false; + } + return true; + }); + } + return true; } // naively culls unused css rules based on which classes are present in the html, // doesn't cull rules which won't apply due to the full selector not matching but gets rid of a LOT of cruft anyway. +// We cannot use document.styleSheets as it does not handle variables in shorthand properties sanely, +// see https://github.com/element-hq/element-web/issues/26761 const getExportCSS = async (usedClasses: Set): Promise => { - // only include bundle.css and the data-mx-theme=light styling - const stylesheets = Array.from(document.styleSheets).filter((s) => { - return s.href?.endsWith("bundle.css") || isLightTheme(s); + const csstree = await import("css-tree"); + + // only include bundle.css and light theme styling + const hrefs = ["bundle.css", "theme-light.css"].map((name) => { + return document.querySelector(`link[rel="stylesheet"][href$="${name}"]`)?.href; }); - // If the light theme isn't loaded we will have to fetch & parse it manually - if (!stylesheets.some(isLightTheme)) { - const href = document.querySelector('link[rel="stylesheet"][href$="theme-light.css"]')?.href; - if (href) stylesheets.push(await getRulesFromCssFile(href)); - } - let css = ""; - for (const stylesheet of stylesheets) { - for (const rule of stylesheet.cssRules) { - if (rule instanceof CSSFontFaceRule) continue; // we don't want to bundle any fonts - const selectorText = (rule as CSSStyleRule).selectorText; + for (const href of hrefs) { + if (!href) continue; + const res = await fetch(href); + const text = await res.text(); - // only skip the rule if all branches (,) of the selector are redundant - if ( - selectorText?.split(",").every((selector) => { - const classes = selector.match(cssSelectorTextClassesRegex); - if (classes && !classes.every((c) => usedClasses.has(c.substring(1)))) { - return true; // signal as a redundant selector - } - }) - ) { - continue; // skip this rule as it is redundant + const ast = csstree.parse(text, { + context: "stylesheet", + parseAtrulePrelude: false, + parseRulePrelude: false, + parseValue: false, + parseCustomProperty: false, + }) as StyleSheet; + + for (const rule of ast.children) { + if (rule.type === "Atrule") { + if (rule.name === "font-face") { + continue; + } } - css += mutateCssText(rule.cssText) + "\n"; + if (rule.type === "Rule" && !includeRule(rule, usedClasses)) { + continue; + } + + css += mutateCssText(csstree.generate(rule)); } } diff --git a/src/utils/exportUtils/exportCustomCSS.css b/src/utils/exportUtils/exportCustomCSS.css index 4807e316a8..bd0de64265 100644 --- a/src/utils/exportUtils/exportCustomCSS.css +++ b/src/utils/exportUtils/exportCustomCSS.css @@ -18,6 +18,11 @@ limitations under the License. This file is raw-imported (imported as plain text) for the export bundle, which is the reason for the .css format and the colours being hard-coded hard-coded. */ +html, +body { + font-size: var(--cpd-font-size-root) !important; +} + #snackbar { display: flex; visibility: hidden; diff --git a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index aca4e162c8..3958005c5b 100644 --- a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -12,77 +12,73 @@ exports[`HTMLExport should export 1`] = ` Exported Data - -
-
-
-
-
-
-
-
- ! -
-
-
-
- !myroom:example.org -
-
-
-
-
- -
-
-
-
-
-
    +
    +
    +
    +
    +
    +
    +
    +
    + ! +
    +
    +
    +
    -
    - ! -

    !myroom:example.org

    -

    created this room.

    This is the start of export of !myroom:example.org. Exported by @userId:matrix.org at 11/17/2022.

    -
    -

    + !myroom:example.org +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
      +
      + ! +

      !myroom:example.org

      +

      created this room.

      This is the start of export of !myroom:example.org. Exported by @userId:matrix.org at 11/17/2022.

      +
      +

      +
      +
    1. @user49:example.com
      Message #49
    2. @user48:example.com
      Message #48
    3. @user47:example.com
      Message #47
    4. @user46:example.com
      Message #46
    5. @user45:example.com
      Message #45
    6. @user44:example.com
      Message #44
    7. @user43:example.com
      Message #43
    8. @user42:example.com
      Message #42
    9. @user41:example.com
      Message #41
    10. @user40:example.com
      Message #40
    11. @user39:example.com
      Message #39
    12. @user38:example.com
      Message #38
    13. @user37:example.com
      Message #37
    14. @user36:example.com
      Message #36
    15. @user35:example.com
      Message #35
    16. @user34:example.com
      Message #34
    17. @user33:example.com
      Message #33
    18. @user32:example.com
      Message #32
    19. @user31:example.com
      Message #31
    20. @user30:example.com
      Message #30
    21. @user29:example.com
      Message #29
    22. @user28:example.com
      Message #28
    23. @user27:example.com
      Message #27
    24. @user26:example.com
      Message #26
    25. @user25:example.com
      Message #25
    26. @user24:example.com
      Message #24
    27. @user23:example.com
      Message #23
    28. @user22:example.com
      Message #22
    29. @user21:example.com
      Message #21
    30. @user20:example.com
      Message #20
    31. @user19:example.com
      Message #19
    32. @user18:example.com
      Message #18
    33. @user17:example.com
      Message #17
    34. @user16:example.com
      Message #16
    35. @user15:example.com
      Message #15
    36. @user14:example.com
      Message #14
    37. @user13:example.com
      Message #13
    38. @user12:example.com
      Message #12
    39. @user11:example.com
      Message #11
    40. @user10:example.com
      Message #10
    41. @user9:example.com
      Message #9
    42. @user8:example.com
      Message #8
    43. @user7:example.com
      Message #7
    44. @user6:example.com
      Message #6
    45. @user5:example.com
      Message #5
    46. @user4:example.com
      Message #4
    47. @user3:example.com
      Message #3
    48. @user2:example.com
      Message #2
    49. @user1:example.com
      Message #1
    50. @user0:example.com
      Message #0
    51. +
    -
  1. @user49:example.com
    Message #49
  2. @user48:example.com
    Message #48
  3. @user47:example.com
    Message #47
  4. @user46:example.com
    Message #46
  5. @user45:example.com
    Message #45
  6. @user44:example.com
    Message #44
  7. @user43:example.com
    Message #43
  8. @user42:example.com
    Message #42
  9. @user41:example.com
    Message #41
  10. @user40:example.com
    Message #40
  11. @user39:example.com
    Message #39
  12. @user38:example.com
    Message #38
  13. @user37:example.com
    Message #37
  14. @user36:example.com
    Message #36
  15. @user35:example.com
    Message #35
  16. @user34:example.com
    Message #34
  17. @user33:example.com
    Message #33
  18. @user32:example.com
    Message #32
  19. @user31:example.com
    Message #31
  20. @user30:example.com
    Message #30
  21. @user29:example.com
    Message #29
  22. @user28:example.com
    Message #28
  23. @user27:example.com
    Message #27
  24. @user26:example.com
    Message #26
  25. @user25:example.com
    Message #25
  26. @user24:example.com
    Message #24
  27. @user23:example.com
    Message #23
  28. @user22:example.com
    Message #22
  29. @user21:example.com
    Message #21
  30. @user20:example.com
    Message #20
  31. @user19:example.com
    Message #19
  32. @user18:example.com
    Message #18
  33. @user17:example.com
    Message #17
  34. @user16:example.com
    Message #16
  35. @user15:example.com
    Message #15
  36. @user14:example.com
    Message #14
  37. @user13:example.com
    Message #13
  38. @user12:example.com
    Message #12
  39. @user11:example.com
    Message #11
  40. @user10:example.com
    Message #10
  41. @user9:example.com
    Message #9
  42. @user8:example.com
    Message #8
  43. @user7:example.com
    Message #7
  44. @user6:example.com
    Message #6
  45. @user5:example.com
    Message #5
  46. @user4:example.com
    Message #4
  47. @user3:example.com
    Message #3
  48. @user2:example.com
    Message #2
  49. @user1:example.com
    Message #1
  50. @user0:example.com
    Message #0
  51. -
+
+
+
+
+
+
-
-
-
-
-
+ +
-
- -
-
" diff --git a/yarn.lock b/yarn.lock index 11678cb0be..b6585e26ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2479,6 +2479,11 @@ resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.4.tgz#e3e331b7e0d5496873d417839f3b2bbcf555bb73" integrity sha512-aqBg5oAGo/qh/+wxUfuMadDu2WO0MEWOblyzwaM1Ske2xilUxBfgPqapAFVAfrVTDMVwa0UMarzGot8m64IAzA== +"@types/css-tree@^2.3.8": + version "2.3.8" + resolved "https://registry.yarnpkg.com/@types/css-tree/-/css-tree-2.3.8.tgz#0eabc115e45051b2f7abe51ee1531074b234ed19" + integrity sha512-zABG3nI2UENsx7AQv63tI5/ptoAG/7kQR1H0OvG+WTWYHOR5pfAT3cGgC8SdyCrgX/TTxJBZNmx82IjCXs1juQ== + "@types/diff-match-patch@^1.0.32": version "1.0.36" resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af"