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 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[];