@curiousvlxd/linkedin-badge-renderer 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/package.json +63 -0
- package/src/cli.mjs +35 -0
- package/src/index.mjs +3 -0
- package/src/render-linkedin-badge.mjs +350 -0
- package/src/renderer/formats/index.mjs +23 -0
- package/src/renderer/formats/pdf.mjs +82 -0
- package/src/renderer/formats/svg.mjs +48 -0
- package/src/renderer/linkedin.mjs +60 -0
- package/src/renderer/playwright.mjs +37 -0
- package/src/utils/args.mjs +73 -0
- package/src/utils/fs.mjs +11 -0
- package/src/utils/log.mjs +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladyslav Timchenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# linkedin-badge-renderer
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="./assets/logo.png" width="200" alt="LinkedIn Badge Renderer Logo" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="./assets/example.svg" width="400" alt="Generated badge example" />
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
CLI tool for generating LinkedIn profile badges in SVG, PNG, JPEG, or PDF format.
|
|
12
|
+
|
|
13
|
+
Renders the official LinkedIn badge using Playwright and exports pixel-perfect assets for websites, resumes, and portfolios.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Generate badges as SVG, PNG, JPEG, or PDF
|
|
20
|
+
- Pixel-perfect rendering via Chromium
|
|
21
|
+
- Transparent background for images
|
|
22
|
+
- No manual screenshots
|
|
23
|
+
- Works in CI and GitHub Actions
|
|
24
|
+
- Cross-platform (Windows, macOS, Linux)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Clone the repository and install dependencies:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install
|
|
34
|
+
npx playwright install chromium
|
|
35
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@curiousvlxd/linkedin-badge-renderer",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"linkedin-badge": "src/cli.mjs"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"index.mjs",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"registry": "https://registry.npmjs.org/"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/curiousvlxd/linkedin-badge-renderer.git"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/curiousvlxd/linkedin-badge-renderer",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/curiousvlxd/linkedin-badge-renderer/issues"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"linkedin",
|
|
35
|
+
"badge",
|
|
36
|
+
"svg",
|
|
37
|
+
"pdf",
|
|
38
|
+
"png",
|
|
39
|
+
"jpeg",
|
|
40
|
+
"cli",
|
|
41
|
+
"playwright",
|
|
42
|
+
"renderer",
|
|
43
|
+
"automation",
|
|
44
|
+
"profile"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"prepare": "node -e \"process.env.CI ? 0 : require('child_process').execSync('husky install', { stdio: 'inherit' })\"",
|
|
48
|
+
"badge": "node src/cli.mjs",
|
|
49
|
+
"badge:svg": "node src/cli.mjs --format svg",
|
|
50
|
+
"badge:pdf": "node src/cli.mjs --format pdf",
|
|
51
|
+
"badge:png": "node src/cli.mjs --format png",
|
|
52
|
+
"badge:jpeg": "node src/cli.mjs --format jpeg"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"mupdf": "^1.3.0",
|
|
56
|
+
"playwright": "^1.49.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@commitlint/cli": "^20.3.1",
|
|
60
|
+
"@commitlint/config-conventional": "^20.3.1",
|
|
61
|
+
"husky": "^9.1.7"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs, normFormat, normTheme, normSize, normLocale, normOrientation, toInt, ensureExt, DEFAULTS, usageAndExit } from "./utils/args.mjs";
|
|
4
|
+
import { log } from "./utils/log.mjs";
|
|
5
|
+
import { renderBadgeArtifacts } from "./renderer/formats/pdf.mjs";
|
|
6
|
+
import { writeOutput } from "./renderer/formats/index.mjs";
|
|
7
|
+
|
|
8
|
+
export async function runCli(argv = process.argv) {
|
|
9
|
+
const args = parseArgs(argv);
|
|
10
|
+
|
|
11
|
+
const handle = String(args.handle || "").trim();
|
|
12
|
+
if (!handle) usageAndExit();
|
|
13
|
+
|
|
14
|
+
const format = normFormat(args.format);
|
|
15
|
+
const theme = normTheme(args.theme);
|
|
16
|
+
const size = normSize(args.size);
|
|
17
|
+
const locale = normLocale(args.locale);
|
|
18
|
+
const orientation = normOrientation(args.orientation ?? args.type);
|
|
19
|
+
const pad = Math.max(0, toInt(args.pad, DEFAULTS.pad));
|
|
20
|
+
|
|
21
|
+
const outPath = ensureExt(args.out || DEFAULTS.out, format);
|
|
22
|
+
|
|
23
|
+
log("Params:", { handle, format, theme, size, locale, orientation, out: outPath, pad });
|
|
24
|
+
|
|
25
|
+
const artifacts = await renderBadgeArtifacts({ handle, theme, size, locale, orientation, pad });
|
|
26
|
+
|
|
27
|
+
await writeOutput({ format, outPath, artifacts });
|
|
28
|
+
|
|
29
|
+
log("Wrote:", outPath);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
runCli().catch((e) => {
|
|
33
|
+
console.error(e);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
});
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import process from "process";
|
|
4
|
+
import { chromium } from "playwright";
|
|
5
|
+
import * as mupdf from "mupdf";
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
locale: "en_US",
|
|
9
|
+
orientation: "HORIZONTAL",
|
|
10
|
+
size: "large",
|
|
11
|
+
theme: "light",
|
|
12
|
+
pad: 20,
|
|
13
|
+
format: "svg",
|
|
14
|
+
out: "dist/linkedin-badge.svg",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function log(...args) {
|
|
18
|
+
console.log(new Date().toISOString(), "|", ...args);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const m = {};
|
|
23
|
+
for (let i = 2; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
if (!a.startsWith("--")) continue;
|
|
26
|
+
const key = a.slice(2);
|
|
27
|
+
const next = argv[i + 1];
|
|
28
|
+
if (!next || next.startsWith("--")) m[key] = true;
|
|
29
|
+
else {
|
|
30
|
+
m[key] = next;
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return m;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normTheme(v) {
|
|
38
|
+
const t = String(v ?? DEFAULTS.theme).toLowerCase();
|
|
39
|
+
return t === "dark" ? "dark" : "light";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normSize(v) {
|
|
43
|
+
const s = String(v ?? DEFAULTS.size).toLowerCase();
|
|
44
|
+
if (s === "small") return "small";
|
|
45
|
+
if (s === "medium") return "medium";
|
|
46
|
+
return "large";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normOrientation(v) {
|
|
50
|
+
const o = String(v ?? DEFAULTS.orientation).toUpperCase();
|
|
51
|
+
return o === "VERTICAL" ? "VERTICAL" : "HORIZONTAL";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normLocale(v) {
|
|
55
|
+
return String(v ?? DEFAULTS.locale);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normFormat(v) {
|
|
59
|
+
const f = String(v ?? DEFAULTS.format).toLowerCase();
|
|
60
|
+
if (f === "pdf" || f === "svg" || f === "png" || f === "jpeg" || f === "jpg") return f === "jpg" ? "jpeg" : f;
|
|
61
|
+
return "svg";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function toInt(v, def) {
|
|
65
|
+
const n = Number(v);
|
|
66
|
+
return Number.isFinite(n) ? n : def;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ensureExt(out, format) {
|
|
70
|
+
const ext = `.${format}`;
|
|
71
|
+
const s = String(out || DEFAULTS.out);
|
|
72
|
+
const base = s.replace(/[?#].*$/, "");
|
|
73
|
+
const i = base.lastIndexOf(".");
|
|
74
|
+
if (i > -1 && i > base.lastIndexOf("/") && i > base.lastIndexOf("\\")) return s.slice(0, i) + ext;
|
|
75
|
+
return s.endsWith(".") ? `${s}${format}` : `${s}${ext}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function ensureDir(filePath) {
|
|
79
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function usageAndExit() {
|
|
83
|
+
console.error(
|
|
84
|
+
"Usage: node src/linkedin-badge-renderer.mjs --handle <vanity> [--format svg|pdf|png|jpeg] [--out dist/out.ext] [--theme light|dark] [--size small|medium|large] [--locale en_US] [--orientation HORIZONTAL|VERTICAL] [--pad 20]"
|
|
85
|
+
);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildLinkedinWrapperHtml({ handle, theme, size, locale, orientation }) {
|
|
90
|
+
const sz = String(size).toLowerCase();
|
|
91
|
+
return `<!doctype html>
|
|
92
|
+
<html>
|
|
93
|
+
<head>
|
|
94
|
+
<meta charset="utf-8" />
|
|
95
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
96
|
+
</head>
|
|
97
|
+
<body style="margin:0;padding:0;background:transparent">
|
|
98
|
+
<div class="badge-base LI-profile-badge"
|
|
99
|
+
data-version="v1"
|
|
100
|
+
data-size="${sz}"
|
|
101
|
+
data-locale="${locale}"
|
|
102
|
+
data-type="${orientation}"
|
|
103
|
+
data-theme="${theme}"
|
|
104
|
+
data-vanity="${handle}"
|
|
105
|
+
style="display:inline-block">
|
|
106
|
+
<a class="badge-base__link LI-simple-link" href="https://www.linkedin.com/in/${handle}?trk=profile-badge">${handle}</a>
|
|
107
|
+
</div>
|
|
108
|
+
<script src="https://platform.linkedin.com/badges/js/profile.js" async defer></script>
|
|
109
|
+
</body>
|
|
110
|
+
</html>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function warmUp(context) {
|
|
114
|
+
const page = await context.newPage();
|
|
115
|
+
try {
|
|
116
|
+
await page.goto("https://www.linkedin.com/", { waitUntil: "domcontentloaded", timeout: 25000 });
|
|
117
|
+
await page.waitForTimeout(800);
|
|
118
|
+
} catch {}
|
|
119
|
+
await page.close().catch(() => {});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function waitFonts(page) {
|
|
123
|
+
await page.evaluate(async () => {
|
|
124
|
+
try {
|
|
125
|
+
if (document.fonts?.ready) await document.fonts.ready;
|
|
126
|
+
} catch {}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function extractBadgeFromIframe(frame) {
|
|
131
|
+
const badgeInner = await frame.evaluate(() => document.body.innerHTML);
|
|
132
|
+
const cssHrefs = await frame.evaluate(() =>
|
|
133
|
+
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href)
|
|
134
|
+
);
|
|
135
|
+
return { badgeInner, cssHrefs };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildRenderHtml({ badgeInner, cssHrefs, pad }) {
|
|
139
|
+
const p = Math.max(0, Number(pad || 0));
|
|
140
|
+
return `<!doctype html>
|
|
141
|
+
<html>
|
|
142
|
+
<head>
|
|
143
|
+
<meta charset="utf-8" />
|
|
144
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
145
|
+
${cssHrefs.map((u) => `<link rel="stylesheet" href="${u}">`).join("\n")}
|
|
146
|
+
<style>
|
|
147
|
+
html, body { margin:0; padding:0; background:transparent; }
|
|
148
|
+
body { width:100vw; height:100vh; display:flex; align-items:center; justify-content:center; overflow:hidden; }
|
|
149
|
+
#root { display:inline-block; padding:${p}px; }
|
|
150
|
+
* { box-sizing:border-box; }
|
|
151
|
+
</style>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
<div id="root">${badgeInner}</div>
|
|
155
|
+
</body>
|
|
156
|
+
</html>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function measureRoot(page) {
|
|
160
|
+
return await page.evaluate(() => {
|
|
161
|
+
const el = document.getElementById("root") || document.body;
|
|
162
|
+
const r = el.getBoundingClientRect();
|
|
163
|
+
return { w: r.width, h: r.height };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function renderBadge({ handle, theme, size, locale, orientation, pad }) {
|
|
168
|
+
const browser = await chromium.launch({ args: ["--no-sandbox"] });
|
|
169
|
+
const context = await browser.newContext({
|
|
170
|
+
userAgent:
|
|
171
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
172
|
+
locale: "en-US",
|
|
173
|
+
viewport: { width: 1400, height: 900 },
|
|
174
|
+
deviceScaleFactor: 2,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const page = await context.newPage();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await warmUp(context);
|
|
181
|
+
|
|
182
|
+
await page.setContent(buildLinkedinWrapperHtml({ handle, theme, size, locale, orientation }), {
|
|
183
|
+
waitUntil: "domcontentloaded",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const iframeLocator = page.locator(".LI-profile-badge iframe");
|
|
187
|
+
await iframeLocator.waitFor({ state: "attached", timeout: 30000 });
|
|
188
|
+
await iframeLocator.waitFor({ state: "visible", timeout: 30000 });
|
|
189
|
+
|
|
190
|
+
const iframeEl = await page.$(".LI-profile-badge iframe");
|
|
191
|
+
if (!iframeEl) throw new Error("LinkedIn badge iframe not found");
|
|
192
|
+
|
|
193
|
+
const frame = await iframeEl.contentFrame();
|
|
194
|
+
if (!frame) throw new Error("LinkedIn badge iframe frame is null");
|
|
195
|
+
|
|
196
|
+
await frame.waitForLoadState("load").catch(() => {});
|
|
197
|
+
await frame.waitForTimeout(700);
|
|
198
|
+
|
|
199
|
+
const { badgeInner, cssHrefs } = await extractBadgeFromIframe(frame);
|
|
200
|
+
const renderHtml = buildRenderHtml({ badgeInner, cssHrefs, pad });
|
|
201
|
+
|
|
202
|
+
const renderPage = await context.newPage();
|
|
203
|
+
await renderPage.setViewportSize({ width: 2000, height: 1400 });
|
|
204
|
+
|
|
205
|
+
await renderPage.setContent(renderHtml, { waitUntil: "domcontentloaded" });
|
|
206
|
+
await renderPage.waitForTimeout(250);
|
|
207
|
+
await waitFonts(renderPage);
|
|
208
|
+
await renderPage.waitForTimeout(150);
|
|
209
|
+
|
|
210
|
+
const m1 = await measureRoot(renderPage);
|
|
211
|
+
const widthPx = Math.max(1, Math.ceil(m1.w));
|
|
212
|
+
const heightPx = Math.max(1, Math.ceil(m1.h));
|
|
213
|
+
|
|
214
|
+
const vW = Math.min(4096, Math.max(64, widthPx));
|
|
215
|
+
const vH = Math.min(4096, Math.max(64, heightPx));
|
|
216
|
+
|
|
217
|
+
await renderPage.setViewportSize({ width: vW, height: vH });
|
|
218
|
+
await renderPage.waitForTimeout(100);
|
|
219
|
+
await waitFonts(renderPage);
|
|
220
|
+
|
|
221
|
+
const m2 = await measureRoot(renderPage);
|
|
222
|
+
const widthPx2 = Math.max(1, Math.ceil(m2.w));
|
|
223
|
+
const heightPx2 = Math.max(1, Math.ceil(m2.h));
|
|
224
|
+
|
|
225
|
+
const pdf = await renderPage.pdf({
|
|
226
|
+
printBackground: true,
|
|
227
|
+
width: `${widthPx2}px`,
|
|
228
|
+
height: `${heightPx2}px`,
|
|
229
|
+
margin: { top: "0px", right: "0px", bottom: "0px", left: "0px" },
|
|
230
|
+
preferCSSPageSize: false,
|
|
231
|
+
pageRanges: "1",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const png = await renderPage.screenshot({
|
|
235
|
+
type: "png",
|
|
236
|
+
omitBackground: false,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const jpeg = await renderPage.screenshot({
|
|
240
|
+
type: "jpeg",
|
|
241
|
+
quality: 95,
|
|
242
|
+
omitBackground: false,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await renderPage.close().catch(() => {});
|
|
246
|
+
return { pdf, png, jpeg, widthPx: widthPx2, heightPx: heightPx2 };
|
|
247
|
+
} finally {
|
|
248
|
+
await page.close().catch(() => {});
|
|
249
|
+
await context.close().catch(() => {});
|
|
250
|
+
await browser.close().catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function pdfToSvgViaMupdf(pdfBuffer) {
|
|
255
|
+
const doc = mupdf.Document.openDocument(new Uint8Array(pdfBuffer), "application/pdf");
|
|
256
|
+
const page = doc.loadPage(0);
|
|
257
|
+
const bounds = page.getBounds();
|
|
258
|
+
const b = Array.isArray(bounds) ? bounds : [0, 0, 0, 0];
|
|
259
|
+
|
|
260
|
+
const buf = new mupdf.Buffer();
|
|
261
|
+
const writer = new mupdf.DocumentWriter(buf, "svg", "");
|
|
262
|
+
const device = writer.beginPage(b);
|
|
263
|
+
|
|
264
|
+
page.run(device, mupdf.Matrix.identity, mupdf.ColorSpace.DeviceRGB);
|
|
265
|
+
|
|
266
|
+
writer.endPage();
|
|
267
|
+
writer.close();
|
|
268
|
+
|
|
269
|
+
const svg = buf.asString();
|
|
270
|
+
return { svg: typeof svg === "string" ? svg : String(svg), bounds: b };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function patchSvg(svg, { widthPx, heightPx, bounds }) {
|
|
274
|
+
const w = Math.max(1, Math.ceil(widthPx));
|
|
275
|
+
const h = Math.max(1, Math.ceil(heightPx));
|
|
276
|
+
|
|
277
|
+
const x0 = Number(bounds?.[0] ?? 0);
|
|
278
|
+
const y0 = Number(bounds?.[1] ?? 0);
|
|
279
|
+
const x1 = Number(bounds?.[2] ?? 0);
|
|
280
|
+
const y1 = Number(bounds?.[3] ?? 0);
|
|
281
|
+
|
|
282
|
+
const vbW = Math.max(0.001, x1 - x0);
|
|
283
|
+
const vbH = Math.max(0.001, y1 - y0);
|
|
284
|
+
|
|
285
|
+
let out = String(svg || "");
|
|
286
|
+
if (!/<svg\b/i.test(out)) return out;
|
|
287
|
+
|
|
288
|
+
out = out.replace(/<\?xml[^>]*>\s*/i, "");
|
|
289
|
+
|
|
290
|
+
out = out.replace(/<svg\b([^>]*)>/i, (_m, attrs) => {
|
|
291
|
+
let a = String(attrs || "");
|
|
292
|
+
a = a.replace(/\s(width|height|viewBox|preserveAspectRatio)=(".*?"|'.*?')/gi, "");
|
|
293
|
+
const hasXmlns = /\sxmlns=/.test(a);
|
|
294
|
+
const xmlns = hasXmlns ? "" : ' xmlns="http://www.w3.org/2000/svg"';
|
|
295
|
+
return `<svg${xmlns}${a} width="${w}px" height="${h}px" viewBox="${x0} ${y0} ${vbW} ${vbH}" preserveAspectRatio="xMinYMin meet">`;
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n${out}`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function writeOutput({ format, outPath, badge }) {
|
|
302
|
+
await ensureDir(outPath);
|
|
303
|
+
|
|
304
|
+
if (format === "pdf") {
|
|
305
|
+
await fs.writeFile(outPath, Buffer.from(badge.pdf));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (format === "png") {
|
|
310
|
+
await fs.writeFile(outPath, Buffer.from(badge.png));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (format === "jpeg") {
|
|
315
|
+
await fs.writeFile(outPath, Buffer.from(badge.jpeg));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const { svg: rawSvg, bounds } = pdfToSvgViaMupdf(Buffer.from(badge.pdf));
|
|
320
|
+
const svg = patchSvg(rawSvg, { widthPx: badge.widthPx, heightPx: badge.heightPx, bounds });
|
|
321
|
+
await fs.writeFile(outPath, svg, "utf8");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function main() {
|
|
325
|
+
const args = parseArgs(process.argv);
|
|
326
|
+
|
|
327
|
+
const handle = String(args.handle || "").trim();
|
|
328
|
+
if (!handle) usageAndExit();
|
|
329
|
+
|
|
330
|
+
const format = normFormat(args.format);
|
|
331
|
+
const theme = normTheme(args.theme);
|
|
332
|
+
const size = normSize(args.size);
|
|
333
|
+
const locale = normLocale(args.locale);
|
|
334
|
+
const orientation = normOrientation(args.orientation ?? args.type);
|
|
335
|
+
const pad = Math.max(0, toInt(args.pad, DEFAULTS.pad));
|
|
336
|
+
|
|
337
|
+
const outPath = ensureExt(args.out || DEFAULTS.out, format);
|
|
338
|
+
|
|
339
|
+
log("Params:", { handle, format, theme, size, locale, orientation, out: outPath, pad });
|
|
340
|
+
|
|
341
|
+
const badge = await renderBadge({ handle, theme, size, locale, orientation, pad });
|
|
342
|
+
|
|
343
|
+
await writeOutput({ format, outPath, badge });
|
|
344
|
+
log("Wrote:", outPath);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
main().catch((e) => {
|
|
348
|
+
console.error(e);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { writeFileEnsured } from "../../utils/fs.mjs";
|
|
2
|
+
import { pdfToSvgViaMupdf, patchSvg } from "../../renderer/formats/svg.mjs";
|
|
3
|
+
|
|
4
|
+
export async function writeOutput({ format, outPath, artifacts }) {
|
|
5
|
+
if (format === "pdf") {
|
|
6
|
+
await writeFileEnsured(outPath, Buffer.from(artifacts.pdf));
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (format === "png") {
|
|
11
|
+
await writeFileEnsured(outPath, Buffer.from(artifacts.png));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (format === "jpeg") {
|
|
16
|
+
await writeFileEnsured(outPath, Buffer.from(artifacts.jpeg));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { svg: rawSvg, bounds } = pdfToSvgViaMupdf(Buffer.from(artifacts.pdf));
|
|
21
|
+
const svg = patchSvg(rawSvg, { widthPx: artifacts.widthPx, heightPx: artifacts.heightPx, bounds });
|
|
22
|
+
await writeFileEnsured(outPath, svg, "utf8");
|
|
23
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { withBrowser, createContext, warmUp, waitFonts } from "../playwright.mjs";
|
|
2
|
+
import { buildLinkedinWrapperHtml, extractBadgeFromIframe, buildRenderHtml, measureRoot } from "../linkedin.mjs";
|
|
3
|
+
|
|
4
|
+
export async function renderBadgeArtifacts({ handle, theme, size, locale, orientation, pad }) {
|
|
5
|
+
return await withBrowser(async (browser) => {
|
|
6
|
+
const context = await createContext(browser);
|
|
7
|
+
const page = await context.newPage();
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await warmUp(context);
|
|
11
|
+
|
|
12
|
+
await page.setContent(buildLinkedinWrapperHtml({ handle, theme, size, locale, orientation }), {
|
|
13
|
+
waitUntil: "domcontentloaded",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const iframeLocator = page.locator(".LI-profile-badge iframe");
|
|
17
|
+
await iframeLocator.waitFor({ state: "attached", timeout: 30000 });
|
|
18
|
+
await iframeLocator.waitFor({ state: "visible", timeout: 30000 });
|
|
19
|
+
|
|
20
|
+
const iframeEl = await page.$(".LI-profile-badge iframe");
|
|
21
|
+
if (!iframeEl) throw new Error("LinkedIn badge iframe not found");
|
|
22
|
+
|
|
23
|
+
const frame = await iframeEl.contentFrame();
|
|
24
|
+
if (!frame) throw new Error("LinkedIn badge iframe frame is null");
|
|
25
|
+
|
|
26
|
+
await frame.waitForLoadState("load").catch(() => {});
|
|
27
|
+
await frame.waitForTimeout(700);
|
|
28
|
+
|
|
29
|
+
const { badgeInner, cssHrefs } = await extractBadgeFromIframe(frame);
|
|
30
|
+
const renderHtml = buildRenderHtml({ badgeInner, cssHrefs, pad });
|
|
31
|
+
|
|
32
|
+
const renderPage = await context.newPage();
|
|
33
|
+
await renderPage.setViewportSize({ width: 2000, height: 1400 });
|
|
34
|
+
|
|
35
|
+
await renderPage.setContent(renderHtml, { waitUntil: "domcontentloaded" });
|
|
36
|
+
await renderPage.waitForTimeout(250);
|
|
37
|
+
await waitFonts(renderPage);
|
|
38
|
+
await renderPage.waitForTimeout(150);
|
|
39
|
+
|
|
40
|
+
const m1 = await measureRoot(renderPage);
|
|
41
|
+
const widthPx = Math.max(1, Math.ceil(m1.w));
|
|
42
|
+
const heightPx = Math.max(1, Math.ceil(m1.h));
|
|
43
|
+
|
|
44
|
+
const vW = Math.min(4096, Math.max(64, widthPx));
|
|
45
|
+
const vH = Math.min(4096, Math.max(64, heightPx));
|
|
46
|
+
|
|
47
|
+
await renderPage.setViewportSize({ width: vW, height: vH });
|
|
48
|
+
await renderPage.waitForTimeout(100);
|
|
49
|
+
await waitFonts(renderPage);
|
|
50
|
+
|
|
51
|
+
const m2 = await measureRoot(renderPage);
|
|
52
|
+
const widthPx2 = Math.max(1, Math.ceil(m2.w));
|
|
53
|
+
const heightPx2 = Math.max(1, Math.ceil(m2.h));
|
|
54
|
+
|
|
55
|
+
const pdf = await renderPage.pdf({
|
|
56
|
+
printBackground: true,
|
|
57
|
+
width: `${widthPx2}px`,
|
|
58
|
+
height: `${heightPx2}px`,
|
|
59
|
+
margin: { top: "0px", right: "0px", bottom: "0px", left: "0px" },
|
|
60
|
+
preferCSSPageSize: false,
|
|
61
|
+
pageRanges: "1",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const png = await renderPage.screenshot({
|
|
65
|
+
type: "png",
|
|
66
|
+
omitBackground: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const jpeg = await renderPage.screenshot({
|
|
70
|
+
type: "jpeg",
|
|
71
|
+
quality: 95,
|
|
72
|
+
omitBackground: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await renderPage.close().catch(() => {});
|
|
76
|
+
return { pdf, png, jpeg, widthPx: widthPx2, heightPx: heightPx2 };
|
|
77
|
+
} finally {
|
|
78
|
+
await page.close().catch(() => {});
|
|
79
|
+
await context.close().catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as mupdf from "mupdf";
|
|
2
|
+
|
|
3
|
+
export function pdfToSvgViaMupdf(pdfBuffer) {
|
|
4
|
+
const doc = mupdf.Document.openDocument(new Uint8Array(pdfBuffer), "application/pdf");
|
|
5
|
+
const page = doc.loadPage(0);
|
|
6
|
+
const bounds = page.getBounds();
|
|
7
|
+
const b = Array.isArray(bounds) ? bounds : [0, 0, 0, 0];
|
|
8
|
+
|
|
9
|
+
const buf = new mupdf.Buffer();
|
|
10
|
+
const writer = new mupdf.DocumentWriter(buf, "svg", "");
|
|
11
|
+
const device = writer.beginPage(b);
|
|
12
|
+
|
|
13
|
+
page.run(device, mupdf.Matrix.identity, mupdf.ColorSpace.DeviceRGB);
|
|
14
|
+
|
|
15
|
+
writer.endPage();
|
|
16
|
+
writer.close();
|
|
17
|
+
|
|
18
|
+
const svg = buf.asString();
|
|
19
|
+
return { svg: typeof svg === "string" ? svg : String(svg), bounds: b };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function patchSvg(svg, { widthPx, heightPx, bounds }) {
|
|
23
|
+
const w = Math.max(1, Math.ceil(widthPx));
|
|
24
|
+
const h = Math.max(1, Math.ceil(heightPx));
|
|
25
|
+
|
|
26
|
+
const x0 = Number(bounds?.[0] ?? 0);
|
|
27
|
+
const y0 = Number(bounds?.[1] ?? 0);
|
|
28
|
+
const x1 = Number(bounds?.[2] ?? 0);
|
|
29
|
+
const y1 = Number(bounds?.[3] ?? 0);
|
|
30
|
+
|
|
31
|
+
const vbW = Math.max(0.001, x1 - x0);
|
|
32
|
+
const vbH = Math.max(0.001, y1 - y0);
|
|
33
|
+
|
|
34
|
+
let out = String(svg || "");
|
|
35
|
+
if (!/<svg\b/i.test(out)) return out;
|
|
36
|
+
|
|
37
|
+
out = out.replace(/<\?xml[^>]*>\s*/i, "");
|
|
38
|
+
|
|
39
|
+
out = out.replace(/<svg\b([^>]*)>/i, (_m, attrs) => {
|
|
40
|
+
let a = String(attrs || "");
|
|
41
|
+
a = a.replace(/\s(width|height|viewBox|preserveAspectRatio)=(".*?"|'.*?')/gi, "");
|
|
42
|
+
const hasXmlns = /\sxmlns=/.test(a);
|
|
43
|
+
const xmlns = hasXmlns ? "" : ' xmlns="http://www.w3.org/2000/svg"';
|
|
44
|
+
return `<svg${xmlns}${a} width="${w}px" height="${h}px" viewBox="${x0} ${y0} ${vbW} ${vbH}" preserveAspectRatio="xMinYMin meet">`;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n${out}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function buildLinkedinWrapperHtml({ handle, theme, size, locale, orientation }) {
|
|
2
|
+
const sz = String(size).toLowerCase();
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html>
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
</head>
|
|
9
|
+
<body style="margin:0;padding:0;background:transparent">
|
|
10
|
+
<div class="badge-base LI-profile-badge"
|
|
11
|
+
data-version="v1"
|
|
12
|
+
data-size="${sz}"
|
|
13
|
+
data-locale="${locale}"
|
|
14
|
+
data-type="${orientation}"
|
|
15
|
+
data-theme="${theme}"
|
|
16
|
+
data-vanity="${handle}"
|
|
17
|
+
style="display:inline-block">
|
|
18
|
+
<a class="badge-base__link LI-simple-link" href="https://www.linkedin.com/in/${handle}?trk=profile-badge">${handle}</a>
|
|
19
|
+
</div>
|
|
20
|
+
<script src="https://platform.linkedin.com/badges/js/profile.js" async defer></script>
|
|
21
|
+
</body>
|
|
22
|
+
</html>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function extractBadgeFromIframe(frame) {
|
|
26
|
+
const badgeInner = await frame.evaluate(() => document.body.innerHTML);
|
|
27
|
+
const cssHrefs = await frame.evaluate(() =>
|
|
28
|
+
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href)
|
|
29
|
+
);
|
|
30
|
+
return { badgeInner, cssHrefs };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildRenderHtml({ badgeInner, cssHrefs, pad }) {
|
|
34
|
+
const p = Math.max(0, Number(pad || 0));
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html>
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="utf-8" />
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
40
|
+
${cssHrefs.map((u) => `<link rel="stylesheet" href="${u}">`).join("\n")}
|
|
41
|
+
<style>
|
|
42
|
+
html, body { margin:0; padding:0; background:transparent; }
|
|
43
|
+
body { width:100vw; height:100vh; display:flex; align-items:center; justify-content:center; overflow:hidden; background:transparent; }
|
|
44
|
+
#root { display:inline-block; padding:${p}px; background:transparent; }
|
|
45
|
+
* { box-sizing:border-box; }
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<div id="root">${badgeInner}</div>
|
|
50
|
+
</body>
|
|
51
|
+
</html>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function measureRoot(page) {
|
|
55
|
+
return await page.evaluate(() => {
|
|
56
|
+
const el = document.getElementById("root") || document.body;
|
|
57
|
+
const r = el.getBoundingClientRect();
|
|
58
|
+
return { w: r.width, h: r.height };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
|
|
3
|
+
export async function withBrowser(fn) {
|
|
4
|
+
const browser = await chromium.launch({ args: ["--no-sandbox"] });
|
|
5
|
+
try {
|
|
6
|
+
return await fn(browser);
|
|
7
|
+
} finally {
|
|
8
|
+
await browser.close().catch(() => {});
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function createContext(browser) {
|
|
13
|
+
return await browser.newContext({
|
|
14
|
+
userAgent:
|
|
15
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
16
|
+
locale: "en-US",
|
|
17
|
+
viewport: { width: 1400, height: 900 },
|
|
18
|
+
deviceScaleFactor: 2,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function warmUp(context) {
|
|
23
|
+
const page = await context.newPage();
|
|
24
|
+
try {
|
|
25
|
+
await page.goto("https://www.linkedin.com/", { waitUntil: "domcontentloaded", timeout: 25000 });
|
|
26
|
+
await page.waitForTimeout(800);
|
|
27
|
+
} catch {}
|
|
28
|
+
await page.close().catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function waitFonts(page) {
|
|
32
|
+
await page.evaluate(async () => {
|
|
33
|
+
try {
|
|
34
|
+
if (document.fonts?.ready) await document.fonts.ready;
|
|
35
|
+
} catch {}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export const DEFAULTS = {
|
|
2
|
+
locale: "en_US",
|
|
3
|
+
orientation: "HORIZONTAL",
|
|
4
|
+
size: "large",
|
|
5
|
+
theme: "light",
|
|
6
|
+
pad: 20,
|
|
7
|
+
format: "svg",
|
|
8
|
+
out: "dist/linkedin-badge.svg",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function parseArgs(argv) {
|
|
12
|
+
const m = {};
|
|
13
|
+
for (let i = 2; i < argv.length; i++) {
|
|
14
|
+
const a = argv[i];
|
|
15
|
+
if (!a.startsWith("--")) continue;
|
|
16
|
+
const key = a.slice(2);
|
|
17
|
+
const next = argv[i + 1];
|
|
18
|
+
if (!next || next.startsWith("--")) m[key] = true;
|
|
19
|
+
else {
|
|
20
|
+
m[key] = next;
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return m;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function normTheme(v) {
|
|
28
|
+
const t = String(v ?? DEFAULTS.theme).toLowerCase();
|
|
29
|
+
return t === "dark" ? "dark" : "light";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normSize(v) {
|
|
33
|
+
const s = String(v ?? DEFAULTS.size).toLowerCase();
|
|
34
|
+
if (s === "small") return "small";
|
|
35
|
+
if (s === "medium") return "medium";
|
|
36
|
+
return "large";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normOrientation(v) {
|
|
40
|
+
const o = String(v ?? DEFAULTS.orientation).toUpperCase();
|
|
41
|
+
return o === "VERTICAL" ? "VERTICAL" : "HORIZONTAL";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normLocale(v) {
|
|
45
|
+
return String(v ?? DEFAULTS.locale);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normFormat(v) {
|
|
49
|
+
const f = String(v ?? DEFAULTS.format).toLowerCase();
|
|
50
|
+
if (f === "pdf" || f === "svg" || f === "png" || f === "jpeg" || f === "jpg") return f === "jpg" ? "jpeg" : f;
|
|
51
|
+
return "svg";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function toInt(v, def) {
|
|
55
|
+
const n = Number(v);
|
|
56
|
+
return Number.isFinite(n) ? n : def;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ensureExt(out, format) {
|
|
60
|
+
const ext = `.${format}`;
|
|
61
|
+
const s = String(out || DEFAULTS.out);
|
|
62
|
+
const base = s.replace(/[?#].*$/, "");
|
|
63
|
+
const i = base.lastIndexOf(".");
|
|
64
|
+
if (i > -1 && i > base.lastIndexOf("/") && i > base.lastIndexOf("\\")) return s.slice(0, i) + ext;
|
|
65
|
+
return s.endsWith(".") ? `${s}${format}` : `${s}${ext}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function usageAndExit() {
|
|
69
|
+
console.error(
|
|
70
|
+
"Usage: node src/cli.mjs --handle <vanity> [--format svg|pdf|png|jpeg] [--out dist/out.ext] [--theme light|dark] [--size small|medium|large] [--locale en_US] [--orientation HORIZONTAL|VERTICAL] [--pad 20]"
|
|
71
|
+
);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
package/src/utils/fs.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fs from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export async function ensureDir(filePath) {
|
|
5
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function writeFileEnsured(filePath, data, encoding) {
|
|
9
|
+
await ensureDir(filePath);
|
|
10
|
+
await fs.writeFile(filePath, data, encoding);
|
|
11
|
+
}
|