qr-kit 2.1.0
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/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.md +323 -0
- package/components/QRCodeGenerator.jsx +88 -0
- package/components/useQrCode.js +59 -0
- package/components/useQrWorker.js +81 -0
- package/index.js +40 -0
- package/package.json +71 -0
- package/qr/qr-core.js +408 -0
- package/renderers/canvas.js +96 -0
- package/renderers/svg.js +132 -0
- package/types/index.d.ts +462 -0
- package/utils/jpegQr.js +54 -0
- package/utils/layout.js +31 -0
- package/utils/link.js +154 -0
- package/utils/logo.js +189 -0
- package/utils/pdf.js +314 -0
- package/utils/poster.js +102 -0
- package/utils/raster.js +140 -0
- package/utils/url.js +20 -0
- package/worker/qr.worker.js +45 -0
package/utils/link.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// utils/link.js
|
|
2
|
+
// Universal QR link builder with automatic payload fitting.
|
|
3
|
+
// No external dependencies. Works in any browser environment.
|
|
4
|
+
|
|
5
|
+
import { sanitizeUrlForQR, utf8ByteLen } from './url.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Encodes a string to Base64 preserving full UTF-8.
|
|
9
|
+
* @param {string} str
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
function b64utf8(str) {
|
|
13
|
+
if (typeof TextEncoder !== 'undefined') {
|
|
14
|
+
const u8 = new TextEncoder().encode(str);
|
|
15
|
+
let bin = '';
|
|
16
|
+
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
|
|
17
|
+
return btoa(bin);
|
|
18
|
+
}
|
|
19
|
+
// Fallback for older environments
|
|
20
|
+
return btoa(unescape(encodeURIComponent(str)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Builds a QR-friendly URL with a JSON payload encoded as Base64 in a query parameter.
|
|
25
|
+
*
|
|
26
|
+
* Two URLs are returned:
|
|
27
|
+
* - `fullUrl` — complete URL for sharing/copying (includes all params)
|
|
28
|
+
* - `qrUrl` — trimmed URL optimised to fit within `qrBudgetBytes` (for the QR code itself)
|
|
29
|
+
*
|
|
30
|
+
* The `payload` object is serialised to JSON and base64-encoded into `paramName`.
|
|
31
|
+
* An optional `trimKey` specifies which string field to shorten (binary search)
|
|
32
|
+
* if the full payload exceeds the byte budget.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} opts
|
|
35
|
+
* @param {string} opts.baseUrl - Base URL, e.g. "https://example.com/landing"
|
|
36
|
+
* @param {object} opts.payload - Arbitrary JSON payload (will be base64-encoded)
|
|
37
|
+
* @param {string} [opts.paramName] - Query parameter name for the encoded payload (default: "data")
|
|
38
|
+
* @param {object} [opts.extraParams] - Additional query params added to fullUrl only (e.g. UTM tags)
|
|
39
|
+
* @param {number} [opts.qrBudgetBytes] - Max byte length of qrUrl (default: 134, fits QR v6-L)
|
|
40
|
+
* @param {boolean} [opts.removeProtocol] - Strip https:// from qrUrl to save bytes (default: true)
|
|
41
|
+
* @param {string} [opts.trimKey] - Payload key to shorten when budget is exceeded
|
|
42
|
+
* @param {'trim'|'drop'|'error'} [opts.strategy] - Overflow strategy (default: 'trim')
|
|
43
|
+
*
|
|
44
|
+
* @returns {{ fullUrl: string, qrUrl: string, trimmed: boolean, removed: boolean, error?: string }}
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* // Basic usage
|
|
48
|
+
* const { fullUrl, qrUrl } = buildQrLink({
|
|
49
|
+
* baseUrl: 'https://example.com/join',
|
|
50
|
+
* payload: { userId: '123', plan: 'pro' },
|
|
51
|
+
* extraParams: { utm_source: 'poster', utm_medium: 'print' },
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* // With promo code trimming
|
|
56
|
+
* const { fullUrl, qrUrl, trimmed } = buildQrLink({
|
|
57
|
+
* baseUrl: 'https://example.com/promo',
|
|
58
|
+
* payload: { ref: 'abc', code: 'SUPERSALE2024' },
|
|
59
|
+
* trimKey: 'code',
|
|
60
|
+
* strategy: 'trim',
|
|
61
|
+
* });
|
|
62
|
+
*/
|
|
63
|
+
export function buildQrLink({
|
|
64
|
+
baseUrl,
|
|
65
|
+
payload,
|
|
66
|
+
paramName = 'data',
|
|
67
|
+
extraParams = {},
|
|
68
|
+
qrBudgetBytes = 134,
|
|
69
|
+
removeProtocol = true,
|
|
70
|
+
trimKey = null,
|
|
71
|
+
strategy = 'trim',
|
|
72
|
+
}) {
|
|
73
|
+
if (!baseUrl) throw new Error('buildQrLink: baseUrl is required');
|
|
74
|
+
if (!payload || typeof payload !== 'object') throw new Error('buildQrLink: payload must be an object');
|
|
75
|
+
|
|
76
|
+
// --- Build fullUrl (all params) ---
|
|
77
|
+
const fullB64 = b64utf8(JSON.stringify(payload));
|
|
78
|
+
const fullUrlObj = new URL(baseUrl);
|
|
79
|
+
fullUrlObj.searchParams.set(paramName, fullB64);
|
|
80
|
+
for (const [k, v] of Object.entries(extraParams)) {
|
|
81
|
+
fullUrlObj.searchParams.set(k, v);
|
|
82
|
+
}
|
|
83
|
+
const fullUrl = fullUrlObj.toString();
|
|
84
|
+
|
|
85
|
+
// --- Build qrUrl (minimal params, optional protocol stripping) ---
|
|
86
|
+
const mkQrUrl = (b64) => {
|
|
87
|
+
const raw = `${baseUrl}?${paramName}=${b64}`;
|
|
88
|
+
return sanitizeUrlForQR(raw, {
|
|
89
|
+
whitelist: [paramName],
|
|
90
|
+
aggressive: true,
|
|
91
|
+
preserveProtocol: !removeProtocol,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Try full payload first
|
|
96
|
+
let qrUrl = mkQrUrl(fullB64);
|
|
97
|
+
if (utf8ByteLen(qrUrl) <= qrBudgetBytes) {
|
|
98
|
+
return { fullUrl, qrUrl, trimmed: false, removed: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Overflow handling ---
|
|
102
|
+
if (strategy === 'error') {
|
|
103
|
+
return { fullUrl, qrUrl: '', trimmed: false, removed: false, error: 'QR payload too long for budget' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (strategy === 'drop' || !trimKey) {
|
|
107
|
+
const reduced = { ...payload };
|
|
108
|
+
if (trimKey) delete reduced[trimKey];
|
|
109
|
+
const b64 = b64utf8(JSON.stringify(reduced));
|
|
110
|
+
return { fullUrl, qrUrl: mkQrUrl(b64), trimmed: false, removed: !!trimKey };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// strategy === 'trim': binary-search the shortest trimKey value that still fits
|
|
114
|
+
const rawValue = payload[trimKey];
|
|
115
|
+
if (rawValue != null && typeof rawValue !== 'string') {
|
|
116
|
+
// trimKey points to a non-string field (number, boolean, object) — cannot be trimmed.
|
|
117
|
+
// Fall through to 'drop' behaviour instead of silently corrupting data.
|
|
118
|
+
const reduced = { ...payload };
|
|
119
|
+
delete reduced[trimKey];
|
|
120
|
+
return { fullUrl, qrUrl: mkQrUrl(b64utf8(JSON.stringify(reduced))), trimmed: false, removed: true };
|
|
121
|
+
}
|
|
122
|
+
const originalValue = String(rawValue || '').trim();
|
|
123
|
+
let lo = 0, hi = originalValue.length, best = -1, bestQr = '';
|
|
124
|
+
|
|
125
|
+
while (lo <= hi) {
|
|
126
|
+
const mid = (lo + hi) >> 1;
|
|
127
|
+
const cut = originalValue.slice(0, mid);
|
|
128
|
+
const candidate = { ...payload };
|
|
129
|
+
if (cut) {
|
|
130
|
+
candidate[trimKey] = cut;
|
|
131
|
+
} else {
|
|
132
|
+
delete candidate[trimKey];
|
|
133
|
+
}
|
|
134
|
+
const b64 = b64utf8(JSON.stringify(candidate));
|
|
135
|
+
const url = mkQrUrl(b64);
|
|
136
|
+
if (utf8ByteLen(url) <= qrBudgetBytes) {
|
|
137
|
+
best = mid;
|
|
138
|
+
bestQr = url;
|
|
139
|
+
lo = mid + 1;
|
|
140
|
+
} else {
|
|
141
|
+
hi = mid - 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (best >= 0) {
|
|
146
|
+
return { fullUrl, qrUrl: bestQr, trimmed: best < originalValue.length, removed: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Last resort: drop the trimKey entirely
|
|
150
|
+
const fallback = { ...payload };
|
|
151
|
+
delete fallback[trimKey];
|
|
152
|
+
const b64 = b64utf8(JSON.stringify(fallback));
|
|
153
|
+
return { fullUrl, qrUrl: mkQrUrl(b64), trimmed: false, removed: true };
|
|
154
|
+
}
|
package/utils/logo.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// utils/logo.js
|
|
2
|
+
// Embeds a logo image inside a QR code using the error-correction recovery budget.
|
|
3
|
+
//
|
|
4
|
+
// Design:
|
|
5
|
+
// - makeQrWithLogoSvg(model, logoDataUrl, opts) — zero-DOM, works in Node/Worker/browser
|
|
6
|
+
// - loadLogoAsDataUrl(src) — browser helper: URL → data URL
|
|
7
|
+
// - buildQrWithLogoSvgAsync(model, src, opts) — browser convenience wrapper
|
|
8
|
+
//
|
|
9
|
+
// How it works:
|
|
10
|
+
// ECC M corrects up to ~15% of damaged codewords. We use at most `maxCoverage`
|
|
11
|
+
// (default 11%) of the total module area for the logo, leaving the remaining
|
|
12
|
+
// 4% as a safety buffer for real-world damage (dirty scanner, low contrast, etc.)
|
|
13
|
+
//
|
|
14
|
+
// Finder patterns + timing strips + alignment patterns are NEVER obscured —
|
|
15
|
+
// they are drawn on a separate layer that composites above the logo.
|
|
16
|
+
//
|
|
17
|
+
// Why zero-DOM:
|
|
18
|
+
// The SVG renderer works in Node.js, Deno, Cloudflare Workers, and any environment
|
|
19
|
+
// with no `document`. The logo must be provided as a data URL (base64-encoded).
|
|
20
|
+
// For browser use, call `buildQrWithLogoSvgAsync(model, '/logo.png')` which
|
|
21
|
+
// fetches and encodes the image for you.
|
|
22
|
+
|
|
23
|
+
import { computeLayout } from './layout.js';
|
|
24
|
+
import { makeQrPathSplit } from '../renderers/svg.js';
|
|
25
|
+
|
|
26
|
+
// ─── Public constants ──────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/** Maximum fraction of QR area safe to cover with ECC M (~15% budget, 4% safety margin). */
|
|
29
|
+
export const LOGO_MAX_COVERAGE_ECC_M = 0.11;
|
|
30
|
+
|
|
31
|
+
/** Maximum fraction of QR area safe to cover with ECC L (~7% budget, 3% safety margin). */
|
|
32
|
+
export const LOGO_MAX_COVERAGE_ECC_L = 0.04;
|
|
33
|
+
|
|
34
|
+
// ─── Core: zero-DOM SVG builder ───────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Builds a complete SVG string with a logo embedded in the centre.
|
|
38
|
+
* The logo sits above data modules; function modules (finder, timing) are
|
|
39
|
+
* rendered on a separate layer above the logo to remain scannable.
|
|
40
|
+
*
|
|
41
|
+
* @param {import('../qr/qr-core.js').QRModel} model - Output of makeQr().
|
|
42
|
+
* @param {string} logoDataUrl - base64 data URL of the logo (any image format).
|
|
43
|
+
* Use loadLogoAsDataUrl() in the browser to obtain this.
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {number} [opts.size=256] - Outer SVG size in px.
|
|
46
|
+
* @param {number} [opts.margin=16] - Quiet-zone padding in px.
|
|
47
|
+
* @param {string} [opts.fg='#000'] - Data module colour.
|
|
48
|
+
* @param {string} [opts.fnFg='#000'] - Function module colour (finder/timing).
|
|
49
|
+
* @param {string} [opts.bg='#fff'] - Background colour.
|
|
50
|
+
* @param {number} [opts.maxCoverage] - Max fraction of QR area to cover.
|
|
51
|
+
* Default: 0.11 for ECC M, 0.04 for ECC L.
|
|
52
|
+
* @param {number} [opts.logoPadding=4] - White padding around logo in px.
|
|
53
|
+
* @param {string} [opts.logoBg='#fff'] - Colour of logo background pad.
|
|
54
|
+
* @param {number} [opts.logoRadius=6] - Border-radius of logo bg rect in px.
|
|
55
|
+
* @param {string} [opts.title='QR Code']
|
|
56
|
+
* @returns {string} Complete SVG markup.
|
|
57
|
+
*/
|
|
58
|
+
export function makeQrWithLogoSvg(model, logoDataUrl, {
|
|
59
|
+
size = 256,
|
|
60
|
+
margin = 16,
|
|
61
|
+
fg = '#000',
|
|
62
|
+
fnFg = '#000',
|
|
63
|
+
bg = '#fff',
|
|
64
|
+
maxCoverage,
|
|
65
|
+
logoPadding = 4,
|
|
66
|
+
logoBg = '#fff',
|
|
67
|
+
logoRadius = 6,
|
|
68
|
+
title = 'QR Code',
|
|
69
|
+
} = {}) {
|
|
70
|
+
if (!logoDataUrl) throw new Error('makeQrWithLogoSvg: logoDataUrl is required');
|
|
71
|
+
|
|
72
|
+
const ecc = model.eccLevel ?? 'M';
|
|
73
|
+
const maxCov = maxCoverage ?? (ecc === 'M' ? LOGO_MAX_COVERAGE_ECC_M : LOGO_MAX_COVERAGE_ECC_L);
|
|
74
|
+
|
|
75
|
+
const { outer, moduleSize, quietLeft, quietTop } = computeLayout(model.size, size, margin);
|
|
76
|
+
|
|
77
|
+
// Maximum logo size in pixels based on ECC budget
|
|
78
|
+
const qrPx = model.size * moduleSize;
|
|
79
|
+
const maxLogoPx = Math.floor(Math.sqrt(maxCov) * qrPx);
|
|
80
|
+
|
|
81
|
+
// Logo sits in the centre of the QR grid (not the outer canvas)
|
|
82
|
+
const gridCentreX = quietLeft + qrPx / 2;
|
|
83
|
+
const gridCentreY = quietTop + qrPx / 2;
|
|
84
|
+
|
|
85
|
+
const logoSize = maxLogoPx;
|
|
86
|
+
const padded = logoSize + logoPadding * 2;
|
|
87
|
+
const logoX = gridCentreX - padded / 2;
|
|
88
|
+
const logoY = gridCentreY - padded / 2;
|
|
89
|
+
|
|
90
|
+
// Get separate paths: data modules below the logo, function modules above
|
|
91
|
+
const { dataPath, functionPath } = makeQrPathSplit(model, { size, margin });
|
|
92
|
+
|
|
93
|
+
// Escape helpers
|
|
94
|
+
const ea = s => String(s).replace(/[&<>"']/g, c =>
|
|
95
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
96
|
+
|
|
97
|
+
const logoImgX = logoX + logoPadding;
|
|
98
|
+
const logoImgY = logoY + logoPadding;
|
|
99
|
+
|
|
100
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"`
|
|
101
|
+
+ ` width="${outer}" height="${outer}" viewBox="0 0 ${outer} ${outer}"`
|
|
102
|
+
+ ` role="img" aria-label="${ea(title)}" shape-rendering="crispEdges">`
|
|
103
|
+
+ `<title>${ea(title)}</title>`
|
|
104
|
+
+ `<rect width="100%" height="100%" fill="${ea(bg)}"/>`
|
|
105
|
+
|
|
106
|
+
// Layer 1: data modules (covered by logo, but scannable due to ECC)
|
|
107
|
+
+ `<path fill="${ea(fg)}" d="${dataPath}"/>`
|
|
108
|
+
|
|
109
|
+
// Layer 2: logo background + logo image
|
|
110
|
+
+ `<rect x="${logoX.toFixed(1)}" y="${logoY.toFixed(1)}"`
|
|
111
|
+
+ ` width="${padded.toFixed(1)}" height="${padded.toFixed(1)}"`
|
|
112
|
+
+ ` rx="${logoRadius}" ry="${logoRadius}"`
|
|
113
|
+
+ ` fill="${ea(logoBg)}"/>`
|
|
114
|
+
+ `<image href="${logoDataUrl}"`
|
|
115
|
+
+ ` x="${logoImgX.toFixed(1)}" y="${logoImgY.toFixed(1)}"`
|
|
116
|
+
+ ` width="${logoSize.toFixed(1)}" height="${logoSize.toFixed(1)}"`
|
|
117
|
+
+ ` preserveAspectRatio="xMidYMid meet"/>`
|
|
118
|
+
|
|
119
|
+
// Layer 3: function modules — always on top, never obscured
|
|
120
|
+
+ `<path fill="${ea(fnFg)}" d="${functionPath}"/>`
|
|
121
|
+
|
|
122
|
+
+ `</svg>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Returns the maximum safe logo size in pixels for a given QR model and output size.
|
|
127
|
+
* Use this to pre-validate a logo before calling makeQrWithLogoSvg.
|
|
128
|
+
*
|
|
129
|
+
* @param {import('../qr/qr-core.js').QRModel} model
|
|
130
|
+
* @param {number} size - Outer SVG size in px (same as passed to makeQrWithLogoSvg).
|
|
131
|
+
* @param {number} margin - Quiet-zone in px.
|
|
132
|
+
* @param {number} [maxCoverage]
|
|
133
|
+
* @returns {{ maxLogoSize: number, paddedSize: number, coverageFraction: number }}
|
|
134
|
+
*/
|
|
135
|
+
export function getLogoConstraints(model, size, margin, maxCoverage) {
|
|
136
|
+
const ecc = model.eccLevel ?? 'M';
|
|
137
|
+
const maxCov = maxCoverage ?? (ecc === 'M' ? LOGO_MAX_COVERAGE_ECC_M : LOGO_MAX_COVERAGE_ECC_L);
|
|
138
|
+
const { moduleSize } = computeLayout(model.size, size, margin);
|
|
139
|
+
const qrPx = model.size * moduleSize;
|
|
140
|
+
const maxLogoPx = Math.floor(Math.sqrt(maxCov) * qrPx);
|
|
141
|
+
return {
|
|
142
|
+
maxLogoSize: maxLogoPx,
|
|
143
|
+
paddedSize: maxLogoPx + 8, // +8 = default padding (4px each side)
|
|
144
|
+
coverageFraction: (maxLogoPx / qrPx) ** 2,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Browser helpers ───────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fetches an image URL and converts it to a base64 data URL.
|
|
152
|
+
* Browser-only (requires fetch + FileReader or canvas).
|
|
153
|
+
*
|
|
154
|
+
* @param {string} src - URL of the image (JPEG, PNG, SVG, WebP…)
|
|
155
|
+
* @param {object} [opts]
|
|
156
|
+
* @param {AbortSignal} [opts.signal]
|
|
157
|
+
* @returns {Promise<string>} base64 data URL
|
|
158
|
+
*/
|
|
159
|
+
export async function loadLogoAsDataUrl(src, { signal } = {}) {
|
|
160
|
+
// Try fetch first (works for remote URLs with CORS, local URLs, data URIs)
|
|
161
|
+
if (src.startsWith('data:')) return src; // already a data URL
|
|
162
|
+
|
|
163
|
+
const res = await fetch(src, { signal });
|
|
164
|
+
if (!res.ok) throw new Error(`loadLogoAsDataUrl: fetch failed ${res.status} for ${src}`);
|
|
165
|
+
|
|
166
|
+
const blob = await res.blob();
|
|
167
|
+
const reader = new FileReader();
|
|
168
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
169
|
+
reader.onload = () => resolve(/** @type {string} */ (reader.result));
|
|
170
|
+
reader.onerror = () => reject(new Error('FileReader failed'));
|
|
171
|
+
reader.readAsDataURL(blob);
|
|
172
|
+
});
|
|
173
|
+
return dataUrl;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Browser convenience: loads a logo from a URL and returns a complete QR SVG string.
|
|
178
|
+
* Equivalent to `makeQrWithLogoSvg(model, await loadLogoAsDataUrl(src), opts)`.
|
|
179
|
+
*
|
|
180
|
+
* @param {import('../qr/qr-core.js').QRModel} model
|
|
181
|
+
* @param {string} logoSrc - URL of the logo image.
|
|
182
|
+
* @param {object} [opts] - Same options as makeQrWithLogoSvg, plus `signal`.
|
|
183
|
+
* @returns {Promise<string>} Complete SVG markup.
|
|
184
|
+
*/
|
|
185
|
+
export async function buildQrWithLogoSvgAsync(model, logoSrc, opts = {}) {
|
|
186
|
+
const { signal, ...svgOpts } = opts;
|
|
187
|
+
const dataUrl = await loadLogoAsDataUrl(logoSrc, { signal });
|
|
188
|
+
return makeQrWithLogoSvg(model, dataUrl, svgOpts);
|
|
189
|
+
}
|
package/utils/pdf.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// utils/pdf.js
|
|
2
|
+
// Zero-dependency browser PDF builder: assembles a PDF with a background image
|
|
3
|
+
// and a QR code, both embedded as JPEG XObjects.
|
|
4
|
+
//
|
|
5
|
+
// IMPORTANT: PDF coordinates originate from the BOTTOM-LEFT corner of the page.
|
|
6
|
+
|
|
7
|
+
import { svgToPngDataURL, pngDataURLtoJpegBytes, dataURLToImage, dataURLToBytes } from './raster.js';
|
|
8
|
+
|
|
9
|
+
// -----------------------------
|
|
10
|
+
// Internal helpers: image loading
|
|
11
|
+
// -----------------------------
|
|
12
|
+
|
|
13
|
+
// Fetches raw image bytes (JPEG/PNG) without re-encoding
|
|
14
|
+
async function fetchBytes(url, { signal } = {}) {
|
|
15
|
+
const res = await fetch(url, { signal });
|
|
16
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
|
17
|
+
const buf = await res.arrayBuffer();
|
|
18
|
+
return new Uint8Array(buf);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Loads an <img> element without crossOrigin (we only need width/height)
|
|
22
|
+
function loadImgNoCors(src) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const img = new Image();
|
|
25
|
+
img.onload = () => resolve(img);
|
|
26
|
+
img.onerror = reject;
|
|
27
|
+
img.src = src;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Returns true if bytes start with the JPEG magic number FF D8
|
|
32
|
+
function isJpeg(u8) {
|
|
33
|
+
return u8.length > 2 && u8[0] === 0xFF && u8[1] === 0xD8;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Re-encodes any image (PNG, WebP, GIF, CMYK JPEG, …) to RGB JPEG via canvas
|
|
37
|
+
async function recodeToRgbJpeg(src, quality = 0.92) {
|
|
38
|
+
const img = await loadImgNoCors(src);
|
|
39
|
+
const canvas = document.createElement('canvas');
|
|
40
|
+
canvas.width = img.width;
|
|
41
|
+
canvas.height = img.height;
|
|
42
|
+
canvas.getContext('2d').drawImage(img, 0, 0);
|
|
43
|
+
return dataURLToBytes(canvas.toDataURL('image/jpeg', quality));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Detects the number of colour components in a JPEG (1=Gray, 3=RGB/YCbCr, 4=CMYK)
|
|
47
|
+
function jpegComponents(u8) {
|
|
48
|
+
let i = 0;
|
|
49
|
+
if (u8[i++] !== 0xFF || u8[i++] !== 0xD8) return null; // not a JPEG
|
|
50
|
+
while (i + 4 < u8.length) {
|
|
51
|
+
if (u8[i] !== 0xFF) { i++; continue; }
|
|
52
|
+
const marker = u8[i + 1];
|
|
53
|
+
i += 2;
|
|
54
|
+
if (marker === 0xDA) break; // SOS marker — image data starts here
|
|
55
|
+
const len = (u8[i] << 8) | u8[i + 1]; i += 2;
|
|
56
|
+
if (marker === 0xC0 || marker === 0xC1 || marker === 0xC2) {
|
|
57
|
+
// SOF0/1/2: precision(1), H(2), W(2), Nf(1)
|
|
58
|
+
return u8[i + 5] || null;
|
|
59
|
+
} else {
|
|
60
|
+
i += len - 2;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function colorSpaceDictForJpeg(u8) {
|
|
67
|
+
const nf = jpegComponents(u8);
|
|
68
|
+
if (nf === 1) return { cs: '/DeviceGray', decode: '' };
|
|
69
|
+
if (nf === 4) return { cs: '/DeviceCMYK', decode: ' /Decode [1 0 1 0 1 0 1 0]' };
|
|
70
|
+
return { cs: '/DeviceRGB', decode: '' }; // default
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// -----------------------------
|
|
74
|
+
// Internal helpers: PDF low-level
|
|
75
|
+
// -----------------------------
|
|
76
|
+
|
|
77
|
+
function arrayBufferToLatin1(buf) {
|
|
78
|
+
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
|
79
|
+
let s = '';
|
|
80
|
+
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function strToBytesLatin1(str) {
|
|
85
|
+
const out = new Uint8Array(str.length);
|
|
86
|
+
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i) & 0xFF;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function concatBytes(...parts) {
|
|
91
|
+
let total = 0;
|
|
92
|
+
for (const p of parts) total += p.length;
|
|
93
|
+
const out = new Uint8Array(total);
|
|
94
|
+
let o = 0;
|
|
95
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Binary stream object (used for image XObjects)
|
|
100
|
+
// << >> delimiters and CRLF around stream/endstream are required by PDF spec
|
|
101
|
+
function streamObj({ dict, bytes }) {
|
|
102
|
+
const head = `<< ${dict} >>\r\nstream\r\n`;
|
|
103
|
+
const tail = `\r\nendstream`;
|
|
104
|
+
return head + arrayBufferToLatin1(bytes) + tail;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ASCII content stream (used for page content operators)
|
|
108
|
+
function streamRaw(content) {
|
|
109
|
+
const length = content.length;
|
|
110
|
+
return `<< /Length ${length} >>\r\nstream\r\n${content}\r\nendstream`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Assembles a complete PDF byte sequence from an array of object strings.
|
|
114
|
+
// The binary comment after the header tells viewers that the file contains binary streams.
|
|
115
|
+
function assemblePDF(objects) {
|
|
116
|
+
const header = '%PDF-1.7\r\n%\xFF\xFF\xFF\xFF\r\n';
|
|
117
|
+
const bodyParts = objects.map((o, i) => `${i + 1} 0 obj\r\n${o}\r\nendobj\r\n`);
|
|
118
|
+
const body = bodyParts.join('');
|
|
119
|
+
|
|
120
|
+
const offsets = [0];
|
|
121
|
+
let cursor = header.length;
|
|
122
|
+
for (const part of bodyParts) { offsets.push(cursor); cursor += part.length; }
|
|
123
|
+
|
|
124
|
+
const xrefStart = header.length + body.length;
|
|
125
|
+
let xref = `xref\r\n0 ${objects.length + 1}\r\n0000000000 65535 f \r\n`;
|
|
126
|
+
for (let i = 1; i <= objects.length; i++) {
|
|
127
|
+
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \r\n`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const trailer = `trailer\r\n<< /Size ${objects.length + 1} /Root ${objects.length} 0 R >>\r\nstartxref\r\n${xrefStart}\r\n%%EOF`;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
pdfBytes: concatBytes(
|
|
134
|
+
strToBytesLatin1(header),
|
|
135
|
+
strToBytesLatin1(body),
|
|
136
|
+
strToBytesLatin1(xref),
|
|
137
|
+
strToBytesLatin1(trailer),
|
|
138
|
+
),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function downloadBytes(bytes, filename) {
|
|
143
|
+
const blob = new Blob([bytes], { type: 'application/pdf' });
|
|
144
|
+
const url = URL.createObjectURL(blob);
|
|
145
|
+
const a = document.createElement('a');
|
|
146
|
+
a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
|
|
147
|
+
URL.revokeObjectURL(url);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Escapes a string for use inside a PDF text literal (ASCII only)
|
|
151
|
+
function escapePDFText(s) {
|
|
152
|
+
return String(s).replace(/[\\()]/g, m => ({ '\\': '\\\\', '(': '\\(', ')': '\\)' }[m]));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// -----------------------------
|
|
156
|
+
// PUBLIC API — Data primitives (return bytes, no side effects)
|
|
157
|
+
// -----------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Builds a simple A4 PDF and returns it as a Uint8Array.
|
|
161
|
+
* Use this to send the PDF to a server, store in state, or test without triggering a download.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} opts - Same options as downloadQrPdf except no `fileName`.
|
|
164
|
+
* @returns {Promise<Uint8Array>}
|
|
165
|
+
*/
|
|
166
|
+
export async function buildQrPdfBytes({
|
|
167
|
+
svgEl,
|
|
168
|
+
title = 'Leaflet',
|
|
169
|
+
org,
|
|
170
|
+
url,
|
|
171
|
+
notes = [],
|
|
172
|
+
images = [],
|
|
173
|
+
}) {
|
|
174
|
+
const pageW = 595.276, pageH = 841.890;
|
|
175
|
+
const qrDataUrl = await svgToPngDataURL(svgEl, 3);
|
|
176
|
+
const qrJpegBytes = await pngDataURLtoJpegBytes(qrDataUrl, 0.92);
|
|
177
|
+
const qrImgEl = await dataURLToImage(qrDataUrl);
|
|
178
|
+
|
|
179
|
+
const extra = [];
|
|
180
|
+
for (const item of images) {
|
|
181
|
+
try {
|
|
182
|
+
const imgEl = await loadImgNoCors(item.src);
|
|
183
|
+
let bytes = await fetchBytes(item.src);
|
|
184
|
+
if (!isJpeg(bytes)) bytes = await recodeToRgbJpeg(item.src, 0.9);
|
|
185
|
+
extra.push({ imgEl, bytes, spec: colorSpaceDictForJpeg(bytes), caption: item.caption });
|
|
186
|
+
} catch (e) { console.warn('Image load failed:', item.src, e); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const objs = [], addObj = s => { objs.push(s); return objs.length; };
|
|
190
|
+
const fontId = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`);
|
|
191
|
+
const qrSpec = colorSpaceDictForJpeg(qrJpegBytes);
|
|
192
|
+
const qrId = addObj(streamObj({ dict: `/Type /XObject /Subtype /Image /Width ${qrImgEl.width} /Height ${qrImgEl.height} /ColorSpace ${qrSpec.cs}${qrSpec.decode} /BitsPerComponent 8 /Filter /DCTDecode /Length ${qrJpegBytes.length}`, bytes: qrJpegBytes }));
|
|
193
|
+
const extraIds = extra.map(e => addObj(streamObj({ dict: `/Type /XObject /Subtype /Image /Width ${e.imgEl.width} /Height ${e.imgEl.height} /ColorSpace ${e.spec.cs}${e.spec.decode} /BitsPerComponent 8 /Filter /DCTDecode /Length ${e.bytes.length}`, bytes: e.bytes })));
|
|
194
|
+
|
|
195
|
+
const c = [];
|
|
196
|
+
c.push(`BT /F1 20 Tf 50 ${pageH - 60} Td (${escapePDFText(title)}) Tj ET`);
|
|
197
|
+
const lines = [org && `Org: ${org}`, url && `URL: ${url}`, ...notes].filter(Boolean);
|
|
198
|
+
let y = pageH - 90;
|
|
199
|
+
c.push('BT /F1 11 Tf');
|
|
200
|
+
for (const line of lines) { c.push(`50 ${y} Td (${escapePDFText(line)}) Tj`); y -= 16; }
|
|
201
|
+
c.push('ET');
|
|
202
|
+
const qrSize = 220, qrX = pageW - 50 - qrSize, qrY = pageH - 60 - qrSize;
|
|
203
|
+
c.push(`q ${qrSize} 0 0 ${qrSize} ${qrX} ${qrY} cm /ImQR Do Q`);
|
|
204
|
+
let imgX = 50, imgY = qrY - 30;
|
|
205
|
+
for (let i = 0; i < extraIds.length; i++) {
|
|
206
|
+
if (imgX + 120 > pageW - 50) { imgX = 50; imgY -= 110; }
|
|
207
|
+
c.push(`q 120 0 0 80 ${imgX} ${imgY - 80} cm /Im${i} Do Q`);
|
|
208
|
+
if (extra[i].caption) c.push(`BT /F1 10 Tf ${imgX} ${imgY - 94} Td (${escapePDFText(extra[i].caption)}) Tj ET`);
|
|
209
|
+
imgX += 136;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cId = addObj(streamRaw(c.join('\n')));
|
|
213
|
+
const xObj = [`/ImQR ${qrId} 0 R`, ...extraIds.map((id, i) => `/Im${i} ${id} 0 R`)].join(' ');
|
|
214
|
+
const rId = addObj(`<< /ProcSet [/PDF /ImageC] /Font << /F1 ${fontId} 0 R >> /XObject << ${xObj} >> >>`);
|
|
215
|
+
const pgId = addObj(`<< /Type /Page /Parent 0 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Resources ${rId} 0 R /Contents ${cId} 0 R >>`);
|
|
216
|
+
const psId = addObj(`<< /Type /Pages /Kids [${pgId} 0 R] /Count 1 >>`);
|
|
217
|
+
objs[pgId - 1] = `<< /Type /Page /Parent ${psId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Resources ${rId} 0 R /Contents ${cId} 0 R >>`;
|
|
218
|
+
addObj(`<< /Type /Catalog /Pages ${psId} 0 R >>`);
|
|
219
|
+
return assemblePDF(objs).pdfBytes;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Builds a PDF with a full-page background image + QR overlay, returns Uint8Array.
|
|
224
|
+
* Use this to upload to a server, test, or create a Blob manually.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} opts - Same as downloadPdfWithTemplateImage except no `fileName`.
|
|
227
|
+
* @returns {Promise<Uint8Array>}
|
|
228
|
+
*/
|
|
229
|
+
export async function buildPdfWithTemplateBytes({
|
|
230
|
+
svgEl,
|
|
231
|
+
templateSrc,
|
|
232
|
+
page = { width: 595.276, height: 841.890 },
|
|
233
|
+
qr = { x: 420, y: 130, size: 160 },
|
|
234
|
+
recodeBgToRgb = false,
|
|
235
|
+
signal,
|
|
236
|
+
}) {
|
|
237
|
+
signal?.throwIfAborted?.();
|
|
238
|
+
const pageW = page.width || 595.276, pageH = page.height || 841.890;
|
|
239
|
+
const bgImgEl = await loadImgNoCors(templateSrc);
|
|
240
|
+
let bgBytes = await fetchBytes(templateSrc, { signal });
|
|
241
|
+
if (recodeBgToRgb || !isJpeg(bgBytes)) bgBytes = await recodeToRgbJpeg(templateSrc, 0.92);
|
|
242
|
+
const bgSpec = colorSpaceDictForJpeg(bgBytes);
|
|
243
|
+
|
|
244
|
+
const qrPngUrl = await svgToPngDataURL(svgEl, 3);
|
|
245
|
+
const qrImgEl = await dataURLToImage(qrPngUrl);
|
|
246
|
+
const qrBytes = await pngDataURLtoJpegBytes(qrPngUrl, 0.92);
|
|
247
|
+
const qrSpec = colorSpaceDictForJpeg(qrBytes);
|
|
248
|
+
|
|
249
|
+
const objs = [], addObj = s => { objs.push(s); return objs.length; };
|
|
250
|
+
const bgId = addObj(streamObj({ dict: `/Type /XObject /Subtype /Image /Width ${bgImgEl.width} /Height ${bgImgEl.height} /ColorSpace ${bgSpec.cs}${bgSpec.decode} /BitsPerComponent 8 /Filter /DCTDecode /Length ${bgBytes.length}`, bytes: bgBytes }));
|
|
251
|
+
const qrId = addObj(streamObj({ dict: `/Type /XObject /Subtype /Image /Width ${qrImgEl.width} /Height ${qrImgEl.height} /ColorSpace ${qrSpec.cs}${qrSpec.decode} /BitsPerComponent 8 /Filter /DCTDecode /Length ${qrBytes.length}`, bytes: qrBytes }));
|
|
252
|
+
const cId = addObj(streamRaw(`q ${pageW} 0 0 ${pageH} 0 0 cm /ImBG Do Q\nq ${qr.size} 0 0 ${qr.size} ${qr.x} ${qr.y} cm /ImQR Do Q`));
|
|
253
|
+
const rId = addObj(`<< /ProcSet [/PDF /ImageC] /XObject << /ImBG ${bgId} 0 R /ImQR ${qrId} 0 R >> >>`);
|
|
254
|
+
const pgId = addObj(`<< /Type /Page /Parent 0 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Resources ${rId} 0 R /Contents ${cId} 0 R >>`);
|
|
255
|
+
const psId = addObj(`<< /Type /Pages /Kids [${pgId} 0 R] /Count 1 >>`);
|
|
256
|
+
objs[pgId - 1] = `<< /Type /Page /Parent ${psId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Resources ${rId} 0 R /Contents ${cId} 0 R >>`;
|
|
257
|
+
addObj(`<< /Type /Catalog /Pages ${psId} 0 R >>`);
|
|
258
|
+
return assemblePDF(objs).pdfBytes;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// -----------------------------
|
|
262
|
+
// PUBLIC API — Download wrappers (browser side-effects)
|
|
263
|
+
// -----------------------------
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generates a simple A4 PDF (title + optional text + QR code) and triggers a download.
|
|
267
|
+
*
|
|
268
|
+
* Note: text rendering uses the built-in Helvetica font — only ASCII characters
|
|
269
|
+
* are supported in text fields. For Cyrillic/non-Latin, use the image-based
|
|
270
|
+
* variant `downloadPdfWithTemplateImage` with a pre-rendered background.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} opts
|
|
273
|
+
* @param {SVGSVGElement} opts.svgEl
|
|
274
|
+
* @param {string} [opts.title]
|
|
275
|
+
* @param {string} [opts.org]
|
|
276
|
+
* @param {string} [opts.url]
|
|
277
|
+
* @param {string[]} [opts.notes]
|
|
278
|
+
* @param {Array<{src: string, caption?: string}>} [opts.images]
|
|
279
|
+
* @param {string} [opts.fileName='leaflet.pdf']
|
|
280
|
+
*/
|
|
281
|
+
export async function downloadQrPdf({
|
|
282
|
+
svgEl, title = 'Leaflet', org, url, notes = [], images = [], fileName = 'leaflet.pdf',
|
|
283
|
+
}) {
|
|
284
|
+
const bytes = await buildQrPdfBytes({ svgEl, title, org, url, notes, images });
|
|
285
|
+
downloadBytes(bytes, fileName);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @deprecated Use `downloadQrPdf` instead. */
|
|
289
|
+
export const downloadLeafletPDF = downloadQrPdf;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Generates a PDF with a full-page background image and QR overlay, then triggers a download.
|
|
293
|
+
*
|
|
294
|
+
* Background images are auto-converted to RGB JPEG if needed (PNG, WebP, CMYK JPEG).
|
|
295
|
+
* Set `recodeBgToRgb: true` to force this even for regular JPEGs (e.g. CMYK sources).
|
|
296
|
+
*
|
|
297
|
+
* @param {object} opts
|
|
298
|
+
* @param {SVGSVGElement} opts.svgEl
|
|
299
|
+
* @param {string} opts.templateSrc
|
|
300
|
+
* @param {{ width: number, height: number }} [opts.page] - Default: A4
|
|
301
|
+
* @param {{ x: number, y: number, size: number }} [opts.qr] - pt from bottom-left
|
|
302
|
+
* @param {string} [opts.fileName='leaflet.pdf']
|
|
303
|
+
* @param {boolean} [opts.recodeBgToRgb=false]
|
|
304
|
+
*/
|
|
305
|
+
export async function downloadPdfWithTemplateImage({
|
|
306
|
+
svgEl, templateSrc,
|
|
307
|
+
page = { width: 595.276, height: 841.890 },
|
|
308
|
+
qr = { x: 420, y: 130, size: 160 },
|
|
309
|
+
fileName = 'leaflet.pdf',
|
|
310
|
+
recodeBgToRgb = false,
|
|
311
|
+
}) {
|
|
312
|
+
const bytes = await buildPdfWithTemplateBytes({ svgEl, templateSrc, page, qr, recodeBgToRgb });
|
|
313
|
+
downloadBytes(bytes, fileName);
|
|
314
|
+
}
|