@jasy/pdf 1.0.0-alpha.4 → 1.0.0-alpha.6
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/README.md +5 -2
- package/dist/api/content.d.ts +9 -3
- package/dist/api/content.js +13 -3
- package/dist/api/descriptor.js +22 -1
- package/dist/api/dimension.d.ts +20 -0
- package/dist/api/dimension.js +16 -0
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +2 -0
- package/dist/api/layout.d.ts +65 -3
- package/dist/api/layout.js +71 -2
- package/dist/api/page-builder.d.ts +32 -0
- package/dist/api/page-builder.js +32 -0
- package/dist/api/structure.d.ts +19 -0
- package/dist/api/structure.js +25 -0
- package/dist/api/table.js +13 -5
- package/dist/api/text.d.ts +10 -1
- package/dist/api/text.js +32 -4
- package/dist/elements/container-element.d.ts +17 -1
- package/dist/elements/container-element.js +54 -22
- package/dist/elements/image-element.d.ts +23 -1
- package/dist/elements/image-element.js +54 -11
- package/dist/elements/index.d.ts +6 -0
- package/dist/elements/index.js +6 -0
- package/dist/elements/layout/anchor-element.d.ts +23 -0
- package/dist/elements/layout/anchor-element.js +27 -0
- package/dist/elements/layout/bookmark-element.d.ts +27 -0
- package/dist/elements/layout/bookmark-element.js +30 -0
- package/dist/elements/layout/link-element.d.ts +33 -0
- package/dist/elements/layout/link-element.js +39 -0
- package/dist/elements/layout/page-builder-element.d.ts +32 -0
- package/dist/elements/layout/page-builder-element.js +43 -0
- package/dist/elements/layout/rotated-box-element.d.ts +35 -0
- package/dist/elements/layout/rotated-box-element.js +54 -0
- package/dist/elements/layout/rotated-element.d.ts +32 -0
- package/dist/elements/layout/rotated-element.js +41 -0
- package/dist/elements/layout/struct-group.d.ts +28 -0
- package/dist/elements/layout/struct-group.js +42 -0
- package/dist/elements/page-element.d.ts +8 -0
- package/dist/elements/page-element.js +13 -1
- package/dist/elements/pdf-element.d.ts +35 -0
- package/dist/elements/pdf-element.js +34 -0
- package/dist/elements/rectangle-element.d.ts +11 -1
- package/dist/elements/rectangle-element.js +49 -13
- package/dist/elements/row-element.d.ts +11 -1
- package/dist/elements/row-element.js +37 -8
- package/dist/elements/text-element.d.ts +11 -1
- package/dist/elements/text-element.js +5 -2
- package/dist/ir/display-list.d.ts +137 -1
- package/dist/layout/box-constraints.d.ts +8 -0
- package/dist/layout/box-constraints.js +14 -0
- package/dist/layout/collect-images.d.ts +9 -0
- package/dist/layout/collect-images.js +34 -0
- package/dist/layout/fragmentation.js +9 -2
- package/dist/renderer/anchor-renderer.d.ts +6 -0
- package/dist/renderer/anchor-renderer.js +11 -0
- package/dist/renderer/bookmark-renderer.d.ts +6 -0
- package/dist/renderer/bookmark-renderer.js +11 -0
- package/dist/renderer/emoji-image.d.ts +9 -0
- package/dist/renderer/emoji-image.js +68 -0
- package/dist/renderer/image-renderer.d.ts +1 -1
- package/dist/renderer/image-renderer.js +10 -2
- package/dist/renderer/link-renderer.d.ts +6 -0
- package/dist/renderer/link-renderer.js +11 -0
- package/dist/renderer/page-builder-renderer.d.ts +6 -0
- package/dist/renderer/page-builder-renderer.js +11 -0
- package/dist/renderer/page-renderer.js +54 -4
- package/dist/renderer/pdf-backend.d.ts +16 -2
- package/dist/renderer/pdf-backend.js +135 -3
- package/dist/renderer/pdf-document-renderer.d.ts +16 -7
- package/dist/renderer/pdf-document-renderer.js +65 -33
- package/dist/renderer/pdf-renderer.js +58 -2
- package/dist/renderer/rotated-renderer.d.ts +7 -0
- package/dist/renderer/rotated-renderer.js +29 -0
- package/dist/renderer/struct-group-renderer.d.ts +10 -0
- package/dist/renderer/struct-group-renderer.js +24 -0
- package/dist/renderer/text-renderer.d.ts +3 -0
- package/dist/renderer/text-renderer.js +206 -5
- package/dist/text/emoji-codepoints.d.ts +1 -0
- package/dist/text/emoji-codepoints.js +29 -0
- package/dist/text/line-breaker.js +6 -3
- package/dist/utils/afm-parser.js +6 -4
- package/dist/utils/dest-registry.d.ts +19 -0
- package/dist/utils/dest-registry.js +32 -0
- package/dist/utils/flex-layout.d.ts +10 -3
- package/dist/utils/flex-layout.js +26 -6
- package/dist/utils/outline.d.ts +21 -0
- package/dist/utils/outline.js +80 -0
- package/dist/utils/pdf-object-manager.d.ts +26 -1
- package/dist/utils/pdf-object-manager.js +0 -0
- package/dist/utils/struct-tree.d.ts +44 -0
- package/dist/utils/struct-tree.js +130 -0
- package/dist/utils/ttf-parser.d.ts +73 -0
- package/dist/utils/ttf-parser.js +456 -16
- package/dist/utils/ua-xmp.d.ts +9 -0
- package/dist/utils/ua-xmp.js +32 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,8 +117,8 @@ const pdf = await renderToBytes(
|
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
Real text layout (Adobe AFM metrics, kerning, word-wrap), flexbox-style `gap` / `justify` / `align`,
|
|
120
|
-
boxes with radius and alpha, images, custom TrueType fonts,
|
|
121
|
-
overflows flows onto the next page, headers and footers repeat.
|
|
120
|
+
boxes with radius and alpha, images, custom TrueType fonts, **AES-256 password encryption**, and **real
|
|
121
|
+
pagination** - content that overflows flows onto the next page, headers and footers repeat.
|
|
122
122
|
|
|
123
123
|
---
|
|
124
124
|
|
|
@@ -132,6 +132,9 @@ verify all of it:
|
|
|
132
132
|
740 KB font ships as a ~76 KB subset, and the text stays copy- and searchable.
|
|
133
133
|
- **Compression.** Content streams and images are FlateDecode-compressed; the spreadsheets `jasy export`
|
|
134
134
|
writes are real `.xlsx` ZIPs we deflate with our own writer and CRC32, zero dependencies.
|
|
135
|
+
- **AES-256 encryption, pure JS.** Lock a PDF with `renderToBytes(doc, { encrypt: { userPassword } })` - the
|
|
136
|
+
newest standard handler (AES-256, R6 / ISO 32000-2) via the platform WebCrypto: no native crypto, no deps,
|
|
137
|
+
the same code in Node **and** the browser. Owner password + permissions optional.
|
|
135
138
|
- **Real font metrics.** Text is laid out with the Adobe AFM metrics of the standard-14 fonts - kerning
|
|
136
139
|
and word-wrap are _computed_, not guessed.
|
|
137
140
|
- **PDF/A-3, matched not approximated.** The conformance graph is hand-built and **passes veraPDF**, the
|
package/dist/api/content.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ImageElement, CustomImage } from "../elements/image-element.js";
|
|
|
2
2
|
import { PDFElement } from "../elements/pdf-element.js";
|
|
3
3
|
import { ColorInput } from "./color.js";
|
|
4
4
|
import { Insets } from "./insets.js";
|
|
5
|
+
import { SizeInput } from "./dimension.js";
|
|
5
6
|
/** A horizontal rule (locked §4). */
|
|
6
7
|
export interface DividerOptions {
|
|
7
8
|
/** Line color (default a light gray). */
|
|
@@ -22,12 +23,17 @@ export type ImageSource = string | Uint8Array | CustomImage;
|
|
|
22
23
|
/** How the image fills its box (locked §4). Mirrors CSS `object-fit`. */
|
|
23
24
|
export type ImageFit = "none" | "contain" | "cover" | "fill";
|
|
24
25
|
export interface ImageOptions {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
|
|
27
|
+
* space). Pin exactly ONE axis and the other follows the image's aspect ratio (CSS `height: auto`). */
|
|
28
|
+
width?: SizeInput;
|
|
29
|
+
height?: SizeInput;
|
|
30
|
+
/** Fit within the box (default `none`; `fill` when exactly one axis is pinned so it scales to fit). */
|
|
28
31
|
fit?: ImageFit;
|
|
29
32
|
/** Corner radius in points (rounds the image box). */
|
|
30
33
|
radius?: number;
|
|
34
|
+
/** Alternate text for accessibility (tagged PDF): describes the image for screen readers. With `alt`
|
|
35
|
+
* the image is a `Figure`; without it (and when rendered `accessible`) it counts as decoration. */
|
|
36
|
+
alt?: string;
|
|
31
37
|
}
|
|
32
38
|
/**
|
|
33
39
|
* An image. `src` is a local file path (wrapped in a `CustomLocalImage`) or a ready
|
package/dist/api/content.js
CHANGED
|
@@ -3,6 +3,7 @@ import { PaddingElement } from "../elements/layout/padding-element.js";
|
|
|
3
3
|
import { ImageElement, CustomLocalImage, CustomBytesImage, BoxFit, } from "../elements/image-element.js";
|
|
4
4
|
import { toColor } from "./color.js";
|
|
5
5
|
import { toEdges } from "./insets.js";
|
|
6
|
+
import { toDimension } from "./dimension.js";
|
|
6
7
|
const DEFAULT_DIVIDER_COLOR = "lightgray";
|
|
7
8
|
const DEFAULT_DIVIDER_MARGIN = { y: 6 };
|
|
8
9
|
/**
|
|
@@ -35,15 +36,24 @@ const FIT = {
|
|
|
35
36
|
* `CustomImage` for non-filesystem sources. Maps to an `ImageElement`.
|
|
36
37
|
*/
|
|
37
38
|
export function Image(src, opts = {}) {
|
|
39
|
+
const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
|
|
40
|
+
const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
|
|
41
|
+
// Exactly one axis pinned -> the other is derived from the aspect ratio, so scale the image to the
|
|
42
|
+
// resulting box (fit: fill). Both or neither pinned keeps the default fit (none).
|
|
43
|
+
const autoScale = (opts.width !== undefined) !== (opts.height !== undefined);
|
|
44
|
+
const fit = opts.fit ? FIT[opts.fit] : autoScale ? BoxFit.fill : undefined;
|
|
38
45
|
return new ImageElement({
|
|
39
46
|
image: typeof src === "string"
|
|
40
47
|
? new CustomLocalImage(src)
|
|
41
48
|
: src instanceof Uint8Array
|
|
42
49
|
? new CustomBytesImage(src)
|
|
43
50
|
: src,
|
|
44
|
-
width:
|
|
45
|
-
height:
|
|
46
|
-
|
|
51
|
+
width: w?.points,
|
|
52
|
+
height: h?.points,
|
|
53
|
+
widthFactor: w?.factor,
|
|
54
|
+
heightFactor: h?.factor,
|
|
55
|
+
fit,
|
|
47
56
|
radius: opts.radius,
|
|
57
|
+
alt: opts.alt,
|
|
48
58
|
});
|
|
49
59
|
}
|
package/dist/api/descriptor.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Text, Paragraph, span } from "./text.js";
|
|
2
|
-
import { Column, Row, Box, Padding, Spacer, Expanded, Positioned } from "./layout.js";
|
|
2
|
+
import { Column, Row, Box, Padding, Spacer, Expanded, Positioned, Link, Anchor, Bookmark, Rotated, RotatedBox, } from "./layout.js";
|
|
3
3
|
import { Divider, Image } from "./content.js";
|
|
4
4
|
import { Page, Document, DefaultTextStyle } from "./structure.js";
|
|
5
|
+
import { PageNumber, PageCount } from "./page-builder.js";
|
|
5
6
|
import { Table } from "./table.js";
|
|
6
7
|
// Element children (Column/Row/Box/Page/…) → built recursively. Text/Paragraph children are
|
|
7
8
|
// strings or `span` descriptors and become content instead.
|
|
@@ -50,6 +51,14 @@ function slotElement(node) {
|
|
|
50
51
|
const els = (node.children ?? []).map((c) => (typeof c === "string" ? Text(c) : build(c)));
|
|
51
52
|
return els.length === 1 ? els[0] : Column(els);
|
|
52
53
|
}
|
|
54
|
+
// The single child a wrapper takes (`Link`, `Anchor`, `Bookmark`, `Rotated`, ...). Several children are
|
|
55
|
+
// stacked in a Column, so `<JasyLink><Text/><Text/></JasyLink>` behaves like the factory would.
|
|
56
|
+
function wrappedChild(children) {
|
|
57
|
+
const els = children.map((c) => (typeof c === "string" ? Text(c) : build(c)));
|
|
58
|
+
if (els.length === 0)
|
|
59
|
+
throw new Error("A wrapper element needs exactly one child");
|
|
60
|
+
return els.length === 1 ? els[0] : Column(els);
|
|
61
|
+
}
|
|
53
62
|
const REGISTRY = {
|
|
54
63
|
document: (props, children) => {
|
|
55
64
|
const doc = Document(props, elementChildren(children));
|
|
@@ -89,6 +98,18 @@ const REGISTRY = {
|
|
|
89
98
|
},
|
|
90
99
|
positioned: (props, children) => Positioned(props, elementChildren(children)[0]),
|
|
91
100
|
"default-text-style": (props, children) => DefaultTextStyle(props, elementChildren(children)),
|
|
101
|
+
// Navigation. `Link` takes exactly one of `href` (a URL) or `to` (an `Anchor` name) - the factory
|
|
102
|
+
// enforces that, so a template typo surfaces as an error instead of a dead link.
|
|
103
|
+
link: (props, children) => Link(props, wrappedChild(children)),
|
|
104
|
+
anchor: (props, children) => Anchor(props, wrappedChild(children)),
|
|
105
|
+
bookmark: (props, children) => Bookmark(props, wrappedChild(children)),
|
|
106
|
+
// Transforms.
|
|
107
|
+
rotated: (props, children) => Rotated(props, wrappedChild(children)),
|
|
108
|
+
"rotated-box": (props, children) => RotatedBox(props, wrappedChild(children)),
|
|
109
|
+
// Page numbers. `PageBuilder` itself is deliberately NOT exposed: it takes a closure, which a template
|
|
110
|
+
// cannot express. These two cover what the closure exists for.
|
|
111
|
+
"page-number": (props) => PageNumber(props),
|
|
112
|
+
"page-count": (props) => PageCount(props),
|
|
92
113
|
};
|
|
93
114
|
/**
|
|
94
115
|
* Registers a custom element type, so a binding (or a user-defined component) can introduce
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size input for a Box's `width` / `height` (relative sizing). Either a number of PDF points (a
|
|
3
|
+
* fixed size) or a percentage string like `"50%"` - a fraction of the space the parent offers on
|
|
4
|
+
* that axis. Every form normalizes to a `Dimension` via `toDimension`, so the engine only ever sees
|
|
5
|
+
* points and factors, never a string. A percentage resolves against the parent's offered extent, so
|
|
6
|
+
* it only has meaning inside a bounded region (a fraction of an unbounded axis is a no-op).
|
|
7
|
+
*/
|
|
8
|
+
export type SizeInput = number | `${number}%`;
|
|
9
|
+
/** A resolved `SizeInput`: exactly one of an absolute point size or a 0..1 fraction of the parent. */
|
|
10
|
+
export interface Dimension {
|
|
11
|
+
/** Absolute size in PDF points. */
|
|
12
|
+
points?: number;
|
|
13
|
+
/** Fraction of the parent's offered extent on this axis; a `"50%"` input is `0.5`. */
|
|
14
|
+
factor?: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Normalizes a `SizeInput` to a `Dimension` (a point size or a fraction). Rejects negatives at this
|
|
18
|
+
* boundary so `resolveExtent` and the layout never see a negative size.
|
|
19
|
+
*/
|
|
20
|
+
export declare function toDimension(value: SizeInput): Dimension;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const PERCENT = /^(\d+(?:\.\d+)?)%$/; // no leading "-": a size cannot be negative
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes a `SizeInput` to a `Dimension` (a point size or a fraction). Rejects negatives at this
|
|
4
|
+
* boundary so `resolveExtent` and the layout never see a negative size.
|
|
5
|
+
*/
|
|
6
|
+
export function toDimension(value) {
|
|
7
|
+
if (typeof value === "number") {
|
|
8
|
+
if (value < 0)
|
|
9
|
+
throw new Error(`Invalid size ${value}: a size cannot be negative.`);
|
|
10
|
+
return { points: value };
|
|
11
|
+
}
|
|
12
|
+
const m = PERCENT.exec(value.trim());
|
|
13
|
+
if (m)
|
|
14
|
+
return { factor: parseFloat(m[1]) / 100 };
|
|
15
|
+
throw new Error(`Invalid size "${value}": use a number of points or a percentage like "50%".`);
|
|
16
|
+
}
|
package/dist/api/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from "./color.js";
|
|
2
2
|
export * from "./insets.js";
|
|
3
|
+
export * from "./dimension.js";
|
|
3
4
|
export * from "./text.js";
|
|
4
5
|
export * from "./layout.js";
|
|
5
6
|
export * from "./content.js";
|
|
7
|
+
export * from "./page-builder.js";
|
|
6
8
|
export * from "./structure.js";
|
|
7
9
|
export * from "./table.js";
|
|
8
10
|
export * from "./descriptor.js";
|
package/dist/api/index.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// core classes stay untouched and remain exported for power users.
|
|
4
4
|
export * from "./color.js";
|
|
5
5
|
export * from "./insets.js";
|
|
6
|
+
export * from "./dimension.js";
|
|
6
7
|
export * from "./text.js";
|
|
7
8
|
export * from "./layout.js";
|
|
8
9
|
export * from "./content.js";
|
|
10
|
+
export * from "./page-builder.js";
|
|
9
11
|
export * from "./structure.js";
|
|
10
12
|
export * from "./table.js";
|
|
11
13
|
export * from "./descriptor.js";
|
package/dist/api/layout.d.ts
CHANGED
|
@@ -4,10 +4,16 @@ import { RectangleElement } from "../elements/rectangle-element.js";
|
|
|
4
4
|
import { ExpandedElement } from "../elements/layout/expanded-element.js";
|
|
5
5
|
import { PaddingElement } from "../elements/layout/padding-element.js";
|
|
6
6
|
import { PositionedElement, PositionedInsets } from "../elements/layout/positioned-element.js";
|
|
7
|
+
import { LinkElement } from "../elements/layout/link-element.js";
|
|
8
|
+
import { BookmarkElement } from "../elements/layout/bookmark-element.js";
|
|
9
|
+
import { AnchorElement } from "../elements/layout/anchor-element.js";
|
|
10
|
+
import { RotatedElement } from "../elements/layout/rotated-element.js";
|
|
11
|
+
import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
|
|
7
12
|
import { PDFElement } from "../elements/pdf-element.js";
|
|
8
13
|
import { MainAlign, CrossAlign } from "../utils/flex-layout.js";
|
|
9
14
|
import { ColorInput } from "./color.js";
|
|
10
15
|
import { Insets } from "./insets.js";
|
|
16
|
+
import { SizeInput } from "./dimension.js";
|
|
11
17
|
/** Options shared by the `Column` and `Row` stacks (locked §4). */
|
|
12
18
|
export interface StackOptions {
|
|
13
19
|
/** Space inserted between children, in points. */
|
|
@@ -17,6 +23,11 @@ export interface StackOptions {
|
|
|
17
23
|
justify?: MainAlign;
|
|
18
24
|
/** Position across the axis (CSS `align-items`): start (default) · center · end · stretch. */
|
|
19
25
|
align?: CrossAlign;
|
|
26
|
+
/** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
|
|
27
|
+
* space). Omit to fill the offered width / shrink-wrap to the children. A percentage needs a bounded
|
|
28
|
+
* axis (`height: "50%"` only resolves where the parent bounds the height). */
|
|
29
|
+
width?: SizeInput;
|
|
30
|
+
height?: SizeInput;
|
|
20
31
|
}
|
|
21
32
|
/** A vertical stack. `Column(children)` or `Column(opts, children)`. */
|
|
22
33
|
export declare function Column(children: PDFElement[]): ContainerElement;
|
|
@@ -41,9 +52,11 @@ export interface BoxOptions {
|
|
|
41
52
|
bg?: ColorInput;
|
|
42
53
|
/** Inner padding between the border and the children. */
|
|
43
54
|
padding?: Insets;
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
/** Size on each axis: a number of points (fixed) or a percentage string like `"50%"` (a fraction
|
|
56
|
+
* of the space the parent offers on that axis). Omit `width` to fill the offered width, omit
|
|
57
|
+
* `height` to shrink-wrap the content. A percentage only resolves in a bounded region. */
|
|
58
|
+
width?: SizeInput;
|
|
59
|
+
height?: SizeInput;
|
|
47
60
|
/** Corner radius in points. */
|
|
48
61
|
radius?: number;
|
|
49
62
|
/** Make this box a positioning frame: `Positioned` children placed inside it resolve their
|
|
@@ -70,6 +83,55 @@ export declare function Padding(padding: Insets, child: PDFElement): PaddingElem
|
|
|
70
83
|
* end-aligned with a pixel offset. An edge wins over an anchor on the same axis.
|
|
71
84
|
*/
|
|
72
85
|
export declare function Positioned(opts: PositionedInsets, child: PDFElement): PositionedElement;
|
|
86
|
+
/**
|
|
87
|
+
* Makes any `child` a clickable hyperlink - the child's whole box is the clickable region (an image, a
|
|
88
|
+
* box, a row). Pass `href` for an external URL, or `to` for an internal jump to an `Anchor({ name })`
|
|
89
|
+
* elsewhere in the document (e.g. a clickable table-of-contents row). Exactly one of the two. The link
|
|
90
|
+
* draws nothing itself, so style the child - e.g. `Link({ href }, Text("jasy.dev", { color: "#1450aa" }))`
|
|
91
|
+
* or `Link({ to: "chapter-2" }, Text("Chapter 2 ....... 5"))`. For a link on part of a line, put `href`/
|
|
92
|
+
* `to` on a `span` instead (`Text([span("Visit "), span("jasy.dev", { href })])`), linking just that run.
|
|
93
|
+
*/
|
|
94
|
+
export declare function Link(opts: {
|
|
95
|
+
href?: string;
|
|
96
|
+
to?: string;
|
|
97
|
+
}, child: PDFElement): LinkElement;
|
|
98
|
+
/**
|
|
99
|
+
* Marks `child` as a named jump target that an internal `Link({ to: name })` can point at (a table of
|
|
100
|
+
* contents linking to sections, cross-references). Layout-transparent: `child` renders exactly as it
|
|
101
|
+
* would on its own, on whatever page it lands on. `name` must match the link's `to`.
|
|
102
|
+
* `Anchor({ name: "chapter-2" }, Text("Chapter 2", { size: 24 }))`.
|
|
103
|
+
*/
|
|
104
|
+
export declare function Anchor(opts: {
|
|
105
|
+
name: string;
|
|
106
|
+
}, child: PDFElement): AnchorElement;
|
|
107
|
+
/**
|
|
108
|
+
* Adds a bookmark to the document outline (the viewer's sidebar) that jumps to `child`. `title` is the
|
|
109
|
+
* label; `level` (1-based, default 1) nests it - a `level: 2` bookmark hangs under the nearest preceding
|
|
110
|
+
* `level: 1`, so you get a collapsible tree (chapters -> sections). It is layout-transparent: `child`
|
|
111
|
+
* renders exactly as it would on its own. `Bookmark({ title: "Chapter 2", level: 1 }, Text("Chapter 2"))`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function Bookmark(opts: {
|
|
114
|
+
title: string;
|
|
115
|
+
level?: number;
|
|
116
|
+
}, child: PDFElement): BookmarkElement;
|
|
117
|
+
/**
|
|
118
|
+
* Rotates `child` at PAINT time by `angle` degrees (clockwise), around its center - like CSS
|
|
119
|
+
* `transform: rotate()` / Flutter `Transform.rotate`. The child keeps its normal, unrotated layout box,
|
|
120
|
+
* so siblings do NOT reflow around the spun shape and the drawing may overflow its slot. Pair it with a
|
|
121
|
+
* `relative` Box + `Positioned` for a diagonal watermark or a "PAID" stamp laid over a document.
|
|
122
|
+
*/
|
|
123
|
+
export declare function Rotated(opts: {
|
|
124
|
+
angle: number;
|
|
125
|
+
}, child: PDFElement): RotatedElement;
|
|
126
|
+
/**
|
|
127
|
+
* Rotates `child` by whole quarter-turns and, unlike `Rotated`, is LAYOUT-AWARE: a 90 / 270 turn swaps
|
|
128
|
+
* the box's width and height so siblings reflow around it (a tall label becomes a narrow, tall strip
|
|
129
|
+
* that reserves exactly that footprint). `turns` = clockwise 90-degree steps (1 = 90, 2 = 180, 3 = 270).
|
|
130
|
+
* Use it for a vertical label beside a table; use `Rotated` for a free-angle stamp / watermark.
|
|
131
|
+
*/
|
|
132
|
+
export declare function RotatedBox(opts: {
|
|
133
|
+
turns: number;
|
|
134
|
+
}, child: PDFElement): RotatedBoxElement;
|
|
73
135
|
/**
|
|
74
136
|
* A flexible empty gap that pushes its siblings apart - `Row([a, Spacer(), b])` sends `a`
|
|
75
137
|
* and `b` to the edges. `flex` weights it against other flex children (default 1).
|
package/dist/api/layout.js
CHANGED
|
@@ -4,9 +4,21 @@ import { RectangleElement } from "../elements/rectangle-element.js";
|
|
|
4
4
|
import { ExpandedElement } from "../elements/layout/expanded-element.js";
|
|
5
5
|
import { PaddingElement } from "../elements/layout/padding-element.js";
|
|
6
6
|
import { PositionedElement } from "../elements/layout/positioned-element.js";
|
|
7
|
+
import { LinkElement } from "../elements/layout/link-element.js";
|
|
8
|
+
import { BookmarkElement } from "../elements/layout/bookmark-element.js";
|
|
9
|
+
import { AnchorElement } from "../elements/layout/anchor-element.js";
|
|
10
|
+
import { RotatedElement } from "../elements/layout/rotated-element.js";
|
|
11
|
+
import { RotatedBoxElement } from "../elements/layout/rotated-box-element.js";
|
|
7
12
|
import { toColor } from "./color.js";
|
|
8
13
|
import { toEdges } from "./insets.js";
|
|
14
|
+
import { toDimension } from "./dimension.js";
|
|
9
15
|
import { splitArgs } from "./args.js";
|
|
16
|
+
/** Splits `StackOptions` width/height into the fixed points + fraction the stack elements expect. */
|
|
17
|
+
function stackSize(opts) {
|
|
18
|
+
const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
|
|
19
|
+
const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
|
|
20
|
+
return { width: w?.points, height: h?.points, widthFactor: w?.factor, heightFactor: h?.factor };
|
|
21
|
+
}
|
|
10
22
|
// The public default for `cross` is `start` (locked §5) - i.e. don't stretch a child unless
|
|
11
23
|
// asked. The engine default is `stretch`; the factory sets the friendlier public one.
|
|
12
24
|
const DEFAULT_CROSS = "start";
|
|
@@ -19,6 +31,7 @@ export function Column(a, b) {
|
|
|
19
31
|
gap: opts.gap,
|
|
20
32
|
main: opts.justify, // undefined → engine default `start` (matches §5)
|
|
21
33
|
cross: opts.align ?? DEFAULT_CROSS,
|
|
34
|
+
...stackSize(opts),
|
|
22
35
|
});
|
|
23
36
|
}
|
|
24
37
|
export function Row(a, b) {
|
|
@@ -28,6 +41,7 @@ export function Row(a, b) {
|
|
|
28
41
|
gap: opts.gap,
|
|
29
42
|
main: opts.justify,
|
|
30
43
|
cross: opts.align ?? DEFAULT_CROSS,
|
|
44
|
+
...stackSize(opts),
|
|
31
45
|
});
|
|
32
46
|
}
|
|
33
47
|
export function Box(a, b) {
|
|
@@ -49,6 +63,9 @@ export function Box(a, b) {
|
|
|
49
63
|
return c !== undefined ? toColor(c) : undefined;
|
|
50
64
|
};
|
|
51
65
|
const hasBorder = opts.border !== undefined || opts.borderWidth !== undefined || hasPerSide;
|
|
66
|
+
// A point size fills `width`/`height`; a percentage fills `widthFactor`/`heightFactor`.
|
|
67
|
+
const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
|
|
68
|
+
const h = opts.height !== undefined ? toDimension(opts.height) : undefined;
|
|
52
69
|
return new RectangleElement({
|
|
53
70
|
x: 0,
|
|
54
71
|
y: 0,
|
|
@@ -56,8 +73,10 @@ export function Box(a, b) {
|
|
|
56
73
|
color: opts.border !== undefined ? toColor(opts.border) : undefined,
|
|
57
74
|
backgroundColor: opts.bg !== undefined ? toColor(opts.bg) : undefined,
|
|
58
75
|
borderWidth: hasBorder ? (opts.borderWidth ?? 1) : 0,
|
|
59
|
-
width:
|
|
60
|
-
height:
|
|
76
|
+
width: w?.points,
|
|
77
|
+
height: h?.points,
|
|
78
|
+
widthFactor: w?.factor,
|
|
79
|
+
heightFactor: h?.factor,
|
|
61
80
|
radius: opts.radius,
|
|
62
81
|
sideBorders: hasPerSide
|
|
63
82
|
? {
|
|
@@ -85,6 +104,56 @@ export function Padding(padding, child) {
|
|
|
85
104
|
export function Positioned(opts, child) {
|
|
86
105
|
return new PositionedElement({ child, ...opts });
|
|
87
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Makes any `child` a clickable hyperlink - the child's whole box is the clickable region (an image, a
|
|
109
|
+
* box, a row). Pass `href` for an external URL, or `to` for an internal jump to an `Anchor({ name })`
|
|
110
|
+
* elsewhere in the document (e.g. a clickable table-of-contents row). Exactly one of the two. The link
|
|
111
|
+
* draws nothing itself, so style the child - e.g. `Link({ href }, Text("jasy.dev", { color: "#1450aa" }))`
|
|
112
|
+
* or `Link({ to: "chapter-2" }, Text("Chapter 2 ....... 5"))`. For a link on part of a line, put `href`/
|
|
113
|
+
* `to` on a `span` instead (`Text([span("Visit "), span("jasy.dev", { href })])`), linking just that run.
|
|
114
|
+
*/
|
|
115
|
+
export function Link(opts, child) {
|
|
116
|
+
if ((opts.href === undefined) === (opts.to === undefined)) {
|
|
117
|
+
throw new Error("Link needs exactly one of `href` (external URL) or `to` (internal Anchor name).");
|
|
118
|
+
}
|
|
119
|
+
return new LinkElement({ href: opts.href, dest: opts.to, child });
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Marks `child` as a named jump target that an internal `Link({ to: name })` can point at (a table of
|
|
123
|
+
* contents linking to sections, cross-references). Layout-transparent: `child` renders exactly as it
|
|
124
|
+
* would on its own, on whatever page it lands on. `name` must match the link's `to`.
|
|
125
|
+
* `Anchor({ name: "chapter-2" }, Text("Chapter 2", { size: 24 }))`.
|
|
126
|
+
*/
|
|
127
|
+
export function Anchor(opts, child) {
|
|
128
|
+
return new AnchorElement({ name: opts.name, child });
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Adds a bookmark to the document outline (the viewer's sidebar) that jumps to `child`. `title` is the
|
|
132
|
+
* label; `level` (1-based, default 1) nests it - a `level: 2` bookmark hangs under the nearest preceding
|
|
133
|
+
* `level: 1`, so you get a collapsible tree (chapters -> sections). It is layout-transparent: `child`
|
|
134
|
+
* renders exactly as it would on its own. `Bookmark({ title: "Chapter 2", level: 1 }, Text("Chapter 2"))`.
|
|
135
|
+
*/
|
|
136
|
+
export function Bookmark(opts, child) {
|
|
137
|
+
return new BookmarkElement({ title: opts.title, level: opts.level, child });
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Rotates `child` at PAINT time by `angle` degrees (clockwise), around its center - like CSS
|
|
141
|
+
* `transform: rotate()` / Flutter `Transform.rotate`. The child keeps its normal, unrotated layout box,
|
|
142
|
+
* so siblings do NOT reflow around the spun shape and the drawing may overflow its slot. Pair it with a
|
|
143
|
+
* `relative` Box + `Positioned` for a diagonal watermark or a "PAID" stamp laid over a document.
|
|
144
|
+
*/
|
|
145
|
+
export function Rotated(opts, child) {
|
|
146
|
+
return new RotatedElement({ angle: opts.angle, child });
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Rotates `child` by whole quarter-turns and, unlike `Rotated`, is LAYOUT-AWARE: a 90 / 270 turn swaps
|
|
150
|
+
* the box's width and height so siblings reflow around it (a tall label becomes a narrow, tall strip
|
|
151
|
+
* that reserves exactly that footprint). `turns` = clockwise 90-degree steps (1 = 90, 2 = 180, 3 = 270).
|
|
152
|
+
* Use it for a vertical label beside a table; use `Rotated` for a free-angle stamp / watermark.
|
|
153
|
+
*/
|
|
154
|
+
export function RotatedBox(opts, child) {
|
|
155
|
+
return new RotatedBoxElement({ turns: opts.turns, child });
|
|
156
|
+
}
|
|
88
157
|
/**
|
|
89
158
|
* A flexible empty gap that pushes its siblings apart - `Row([a, Spacer(), b])` sends `a`
|
|
90
159
|
* and `b` to the edges. `flex` weights it against other flex children (default 1).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
|
|
2
|
+
import { PageInfo, PDFElement } from "../elements/pdf-element.js";
|
|
3
|
+
import { TextOptions } from "./text.js";
|
|
4
|
+
export type { PageInfo };
|
|
5
|
+
/**
|
|
6
|
+
* Builds content from the page it ends up on - this is how you show `pageNumber` / `pageCount`. Place it
|
|
7
|
+
* anywhere: in a `Page`'s `header`/`footer`, in the body, in a table cell, several times on one page.
|
|
8
|
+
* Because you write the closure, formatting and conditions are yours:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* PageBuilder(({ pageNumber, pageCount }) => Text(`Seite ${pageNumber} von ${pageCount}`))
|
|
12
|
+
* PageBuilder(({ pageNumber }) => (pageNumber === 1 ? Logo() : Text("Fortsetzung")))
|
|
13
|
+
* PageBuilder(({ pageNumber, pageCount }) => Text(`${pageNumber - 1} / ${pageCount - 1}`)) // cover page
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* `pageNumber` is 1-based and counts PHYSICAL pages (an overflowing `Page` contributes several);
|
|
17
|
+
* `pageCount` is the document total; `pageSize` is the media box in points.
|
|
18
|
+
*
|
|
19
|
+
* The numbers themselves are always exact. Two sizing caveats, both from the same chicken-and-egg (the
|
|
20
|
+
* content decides the count that the content displays): in the flowing BODY the box is reserved before the
|
|
21
|
+
* total is known, so a much wider final string can paint slightly past it; and a conditional header may
|
|
22
|
+
* SHRINK on later pages but must not GROW, because the body band was measured against the first build.
|
|
23
|
+
*/
|
|
24
|
+
export declare function PageBuilder(build: (info: PageInfo) => PDFElement): PageBuilderElement;
|
|
25
|
+
/** `Text` options plus an `offset` added to the number (e.g. `-1` to not count a cover page). */
|
|
26
|
+
export interface PageNumberOptions extends TextOptions {
|
|
27
|
+
offset?: number;
|
|
28
|
+
}
|
|
29
|
+
/** The current page number as text. Sugar for `PageBuilder` - same thing, one word. */
|
|
30
|
+
export declare function PageNumber({ offset, ...style }?: PageNumberOptions): PageBuilderElement;
|
|
31
|
+
/** The document's total page count as text. Sugar for `PageBuilder`. */
|
|
32
|
+
export declare function PageCount({ offset, ...style }?: PageNumberOptions): PageBuilderElement;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
|
|
2
|
+
import { Text } from "./text.js";
|
|
3
|
+
/**
|
|
4
|
+
* Builds content from the page it ends up on - this is how you show `pageNumber` / `pageCount`. Place it
|
|
5
|
+
* anywhere: in a `Page`'s `header`/`footer`, in the body, in a table cell, several times on one page.
|
|
6
|
+
* Because you write the closure, formatting and conditions are yours:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* PageBuilder(({ pageNumber, pageCount }) => Text(`Seite ${pageNumber} von ${pageCount}`))
|
|
10
|
+
* PageBuilder(({ pageNumber }) => (pageNumber === 1 ? Logo() : Text("Fortsetzung")))
|
|
11
|
+
* PageBuilder(({ pageNumber, pageCount }) => Text(`${pageNumber - 1} / ${pageCount - 1}`)) // cover page
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* `pageNumber` is 1-based and counts PHYSICAL pages (an overflowing `Page` contributes several);
|
|
15
|
+
* `pageCount` is the document total; `pageSize` is the media box in points.
|
|
16
|
+
*
|
|
17
|
+
* The numbers themselves are always exact. Two sizing caveats, both from the same chicken-and-egg (the
|
|
18
|
+
* content decides the count that the content displays): in the flowing BODY the box is reserved before the
|
|
19
|
+
* total is known, so a much wider final string can paint slightly past it; and a conditional header may
|
|
20
|
+
* SHRINK on later pages but must not GROW, because the body band was measured against the first build.
|
|
21
|
+
*/
|
|
22
|
+
export function PageBuilder(build) {
|
|
23
|
+
return new PageBuilderElement({ build });
|
|
24
|
+
}
|
|
25
|
+
/** The current page number as text. Sugar for `PageBuilder` - same thing, one word. */
|
|
26
|
+
export function PageNumber({ offset = 0, ...style } = {}) {
|
|
27
|
+
return PageBuilder(({ pageNumber }) => Text(String(pageNumber + offset), style));
|
|
28
|
+
}
|
|
29
|
+
/** The document's total page count as text. Sugar for `PageBuilder`. */
|
|
30
|
+
export function PageCount({ offset = 0, ...style } = {}) {
|
|
31
|
+
return PageBuilder(({ pageCount }) => Text(String(pageCount + offset), style));
|
|
32
|
+
}
|
package/dist/api/structure.d.ts
CHANGED
|
@@ -39,12 +39,25 @@ export declare function Page(children: PDFElement[]): PageElement;
|
|
|
39
39
|
export declare function Page(opts: PageOptions, children: PDFElement[]): PageElement;
|
|
40
40
|
/** Document options: PDF metadata plus the inheritable text defaults (font, size, color, lineHeight,
|
|
41
41
|
* align, bold/italic) every `Text` inherits unless it sets its own - Flutter's `DefaultTextStyle`. */
|
|
42
|
+
/** Where color emoji come from when a code point isn't in the current text font, so `Text("Hi 😅")`
|
|
43
|
+
* works in one string. Either a **font** (native vector, offline - recommended; register it in
|
|
44
|
+
* `fonts`) as a string name or `{ font }`, or an **image/CDN source** `{ url, format }` (react-pdf
|
|
45
|
+
* style: fetches `${url}${hexCodePoint}.${format}`, e.g. a Twemoji CDN). The font source is better
|
|
46
|
+
* (vector, no network); the image source exists for parity + as an escape hatch. */
|
|
47
|
+
export type EmojiSource = string | {
|
|
48
|
+
font: string;
|
|
49
|
+
} | {
|
|
50
|
+
url: string;
|
|
51
|
+
format?: "png";
|
|
52
|
+
};
|
|
42
53
|
export interface DocumentOptions extends TextDefaults {
|
|
43
54
|
/** PDF metadata. */
|
|
44
55
|
meta?: {
|
|
45
56
|
title?: string;
|
|
46
57
|
author?: string;
|
|
47
58
|
};
|
|
59
|
+
/** Color-emoji fallback: emoji code points not in the text font render from this source. */
|
|
60
|
+
emoji?: EmojiSource;
|
|
48
61
|
}
|
|
49
62
|
/** A single font file for `addFont`: a `.ttf` path (read on Node) or its raw bytes (e.g. a browser
|
|
50
63
|
* upload). */
|
|
@@ -125,6 +138,12 @@ export interface RenderOptions {
|
|
|
125
138
|
/** Encrypt the PDF with a password (AES-256, the newest standard). NOT compatible with PDF/A
|
|
126
139
|
* (ZUGFeRD invoices) - encrypting one throws, since PDF/A forbids encryption. */
|
|
127
140
|
encrypt?: EncryptOptions;
|
|
141
|
+
/** Emit an accessible, tagged PDF (structure tree for screen readers). */
|
|
142
|
+
accessible?: boolean;
|
|
143
|
+
/** Document language for accessibility, e.g. `"en-US"` / `"de-DE"` (default `"en-US"`). */
|
|
144
|
+
lang?: string;
|
|
145
|
+
/** Document title (used by accessible readers; also good metadata). */
|
|
146
|
+
title?: string;
|
|
128
147
|
}
|
|
129
148
|
/** Renders a `Document(...)` tree to the raw PDF string. */
|
|
130
149
|
export declare function renderPdf(doc: PDFDocumentElement, options?: RenderOptions): Promise<string>;
|
package/dist/api/structure.js
CHANGED
|
@@ -8,6 +8,7 @@ import { PDFDocument } from "../renderer/pdf-document-class.js";
|
|
|
8
8
|
import { FontStyle } from "../utils/pdf-object-manager.js";
|
|
9
9
|
import { getArrayBuffer } from "../utils/utf8-to-windows1252-encoder.js";
|
|
10
10
|
import { createSecurityHandler } from "../crypto/security-handler.js";
|
|
11
|
+
import { uaXmp } from "../utils/ua-xmp.js";
|
|
11
12
|
import { Column } from "./layout.js";
|
|
12
13
|
import { toEdges } from "./insets.js";
|
|
13
14
|
import { toTextStyleOverride } from "./text.js";
|
|
@@ -56,6 +57,8 @@ const docMeta = new WeakMap();
|
|
|
56
57
|
// Fonts registered via doc.addFont(...), kept beside the element (like meta) and registered on the
|
|
57
58
|
// object manager at render time. Path sources are read to bytes in addFont, so the map holds bytes.
|
|
58
59
|
const docFonts = new WeakMap();
|
|
60
|
+
// The document's color-emoji fallback source, kept beside the element and applied at render time.
|
|
61
|
+
const docEmoji = new WeakMap();
|
|
59
62
|
/** Reads any path sources to bytes, leaving bytes / families as-is. */
|
|
60
63
|
function resolveFontSource(source) {
|
|
61
64
|
const read = (s) => (typeof s === "string" ? readFileBytes(s) : s);
|
|
@@ -81,6 +84,8 @@ export function Document(a, b) {
|
|
|
81
84
|
});
|
|
82
85
|
if (opts.meta)
|
|
83
86
|
docMeta.set(doc, opts.meta);
|
|
87
|
+
if (opts.emoji)
|
|
88
|
+
docEmoji.set(doc, opts.emoji);
|
|
84
89
|
// Managed font registry: addFont registers, getFonts/hasFont query, render reads it (below).
|
|
85
90
|
const registry = new Map();
|
|
86
91
|
docFonts.set(doc, registry);
|
|
@@ -153,6 +158,14 @@ export async function renderPdf(doc, options) {
|
|
|
153
158
|
description: a.description,
|
|
154
159
|
});
|
|
155
160
|
}
|
|
161
|
+
// Color-emoji fallback (Document({ emoji })): a font (native vector) or a CDN/image source.
|
|
162
|
+
const emoji = docEmoji.get(doc);
|
|
163
|
+
if (typeof emoji === "string")
|
|
164
|
+
om.setEmojiFont(emoji);
|
|
165
|
+
else if (emoji && "font" in emoji)
|
|
166
|
+
om.setEmojiFont(emoji.font);
|
|
167
|
+
else if (emoji && "url" in emoji)
|
|
168
|
+
om.setEmojiImageSource(emoji.url, emoji.format ?? "png");
|
|
156
169
|
if (options?.xmp)
|
|
157
170
|
om.setXmpMetadata(options.xmp);
|
|
158
171
|
if (options?.outputIntent)
|
|
@@ -163,6 +176,18 @@ export async function renderPdf(doc, options) {
|
|
|
163
176
|
om.enableDocumentId();
|
|
164
177
|
if (securityHandler)
|
|
165
178
|
om.setSecurityHandler(securityHandler);
|
|
179
|
+
if (options?.accessible) {
|
|
180
|
+
om.struct.enabled = true;
|
|
181
|
+
if (options.lang)
|
|
182
|
+
om.struct.lang = options.lang;
|
|
183
|
+
// Fall back to the document's own title (Document({ meta })) when no explicit title is given.
|
|
184
|
+
const title = options.title ?? meta?.title;
|
|
185
|
+
if (title)
|
|
186
|
+
om.struct.title = title;
|
|
187
|
+
// Declare PDF/UA-1 in the XMP metadata, unless the caller supplied their own packet.
|
|
188
|
+
if (!options.xmp)
|
|
189
|
+
om.setXmpMetadata(uaXmp({ title }));
|
|
190
|
+
}
|
|
166
191
|
}
|
|
167
192
|
build() {
|
|
168
193
|
return doc;
|
package/dist/api/table.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BoxConstraints } from "../layout/box-constraints.js";
|
|
2
2
|
import { RepeatingHeaderElement } from "../elements/layout/repeating-header-element.js";
|
|
3
3
|
import { DeferredElement } from "../elements/layout/deferred-element.js";
|
|
4
|
+
import { StructGroup } from "../elements/layout/struct-group.js";
|
|
4
5
|
import { Column, Row, Box, Expanded, Padding } from "./layout.js";
|
|
5
6
|
import { Text } from "./text.js";
|
|
6
7
|
function asElement(c) {
|
|
@@ -52,8 +53,11 @@ function composeTable(opts, rows, columns) {
|
|
|
52
53
|
...(firstCol ? { borderLeft: cb } : {}),
|
|
53
54
|
};
|
|
54
55
|
// `ruled` rows get just a bottom line (the header underline / the table foot), without the full grid.
|
|
55
|
-
const wrap = (cell, col, firstRow, firstCol, ruled) => {
|
|
56
|
-
const
|
|
56
|
+
const wrap = (cell, col, firstRow, firstCol, ruled, isHeader) => {
|
|
57
|
+
const content = cellPadding !== undefined ? Padding(cellPadding, asElement(cell)) : asElement(cell);
|
|
58
|
+
// Tag the cell (TH for a header cell, else TD). StructGroup is layout-transparent - visually nothing
|
|
59
|
+
// changes; it only nests the cell's content under a table-cell element in the accessible structure tree.
|
|
60
|
+
const inner = new StructGroup({ role: isHeader ? "TH" : "TD", child: content });
|
|
57
61
|
// The border lives on the wrapper, which stretches to the row height (crisp lines).
|
|
58
62
|
const border = {
|
|
59
63
|
...borderFor(firstRow, firstCol),
|
|
@@ -68,14 +72,18 @@ function composeTable(opts, rows, columns) {
|
|
|
68
72
|
throw new Error(`Unsupported column width "${col}" - use a number of points or "<n>fr"`);
|
|
69
73
|
};
|
|
70
74
|
// align:stretch → equal-height cells, so a wrapping cell keeps the row's bottom rule straight.
|
|
71
|
-
const buildRow = (cells, firstRow, ruled = false
|
|
75
|
+
const buildRow = (cells, firstRow, ruled = false, isHeader = false) => new StructGroup({
|
|
76
|
+
role: "TR",
|
|
77
|
+
child: Row({ gap: colGap, align: "stretch" }, cells.map((cell, i) => wrap(cell, columns[i] ?? "1fr", firstRow, i === 0, ruled, isHeader))),
|
|
78
|
+
});
|
|
72
79
|
// The first row (which gets the top border) is the header if present, else body row 0.
|
|
73
80
|
// `rule` underlines the header and the last body row (the table foot).
|
|
74
81
|
const last = rows.length - 1;
|
|
75
82
|
const body = Column({ gap: rowGap }, rows.map((cells, idx) => buildRow(cells, !opts.header && idx === 0, rule !== undefined && idx === last)));
|
|
76
|
-
|
|
77
|
-
? new RepeatingHeaderElement(buildRow(opts.header, true, rule !== undefined), body, rowGap)
|
|
83
|
+
const table = opts.header
|
|
84
|
+
? new RepeatingHeaderElement(buildRow(opts.header, true, rule !== undefined, true), body, rowGap)
|
|
78
85
|
: body;
|
|
86
|
+
return new StructGroup({ role: "Table", child: table });
|
|
79
87
|
}
|
|
80
88
|
/**
|
|
81
89
|
* A `Column` of `Row`s. Fixed-point columns sit in a fixed-width `Box`, `"Nfr"` fractions
|