clipper: implement web clipper core library
@@ -20,6 +20,7 @@ const SCOPES = [
|
|||||||
"logger",
|
"logger",
|
||||||
"theme",
|
"theme",
|
||||||
"core",
|
"core",
|
||||||
|
"clipper",
|
||||||
"config",
|
"config",
|
||||||
"ci",
|
"ci",
|
||||||
"setup",
|
"setup",
|
||||||
|
|||||||
6
packages/clipper/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/__tests__/temp
|
||||||
|
test.ts
|
||||||
197
packages/clipper/__tests__/example.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { buildSync } from "esbuild";
|
||||||
|
import path, { join } from "path";
|
||||||
|
import type { Clipper } from "../src";
|
||||||
|
import Websites from "./pages.json";
|
||||||
|
import slugify from "slugify";
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
clipper: Clipper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.setTimeout(0);
|
||||||
|
test.use({ bypassCSP: true });
|
||||||
|
|
||||||
|
const tempDirPath = join(__dirname, "temp");
|
||||||
|
const output = buildSync({
|
||||||
|
bundle: true,
|
||||||
|
entryPoints: [path.join(__dirname, "../src/index.ts")],
|
||||||
|
minify: false,
|
||||||
|
write: false,
|
||||||
|
globalName: "clipper"
|
||||||
|
}).outputFiles[0].text;
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
mkdirSync(tempDirPath, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// test.afterAll(() => {
|
||||||
|
// rmSync(tempDirPath, { recursive: true, force: true });
|
||||||
|
// });
|
||||||
|
|
||||||
|
for (const website of Websites) {
|
||||||
|
const domain = new URL(website.url).hostname;
|
||||||
|
test(`clip ${domain} (${website.title})`, async ({ page }, info) => {
|
||||||
|
info.setTimeout(0);
|
||||||
|
|
||||||
|
await page.goto(website.url);
|
||||||
|
|
||||||
|
await page.addScriptTag({ content: output, type: "text/javascript" });
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// const originalScreenshot = await page.screenshot({
|
||||||
|
// fullPage: true,
|
||||||
|
// type: "jpeg"
|
||||||
|
// });
|
||||||
|
|
||||||
|
// expect(originalScreenshot).toMatchSnapshot({
|
||||||
|
// name: `${slugify(website.title)}.jpg`,
|
||||||
|
// maxDiffPixelRatio: 0.1
|
||||||
|
// });
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const html = await window.clipper.clipPage(window.document, true, false);
|
||||||
|
if (html) {
|
||||||
|
return `\ufeff${html}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) throw new Error("Failed to clip page.");
|
||||||
|
|
||||||
|
const tempFilePath = join(
|
||||||
|
tempDirPath,
|
||||||
|
`${slugify(website.title.toLowerCase())}.html`
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(join(tempFilePath), result);
|
||||||
|
|
||||||
|
await page.goto(`file://${tempFilePath}`);
|
||||||
|
|
||||||
|
const clippedScreenshot = await page.screenshot({
|
||||||
|
fullPage: true,
|
||||||
|
type: "jpeg"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clippedScreenshot).toMatchSnapshot({
|
||||||
|
name: `${slugify(website.title)}.jpg`,
|
||||||
|
maxDiffPixelRatio: 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
rmSync(tempFilePath, { force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const website of Websites) {
|
||||||
|
const domain = new URL(website.url).hostname;
|
||||||
|
test(`clip as image ${domain} (${website.title})`, async ({ page }, info) => {
|
||||||
|
info.setTimeout(0);
|
||||||
|
|
||||||
|
await page.goto(website.url);
|
||||||
|
|
||||||
|
await page.addScriptTag({ content: output, type: "text/javascript" });
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// const originalScreenshot = await page.screenshot({
|
||||||
|
// fullPage: true,
|
||||||
|
// type: "jpeg"
|
||||||
|
// });
|
||||||
|
|
||||||
|
// expect(originalScreenshot).toMatchSnapshot({
|
||||||
|
// name: `${slugify(website.title)}.jpg`,
|
||||||
|
// maxDiffPixelRatio: 0.1
|
||||||
|
// });
|
||||||
|
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const data = await window.clipper.clipScreenshot(undefined, "raw");
|
||||||
|
if (data) {
|
||||||
|
return base64ArrayBuffer(await data.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ArrayBuffer(arrayBuffer) {
|
||||||
|
let base64 = "";
|
||||||
|
const encodings =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(arrayBuffer);
|
||||||
|
const byteLength = bytes.byteLength;
|
||||||
|
const byteRemainder = byteLength % 3;
|
||||||
|
const mainLength = byteLength - byteRemainder;
|
||||||
|
|
||||||
|
let a, b, c, d;
|
||||||
|
let chunk;
|
||||||
|
|
||||||
|
// Main loop deals with bytes in chunks of 3
|
||||||
|
for (let i = 0; i < mainLength; i = i + 3) {
|
||||||
|
// Combine the three bytes into a single integer
|
||||||
|
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
||||||
|
|
||||||
|
// Use bitmasks to extract 6-bit segments from the triplet
|
||||||
|
a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
|
||||||
|
b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
|
||||||
|
c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
|
||||||
|
d = chunk & 63; // 63 = 2^6 - 1
|
||||||
|
|
||||||
|
// Convert the raw binary segments to the appropriate ASCII encoding
|
||||||
|
base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal with the remaining bytes and padding
|
||||||
|
if (byteRemainder == 1) {
|
||||||
|
chunk = bytes[mainLength];
|
||||||
|
|
||||||
|
a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
|
||||||
|
|
||||||
|
// Set the 4 least significant bits to zero
|
||||||
|
b = (chunk & 3) << 4; // 3 = 2^2 - 1
|
||||||
|
|
||||||
|
base64 += encodings[a] + encodings[b] + "==";
|
||||||
|
} else if (byteRemainder == 2) {
|
||||||
|
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
|
||||||
|
|
||||||
|
a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
|
||||||
|
b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
|
||||||
|
|
||||||
|
// Set the 2 least significant bits to zero
|
||||||
|
c = (chunk & 15) << 2; // 15 = 2^4 - 1
|
||||||
|
|
||||||
|
base64 += encodings[a] + encodings[b] + encodings[c] + "=";
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) throw new Error("Failed to clip page.");
|
||||||
|
|
||||||
|
expect(Buffer.from(result, "base64")).toMatchSnapshot({
|
||||||
|
name: `${slugify(website.title)}-image.png`,
|
||||||
|
maxDiffPixelRatio: 0.1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 891 KiB |
|
After Width: | Height: | Size: 890 KiB |
|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
24
packages/clipper/__tests__/pages.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "My Ad Center helps you control the ads you see",
|
||||||
|
"url": "https://blog.google/technology/safety-security/my-ad-center/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How to set Linux environment variables with Ansible - Stack Overflow",
|
||||||
|
"url": "https://stackoverflow.com/questions/27733511/how-to-set-linux-environment-variables-with-ansible"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "IMDb: Ratings, Reviews, and Where to Watch the Best Movies & TV Shows",
|
||||||
|
"url": "https://www.imdb.com/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "House of the Dragon (TV Series 2022– ) - IMDb",
|
||||||
|
"url": "https://www.imdb.com/title/tt11198330/?ref_=hm_fanfav_tt_i_2_pd_fp1"
|
||||||
|
},
|
||||||
|
{ "title": "Apple", "url": "https://www.apple.com/" },
|
||||||
|
{ "title": "Docgen — Github", "url": "https://github.com/thecodrr/docgen" },
|
||||||
|
{
|
||||||
|
"title": "Playwright Docs",
|
||||||
|
"url": "https://playwright.dev/docs/api/class-testoptions"
|
||||||
|
}
|
||||||
|
]
|
||||||
3284
packages/clipper/package-lock.json
generated
Normal file
45
packages/clipper/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@notesnook/clipper",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web clipper core used by the Notesnook Web Clipper",
|
||||||
|
"keywords": [
|
||||||
|
"web-clipper"
|
||||||
|
],
|
||||||
|
"author": "Abdullah Atta <abdullahatta@streetwriters.co>",
|
||||||
|
"homepage": "https://notesnook.com/",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.27.1",
|
||||||
|
"@types/css": "^0.0.33",
|
||||||
|
"@types/css-tree": "^1.0.7",
|
||||||
|
"@types/dompurify": "^2.3.4",
|
||||||
|
"@types/stylis": "^4.0.2",
|
||||||
|
"esbuild": "^0.15.9",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^4.8.2",
|
||||||
|
"slugify": "^1.6.5"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/streetwriters/notesnook.git"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "playwright test"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/streetwriters/notesnook/issues"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mozilla/readability": "^0.4.2",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"dompurify": "^2.4.0",
|
||||||
|
"hyperapp": "^2.0.22",
|
||||||
|
"jest-environment-jsdom": "^29.0.3",
|
||||||
|
"specificity": "^0.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
131
packages/clipper/playwright.config.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PlaywrightTestConfig } from "@playwright/test";
|
||||||
|
import { devices } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
const config: PlaywrightTestConfig = {
|
||||||
|
testDir: "./__tests__",
|
||||||
|
/* Maximum time one test can run for. */
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
/**
|
||||||
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
|
* For example in `await expect(locator).toHaveText();`
|
||||||
|
*/
|
||||||
|
timeout: 5000
|
||||||
|
},
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 2 : 1,
|
||||||
|
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: "list",
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
|
actionTimeout: 0,
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: "retain-on-failure"
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
viewport: {
|
||||||
|
height: 1080,
|
||||||
|
width: 1920
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: "firefox",
|
||||||
|
// use: {
|
||||||
|
// ...devices["Desktop Firefox"]
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: "webkit",
|
||||||
|
// use: {
|
||||||
|
// ...devices["Desktop Safari"]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: {
|
||||||
|
// ...devices['Pixel 5'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: {
|
||||||
|
// ...devices['iPhone 12'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: {
|
||||||
|
// channel: 'msedge',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: {
|
||||||
|
// channel: 'chrome',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
|
// outputDir: 'test-results/',
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// port: 3000,
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
315
packages/clipper/src/clone.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { createImage, FetchOptions } from "./fetch";
|
||||||
|
import { Filter } from "./types";
|
||||||
|
import { uid } from "./utils";
|
||||||
|
|
||||||
|
const SVGElements = [
|
||||||
|
"altGlyph",
|
||||||
|
"altGlyphDef",
|
||||||
|
"altGlyphItem",
|
||||||
|
"animate",
|
||||||
|
"animateColor",
|
||||||
|
"animateMotion",
|
||||||
|
"animateTransform",
|
||||||
|
"circle",
|
||||||
|
"clipPath",
|
||||||
|
"color-profile",
|
||||||
|
"cursor",
|
||||||
|
"defs",
|
||||||
|
"desc",
|
||||||
|
"ellipse",
|
||||||
|
"feBlend",
|
||||||
|
"feColorMatrix",
|
||||||
|
"feComponentTransfer",
|
||||||
|
"feComposite",
|
||||||
|
"feConvolveMatrix",
|
||||||
|
"feDiffuseLighting",
|
||||||
|
"feDisplacementMap",
|
||||||
|
"feDistantLight",
|
||||||
|
"feFlood",
|
||||||
|
"feFuncA",
|
||||||
|
"feFuncB",
|
||||||
|
"feFuncG",
|
||||||
|
"feFuncR",
|
||||||
|
"feGaussianBlur",
|
||||||
|
"feImage",
|
||||||
|
"feMerge",
|
||||||
|
"feMergeNode",
|
||||||
|
"feMorphology",
|
||||||
|
"feOffset",
|
||||||
|
"fePointLight",
|
||||||
|
"feSpecularLighting",
|
||||||
|
"feSpotLight",
|
||||||
|
"feTile",
|
||||||
|
"feTurbulence",
|
||||||
|
"filter",
|
||||||
|
"font-face",
|
||||||
|
"font-face-format",
|
||||||
|
"font-face-name",
|
||||||
|
"font-face-src",
|
||||||
|
"font-face-uri",
|
||||||
|
"foreignObject",
|
||||||
|
"g",
|
||||||
|
"glyph",
|
||||||
|
"glyphRef",
|
||||||
|
"hkern",
|
||||||
|
"image",
|
||||||
|
"line",
|
||||||
|
"linearGradient",
|
||||||
|
"marker",
|
||||||
|
"mask",
|
||||||
|
"metadata",
|
||||||
|
"missing-glyph",
|
||||||
|
"mpath",
|
||||||
|
"path",
|
||||||
|
"pattern",
|
||||||
|
"polygon",
|
||||||
|
"polyline",
|
||||||
|
"radialGradient",
|
||||||
|
"rect",
|
||||||
|
"set",
|
||||||
|
"stop",
|
||||||
|
"svg",
|
||||||
|
"switch",
|
||||||
|
"symbol",
|
||||||
|
"text",
|
||||||
|
"textPath",
|
||||||
|
"title",
|
||||||
|
"tref",
|
||||||
|
"tspan",
|
||||||
|
"use",
|
||||||
|
"view",
|
||||||
|
"vkern"
|
||||||
|
].map((a) => a.toLowerCase());
|
||||||
|
|
||||||
|
const INVALID_ELEMENTS = ["script"].map((a) => a.toLowerCase());
|
||||||
|
|
||||||
|
type CloneProps = {
|
||||||
|
filter?: Filter;
|
||||||
|
root: boolean;
|
||||||
|
vector: boolean;
|
||||||
|
getElementStyles?: (element: HTMLElement) => CSSStyleDeclaration | undefined;
|
||||||
|
getPseudoElementStyles?: (
|
||||||
|
element: HTMLElement,
|
||||||
|
pseudoElement: string
|
||||||
|
) => CSSStyleDeclaration | undefined;
|
||||||
|
fetchOptions?: FetchOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function cloneNode(node: HTMLElement, options: CloneProps) {
|
||||||
|
const { root, filter } = options;
|
||||||
|
if (!root && filter && !filter(node)) return null;
|
||||||
|
|
||||||
|
let clone = await makeNodeCopy(node, options.fetchOptions || {});
|
||||||
|
|
||||||
|
if (!clone) return null;
|
||||||
|
clone = await cloneChildren(node, clone, options);
|
||||||
|
|
||||||
|
const processed = processClone(node, clone, options);
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeNodeCopy(original: HTMLElement, options: FetchOptions) {
|
||||||
|
try {
|
||||||
|
if (original instanceof HTMLCanvasElement)
|
||||||
|
return createImage(original.toDataURL(), options);
|
||||||
|
|
||||||
|
if (original.nodeType === Node.COMMENT_NODE) return null;
|
||||||
|
|
||||||
|
if (isInvalidElement(original)) return null;
|
||||||
|
|
||||||
|
if (original.nodeType !== Node.TEXT_NODE && !isSVGElement(original)) {
|
||||||
|
const { display, width, height } = window.getComputedStyle(original);
|
||||||
|
if (display === "none" || (width === "0px" && height === "0px"))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (isCustomElement(original)) {
|
||||||
|
const isInline = display.includes("inline");
|
||||||
|
const element = document.createElement(isInline ? "span" : "div");
|
||||||
|
for (const attribute of original.attributes) {
|
||||||
|
element.setAttribute(attribute.name, attribute.value);
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return original.cloneNode(false) as HTMLElement;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to clone element", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomElement(element: HTMLElement) {
|
||||||
|
if (!element || !element.tagName) return false;
|
||||||
|
return (
|
||||||
|
!SVGElements.includes(element.tagName.toLowerCase()) &&
|
||||||
|
element.tagName.includes("-")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSVGElement(element: HTMLElement) {
|
||||||
|
if (!element || !element.tagName) return false;
|
||||||
|
return SVGElements.includes(element.tagName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInvalidElement(element: HTMLElement) {
|
||||||
|
if (!element || !element.tagName) return false;
|
||||||
|
return INVALID_ELEMENTS.includes(element.tagName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneChildren(
|
||||||
|
original: HTMLElement,
|
||||||
|
clone: HTMLElement,
|
||||||
|
options: CloneProps
|
||||||
|
) {
|
||||||
|
const children = original.childNodes;
|
||||||
|
if (children.length === 0) return clone;
|
||||||
|
|
||||||
|
await cloneChildrenInOrder(clone, children, options);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneChildrenInOrder(
|
||||||
|
parent: HTMLElement,
|
||||||
|
childs: NodeListOf<ChildNode>,
|
||||||
|
options: CloneProps
|
||||||
|
) {
|
||||||
|
for (const node of childs) {
|
||||||
|
const childClone = await cloneNode(node as HTMLElement, {
|
||||||
|
...options,
|
||||||
|
root: false
|
||||||
|
});
|
||||||
|
if (childClone) parent.appendChild(childClone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processClone(
|
||||||
|
original: HTMLElement,
|
||||||
|
clone: HTMLElement,
|
||||||
|
options: CloneProps
|
||||||
|
) {
|
||||||
|
if (!(clone instanceof Element)) return clone;
|
||||||
|
|
||||||
|
// if (clone instanceof HTMLElement) removeAttributes(clone);
|
||||||
|
|
||||||
|
copyStyle(original, clone, options);
|
||||||
|
clonePseudoElements(original, clone, options);
|
||||||
|
|
||||||
|
copyUserInput(original, clone);
|
||||||
|
fixSvg(clone);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFont(source: CSSStyleDeclaration, target: CSSStyleDeclaration) {
|
||||||
|
target.font = source.font;
|
||||||
|
target.fontFamily = source.fontFamily;
|
||||||
|
target.fontFeatureSettings = source.fontFeatureSettings;
|
||||||
|
target.fontKerning = source.fontKerning;
|
||||||
|
target.fontSize = source.fontSize;
|
||||||
|
target.fontStretch = source.fontStretch;
|
||||||
|
target.fontStyle = source.fontStyle;
|
||||||
|
target.fontVariant = source.fontVariant;
|
||||||
|
target.fontVariantCaps = source.fontVariantCaps;
|
||||||
|
target.fontVariantEastAsian = source.fontVariantEastAsian;
|
||||||
|
target.fontVariantLigatures = source.fontVariantLigatures;
|
||||||
|
target.fontVariantNumeric = source.fontVariantNumeric;
|
||||||
|
target.fontVariationSettings = source.fontVariationSettings;
|
||||||
|
target.fontWeight = source.fontWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyStyle(
|
||||||
|
sourceElement: HTMLElement,
|
||||||
|
targetElement: HTMLElement,
|
||||||
|
options: CloneProps
|
||||||
|
) {
|
||||||
|
const { getElementStyles } = options;
|
||||||
|
const sourceComputedStyles =
|
||||||
|
getElementStyles && getElementStyles(sourceElement);
|
||||||
|
if (!sourceComputedStyles) return;
|
||||||
|
|
||||||
|
targetElement.style.cssText = sourceComputedStyles.cssText;
|
||||||
|
|
||||||
|
if (sourceElement.tagName.toLowerCase() === "body") {
|
||||||
|
copyFont(getComputedStyle(sourceElement), targetElement.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = targetElement.getAttribute("style");
|
||||||
|
if (styles) targetElement.setAttribute("style", minifyStyles(styles));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clonePseudoElements(
|
||||||
|
original: HTMLElement,
|
||||||
|
clone: HTMLElement,
|
||||||
|
options: CloneProps
|
||||||
|
) {
|
||||||
|
const { getPseudoElementStyles } = options;
|
||||||
|
let hasPseudoElements = false;
|
||||||
|
|
||||||
|
const styleElement = document.createElement("style");
|
||||||
|
const className = `pseudo--${uid()}`;
|
||||||
|
|
||||||
|
for (const element of [":before", ":after"]) {
|
||||||
|
const style =
|
||||||
|
(getPseudoElementStyles && getPseudoElementStyles(original, element)) ||
|
||||||
|
getComputedStyle(original, element);
|
||||||
|
|
||||||
|
if (!style.cssText) continue;
|
||||||
|
|
||||||
|
const selector = `.${className}:${element} {
|
||||||
|
${style.cssText}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
styleElement.appendChild(document.createTextNode(selector));
|
||||||
|
hasPseudoElements = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPseudoElements) {
|
||||||
|
clone.className = className;
|
||||||
|
clone.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPseudoElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUserInput(original: HTMLElement, clone: HTMLElement) {
|
||||||
|
if (original instanceof HTMLTextAreaElement) clone.innerHTML = original.value;
|
||||||
|
if (original instanceof HTMLInputElement)
|
||||||
|
clone.setAttribute("value", original.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixSvg(clone: Element) {
|
||||||
|
if (!(clone instanceof SVGElement)) return;
|
||||||
|
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||||
|
|
||||||
|
// if (!(clone instanceof SVGRectElement)) return;
|
||||||
|
["width", "height"].forEach(function (attribute) {
|
||||||
|
const value = clone.getAttribute(attribute);
|
||||||
|
if (!value || !!clone.style.getPropertyValue(attribute)) return;
|
||||||
|
|
||||||
|
clone.style.setProperty(attribute, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function minifyStyles(text: string) {
|
||||||
|
return text.replace(/(:?[:;])(:? +)/gm, (_full, sep) => {
|
||||||
|
return sep;
|
||||||
|
});
|
||||||
|
}
|
||||||
176
packages/clipper/src/css-tokenizer.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const from = String.fromCharCode;
|
||||||
|
|
||||||
|
function trim(value: string): string {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function charat(value: string, index: number): number {
|
||||||
|
return value.charCodeAt(index) | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strlen(value: string): number {
|
||||||
|
return value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function substr(value: string, begin: number, end: number): string {
|
||||||
|
return value.slice(begin, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function append<T>(value: T, array: T[]): T {
|
||||||
|
array.push(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = 1;
|
||||||
|
let column = 1;
|
||||||
|
let length = 0;
|
||||||
|
let position = 0;
|
||||||
|
let character = 0;
|
||||||
|
let characters = "";
|
||||||
|
|
||||||
|
function next(): number {
|
||||||
|
character = position < length ? charat(characters, position++) : 0;
|
||||||
|
|
||||||
|
if ((column++, character === 10)) (column = 1), line++;
|
||||||
|
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
|
function peek(): number {
|
||||||
|
return charat(characters, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slice(begin: number, end: number): string {
|
||||||
|
return substr(characters, begin, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(type: number): number {
|
||||||
|
switch (type) {
|
||||||
|
// \0 \t \n \r \s whitespace token
|
||||||
|
case 0:
|
||||||
|
case 9:
|
||||||
|
case 10:
|
||||||
|
case 13:
|
||||||
|
case 32:
|
||||||
|
return 5;
|
||||||
|
|
||||||
|
// ! + , / > @ ~ isolate token
|
||||||
|
case 33:
|
||||||
|
case 42:
|
||||||
|
case 43:
|
||||||
|
case 44:
|
||||||
|
case 47:
|
||||||
|
case 62:
|
||||||
|
case 64:
|
||||||
|
case 126:
|
||||||
|
case 59: /* ; { } breakpoint token */
|
||||||
|
case 123:
|
||||||
|
case 125:
|
||||||
|
return 4;
|
||||||
|
// : accompanied token
|
||||||
|
case 58:
|
||||||
|
return 3;
|
||||||
|
// " ' ( [ opening delimit token
|
||||||
|
case 34:
|
||||||
|
case 39:
|
||||||
|
case 40:
|
||||||
|
case 91:
|
||||||
|
return 2;
|
||||||
|
// ) ] closing delimit token
|
||||||
|
case 41:
|
||||||
|
case 93:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alloc(value: string): [] {
|
||||||
|
line = column = 1;
|
||||||
|
length = strlen((characters = value));
|
||||||
|
position = 0;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dealloc<T>(value: T): T {
|
||||||
|
characters = "";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delimit(type: number): string {
|
||||||
|
return trim(
|
||||||
|
slice(
|
||||||
|
position - 1,
|
||||||
|
delimiter(type === 91 ? type + 2 : type === 40 ? type + 1 : type)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenize(value: string): string[] {
|
||||||
|
return dealloc(tokenizer(alloc(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizer(children: string[]): string[] {
|
||||||
|
while (next())
|
||||||
|
switch (token(character)) {
|
||||||
|
case 0:
|
||||||
|
append(identifier(position - 1), children);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
append(delimit(character), children);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
append(from(character), children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delimiter(type: number): number {
|
||||||
|
while (next())
|
||||||
|
switch (character) {
|
||||||
|
// ] ) " '
|
||||||
|
case type:
|
||||||
|
return position;
|
||||||
|
// " '
|
||||||
|
case 34:
|
||||||
|
case 39:
|
||||||
|
if (type !== 34 && type !== 39) delimiter(character);
|
||||||
|
break;
|
||||||
|
// (
|
||||||
|
case 40:
|
||||||
|
if (type === 41) delimiter(type);
|
||||||
|
break;
|
||||||
|
// \
|
||||||
|
case 92:
|
||||||
|
next();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifier(index: number): string {
|
||||||
|
while (!token(peek())) next();
|
||||||
|
|
||||||
|
return slice(index, position);
|
||||||
|
}
|
||||||
248
packages/clipper/src/domtoimage.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { cloneNode, isSVGElement } from "./clone";
|
||||||
|
import { createImage, FetchOptions } from "./fetch";
|
||||||
|
import { resolveAll } from "./fontfaces";
|
||||||
|
import { inlineAllImages } from "./images";
|
||||||
|
import { Options } from "./types";
|
||||||
|
import { canvasToBlob, delay, escapeXhtml, height, width } from "./utils";
|
||||||
|
import { cacheStylesheets, inlineStylesheets } from "./styles";
|
||||||
|
import purify from "dompurify";
|
||||||
|
|
||||||
|
// Default impl options
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
fetchOptions: {},
|
||||||
|
inlineOptions: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getInlinedNode(node: HTMLElement, options: Options) {
|
||||||
|
const { fonts, images, stylesheets } = options.inlineOptions || {};
|
||||||
|
|
||||||
|
console.time("inline styles");
|
||||||
|
if (stylesheets) await inlineStylesheets(options.fetchOptions || {});
|
||||||
|
console.timeEnd("inline styles");
|
||||||
|
|
||||||
|
const documentStyles = getComputedStyle(document.documentElement);
|
||||||
|
|
||||||
|
console.time("cache styles");
|
||||||
|
const styleCache = cacheStylesheets(documentStyles);
|
||||||
|
console.timeEnd("cache styles");
|
||||||
|
|
||||||
|
console.time("clone");
|
||||||
|
let clone = await cloneNode(node, {
|
||||||
|
filter: options.filter,
|
||||||
|
root: true,
|
||||||
|
vector: !options.raster,
|
||||||
|
fetchOptions: options.fetchOptions,
|
||||||
|
getElementStyles: styleCache.get,
|
||||||
|
getPseudoElementStyles: styleCache.getPseudo
|
||||||
|
});
|
||||||
|
console.timeEnd("clone");
|
||||||
|
|
||||||
|
if (!clone || clone instanceof Text) return;
|
||||||
|
|
||||||
|
console.time("embed fonts");
|
||||||
|
if (fonts) clone = await embedFonts(clone, options.fetchOptions || {});
|
||||||
|
console.timeEnd("embed fonts");
|
||||||
|
|
||||||
|
console.time("inline images");
|
||||||
|
if (images) await inlineAllImages(clone, options.fetchOptions || {});
|
||||||
|
console.timeEnd("inline images");
|
||||||
|
|
||||||
|
finalize(clone);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toSvg(node: HTMLElement, options: Options) {
|
||||||
|
options.inlineOptions = {
|
||||||
|
fonts: true,
|
||||||
|
images: true,
|
||||||
|
stylesheets: true,
|
||||||
|
...options.inlineOptions
|
||||||
|
};
|
||||||
|
|
||||||
|
let clone = await getInlinedNode(node, options);
|
||||||
|
if (!clone) return;
|
||||||
|
|
||||||
|
clone = applyOptions(clone, options);
|
||||||
|
|
||||||
|
clone = purify.sanitize(clone, {
|
||||||
|
RETURN_DOM: true,
|
||||||
|
KEEP_CONTENT: false,
|
||||||
|
ADD_ATTR: ["style"]
|
||||||
|
});
|
||||||
|
|
||||||
|
return makeSvgDataUri(
|
||||||
|
clone,
|
||||||
|
options.width || width(node),
|
||||||
|
options.height || height(node)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOptions(clone: HTMLElement, options: Options) {
|
||||||
|
if (options.backgroundColor)
|
||||||
|
clone.style.backgroundColor = options.backgroundColor;
|
||||||
|
if (options.width) clone.style.width = options.width + "px";
|
||||||
|
if (options.height) clone.style.height = options.height + "px";
|
||||||
|
|
||||||
|
if (options.style) {
|
||||||
|
const style = options.style;
|
||||||
|
Object.keys(style).forEach(function (property) {
|
||||||
|
clone.style.setProperty(property, style.getPropertyValue(property));
|
||||||
|
// clone.style[property] = style[property];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onCloned?.(clone);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPixelData(node: HTMLElement, options: Options) {
|
||||||
|
options = options || {};
|
||||||
|
options.raster = true;
|
||||||
|
return draw(node, options).then(function (canvas) {
|
||||||
|
return canvas
|
||||||
|
?.getContext("2d")
|
||||||
|
?.getImageData(0, 0, width(node), height(node)).data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPng(node: HTMLElement, options: Options) {
|
||||||
|
options.raster = true;
|
||||||
|
return draw(node, options).then(function (canvas) {
|
||||||
|
return canvas?.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJpeg(node: HTMLElement, options: Options) {
|
||||||
|
options.raster = true;
|
||||||
|
return draw(node, options).then(function (canvas) {
|
||||||
|
return canvas?.toDataURL("image/jpeg", options.quality || 1.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBlob(node: HTMLElement, options: Options) {
|
||||||
|
options.raster = true;
|
||||||
|
return draw(node, options).then((canvas) => canvas && canvasToBlob(canvas));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCanvas(node: HTMLElement, options: Options) {
|
||||||
|
options.raster = true;
|
||||||
|
return draw(node, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(domNode: HTMLElement, options: Options) {
|
||||||
|
options = { ...defaultOptions, ...options };
|
||||||
|
return toSvg(domNode, options)
|
||||||
|
.then((uri) => (uri ? createImage(uri, options.fetchOptions || {}) : null))
|
||||||
|
.then(delay(0))
|
||||||
|
.then(function (image) {
|
||||||
|
const scale = typeof options.scale !== "number" ? 1 : options.scale;
|
||||||
|
const canvas = newCanvas(domNode, scale, options);
|
||||||
|
const ctx = canvas?.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
// ctx.mozImageSmoothingEnabled = false;
|
||||||
|
// ctx.msImageSmoothingEnabled = false;
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
if (image) {
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.drawImage(image, 0, 0);
|
||||||
|
}
|
||||||
|
return canvas;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newCanvas(node: HTMLElement, scale: number, options: Options) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = (options.width || width(node)) * scale;
|
||||||
|
canvas.height = (options.height || height(node)) * scale;
|
||||||
|
|
||||||
|
if (options.backgroundColor) {
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.fillStyle = options.backgroundColor;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function embedFonts(node: HTMLElement, options: FetchOptions) {
|
||||||
|
return resolveAll(options).then(function (cssText) {
|
||||||
|
const styleNode = document.createElement("style");
|
||||||
|
node.appendChild(styleNode);
|
||||||
|
styleNode.appendChild(document.createTextNode(cssText));
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSvgDataUri(node: HTMLElement, width: number, height: number) {
|
||||||
|
node.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
|
||||||
|
const xhtml = escapeXhtml(new XMLSerializer().serializeToString(node));
|
||||||
|
const foreignObject =
|
||||||
|
'<foreignObject x="0" y="0" width="100%" height="100%">' +
|
||||||
|
xhtml +
|
||||||
|
"</foreignObject>";
|
||||||
|
|
||||||
|
const svgStr =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="' +
|
||||||
|
width +
|
||||||
|
'" height="' +
|
||||||
|
height +
|
||||||
|
'">' +
|
||||||
|
foreignObject +
|
||||||
|
"</svg>";
|
||||||
|
|
||||||
|
return "data:image/svg+xml;charset=utf-8," + svgStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toJpeg, toBlob, toCanvas, toPixelData, toPng, toSvg, getInlinedNode };
|
||||||
|
|
||||||
|
const VALID_ATTRIBUTES = [
|
||||||
|
"src",
|
||||||
|
"href",
|
||||||
|
"title",
|
||||||
|
"style",
|
||||||
|
"srcset",
|
||||||
|
"sizes",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"target",
|
||||||
|
"rel"
|
||||||
|
];
|
||||||
|
|
||||||
|
function finalize(root: HTMLElement) {
|
||||||
|
for (const element of root.querySelectorAll("*")) {
|
||||||
|
if (!(element instanceof HTMLElement) || isSVGElement(element)) continue;
|
||||||
|
for (const attribute of Array.from(element.attributes)) {
|
||||||
|
if (attribute.name === "class" && element.className.includes("pseudo--"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!VALID_ATTRIBUTES.includes(attribute.name)) {
|
||||||
|
element.removeAttribute(attribute.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element instanceof HTMLAnchorElement) {
|
||||||
|
element.href = element.href.startsWith("http")
|
||||||
|
? element.href
|
||||||
|
: document.location.origin + element.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
packages/clipper/src/fetch.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
export type FetchOptions = {
|
||||||
|
bypassCors?: boolean;
|
||||||
|
corsHost?: string;
|
||||||
|
noCache?: boolean;
|
||||||
|
crossOrigin?: "anonymous" | "use-credentials" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchResource(url: string, options: FetchOptions) {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const response = await fetch(constructUrl(url, options));
|
||||||
|
if (!response.ok) return "";
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
reader.addEventListener("loadend", () => {
|
||||||
|
if (typeof reader.result === "string") resolve(reader.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImage(url: string, options: FetchOptions) {
|
||||||
|
if (url === "data:,") return Promise.resolve(null);
|
||||||
|
return new Promise<HTMLImageElement>(function (resolve, reject) {
|
||||||
|
const image = new Image();
|
||||||
|
image.crossOrigin = options.crossOrigin || null;
|
||||||
|
image.onload = function () {
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = reject;
|
||||||
|
image.src = constructUrl(url, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadImage(image: HTMLImageElement, options: FetchOptions) {
|
||||||
|
if (options.corsHost && image.currentSrc.startsWith(options.corsHost))
|
||||||
|
return Promise.resolve(null);
|
||||||
|
|
||||||
|
options.noCache = true;
|
||||||
|
return new Promise<HTMLImageElement>(function (resolve, reject) {
|
||||||
|
image.crossOrigin = options.crossOrigin || null;
|
||||||
|
image.onload = function () {
|
||||||
|
resolve(image);
|
||||||
|
};
|
||||||
|
image.onerror = (e) => {
|
||||||
|
console.error("Failed to load image", image.currentSrc);
|
||||||
|
reject(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
image.src = constructUrl(image.currentSrc, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constructUrl(url: string, options: FetchOptions) {
|
||||||
|
if (!url.startsWith("http")) return url;
|
||||||
|
if (options.noCache) {
|
||||||
|
// Cache bypass so we dont have CORS issues with cached images
|
||||||
|
// Source: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
|
||||||
|
url += (/\?/.test(url) ? "&" : "?") + Date.now();
|
||||||
|
}
|
||||||
|
if (options.bypassCors && options.corsHost) {
|
||||||
|
if (url.startsWith(options.corsHost)) return url;
|
||||||
|
|
||||||
|
url = `${options.corsHost}/${url}`;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
79
packages/clipper/src/fontfaces.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { FetchOptions } from "./fetch";
|
||||||
|
import { inlineAll, shouldProcess } from "./inliner";
|
||||||
|
|
||||||
|
async function resolveAll(options: FetchOptions) {
|
||||||
|
const fonts = readAll();
|
||||||
|
const cssStrings: string[] = [];
|
||||||
|
for (const font of fonts) {
|
||||||
|
cssStrings.push(await font.resolve(options));
|
||||||
|
}
|
||||||
|
return cssStrings.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAll() {
|
||||||
|
const cssRules = getWebFonts(document.styleSheets);
|
||||||
|
const fonts = selectWebFontRules(cssRules);
|
||||||
|
return fonts.map(newWebFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebFonts(styleSheets: StyleSheetList) {
|
||||||
|
const cssRules: CSSFontFaceRule[] = [];
|
||||||
|
for (const sheet of styleSheets) {
|
||||||
|
try {
|
||||||
|
const allFonts = selectWebFontRules(Array.from(sheet.cssRules));
|
||||||
|
if (allFonts.length > 3) cssRules.push(allFonts[0]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
console.log(
|
||||||
|
"Error while reading CSS rules from " + sheet.href,
|
||||||
|
e.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cssRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newWebFont(webFontRule: CSSFontFaceRule) {
|
||||||
|
return {
|
||||||
|
resolve: function resolve(options: FetchOptions) {
|
||||||
|
const baseUrl = (webFontRule.parentStyleSheet || {}).href || undefined;
|
||||||
|
return inlineAll(webFontRule.cssText, options, baseUrl);
|
||||||
|
},
|
||||||
|
src: function () {
|
||||||
|
return webFontRule.style.getPropertyValue("src");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWebFontRules(cssRules: CSSRule[]): CSSFontFaceRule[] {
|
||||||
|
return cssRules
|
||||||
|
.filter(function (rule) {
|
||||||
|
return rule.type === CSSRule.FONT_FACE_RULE;
|
||||||
|
})
|
||||||
|
.filter(function (rule) {
|
||||||
|
return shouldProcess(
|
||||||
|
(rule as CSSFontFaceRule).style.getPropertyValue("src")
|
||||||
|
);
|
||||||
|
}) as CSSFontFaceRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export { resolveAll };
|
||||||
81
packages/clipper/src/images.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { FetchOptions, fetchResource } from "./fetch";
|
||||||
|
import { inlineAll } from "./inliner";
|
||||||
|
import { isDataUrl } from "./utils";
|
||||||
|
|
||||||
|
async function inlineAllImages(root: HTMLElement, options: FetchOptions) {
|
||||||
|
const imageNodes = root.querySelectorAll("img");
|
||||||
|
console.log("total images", imageNodes.length);
|
||||||
|
|
||||||
|
const promises: Promise<any>[] = [];
|
||||||
|
for (let i = 0; i < imageNodes.length; ++i) {
|
||||||
|
const image = imageNodes[i];
|
||||||
|
promises.push(inlineImage(image, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundImageNodes = root.querySelectorAll(
|
||||||
|
`[style*="background-image:"],[style*="background:"]`
|
||||||
|
);
|
||||||
|
for (let i = 0; i < backgroundImageNodes.length; ++i) {
|
||||||
|
const image = backgroundImageNodes[i];
|
||||||
|
promises.push(inlineBackground(image as HTMLElement, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises).catch((e) => console.error(e));
|
||||||
|
}
|
||||||
|
export { inlineAllImages };
|
||||||
|
|
||||||
|
async function inlineImage(element: HTMLImageElement, options: FetchOptions) {
|
||||||
|
if (isDataUrl(element.currentSrc)) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const dataURL = await fetchResource(
|
||||||
|
element.currentSrc || element.src,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (!dataURL) return null;
|
||||||
|
|
||||||
|
if (dataURL === "data:,") {
|
||||||
|
element.removeAttribute("src");
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<HTMLImageElement | null>(function (resolve, reject) {
|
||||||
|
if (element.parentElement?.tagName === "PICTURE") {
|
||||||
|
element.parentElement?.replaceWith(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.onload = () => resolve(element);
|
||||||
|
// for any image with invalid src(such as <img src />), just ignore it
|
||||||
|
element.onerror = (e) => reject(e);
|
||||||
|
element.src = dataURL;
|
||||||
|
element.removeAttribute("srcset");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inlineBackground(
|
||||||
|
backgroundNode: HTMLElement,
|
||||||
|
options: FetchOptions
|
||||||
|
) {
|
||||||
|
const background = backgroundNode.style.getPropertyValue("background-image");
|
||||||
|
if (!background) return backgroundNode;
|
||||||
|
const inlined = await inlineAll(background, options);
|
||||||
|
backgroundNode.style.setProperty("background-image", inlined);
|
||||||
|
return backgroundNode;
|
||||||
|
}
|
||||||
500
packages/clipper/src/index.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import purify from "dompurify";
|
||||||
|
import { Readability } from "@mozilla/readability";
|
||||||
|
import { injectCss } from "./utils";
|
||||||
|
import { app, h, text } from "hyperapp";
|
||||||
|
import {
|
||||||
|
getInlinedNode,
|
||||||
|
toBlob,
|
||||||
|
toJpeg,
|
||||||
|
toPixelData,
|
||||||
|
toPng
|
||||||
|
} from "./domtoimage";
|
||||||
|
import { InlineOptions } from "./types";
|
||||||
|
import { FetchOptions } from "./fetch";
|
||||||
|
|
||||||
|
type ReadabilityEnhanced = Readability<string> & {
|
||||||
|
PRESENTATIONAL_ATTRIBUTES: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLASSES = {
|
||||||
|
nodeHover: "nn-node-selection--hover",
|
||||||
|
nodeSelected: "nn-node-selection--selected",
|
||||||
|
nodeSelectionContainer: "nn-node-selection-container"
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLACKLIST = [CLASSES.nodeSelected, CLASSES.nodeSelectionContainer];
|
||||||
|
|
||||||
|
const fetchOptions: FetchOptions = {
|
||||||
|
bypassCors: true,
|
||||||
|
corsHost: "https://cors.eu.org",
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
noCache: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const inlineOptions: InlineOptions = {
|
||||||
|
fonts: false,
|
||||||
|
images: true,
|
||||||
|
stylesheets: true
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getPage(
|
||||||
|
document: Document,
|
||||||
|
styles: boolean,
|
||||||
|
onlyVisible = false
|
||||||
|
) {
|
||||||
|
const body = await getInlinedNode(document.body, {
|
||||||
|
raster: true,
|
||||||
|
fetchOptions,
|
||||||
|
inlineOptions,
|
||||||
|
filter: (node) => {
|
||||||
|
return !onlyVisible || isElementInViewport(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!body) return {};
|
||||||
|
|
||||||
|
const head = document.createElement("head");
|
||||||
|
head.title = document.title;
|
||||||
|
|
||||||
|
return {
|
||||||
|
body,
|
||||||
|
head
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDocument(head: HTMLElement, body: HTMLElement) {
|
||||||
|
const newHTMLDocument = document.implementation.createHTMLDocument();
|
||||||
|
newHTMLDocument.open();
|
||||||
|
newHTMLDocument.write(head.outerHTML, body.outerHTML);
|
||||||
|
newHTMLDocument.close();
|
||||||
|
|
||||||
|
// newHTMLDocument.insertBefore(documentType, newHTMLDocument.childNodes[0]);
|
||||||
|
|
||||||
|
return newHTMLDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clipPage(
|
||||||
|
document: Document,
|
||||||
|
withStyles: boolean,
|
||||||
|
onlyVisible: boolean
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { body, head } = await getPage(document, withStyles, onlyVisible);
|
||||||
|
if (!body || !head) return null;
|
||||||
|
const result = toDocument(head, body).documentElement.outerHTML;
|
||||||
|
return `<!doctype html>\n${result}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll(`.${CLASSES.nodeSelected}`).forEach((node) => {
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
node.classList.remove(CLASSES.nodeSelected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll(`.${CLASSES.nodeSelectionContainer}`)
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
|
||||||
|
removeHoverListeners(document);
|
||||||
|
removeClickHandlers(document);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
function clipNode(
|
||||||
|
element: HTMLElement | null | undefined,
|
||||||
|
keepStyles = true
|
||||||
|
): string | null {
|
||||||
|
if (!element) return null;
|
||||||
|
return purifyBody(element.outerHTML, keepStyles).outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clipArticle(
|
||||||
|
doc: Document,
|
||||||
|
withStyles: boolean
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { body, head } = await getPage(doc, withStyles);
|
||||||
|
if (!body || !head) return null;
|
||||||
|
const newDoc = toDocument(head, body);
|
||||||
|
|
||||||
|
const readability = new Readability(newDoc);
|
||||||
|
(readability as ReadabilityEnhanced).PRESENTATIONAL_ATTRIBUTES = [
|
||||||
|
"align",
|
||||||
|
"background",
|
||||||
|
"bgcolor",
|
||||||
|
"border",
|
||||||
|
"cellpadding",
|
||||||
|
"cellspacing",
|
||||||
|
"frame",
|
||||||
|
"hspace",
|
||||||
|
"rules",
|
||||||
|
"valign",
|
||||||
|
"vspace"
|
||||||
|
];
|
||||||
|
const result = readability.parse();
|
||||||
|
|
||||||
|
return `<html>${head?.outerHTML || ""}<body>${
|
||||||
|
result?.content || ""
|
||||||
|
}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// async function clipSimplifiedArticle(doc: Document): Promise<string | null> {
|
||||||
|
// const article = await clipArticle(doc);
|
||||||
|
// if (!article) return null;
|
||||||
|
// return purifyBody(article, false).outerHTML;
|
||||||
|
// }
|
||||||
|
|
||||||
|
function purifyBody(htmlString: string, keepStyles = true) {
|
||||||
|
return purify.sanitize(htmlString, {
|
||||||
|
RETURN_DOM: true,
|
||||||
|
KEEP_CONTENT: false,
|
||||||
|
ADD_TAGS: keepStyles ? ["use"] : [],
|
||||||
|
ADD_ATTR: keepStyles ? ["style", "class", "id"] : [],
|
||||||
|
FORBID_ATTR: !keepStyles ? ["style", "class", "id"] : [],
|
||||||
|
FORBID_TAGS: !keepStyles ? ["style"] : [],
|
||||||
|
ADD_DATA_URI_TAGS: ["style"],
|
||||||
|
CUSTOM_ELEMENT_HANDLING: {
|
||||||
|
attributeNameCheck: /notesnook/ // allow all attributes containing "baz"
|
||||||
|
}
|
||||||
|
}) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clipScreenshot<
|
||||||
|
TOutputFormat extends "jpeg" | "png" | "raw",
|
||||||
|
TOutput extends TOutputFormat extends "jpeg"
|
||||||
|
? string
|
||||||
|
: TOutputFormat extends "png"
|
||||||
|
? string
|
||||||
|
: Blob | undefined
|
||||||
|
>(
|
||||||
|
target?: HTMLElement,
|
||||||
|
output: TOutputFormat = "jpeg" as TOutputFormat
|
||||||
|
): Promise<TOutput> {
|
||||||
|
const screenshotTarget = target || document.body;
|
||||||
|
|
||||||
|
const func = output === "jpeg" ? toJpeg : output === "png" ? toPng : toBlob;
|
||||||
|
const screenshot = await func(screenshotTarget, {
|
||||||
|
quality: 1,
|
||||||
|
backgroundColor: "white",
|
||||||
|
width: document.body.scrollWidth,
|
||||||
|
height: document.body.scrollHeight,
|
||||||
|
fetchOptions,
|
||||||
|
inlineOptions: {
|
||||||
|
fonts: true,
|
||||||
|
images: true,
|
||||||
|
stylesheets: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (output === "jpeg" || output === "png")
|
||||||
|
return `<img width="${document.body.scrollWidth}px" height="${document.body.scrollHeight}px" src="${screenshot}" />` as TOutput;
|
||||||
|
else return screenshot as TOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSelect(target: HTMLElement) {
|
||||||
|
for (const className of BLACKLIST) {
|
||||||
|
if (target.classList.contains(className) || target.closest(`.${className}`))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseOver = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.classList.contains(CLASSES.nodeHover) || !canSelect(target))
|
||||||
|
return;
|
||||||
|
target.classList.add(CLASSES.nodeHover);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseLeave = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.classList.contains(CLASSES.nodeHover)) return;
|
||||||
|
target.classList.remove(CLASSES.nodeHover);
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerHoverListeners(doc: Document) {
|
||||||
|
doc.body.addEventListener("mouseout", onMouseLeave);
|
||||||
|
doc.body.addEventListener("mouseover", onMouseOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeHoverListeners(doc: Document) {
|
||||||
|
doc.body.removeEventListener("mouseout", onMouseLeave);
|
||||||
|
doc.body.removeEventListener("mouseover", onMouseOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseClick = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
|
if (target.classList.contains(CLASSES.nodeSelected)) {
|
||||||
|
target.classList.remove(CLASSES.nodeSelected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canSelect(target)) return;
|
||||||
|
|
||||||
|
target.classList.add(CLASSES.nodeSelected);
|
||||||
|
// const clipData = clipNode(target, false);
|
||||||
|
// useExtensionStore.getState().setClipData({
|
||||||
|
// type: "manualSelection",
|
||||||
|
// data: clipData,
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
|
function registerClickListeners(doc: Document) {
|
||||||
|
doc.body.addEventListener("click", onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClickHandlers(doc: Document) {
|
||||||
|
doc.body.removeEventListener("click", onMouseClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterNodeSelectionMode(doc: Document) {
|
||||||
|
setTimeout(() => {
|
||||||
|
registerClickListeners(doc);
|
||||||
|
registerHoverListeners(doc);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
injectStyles();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
injectNodeSelectionControls(
|
||||||
|
async () => {
|
||||||
|
const selectedNodes = document.querySelectorAll(
|
||||||
|
`.${CLASSES.nodeSelected}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
for (const node of selectedNodes) {
|
||||||
|
node.classList.remove(CLASSES.nodeSelected);
|
||||||
|
const inlined = await getInlinedNode(node as HTMLElement, {
|
||||||
|
raster: false,
|
||||||
|
fetchOptions,
|
||||||
|
inlineOptions
|
||||||
|
});
|
||||||
|
if (!inlined) continue;
|
||||||
|
div.appendChild(inlined);
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
resolve(div?.outerHTML);
|
||||||
|
},
|
||||||
|
() => reject("Cancelled.")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
clipPage,
|
||||||
|
clipArticle,
|
||||||
|
cleanup,
|
||||||
|
clipNode,
|
||||||
|
clipScreenshot,
|
||||||
|
enterNodeSelectionMode
|
||||||
|
};
|
||||||
|
|
||||||
|
const mod = {
|
||||||
|
clipPage,
|
||||||
|
clipArticle,
|
||||||
|
cleanup,
|
||||||
|
clipNode,
|
||||||
|
clipScreenshot,
|
||||||
|
enterNodeSelectionMode
|
||||||
|
};
|
||||||
|
export type Clipper = typeof mod;
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
const css = `.${CLASSES.nodeHover} {
|
||||||
|
border: 1px solid green;
|
||||||
|
background-color: rgb(0,0,0,0.05);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.${CLASSES.nodeSelected} {
|
||||||
|
border: 2px solid green;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.${CLASSES.nodeSelectionContainer} {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0px;
|
||||||
|
right: 0px;
|
||||||
|
z-index: ${Number.MAX_VALUE};
|
||||||
|
}`;
|
||||||
|
injectCss(css, "nn-clipper-styles");
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectNodeSelectionControls(
|
||||||
|
onDone?: () => void,
|
||||||
|
onCancel?: () => void
|
||||||
|
) {
|
||||||
|
const controlContainer = document.createElement("div");
|
||||||
|
controlContainer.classList.add(CLASSES.nodeSelectionContainer);
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.appendChild(controlContainer);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
app({
|
||||||
|
init: {},
|
||||||
|
view: () =>
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
padding: "10px",
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderRadius: "5px",
|
||||||
|
boxShadow: "0px 0px 10px 0px #00000038"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h("p", { style: { marginBottom: "0px", fontSize: "18px" } }, [
|
||||||
|
text("Notesnook Web Clipper")
|
||||||
|
]),
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
margin: "0px",
|
||||||
|
marginBottom: "5px",
|
||||||
|
fontStyle: "italic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[text("Click on any element to select it.")]
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onclick: (_state) => onDone?.(),
|
||||||
|
style: { marginRight: "5px" }
|
||||||
|
},
|
||||||
|
[text("Done")]
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onclick: (_state) => {
|
||||||
|
cleanup();
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[text("Cancel")]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
node: controlContainer
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElementInViewport(el: HTMLElement) {
|
||||||
|
if (
|
||||||
|
(el.nodeType === Node.TEXT_NODE || !el.getBoundingClientRect) &&
|
||||||
|
el.parentElement
|
||||||
|
)
|
||||||
|
el = el.parentElement;
|
||||||
|
|
||||||
|
const info = getElementViewportInfo(el);
|
||||||
|
return info.isInViewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewportInfo = {
|
||||||
|
isInViewport: boolean;
|
||||||
|
isPartiallyInViewport: boolean;
|
||||||
|
isInsideViewport: boolean;
|
||||||
|
isAroundViewport: boolean;
|
||||||
|
isOnEdge: boolean;
|
||||||
|
isOnTopEdge: boolean;
|
||||||
|
isOnRightEdge: boolean;
|
||||||
|
isOnBottomEdge: boolean;
|
||||||
|
isOnLeftEdge: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getElementViewportInfo(el: HTMLElement) {
|
||||||
|
const result: ViewportInfo = {
|
||||||
|
isInViewport: false,
|
||||||
|
isPartiallyInViewport: false,
|
||||||
|
isInsideViewport: false,
|
||||||
|
isAroundViewport: false,
|
||||||
|
isOnEdge: false,
|
||||||
|
isOnTopEdge: false,
|
||||||
|
isOnRightEdge: false,
|
||||||
|
isOnBottomEdge: false,
|
||||||
|
isOnLeftEdge: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const windowHeight =
|
||||||
|
window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||||
|
|
||||||
|
const insideX = rect.left >= 0 && rect.left + rect.width <= windowWidth;
|
||||||
|
const insideY = rect.top >= 0 && rect.top + rect.height <= windowHeight;
|
||||||
|
|
||||||
|
result.isInsideViewport = insideX && insideY;
|
||||||
|
|
||||||
|
const aroundX = rect.left < 0 && rect.left + rect.width > windowWidth;
|
||||||
|
const aroundY = rect.top < 0 && rect.top + rect.height > windowHeight;
|
||||||
|
|
||||||
|
result.isAroundViewport = aroundX && aroundY;
|
||||||
|
|
||||||
|
const onTop = rect.top < 0 && rect.top + rect.height > 0;
|
||||||
|
const onRight =
|
||||||
|
rect.left < windowWidth && rect.left + rect.width > windowWidth;
|
||||||
|
const onLeft = rect.left < 0 && rect.left + rect.width > 0;
|
||||||
|
const onBottom =
|
||||||
|
rect.top < windowHeight && rect.top + rect.height > windowHeight;
|
||||||
|
|
||||||
|
const onY = insideY || aroundY || onTop || onBottom;
|
||||||
|
const onX = insideX || aroundX || onLeft || onRight;
|
||||||
|
|
||||||
|
result.isOnTopEdge = onTop && onX;
|
||||||
|
result.isOnRightEdge = onRight && onY;
|
||||||
|
result.isOnBottomEdge = onBottom && onX;
|
||||||
|
result.isOnLeftEdge = onLeft && onY;
|
||||||
|
|
||||||
|
result.isOnEdge =
|
||||||
|
result.isOnLeftEdge ||
|
||||||
|
result.isOnRightEdge ||
|
||||||
|
result.isOnTopEdge ||
|
||||||
|
result.isOnBottomEdge;
|
||||||
|
|
||||||
|
const isInX =
|
||||||
|
insideX || aroundX || result.isOnLeftEdge || result.isOnRightEdge;
|
||||||
|
const isInY =
|
||||||
|
insideY || aroundY || result.isOnTopEdge || result.isOnBottomEdge;
|
||||||
|
|
||||||
|
result.isInViewport = isInX && isInY;
|
||||||
|
|
||||||
|
result.isPartiallyInViewport = result.isInViewport && result.isOnEdge;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
71
packages/clipper/src/inliner.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { FetchOptions, fetchResource } from "./fetch";
|
||||||
|
import { isDataUrl, resolveUrl, escape } from "./utils";
|
||||||
|
|
||||||
|
const URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/g;
|
||||||
|
|
||||||
|
function shouldProcess(string: string) {
|
||||||
|
return string.search(URL_REGEX) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUrls(string: string) {
|
||||||
|
const result = [];
|
||||||
|
let match;
|
||||||
|
while ((match = URL_REGEX.exec(string)) !== null) {
|
||||||
|
result.push(match[1]);
|
||||||
|
}
|
||||||
|
return result.filter(function (url) {
|
||||||
|
return !isDataUrl(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inline(
|
||||||
|
string: string,
|
||||||
|
url: string,
|
||||||
|
options: FetchOptions,
|
||||||
|
baseUrl?: string
|
||||||
|
) {
|
||||||
|
url = baseUrl ? resolveUrl(url, baseUrl) : url;
|
||||||
|
const dataUrl = await fetchResource(url, options);
|
||||||
|
// const dataUrl = dataAsUrl(data, mimeType(url));
|
||||||
|
return string.replace(urlAsRegex(url), "$1" + dataUrl + "$3");
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlAsRegex(urlValue: string) {
|
||||||
|
return new RegExp("(url\\(['\"]?)(" + escape(urlValue) + ")(['\"]?\\))", "g");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inlineAll(
|
||||||
|
string: string,
|
||||||
|
options: FetchOptions,
|
||||||
|
baseUrl?: string
|
||||||
|
) {
|
||||||
|
if (!shouldProcess(string)) return string;
|
||||||
|
|
||||||
|
const urls = readUrls(string);
|
||||||
|
|
||||||
|
let prefix = string;
|
||||||
|
for (const url of urls) {
|
||||||
|
prefix = await inline(prefix, url, options, baseUrl);
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { shouldProcess, inlineAll, readUrls };
|
||||||
425
packages/clipper/src/styles.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { constructUrl, FetchOptions } from "./fetch";
|
||||||
|
import { compare, calculate, SpecificityArray } from "specificity";
|
||||||
|
import { tokenize } from "./css-tokenizer";
|
||||||
|
import { stringify, parse, SelectorType } from "css-what";
|
||||||
|
|
||||||
|
const SHORTHANDS = [
|
||||||
|
"animation",
|
||||||
|
"background",
|
||||||
|
"border",
|
||||||
|
"border-block-end",
|
||||||
|
"border-block-start",
|
||||||
|
"border-bottom",
|
||||||
|
"border-color",
|
||||||
|
"border-image",
|
||||||
|
"border-inline-end",
|
||||||
|
"border-inline-start",
|
||||||
|
"border-left",
|
||||||
|
"border-radius",
|
||||||
|
"border-right",
|
||||||
|
"border-style",
|
||||||
|
"border-top",
|
||||||
|
"border-width",
|
||||||
|
"column-rule",
|
||||||
|
"columns",
|
||||||
|
"contain-intrinsic-size",
|
||||||
|
"flex",
|
||||||
|
"flex-flow",
|
||||||
|
"font",
|
||||||
|
"gap",
|
||||||
|
"grid",
|
||||||
|
"grid-area",
|
||||||
|
"grid-column",
|
||||||
|
"grid-row",
|
||||||
|
"grid-template",
|
||||||
|
"grid-gap",
|
||||||
|
"list-style",
|
||||||
|
"margin",
|
||||||
|
"mask",
|
||||||
|
"offset",
|
||||||
|
"outline",
|
||||||
|
"overflow",
|
||||||
|
"padding",
|
||||||
|
"place-content",
|
||||||
|
"place-items",
|
||||||
|
"place-self",
|
||||||
|
"scroll-margin",
|
||||||
|
"scroll-padding",
|
||||||
|
"text-decoration",
|
||||||
|
"text-emphasis",
|
||||||
|
"transition"
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function inlineStylesheets(options: FetchOptions) {
|
||||||
|
for (const sheet of document.styleSheets) {
|
||||||
|
if (skipStyleSheet(sheet)) continue;
|
||||||
|
|
||||||
|
const node = sheet.ownerNode;
|
||||||
|
if (sheet.href && node instanceof HTMLLinkElement) {
|
||||||
|
try {
|
||||||
|
sheet.cssRules.length;
|
||||||
|
} catch (_e) {
|
||||||
|
const styleNode = await downloadStylesheet(node.href, options);
|
||||||
|
if (styleNode) node.replaceWith(styleNode);
|
||||||
|
console.error("Failed to access sheet", node.href, _e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await resolveImports(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImports(options: FetchOptions) {
|
||||||
|
let index = 0;
|
||||||
|
for (const sheet of document.styleSheets) {
|
||||||
|
if (skipStyleSheet(sheet)) continue;
|
||||||
|
|
||||||
|
for (const rule of sheet.cssRules) {
|
||||||
|
if (rule.type === CSSRule.IMPORT_RULE) {
|
||||||
|
const href = (rule as CSSImportRule).href;
|
||||||
|
const result = await downloadStylesheet(href, options);
|
||||||
|
if (result) {
|
||||||
|
if (sheet.ownerNode) sheet.ownerNode.before(result);
|
||||||
|
else document.head.appendChild(result);
|
||||||
|
|
||||||
|
sheet.deleteRule(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadStylesheet(href: string, options: FetchOptions) {
|
||||||
|
try {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
const response = await fetch(constructUrl(href, options));
|
||||||
|
if (!response.ok) return false;
|
||||||
|
style.innerText = await response.text();
|
||||||
|
style.setAttribute("href", href);
|
||||||
|
console.log(href);
|
||||||
|
return style;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to inline stylesheet", href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type StyleableElement = HTMLElement | SVGElement;
|
||||||
|
type BaseStyle = {
|
||||||
|
rule: CSSStyleDeclaration;
|
||||||
|
href: URL | null;
|
||||||
|
};
|
||||||
|
type SpecifiedStyle = BaseStyle & {
|
||||||
|
specificity: SpecificityArray;
|
||||||
|
};
|
||||||
|
type PseudoElementStyle = BaseStyle & {
|
||||||
|
pseudoElement: string;
|
||||||
|
};
|
||||||
|
type CSSStyledElements = Map<StyleableElement, SpecifiedStyle[]>;
|
||||||
|
type CSSPseudoElements = Map<StyleableElement, PseudoElementStyle[]>;
|
||||||
|
|
||||||
|
export function cacheStylesheets(documentStyles: CSSStyleDeclaration) {
|
||||||
|
const styledElements: CSSStyledElements = new Map();
|
||||||
|
const styledPseudoElements: CSSPseudoElements = new Map();
|
||||||
|
|
||||||
|
for (const sheet of document.styleSheets) {
|
||||||
|
if (skipStyleSheet(sheet)) continue;
|
||||||
|
let href = sheet.href || undefined;
|
||||||
|
if (!href && sheet.ownerNode instanceof HTMLElement)
|
||||||
|
href = sheet.ownerNode.getAttribute("href") || undefined;
|
||||||
|
|
||||||
|
walkRules(
|
||||||
|
sheet.cssRules,
|
||||||
|
documentStyles,
|
||||||
|
styledElements,
|
||||||
|
styledPseudoElements,
|
||||||
|
href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPseudo(element: StyleableElement, pseudoElement: string) {
|
||||||
|
const styles = styledPseudoElements
|
||||||
|
.get(element)
|
||||||
|
?.filter((s) => s.pseudoElement.includes(pseudoElement));
|
||||||
|
if (!styles || !styles.length) return;
|
||||||
|
|
||||||
|
return getElementStyles(element, styles, documentStyles);
|
||||||
|
},
|
||||||
|
get(element: StyleableElement) {
|
||||||
|
const styles = styledElements.get(element);
|
||||||
|
if (!styles) return;
|
||||||
|
|
||||||
|
const allStyles = styles.sort((a, b) =>
|
||||||
|
compare(a.specificity, b.specificity)
|
||||||
|
);
|
||||||
|
allStyles.push({
|
||||||
|
rule: element.style,
|
||||||
|
specificity: [0, 0, 0, 0],
|
||||||
|
href: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return getElementStyles(element, allStyles, documentStyles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkRules(
|
||||||
|
cssRules: CSSRuleList,
|
||||||
|
documentStyles: CSSStyleDeclaration,
|
||||||
|
styled: CSSStyledElements,
|
||||||
|
pseudoElements: CSSPseudoElements,
|
||||||
|
href?: string
|
||||||
|
) {
|
||||||
|
for (const rule of cssRules) {
|
||||||
|
if (rule instanceof CSSStyleRule) {
|
||||||
|
if (isPseudoSelector(rule.selectorText)) {
|
||||||
|
const selectors = parsePseudoSelector(rule.selectorText);
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
if (!selector || !selector.selector.trim()) continue;
|
||||||
|
const elements = document.querySelectorAll(
|
||||||
|
selector.selector
|
||||||
|
) as NodeListOf<StyleableElement>;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (
|
||||||
|
!(element instanceof HTMLElement) &&
|
||||||
|
!(element instanceof SVGElement)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const styles: PseudoElementStyle[] =
|
||||||
|
pseudoElements.get(element) || [];
|
||||||
|
pseudoElements.set(element, styles);
|
||||||
|
|
||||||
|
styles.push({
|
||||||
|
rule: rule.style,
|
||||||
|
href: getBaseUrl(href),
|
||||||
|
pseudoElement: selector.pseudoElement
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = document.querySelectorAll(
|
||||||
|
rule.selectorText
|
||||||
|
) as NodeListOf<StyleableElement>;
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (
|
||||||
|
!(element instanceof HTMLElement) &&
|
||||||
|
!(element instanceof SVGElement)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const parts = rule.selectorText.split(",");
|
||||||
|
const styles: SpecifiedStyle[] = styled.get(element) || [];
|
||||||
|
styled.set(element, styles);
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
try {
|
||||||
|
const specificity = calculate(part)[0];
|
||||||
|
styles.push({
|
||||||
|
specificity: specificity.specificityArray,
|
||||||
|
rule: rule.style,
|
||||||
|
href: getBaseUrl(href)
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e, href && getBaseUrl(href));
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
rule instanceof CSSMediaRule &&
|
||||||
|
window.matchMedia(rule.conditionText).matches
|
||||||
|
) {
|
||||||
|
walkRules(rule.cssRules, documentStyles, styled, pseudoElements, href);
|
||||||
|
} else if (
|
||||||
|
rule instanceof CSSSupportsRule &&
|
||||||
|
CSS.supports(rule.conditionText)
|
||||||
|
) {
|
||||||
|
walkRules(rule.cssRules, documentStyles, styled, pseudoElements, href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElementStyles(
|
||||||
|
element: StyleableElement,
|
||||||
|
styles: BaseStyle[],
|
||||||
|
documentStyles: CSSStyleDeclaration
|
||||||
|
) {
|
||||||
|
const newStyles = newStyleDeclaration();
|
||||||
|
const computedStyle = lazyComputedStyle(element);
|
||||||
|
const overrides = ["display"];
|
||||||
|
|
||||||
|
for (const style of styles) {
|
||||||
|
for (const property of [...style.rule, ...SHORTHANDS]) {
|
||||||
|
let value = style.rule.getPropertyValue(property);
|
||||||
|
if (overrides.includes(property))
|
||||||
|
value = computedStyle.style.getPropertyValue(property);
|
||||||
|
|
||||||
|
if (value.trim()) {
|
||||||
|
setStyle(
|
||||||
|
newStyles,
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
(variable) => {
|
||||||
|
return (
|
||||||
|
computedStyle.style.getPropertyValue(variable) ||
|
||||||
|
documentStyles.getPropertyValue(variable)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(url) => {
|
||||||
|
console.log("resolving url", url, style.href);
|
||||||
|
if (url.startsWith("data:") || !style.href) return url;
|
||||||
|
console.log("resolving url", url, style.href.href);
|
||||||
|
if (url.startsWith("/"))
|
||||||
|
return new URL(`${style.href.origin}${url}`).href;
|
||||||
|
|
||||||
|
return new URL(`${style.href.href}${url}`).href;
|
||||||
|
},
|
||||||
|
style.rule.getPropertyPriority(property)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newStyles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStyle(
|
||||||
|
target: CSSStyleDeclaration,
|
||||||
|
property: string,
|
||||||
|
value: string,
|
||||||
|
get: (variable: string) => string,
|
||||||
|
resolveUrl: (variable: string) => string,
|
||||||
|
priority?: string
|
||||||
|
) {
|
||||||
|
value = resolveCssVariables(value, get);
|
||||||
|
value = resolveCssUrl(value, resolveUrl);
|
||||||
|
|
||||||
|
target.setProperty(property, value, priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newStyleDeclaration() {
|
||||||
|
const sheet = new CSSStyleSheet();
|
||||||
|
sheet.insertRule(".dummy{}");
|
||||||
|
return (sheet.cssRules[0] as CSSStyleRule).style;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lazyComputedStyle(element: StyleableElement) {
|
||||||
|
let computedStyle: CSSStyleDeclaration | undefined;
|
||||||
|
|
||||||
|
return Object.defineProperty({}, "style", {
|
||||||
|
get: () => {
|
||||||
|
if (!computedStyle) computedStyle = getComputedStyle(element);
|
||||||
|
return computedStyle;
|
||||||
|
}
|
||||||
|
}) as { style: CSSStyleDeclaration };
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipStyleSheet(sheet: StyleSheet) {
|
||||||
|
return sheet.media.mediaText
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.includes("print");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCssVariables(css: string, get: (variable: string) => string) {
|
||||||
|
const tokens = tokenize(css);
|
||||||
|
const finalTokens: string[] = [];
|
||||||
|
for (let i = 0; i < tokens.length; ++i) {
|
||||||
|
const token = tokens[i];
|
||||||
|
if (token === "var") {
|
||||||
|
const args = tokenize(tokens[++i].slice(1, -1));
|
||||||
|
const [variable, operator, space, ...restArgs] = args;
|
||||||
|
|
||||||
|
const value = get(variable);
|
||||||
|
if (value) {
|
||||||
|
finalTokens.push(value);
|
||||||
|
} else if (operator && restArgs.length <= 1) {
|
||||||
|
finalTokens.push(restArgs[0] || space);
|
||||||
|
} else if (operator && restArgs.length === 2) {
|
||||||
|
finalTokens.push(resolveCssVariables(restArgs.join(""), get));
|
||||||
|
}
|
||||||
|
} else if (token.startsWith("(") && token.endsWith(")")) {
|
||||||
|
finalTokens.push("(", resolveCssVariables(token.slice(1, -1), get), ")");
|
||||||
|
} else finalTokens.push(token);
|
||||||
|
}
|
||||||
|
return finalTokens.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCssUrl(css: string, get: (url: string) => string) {
|
||||||
|
const tokens = tokenize(css);
|
||||||
|
const finalTokens: string[] = [];
|
||||||
|
for (let i = 0; i < tokens.length; ++i) {
|
||||||
|
const token = tokens[i];
|
||||||
|
if (token === "url" && !tokens[i + 1].startsWith("(data")) {
|
||||||
|
const url = tokens[++i].slice(2, -2);
|
||||||
|
const resolvedUrl = get(url);
|
||||||
|
if (resolvedUrl) {
|
||||||
|
finalTokens.push(token);
|
||||||
|
finalTokens.push('("');
|
||||||
|
finalTokens.push(resolvedUrl);
|
||||||
|
finalTokens.push('")');
|
||||||
|
}
|
||||||
|
} else finalTokens.push(token);
|
||||||
|
}
|
||||||
|
return finalTokens.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(href?: string | null) {
|
||||||
|
if (!href) return null;
|
||||||
|
if (href.startsWith("/")) href = `${document.location.origin}${href}`;
|
||||||
|
const url = new URL(href);
|
||||||
|
const basepath = url.pathname.split("/").slice(0, -1).join("/");
|
||||||
|
return new URL(`${url.origin}${basepath}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPseudoSelector(text: string) {
|
||||||
|
return (
|
||||||
|
text.includes(":before") ||
|
||||||
|
text.includes(":after") ||
|
||||||
|
text.includes("::after") ||
|
||||||
|
text.includes("::before")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePseudoSelector(selector: string) {
|
||||||
|
const output = [];
|
||||||
|
const selectors = parse(selector);
|
||||||
|
for (const part of selectors) {
|
||||||
|
const pseduoElementIndex = part.findIndex(
|
||||||
|
(s) =>
|
||||||
|
(s.type === SelectorType.Pseudo ||
|
||||||
|
s.type === SelectorType.PseudoElement) &&
|
||||||
|
(s.name === "after" || s.name === "before")
|
||||||
|
);
|
||||||
|
if (pseduoElementIndex <= -1) continue;
|
||||||
|
|
||||||
|
output.push({
|
||||||
|
selector: stringify([part.slice(0, pseduoElementIndex)]),
|
||||||
|
pseudoElement: stringify([part.slice(pseduoElementIndex)])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
52
packages/clipper/src/types.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { FetchOptions } from "./fetch";
|
||||||
|
|
||||||
|
export type ClipArea = "full-page" | "visible" | "selection" | "article";
|
||||||
|
export type ClipMode = "simplified" | "screenshot" | "complete";
|
||||||
|
// | "full"
|
||||||
|
// | "article"
|
||||||
|
// | "simple-article"
|
||||||
|
// | "full-screenshot"
|
||||||
|
// | "screenshot"
|
||||||
|
// | "manual";
|
||||||
|
|
||||||
|
export type ClipData = string;
|
||||||
|
|
||||||
|
export type Filter = (node: HTMLElement) => boolean;
|
||||||
|
|
||||||
|
export type InlineOptions = {
|
||||||
|
stylesheets?: boolean;
|
||||||
|
fonts?: boolean;
|
||||||
|
images?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Options = {
|
||||||
|
filter?: Filter;
|
||||||
|
onCloned?: (document: HTMLElement) => void;
|
||||||
|
backgroundColor?: CSSStyleDeclaration["backgroundColor"];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
style?: CSSStyleDeclaration;
|
||||||
|
quality?: number;
|
||||||
|
raster?: boolean;
|
||||||
|
scale?: number;
|
||||||
|
fetchOptions?: FetchOptions;
|
||||||
|
inlineOptions?: InlineOptions;
|
||||||
|
};
|
||||||
183
packages/clipper/src/utils.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
This file is part of the Notesnook project (https://notesnook.com/)
|
||||||
|
|
||||||
|
Copyright (C) 2022 Streetwriters (Private) Limited
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Only WOFF and EOT mime types for fonts are 'real'
|
||||||
|
* see http://www.iana.org/assignments/media-types/media-types.xhtml
|
||||||
|
*/
|
||||||
|
const WOFF = "application/font-woff";
|
||||||
|
const JPEG = "image/jpeg";
|
||||||
|
|
||||||
|
const mimes = {
|
||||||
|
woff: WOFF,
|
||||||
|
woff2: WOFF,
|
||||||
|
ttf: "application/font-truetype",
|
||||||
|
eot: "application/vnd.ms-fontobject",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: JPEG,
|
||||||
|
jpeg: JPEG,
|
||||||
|
gif: "image/gif",
|
||||||
|
tiff: "image/tiff",
|
||||||
|
svg: "image/svg+xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseExtension(url: string) {
|
||||||
|
const match = /\.([^./]*?)(\?|$)/g.exec(url);
|
||||||
|
if (match) return match[1];
|
||||||
|
else return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeType(url: string) {
|
||||||
|
const extension = parseExtension(url).toLowerCase();
|
||||||
|
return mimes[extension as keyof typeof mimes] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataUrl(url: string) {
|
||||||
|
return url.search(/^(data:)/) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBlob(canvas: HTMLCanvasElement) {
|
||||||
|
const binaryString = atob(canvas.toDataURL().split(",")[1]);
|
||||||
|
const length = binaryString.length;
|
||||||
|
const binaryArray = new Uint8Array(length);
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) binaryArray[i] = binaryString.charCodeAt(i);
|
||||||
|
|
||||||
|
return new Blob([binaryArray], {
|
||||||
|
type: "image/png"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToBlob(canvas: HTMLCanvasElement) {
|
||||||
|
if (canvas.toBlob)
|
||||||
|
return new Promise<Blob | null>(function (resolve) {
|
||||||
|
canvas.toBlob(resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve(asBlob(canvas));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(url: string, baseUrl: string) {
|
||||||
|
const doc = document.implementation.createHTMLDocument();
|
||||||
|
const base = doc.createElement("base");
|
||||||
|
doc.head.appendChild(base);
|
||||||
|
const a = doc.createElement("a");
|
||||||
|
doc.body.appendChild(a);
|
||||||
|
base.href = baseUrl;
|
||||||
|
a.href = url;
|
||||||
|
return a.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
function uid() {
|
||||||
|
return "u" + fourRandomChars() + index++;
|
||||||
|
|
||||||
|
function fourRandomChars() {
|
||||||
|
/* see http://stackoverflow.com/a/6248722/2519373 */
|
||||||
|
return (
|
||||||
|
"0000" + ((Math.random() * Math.pow(36, 4)) << 0).toString(36)
|
||||||
|
).slice(-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataAsUrl(content: string, type: string) {
|
||||||
|
return "data:" + type + ";base64," + content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(string: string) {
|
||||||
|
return string.replace(/([.*+?^${}()|[\]/\\])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return function <T>(arg: T) {
|
||||||
|
return new Promise<T>(function (resolve) {
|
||||||
|
setTimeout(function () {
|
||||||
|
resolve(arg);
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray<T>(arrayLike: ArrayLike<T>) {
|
||||||
|
const array = [];
|
||||||
|
const length = arrayLike.length;
|
||||||
|
for (let i = 0; i < length; i++) array.push(arrayLike[i]);
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXhtml(string: string) {
|
||||||
|
return string.replace(/%/g, "%25").replace(/#/g, "%23").replace(/\n/g, "%0A");
|
||||||
|
}
|
||||||
|
|
||||||
|
function width(node: HTMLElement) {
|
||||||
|
const leftBorder = px(node, "border-left-width");
|
||||||
|
const rightBorder = px(node, "border-right-width");
|
||||||
|
return node.scrollWidth + leftBorder + rightBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function height(node: HTMLElement) {
|
||||||
|
const topBorder = px(node, "border-top-width");
|
||||||
|
const bottomBorder = px(node, "border-bottom-width");
|
||||||
|
return node.scrollHeight + topBorder + bottomBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function px(node: HTMLElement, styleProperty: string) {
|
||||||
|
const value = getComputedStyle(node).getPropertyValue(styleProperty);
|
||||||
|
return parseFloat(value.replace("px", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectCss(rules: string, id: string) {
|
||||||
|
const variableCss = document.getElementById(id);
|
||||||
|
const head = document.getElementsByTagName("head")[0];
|
||||||
|
if (variableCss) {
|
||||||
|
head.removeChild(variableCss);
|
||||||
|
}
|
||||||
|
const css = document.createElement("style");
|
||||||
|
css.type = "text/css";
|
||||||
|
css.id = id;
|
||||||
|
css.appendChild(document.createTextNode(rules));
|
||||||
|
|
||||||
|
head.insertBefore(css, getRootStylesheet());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRootStylesheet() {
|
||||||
|
for (const sty of document.querySelectorAll("style")) {
|
||||||
|
if (sty.innerHTML.includes("#root")) {
|
||||||
|
return sty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
injectCss,
|
||||||
|
escape,
|
||||||
|
parseExtension,
|
||||||
|
mimeType,
|
||||||
|
dataAsUrl,
|
||||||
|
isDataUrl,
|
||||||
|
canvasToBlob,
|
||||||
|
resolveUrl,
|
||||||
|
uid,
|
||||||
|
delay,
|
||||||
|
asArray,
|
||||||
|
escapeXhtml,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
8
packages/clipper/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"module": "CommonJS"
|
||||||
|
},
|
||||||
|
"include": ["src/"]
|
||||||
|
}
|
||||||