jsafa-pdf 0.1.1
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 +476 -0
- package/dist/capture/baseline.d.ts +16 -0
- package/dist/capture/box.d.ts +62 -0
- package/dist/capture/document.d.ts +62 -0
- package/dist/capture/fragments.d.ts +32 -0
- package/dist/capture/image.d.ts +28 -0
- package/dist/capture/link.d.ts +17 -0
- package/dist/capture/ready.d.ts +12 -0
- package/dist/capture/style.d.ts +32 -0
- package/dist/capture/text.d.ts +12 -0
- package/dist/capture/transform.d.ts +65 -0
- package/dist/emit/image.d.ts +14 -0
- package/dist/emit/page.d.ts +98 -0
- package/dist/emit/pdf.d.ts +14 -0
- package/dist/fonts/autodiscover.d.ts +23 -0
- package/dist/fonts/registry.d.ts +39 -0
- package/dist/geom/units.d.ts +19 -0
- package/dist/index.d.ts +38 -0
- package/dist/jsafa-pdf.js +37039 -0
- package/dist/jsafa-pdf.umd.js +97 -0
- package/dist/types.d.ts +27 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Safwan Murad
|
|
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,476 @@
|
|
|
1
|
+
# jsafa-pdf
|
|
2
|
+
|
|
3
|
+
Client-side HTML/CSS → PDF with **real, selectable, copyable text**. No server, no rasterization.
|
|
4
|
+
|
|
5
|
+
Drop in one script tag and call one function. Latin and Arabic both come out as real text you can
|
|
6
|
+
select, copy and search — not a picture of text.
|
|
7
|
+
|
|
8
|
+
```html
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/jsafa-pdf/dist/jsafa-pdf.umd.js"></script>
|
|
10
|
+
<script>
|
|
11
|
+
JsafaPDF.download('#invoice', 'invoice.pdf');
|
|
12
|
+
</script>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
That's the whole integration. No build step, no bundler, no framework.
|
|
16
|
+
|
|
17
|
+
That tag always fetches the newest release, which is what you want while trying it out. For a page
|
|
18
|
+
you actually ship, [pin a version](#install).
|
|
19
|
+
|
|
20
|
+
> **0.1.1** — the feature set below is implemented and tested on Chromium, Firefox and WebKit. The
|
|
21
|
+
> API may still shift before 1.0; [Limitations](#limitations) lists what isn't covered.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Contents
|
|
26
|
+
|
|
27
|
+
- [Why](#why)
|
|
28
|
+
- [Install](#install)
|
|
29
|
+
- [Quick start](#quick-start)
|
|
30
|
+
- [API reference](#api-reference)
|
|
31
|
+
- [Options](#options)
|
|
32
|
+
- [Fonts](#fonts)
|
|
33
|
+
- [Multiple pages](#multiple-pages)
|
|
34
|
+
- [Limitations](#limitations)
|
|
35
|
+
- [Development](#development)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
jsafa-pdf is built around one idea:
|
|
42
|
+
|
|
43
|
+
> **The browser has already laid out the document. Read it back — don't recompute it.**
|
|
44
|
+
|
|
45
|
+
`Range.getClientRects()` reports one rect per *rendered line box*, so the engine tells us exactly
|
|
46
|
+
where every line sits and where it broke. We never decide where a line breaks; we observe where it
|
|
47
|
+
broke, then transpile that into PDF operators.
|
|
48
|
+
|
|
49
|
+
Three things follow from that:
|
|
50
|
+
|
|
51
|
+
- **Text stays text.** Glyphs are drawn as glyphs with an embedded font, so the output is selectable,
|
|
52
|
+
searchable and small — not a picture of a page.
|
|
53
|
+
- **Your CSS is the layout engine.** Flexbox, grid, floats, bidi, `@font-face` — whatever the browser
|
|
54
|
+
renders is what gets measured, so there's no CSS subset to learn and no document to rewrite.
|
|
55
|
+
- **It runs in the page.** No print dialog, no server, no headless browser to operate.
|
|
56
|
+
|
|
57
|
+
The tradeoff is stated plainly in [Limitations](#limitations): this is a transpiler from computed
|
|
58
|
+
layout to PDF operators, not a second rendering engine, so effects that never produce measurable
|
|
59
|
+
geometry are the parts that don't come across.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
### CDN — nothing to download
|
|
66
|
+
|
|
67
|
+
```html
|
|
68
|
+
<script src="https://cdn.jsdelivr.net/npm/jsafa-pdf@0.1/dist/jsafa-pdf.umd.js"></script>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
unpkg works the same way: `https://unpkg.com/jsafa-pdf@0.1/dist/jsafa-pdf.umd.js`
|
|
72
|
+
|
|
73
|
+
Either defines the `JsafaPDF` global.
|
|
74
|
+
|
|
75
|
+
**Choosing a version.** The CDNs accept a full version, a range, or nothing at all:
|
|
76
|
+
|
|
77
|
+
| URL | Gets |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `jsafa-pdf@0.1` | the newest `0.1.x` — bug fixes, never a breaking change. **Recommended.** |
|
|
80
|
+
| `jsafa-pdf@0.1.1` | exactly that build, forever. Use when you need byte-identical output. |
|
|
81
|
+
| `jsafa-pdf` | the newest release of anything, breaking changes included. This is the tag at the top of this page. |
|
|
82
|
+
|
|
83
|
+
The unversioned form is the right shortcut for a first try, but it updates a live page without you
|
|
84
|
+
touching it. Before 1.0 the API can still shift between minor versions, so `@0.1` gives you fixes
|
|
85
|
+
automatically while keeping a breaking release from reaching users you did not ship it to.
|
|
86
|
+
|
|
87
|
+
### npm
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm install jsafa-pdf
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
import { download, registerFont, mm } from 'jsafa-pdf';
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Self-hosted
|
|
98
|
+
|
|
99
|
+
Serve `jsafa-pdf.umd.js` from your own origin — the file from the CDN, the npm package, or a
|
|
100
|
+
[GitHub release](https://github.com/Safwan-Murad/jsafa-pdf/releases):
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<script src="/js/jsafa-pdf.umd.js"></script>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### From source
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
git clone https://github.com/Safwan-Murad/jsafa-pdf.git
|
|
110
|
+
cd jsafa-pdf && npm install && npm run build
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Every route gives you both builds:
|
|
114
|
+
|
|
115
|
+
| File | Use |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `jsafa-pdf.umd.js` | `<script>` tag. Defines the `JsafaPDF` global. |
|
|
118
|
+
| `jsafa-pdf.js` | ESM, for bundlers. |
|
|
119
|
+
|
|
120
|
+
Type declarations (`.d.ts`) are emitted alongside them.
|
|
121
|
+
|
|
122
|
+
> The UMD build is ~519 KB gzipped. It carries a full PDF writer and font engine — subsetting,
|
|
123
|
+
> Identity-H encoding and OpenType shaping — which is what lets text stay real text.
|
|
124
|
+
|
|
125
|
+
> Serve your page over **http(s)**, not `file://`. On `file://` the browser blocks `fetch` and ES
|
|
126
|
+
> modules, so fonts and images cannot be embedded.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Quick start
|
|
131
|
+
|
|
132
|
+
Everything below is optional configuration. The minimum is one call:
|
|
133
|
+
|
|
134
|
+
```html
|
|
135
|
+
<style>
|
|
136
|
+
/* Declared once in CSS — jsafa-pdf finds and embeds it automatically. */
|
|
137
|
+
@font-face {
|
|
138
|
+
font-family: 'Noto Sans Arabic';
|
|
139
|
+
src: url('/fonts/NotoSansArabic-Regular.ttf') format('truetype');
|
|
140
|
+
}
|
|
141
|
+
#invoice { width: 420px; padding: 20px; border: 2px solid #cbd5e1; }
|
|
142
|
+
#invoice .rtl { direction: rtl; font-family: 'Noto Sans Arabic', serif; }
|
|
143
|
+
</style>
|
|
144
|
+
|
|
145
|
+
<div id="invoice">
|
|
146
|
+
<h2>Invoice #A-1042</h2>
|
|
147
|
+
<p>The quick brown fox jumps over the lazy dog.</p>
|
|
148
|
+
<p class="rtl">مرحبا بالعالم</p>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<button onclick="JsafaPDF.download('#invoice', 'invoice.pdf')">Download PDF</button>
|
|
152
|
+
|
|
153
|
+
<script src="https://cdn.jsdelivr.net/npm/jsafa-pdf@0.1/dist/jsafa-pdf.umd.js"></script>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
jsafa-pdf automatically waits for webfonts and images to load, discovers `@font-face` rules and
|
|
157
|
+
embeds them. See [examples/simple.html](./examples/simple.html).
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## API reference
|
|
162
|
+
|
|
163
|
+
All functions are properties of the `JsafaPDF` global (or named ESM exports).
|
|
164
|
+
|
|
165
|
+
`target` is always an **`Element` or a CSS selector string**.
|
|
166
|
+
|
|
167
|
+
### Producing a PDF
|
|
168
|
+
|
|
169
|
+
| Function | Returns | Description |
|
|
170
|
+
|---|---|---|
|
|
171
|
+
| `toPdf(target, options?)` | `Promise<Uint8Array>` | Raw PDF bytes. |
|
|
172
|
+
| `toBlob(target, options?)` | `Promise<Blob>` | PDF as an `application/pdf` Blob. |
|
|
173
|
+
| `download(target, filename?, options?)` | `Promise<void>` | Generates and saves the file. `filename` defaults to `"document.pdf"`. |
|
|
174
|
+
| `open(target, options?)` | `Promise<Window \| null>` | Opens the PDF in a new browser tab. |
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
// Bytes, for uploading or storing yourself
|
|
178
|
+
const bytes = await JsafaPDF.toPdf('#invoice');
|
|
179
|
+
await fetch('/api/save', { method: 'POST', body: bytes });
|
|
180
|
+
|
|
181
|
+
// Blob, e.g. to preview in an <iframe>
|
|
182
|
+
const blob = await JsafaPDF.toBlob(document.getElementById('invoice'));
|
|
183
|
+
iframe.src = URL.createObjectURL(blob);
|
|
184
|
+
|
|
185
|
+
// Save to the user's downloads
|
|
186
|
+
await JsafaPDF.download('#invoice', 'invoice-1042.pdf');
|
|
187
|
+
|
|
188
|
+
// Open in a new tab
|
|
189
|
+
await JsafaPDF.open('#invoice');
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Fonts
|
|
193
|
+
|
|
194
|
+
| Function | Returns | Description |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `registerFont({ family, src, weight?, style? })` | `Promise<void>` | Make a font embeddable. `src` is a URL, `ArrayBuffer` or `Uint8Array`. |
|
|
197
|
+
| `registerFontsFromCss(document?)` | `Promise<DiscoveredFont[]>` | Register every `@font-face` on the page. Runs automatically. |
|
|
198
|
+
| `registeredFamilies()` | `string[]` | Families currently available for embedding. |
|
|
199
|
+
| `clearFonts()` | `void` | Forget all registered fonts. |
|
|
200
|
+
| `clearDiscoveredFonts()` | `void` | Forget which `@font-face` rules were processed, so they are re-read. |
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
// Only needed when a font is NOT declared via @font-face
|
|
204
|
+
await JsafaPDF.registerFont({
|
|
205
|
+
family: 'Cairo',
|
|
206
|
+
src: '/fonts/Cairo-Bold.ttf',
|
|
207
|
+
weight: 700,
|
|
208
|
+
style: 'normal',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Or from a file the user picked
|
|
212
|
+
await JsafaPDF.registerFont({ family: 'Cairo', src: await file.arrayBuffer() });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Units and page sizes
|
|
216
|
+
|
|
217
|
+
| Function | Returns | Description |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `mm(n)` | `number` | Millimetres → PDF points. |
|
|
220
|
+
| `cm(n)` | `number` | Centimetres → points. |
|
|
221
|
+
| `inch(n)` | `number` | Inches → points. |
|
|
222
|
+
| `PAGE_SIZES` | `object` | `{ A3, A4, A5, Letter, Legal }`, each `[width, height]` in points. |
|
|
223
|
+
|
|
224
|
+
PDF points are 1/72 inch. `A4` is `[595.28, 841.89]`.
|
|
225
|
+
|
|
226
|
+
### Advanced
|
|
227
|
+
|
|
228
|
+
Not needed for ordinary use — these expose the pipeline for custom output or debugging.
|
|
229
|
+
|
|
230
|
+
| Function | Returns | Description |
|
|
231
|
+
|---|---|---|
|
|
232
|
+
| `waitForAssets(element)` | `Promise<void>` | Wait for webfonts and images. Runs automatically. |
|
|
233
|
+
| `captureElement(element)` | `CapturedDocument` | Read layout into a paint list without producing a PDF. |
|
|
234
|
+
| `extractTextRuns(textNode)` | `TextRun[]` | Line- and direction-split runs for one text node. |
|
|
235
|
+
| `planPages(captured, pageOptions)` | `PlannedPage[]` | Slice a captured document into pages. |
|
|
236
|
+
| `emitPdf(captured, options?)` | `Promise<Uint8Array>` | Render an already-captured document. |
|
|
237
|
+
| `clearBaselineCache()` | `void` | Drop memoised font metrics after swapping webfonts. |
|
|
238
|
+
| `pxToPt(px)` / `ptToPx(pt)` | `number` | CSS px ↔ PDF points (× 0.75). |
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
// Inspect what was captured, without generating anything
|
|
242
|
+
const captured = JsafaPDF.captureElement('#invoice');
|
|
243
|
+
console.log(captured.runs.map(r => `${r.direction} "${r.text}"`));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Options
|
|
249
|
+
|
|
250
|
+
Every producing function accepts the same options object.
|
|
251
|
+
|
|
252
|
+
| Option | Type | Default | Description |
|
|
253
|
+
|---|---|---|---|
|
|
254
|
+
| `page` | `PageOptions` | *(none)* | Paginate onto fixed pages. Omit for a single page sized to the element. |
|
|
255
|
+
| `autoFonts` | `boolean` | `true` | Discover and register `@font-face` fonts automatically. |
|
|
256
|
+
| `waitForAssets` | `boolean` | `true` | Wait for fonts and images before reading layout. |
|
|
257
|
+
| `subset` | `boolean` | `true` | Embed only the glyphs used. Smaller files; unsupported by a few font files. |
|
|
258
|
+
| `title` | `string` | *(none)* | PDF document title metadata. |
|
|
259
|
+
|
|
260
|
+
### `PageOptions`
|
|
261
|
+
|
|
262
|
+
| Option | Type | Default | Description |
|
|
263
|
+
|---|---|---|---|
|
|
264
|
+
| `size` | `'A3' \| 'A4' \| 'A5' \| 'Letter' \| 'Legal' \| [w, h]` | `'A4'` | Named size, or explicit `[width, height]` in points. |
|
|
265
|
+
| `orientation` | `'portrait' \| 'landscape'` | `'portrait'` | |
|
|
266
|
+
| `margin` | `number \| { top, right, bottom, left }` | `mm(12)` | In points. Use `mm()` / `cm()` / `inch()`. |
|
|
267
|
+
| `fit` | `'width' \| 'none'` | `'width'` | `width` scales the element to the content width. `none` keeps 1px = 0.75pt. |
|
|
268
|
+
| `avoidBreakInside` | `boolean` | `true` | Move a page boundary above a `break-inside: avoid` element rather than cutting it. |
|
|
269
|
+
| `repeatTableHeaders` | `boolean` | `true` | Repeat `<thead>` at the top of continuation pages. |
|
|
270
|
+
| `footer` | `FooterOptions` | *(none)* | Text drawn in the bottom margin. See below. |
|
|
271
|
+
|
|
272
|
+
### Page numbers and footers
|
|
273
|
+
|
|
274
|
+
```js
|
|
275
|
+
page: {
|
|
276
|
+
size: 'A4',
|
|
277
|
+
footer: {
|
|
278
|
+
text: ({ page, pages }) => `Page ${page} of ${pages}`,
|
|
279
|
+
align: 'center', // 'left' | 'center' | 'right'
|
|
280
|
+
fontSize: 9, // points
|
|
281
|
+
color: '#607d8b', // any CSS colour
|
|
282
|
+
fontFamily: 'Inter', // only needed for a non-Latin footer
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Table pagination
|
|
288
|
+
|
|
289
|
+
Two hints your print stylesheet probably already sets are read automatically:
|
|
290
|
+
|
|
291
|
+
| CSS | effect |
|
|
292
|
+
|---|---|
|
|
293
|
+
| `tr { break-inside: avoid }` (or `page-break-inside`) | the boundary moves **above** the row instead of cutting through it |
|
|
294
|
+
| `thead { display: table-header-group }` | the header **repeats** on every continuation page, with space reserved for it |
|
|
295
|
+
|
|
296
|
+
A region taller than a whole page is still cut — honouring it would leave no legal break anywhere.
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
await JsafaPDF.download('#report', 'report.pdf', {
|
|
300
|
+
page: {
|
|
301
|
+
size: 'A4',
|
|
302
|
+
orientation: 'landscape',
|
|
303
|
+
margin: { top: JsafaPDF.mm(20), right: JsafaPDF.mm(15),
|
|
304
|
+
bottom: JsafaPDF.mm(20), left: JsafaPDF.mm(15) },
|
|
305
|
+
},
|
|
306
|
+
title: 'Quarterly Report',
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Fonts
|
|
313
|
+
|
|
314
|
+
**This is the one constraint worth understanding up front.**
|
|
315
|
+
|
|
316
|
+
JavaScript cannot read arbitrary system font files. A font can only be embedded if its bytes are
|
|
317
|
+
reachable, which means one of:
|
|
318
|
+
|
|
319
|
+
1. **`@font-face` in your CSS** — found and embedded automatically. *Recommended.*
|
|
320
|
+
2. **`registerFont()`** — for fonts loaded some other way.
|
|
321
|
+
|
|
322
|
+
Fonts you don't provide fall back to the closest PDF standard font (serif → Times, sans-serif →
|
|
323
|
+
Helvetica, monospace → Courier). A document set in Georgia will render as Times.
|
|
324
|
+
|
|
325
|
+
> **Non-Latin scripts must be provided.** The 14 standard PDF fonts contain **no Arabic, CJK, Cyrillic
|
|
326
|
+
> or Greek glyphs at all.** If your document has Arabic and you haven't supplied a font that covers
|
|
327
|
+
> it, jsafa-pdf throws an error naming the family rather than silently producing a broken PDF.
|
|
328
|
+
|
|
329
|
+
So if your document uses Arial or Segoe UI, self-host them:
|
|
330
|
+
|
|
331
|
+
```css
|
|
332
|
+
@font-face { font-family: 'Inter'; src: url('/fonts/Inter.ttf') format('truetype'); }
|
|
333
|
+
body { font-family: 'Inter', sans-serif; }
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Prefer **TTF or OTF**. WOFF works; WOFF2 is brotli-compressed and may fail to parse — if your CSS
|
|
337
|
+
offers several formats, jsafa-pdf picks the most compatible one automatically.
|
|
338
|
+
|
|
339
|
+
Font fallback follows the browser's **per-glyph** behaviour: `font-family: 'Noto Sans Arabic', serif`
|
|
340
|
+
paints Arabic with Noto and Latin with serif, exactly as on screen.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Links and transforms
|
|
345
|
+
|
|
346
|
+
**`<a href>` becomes a clickable link annotation.** Nothing to configure. Relative URLs are resolved
|
|
347
|
+
to absolute, so they still work from a PDF opened anywhere. A link that wraps across lines gets one
|
|
348
|
+
annotation per line. `javascript:` and bare `#fragment` targets are skipped — they would do nothing
|
|
349
|
+
in a reader.
|
|
350
|
+
|
|
351
|
+
**CSS `transform` is supported**, including `rotate`, `scale`, `translate` and nested combinations.
|
|
352
|
+
Transformed text stays a single selectable run rather than fragmenting into one run per character.
|
|
353
|
+
This works because transforms are paint-time only: capture suspends them, reads clean geometry, and
|
|
354
|
+
re-applies each as a PDF matrix.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Multiple pages
|
|
359
|
+
|
|
360
|
+
Pass `page` to paginate. Omit it and you get a single page sized exactly to the element — ideal for
|
|
361
|
+
a ticket, badge or label.
|
|
362
|
+
|
|
363
|
+
```js
|
|
364
|
+
await JsafaPDF.download('#statement', 'statement.pdf', {
|
|
365
|
+
page: { size: 'A4', margin: JsafaPDF.mm(12) }
|
|
366
|
+
});
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Pages are produced by **slicing** the captured layout, not re-flowing it, so line breaks stay exactly
|
|
370
|
+
where the browser put them. Text is assigned whole-line to a single page (a line may overhang into
|
|
371
|
+
the margin rather than be cut in half), and backgrounds, borders and images continue across breaks,
|
|
372
|
+
clipped to each page.
|
|
373
|
+
|
|
374
|
+
See [examples/paginate.html](./examples/paginate.html).
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## Limitations
|
|
379
|
+
|
|
380
|
+
Honest list. jsafa-pdf is not a browser.
|
|
381
|
+
|
|
382
|
+
**Not implemented yet**
|
|
383
|
+
|
|
384
|
+
- `box-shadow`, `text-shadow`
|
|
385
|
+
- `background-image` and CSS gradients
|
|
386
|
+
- Running headers (footers and page numbers *are* supported)
|
|
387
|
+
- `break-before` / `break-after`, widow and orphan control — these need layout re-flowed, which
|
|
388
|
+
slicing deliberately does not do. `break-inside: avoid` and repeating `<thead>` **are** honoured
|
|
389
|
+
|
|
390
|
+
**Inherent**
|
|
391
|
+
|
|
392
|
+
- Fonts must be reachable as bytes (see [Fonts](#fonts))
|
|
393
|
+
- Cross-origin images need CORS, or their pixels cannot be read
|
|
394
|
+
- Exotic CSS (filters, blend modes) may not reproduce exactly
|
|
395
|
+
- Serve over http(s); `file://` blocks the fetches this relies on
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Development
|
|
400
|
+
|
|
401
|
+
Requires **Node 20+**.
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
npm install
|
|
405
|
+
npm run build
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
npm run example
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Then open **http://localhost:5174/examples/simple.html**.
|
|
413
|
+
|
|
414
|
+
| Example | |
|
|
415
|
+
|---|---|
|
|
416
|
+
| [simple.html](./examples/simple.html) | Minimal zero-config usage |
|
|
417
|
+
| [verify.html](./examples/verify.html) | Correctness suite — re-opens generated PDFs with pdf.js and checks the text layer, path geometry and pixel colours |
|
|
418
|
+
| [paginate.html](./examples/paginate.html) | Multi-page output, checked for content loss and duplication |
|
|
419
|
+
|
|
420
|
+
### Tests
|
|
421
|
+
|
|
422
|
+
The suite runs in **real browsers** — jsdom has no layout engine, so it would pass while proving
|
|
423
|
+
nothing — and on **all three engines**, because capture reads layout back out of the engine and a
|
|
424
|
+
single-engine run proves very little.
|
|
425
|
+
|
|
426
|
+
**30 tests × Chromium, Firefox, WebKit = 90**, covering line-box and bidi capture, backgrounds,
|
|
427
|
+
borders, `border-radius`, images, `object-fit`, links, transforms, slicing, `break-inside`, repeated
|
|
428
|
+
headers and footers. Generated PDFs are re-opened with pdf.js and asserted against the DOM they came
|
|
429
|
+
from. Fixtures use standard PDF fonts and images encoded at runtime, so nothing needs a font file or
|
|
430
|
+
the network.
|
|
431
|
+
|
|
432
|
+
First fetch the browser binaries (once):
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
npx playwright install chromium firefox webkit
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
npm test
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
CI runs the same thing on every push — see [.github/workflows/ci.yml](./.github/workflows/ci.yml).
|
|
443
|
+
|
|
444
|
+
`npm run dev` serves the internal capture harness at http://localhost:5173. It must not be used to
|
|
445
|
+
serve the examples: Vite transforms what it serves and would rewrite `dist/` into a broken stub.
|
|
446
|
+
|
|
447
|
+
### Releasing
|
|
448
|
+
|
|
449
|
+
Bump `version` in `package.json`, then tag it:
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
git tag v0.1.1 && git push origin v0.1.1
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
That runs [.github/workflows/release.yml](./.github/workflows/release.yml): it refuses to continue if
|
|
456
|
+
the tag disagrees with `package.json`, runs typecheck, build and the full three-engine suite, then
|
|
457
|
+
creates the GitHub release with `jsafa-pdf.umd.js` and `jsafa-pdf.js` attached.
|
|
458
|
+
|
|
459
|
+
Publishing to npm stays manual, since it is the one step that cannot be undone:
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
npm publish --access public
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
`prepublishOnly` re-runs typecheck and build first, so a stale `dist/` cannot ship.
|
|
466
|
+
|
|
467
|
+
`dist/` is intentionally not committed — minified bundles diff badly and would grow the repository by
|
|
468
|
+
roughly its own size on every build. The CDN and release assets serve that need instead.
|
|
469
|
+
|
|
470
|
+
See [DESIGN.md](./DESIGN.md) for architecture and rationale.
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## License
|
|
475
|
+
|
|
476
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where is the text baseline inside a line box?
|
|
3
|
+
*
|
|
4
|
+
* We need this to place text in the PDF, and it must be identical across Chrome,
|
|
5
|
+
* Firefox and Safari. Canvas `TextMetrics.fontBoundingBoxAscent` is unusable for
|
|
6
|
+
* that: Firefox doesn't implement it, and Chrome and Safari disagree.
|
|
7
|
+
*
|
|
8
|
+
* So we ask the engine instead. A zero-size inline-block with
|
|
9
|
+
* `vertical-align: baseline` sits exactly on the baseline without perturbing
|
|
10
|
+
* layout, so its top edge *is* the baseline. Comparing that against the Range
|
|
11
|
+
* rect for adjacent text gives the ascent as a fraction of the run's height —
|
|
12
|
+
* a constant for a given font + size, so it's measured once and cached.
|
|
13
|
+
*/
|
|
14
|
+
export declare function baselineRatio(style: CSSStyleDeclaration): number;
|
|
15
|
+
/** Clear memoised metrics. Call after swapping webfonts. */
|
|
16
|
+
export declare function clearBaselineCache(): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Rgba } from './style';
|
|
2
|
+
import type { Rect } from '../types';
|
|
3
|
+
export interface BorderEdge {
|
|
4
|
+
/** Width in CSS px. Zero means no edge. */
|
|
5
|
+
width: number;
|
|
6
|
+
color: Rgba;
|
|
7
|
+
}
|
|
8
|
+
/** Corner radii in CSS px, clockwise from top-left. */
|
|
9
|
+
export type Radii = [number, number, number, number];
|
|
10
|
+
/** An element's painted box: background fill and border strokes. */
|
|
11
|
+
export interface BoxPaint {
|
|
12
|
+
/** Border box, in CSS px relative to the captured root. */
|
|
13
|
+
rect: Rect;
|
|
14
|
+
background: Rgba | null;
|
|
15
|
+
radii: Radii;
|
|
16
|
+
top: BorderEdge;
|
|
17
|
+
right: BorderEdge;
|
|
18
|
+
bottom: BorderEdge;
|
|
19
|
+
left: BorderEdge;
|
|
20
|
+
/** True when all four edges share a width and colour — strokeable as one path. */
|
|
21
|
+
uniformBorder: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Corner radii for an element, already clamped to its box. */
|
|
24
|
+
export declare function readRadii(element: Element, style?: CSSStyleDeclaration): Radii;
|
|
25
|
+
/**
|
|
26
|
+
* Read an element's paintable box, or null when there is nothing to draw.
|
|
27
|
+
*
|
|
28
|
+
* Most elements in a document have neither a background nor a border, so
|
|
29
|
+
* returning null keeps the paint list to what actually produces marks.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readBox(element: Element, origin: DOMRect): BoxPaint | null;
|
|
32
|
+
/** One path segment, in CSS px, y-down. */
|
|
33
|
+
export type PathSegment = {
|
|
34
|
+
type: 'M' | 'L';
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
} | {
|
|
38
|
+
type: 'C';
|
|
39
|
+
x1: number;
|
|
40
|
+
y1: number;
|
|
41
|
+
x2: number;
|
|
42
|
+
y2: number;
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Geometry for a rounded rectangle, in CSS px, y-down.
|
|
48
|
+
*
|
|
49
|
+
* Kept as segments rather than a string because the same shape is needed both
|
|
50
|
+
* as an SVG path (for fills and strokes) and as PDF path operators in point
|
|
51
|
+
* space (for clipping), and duplicating the bezier maths across the two is how
|
|
52
|
+
* they drift apart.
|
|
53
|
+
*
|
|
54
|
+
* `inset` pulls the path inwards, which is how border strokes stay inside the
|
|
55
|
+
* border box: CSS draws borders within the box, but PDF centres a stroke on its
|
|
56
|
+
* path, so the path must sit half a border-width in.
|
|
57
|
+
*/
|
|
58
|
+
export declare function roundedRectSegments(rect: Rect, radii: Radii, inset?: number): PathSegment[];
|
|
59
|
+
/** Serialise segments as an SVG path string, in CSS px, y-down. */
|
|
60
|
+
export declare function segmentsToSvgPath(segments: PathSegment[]): string;
|
|
61
|
+
/** Convenience: rounded rectangle as an SVG path in CSS px, y-down. */
|
|
62
|
+
export declare function roundedRectPath(rect: Rect, radii: Radii, inset?: number): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type RunStyle } from './style';
|
|
2
|
+
import { type BoxPaint } from './box';
|
|
3
|
+
import { type ImagePaint } from './image';
|
|
4
|
+
import { type LinkPaint } from './link';
|
|
5
|
+
import { type Region, type RepeatHeader } from './fragments';
|
|
6
|
+
import { type Affine } from './transform';
|
|
7
|
+
import type { TextRun } from '../types';
|
|
8
|
+
/** A text run with everything the emitter needs, in container-relative px. */
|
|
9
|
+
export interface CapturedRun extends TextRun {
|
|
10
|
+
style: RunStyle;
|
|
11
|
+
/** Baseline offset from the top of the run's box, in CSS px. */
|
|
12
|
+
baselineOffset: number;
|
|
13
|
+
}
|
|
14
|
+
/** One mark to make, in paint order. */
|
|
15
|
+
export type PaintOp = {
|
|
16
|
+
transform: Affine | null;
|
|
17
|
+
/** The element this mark came from, so the paginator can group by subtree. */
|
|
18
|
+
element: Element;
|
|
19
|
+
} & ({
|
|
20
|
+
kind: 'box';
|
|
21
|
+
box: BoxPaint;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'image';
|
|
24
|
+
image: ImagePaint;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'link';
|
|
27
|
+
link: LinkPaint;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'text';
|
|
30
|
+
run: CapturedRun;
|
|
31
|
+
});
|
|
32
|
+
export interface CapturedDocument {
|
|
33
|
+
/** Page box, in CSS px, matching the captured element. */
|
|
34
|
+
widthPx: number;
|
|
35
|
+
heightPx: number;
|
|
36
|
+
/** Everything to paint, in document order. */
|
|
37
|
+
ops: PaintOp[];
|
|
38
|
+
/** Convenience view of the text ops. */
|
|
39
|
+
runs: CapturedRun[];
|
|
40
|
+
/** Spans a page boundary must not cut through. */
|
|
41
|
+
unbreakable: Region[];
|
|
42
|
+
/** Table headers to repeat at the top of continuation pages. */
|
|
43
|
+
repeatHeaders: RepeatHeader[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Accept either an element or a CSS selector.
|
|
47
|
+
*
|
|
48
|
+
* Every public entry point takes a target the same way; having one of them
|
|
49
|
+
* quietly refuse selectors is the kind of inconsistency that costs a debugging
|
|
50
|
+
* session for no reason.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveTarget(target: Element | string): Element;
|
|
53
|
+
/**
|
|
54
|
+
* Read everything paintable out of `root`, with coordinates relative to `root`'s
|
|
55
|
+
* own top-left rather than the viewport — so the result is independent of scroll
|
|
56
|
+
* position and of where the element sits on the page.
|
|
57
|
+
*
|
|
58
|
+
* Ops come out in document order. Because an element precedes its descendants,
|
|
59
|
+
* a parent's background is painted before the text inside it, which is the
|
|
60
|
+
* painter's order a browser uses for ordinary flow content.
|
|
61
|
+
*/
|
|
62
|
+
export declare function captureElement(target: Element | string): CapturedDocument;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination hints read from the document.
|
|
3
|
+
*
|
|
4
|
+
* Slicing cannot re-flow, but it *can* choose where to cut. Reading the same
|
|
5
|
+
* hints the browser's own print path uses — `break-inside: avoid` and
|
|
6
|
+
* `display: table-header-group` — lets page boundaries land between rows instead
|
|
7
|
+
* of through them, and lets a table's header follow it onto later pages.
|
|
8
|
+
*/
|
|
9
|
+
/** A vertical span in CSS px, relative to the captured root. */
|
|
10
|
+
export interface Region {
|
|
11
|
+
top: number;
|
|
12
|
+
bottom: number;
|
|
13
|
+
}
|
|
14
|
+
export interface RepeatHeader {
|
|
15
|
+
region: Region;
|
|
16
|
+
element: Element;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Regions a page boundary must not pass through.
|
|
20
|
+
*
|
|
21
|
+
* Nested candidates are kept as-is: the planner walks them all and only needs to
|
|
22
|
+
* know whether *some* region straddles a proposed cut.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readUnbreakable(root: Element, origin: DOMRect): Region[];
|
|
25
|
+
/**
|
|
26
|
+
* Table headers that should repeat on continuation pages.
|
|
27
|
+
*
|
|
28
|
+
* `display: table-header-group` is exactly what `<thead>` computes to, so this
|
|
29
|
+
* picks up the intent already expressed in ordinary print stylesheets without a
|
|
30
|
+
* library-specific attribute.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readRepeatHeaders(root: Element, origin: DOMRect): RepeatHeader[];
|