@jasy/pdf 1.0.0-alpha.8 → 1.0.0-alpha.9

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.
Files changed (97) hide show
  1. package/README.md +4 -3
  2. package/dist/api/content.d.ts +5 -4
  3. package/dist/api/content.js +13 -7
  4. package/dist/api/dimension.d.ts +56 -0
  5. package/dist/api/dimension.js +27 -0
  6. package/dist/api/font-url.d.ts +28 -0
  7. package/dist/api/font-url.js +134 -0
  8. package/dist/api/gradient.d.ts +53 -0
  9. package/dist/api/gradient.js +97 -0
  10. package/dist/api/index.d.ts +2 -0
  11. package/dist/api/index.js +2 -0
  12. package/dist/api/insets.d.ts +19 -12
  13. package/dist/api/insets.js +18 -7
  14. package/dist/api/layout.d.ts +20 -6
  15. package/dist/api/layout.js +31 -6
  16. package/dist/api/page-builder.d.ts +8 -0
  17. package/dist/api/page-builder.js +12 -0
  18. package/dist/api/structure.d.ts +5 -0
  19. package/dist/api/structure.js +16 -4
  20. package/dist/api/text.d.ts +42 -6
  21. package/dist/api/text.js +29 -5
  22. package/dist/edit/document.d.ts +14 -4
  23. package/dist/edit/document.js +15 -22
  24. package/dist/edit/fill.d.ts +19 -2
  25. package/dist/edit/fill.js +63 -34
  26. package/dist/edit/flatten.d.ts +13 -1
  27. package/dist/edit/flatten.js +38 -13
  28. package/dist/edit/index.d.ts +2 -0
  29. package/dist/edit/index.js +3 -1
  30. package/dist/edit/objects.d.ts +5 -0
  31. package/dist/edit/objects.js +27 -0
  32. package/dist/edit/writer.d.ts +7 -0
  33. package/dist/edit/writer.js +23 -1
  34. package/dist/elements/container-element.d.ts +21 -1
  35. package/dist/elements/container-element.js +25 -10
  36. package/dist/elements/image-element.d.ts +18 -4
  37. package/dist/elements/image-element.js +27 -12
  38. package/dist/elements/layout/expanded-element.d.ts +3 -1
  39. package/dist/elements/layout/expanded-element.js +12 -3
  40. package/dist/elements/layout/padding-element.d.ts +4 -2
  41. package/dist/elements/layout/padding-element.js +4 -2
  42. package/dist/elements/layout/page-builder-element.js +2 -0
  43. package/dist/elements/pdf-element.d.ts +59 -1
  44. package/dist/elements/pdf-element.js +62 -1
  45. package/dist/elements/rectangle-element.d.ts +20 -6
  46. package/dist/elements/rectangle-element.js +25 -12
  47. package/dist/elements/row-element.d.ts +24 -1
  48. package/dist/elements/row-element.js +25 -10
  49. package/dist/elements/text-element.d.ts +60 -1
  50. package/dist/elements/text-element.js +132 -33
  51. package/dist/ir/display-list.d.ts +23 -6
  52. package/dist/ir/display-list.js +8 -1
  53. package/dist/layout/alignment.d.ts +10 -0
  54. package/dist/layout/alignment.js +6 -0
  55. package/dist/layout/box-constraints.d.ts +53 -0
  56. package/dist/layout/box-constraints.js +72 -0
  57. package/dist/layout/insets.d.ts +28 -0
  58. package/dist/layout/insets.js +20 -0
  59. package/dist/renderer/image-renderer.js +2 -1
  60. package/dist/renderer/pdf-backend.d.ts +6 -0
  61. package/dist/renderer/pdf-backend.js +78 -31
  62. package/dist/renderer/pdf-document-renderer.js +8 -1
  63. package/dist/renderer/rectangle-renderer.js +8 -2
  64. package/dist/renderer/text-renderer.d.ts +2 -2
  65. package/dist/renderer/text-renderer.js +150 -42
  66. package/dist/text/advance.js +5 -2
  67. package/dist/text/arabic.d.ts +20 -0
  68. package/dist/text/arabic.js +101 -0
  69. package/dist/text/bidi.d.ts +33 -0
  70. package/dist/text/bidi.js +94 -0
  71. package/dist/text/font-fallback.d.ts +25 -0
  72. package/dist/text/font-fallback.js +38 -0
  73. package/dist/text/line-breaker.d.ts +35 -1
  74. package/dist/text/line-breaker.js +76 -16
  75. package/dist/text/orphans-widows.d.ts +27 -0
  76. package/dist/text/orphans-widows.js +53 -0
  77. package/dist/text/shape.d.ts +29 -0
  78. package/dist/text/shape.js +67 -0
  79. package/dist/text/text-style.d.ts +25 -0
  80. package/dist/text/text-style.js +44 -1
  81. package/dist/utils/flex-layout.d.ts +24 -4
  82. package/dist/utils/flex-layout.js +169 -7
  83. package/dist/utils/font-metrics.d.ts +5 -0
  84. package/dist/utils/gsub.d.ts +36 -0
  85. package/dist/utils/gsub.js +178 -0
  86. package/dist/utils/inflate.d.ts +16 -0
  87. package/dist/utils/inflate.js +52 -0
  88. package/dist/utils/pdf-object-manager.d.ts +21 -0
  89. package/dist/utils/pdf-object-manager.js +107 -9
  90. package/dist/utils/ttf-parser.d.ts +6 -0
  91. package/dist/utils/ttf-parser.js +19 -0
  92. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  93. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  94. package/dist/utils/woff.d.ts +10 -0
  95. package/dist/utils/woff.js +116 -0
  96. package/dist/validators/element-validator.js +9 -2
  97. package/package.json +2 -1
package/README.md CHANGED
@@ -140,7 +140,7 @@ verify all of it:
140
140
  - **PDF/A-3, matched not approximated.** The conformance graph is hand-built and **passes veraPDF**, the
141
141
  official ISO 19005 validator.
142
142
  - **Byte-exact round-trips.** Generate and parse are inverses: `generate → parse → regenerate` reproduces
143
- the identical XML. 307 tests hold the line.
143
+ the identical XML. Over 900 tests hold the line across the workspace.
144
144
  - **Schematron, local.** The official EN-16931 + XRechnung rules run via saxon-js (the real XSLT, in pure
145
145
  JS) - no Java, no upload.
146
146
 
@@ -158,8 +158,9 @@ verify all of it:
158
158
 
159
159
  ## Why you can trust it
160
160
 
161
- - **307 tests, green.** The generator and the parser are **byte-exact inverses**:
162
- `generate → parse → regenerate` reproduces the identical XML. Nothing is silently lost.
161
+ - **Over 900 tests, green** across the workspace (850 in the engine alone). The generator and the parser
162
+ are **byte-exact inverses**: `generate → parse → regenerate` reproduces the identical XML. Nothing is
163
+ silently lost.
163
164
  - **The same rules the authorities use** - the official KoSIT EN-16931 + XRechnung Schematron, and the
164
165
  official **veraPDF** for the full ISO 19005 (PDF/A) check.
165
166
  - **Proven on real third-party invoices**, not only our own.
@@ -2,7 +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
+ import { BoundsInput, SizeInput, RadiusInput } from "./dimension.js";
6
6
  /** A horizontal rule (locked §4). */
7
7
  export interface DividerOptions {
8
8
  /** Line color (default a light gray). */
@@ -22,15 +22,16 @@ export declare function Divider(opts?: DividerOptions): PDFElement;
22
22
  export type ImageSource = string | Uint8Array | CustomImage;
23
23
  /** How the image fills its box (locked §4). Mirrors CSS `object-fit`. */
24
24
  export type ImageFit = "none" | "contain" | "cover" | "fill";
25
- export interface ImageOptions {
25
+ export interface ImageOptions extends BoundsInput {
26
26
  /** Size on each axis: points (fixed) or a percentage string like `"50%"` (a fraction of the offered
27
27
  * space). Pin exactly ONE axis and the other follows the image's aspect ratio (CSS `height: auto`). */
28
28
  width?: SizeInput;
29
29
  height?: SizeInput;
30
- /** Fit within the box (default `none`; `fill` when exactly one axis is pinned so it scales to fit). */
30
+ /** Fit within the box. Default `none`, except where the box was DERIVED rather than given outright -
31
+ * exactly one axis pinned, or an `aspectRatio` - which defaults to `fill` so the image scales into it. */
31
32
  fit?: ImageFit;
32
33
  /** Corner radius in points (rounds the image box). */
33
- radius?: number;
34
+ radius?: RadiusInput;
34
35
  /** Alternate text for accessibility (tagged PDF): describes the image for screen readers. With `alt`
35
36
  * the image is a `Figure`; without it (and when rendered `accessible`) it counts as decoration. */
36
37
  alt?: string;
@@ -3,7 +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
+ import { toBounds, toDimension, toRadius, } from "./dimension.js";
7
7
  const DEFAULT_DIVIDER_COLOR = "lightgray";
8
8
  const DEFAULT_DIVIDER_MARGIN = { y: 6 };
9
9
  /**
@@ -38,10 +38,12 @@ const FIT = {
38
38
  export function Image(src, opts = {}) {
39
39
  const w = opts.width !== undefined ? toDimension(opts.width) : undefined;
40
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;
41
+ // ANY explicit size scales the image into the box (fit: fill), which is what `<img width height>`
42
+ // does in HTML and what react-pdf does. Two pinned axes used to keep `none` - drawing the image at
43
+ // its pixel size regardless - so a logo asked for at 48x48 spanned a third of the page.
44
+ // An image with NO size given still draws at its intrinsic size, as an unsized `<img>` does.
45
+ const sized = opts.width !== undefined || opts.height !== undefined || opts.aspectRatio !== undefined;
46
+ const fit = opts.fit ? FIT[opts.fit] : sized ? BoxFit.fill : undefined;
45
47
  return new ImageElement({
46
48
  image: typeof src === "string"
47
49
  ? new CustomLocalImage(src)
@@ -52,8 +54,12 @@ export function Image(src, opts = {}) {
52
54
  height: h?.points,
53
55
  widthFactor: w?.factor,
54
56
  heightFactor: h?.factor,
57
+ ...toBounds(opts),
55
58
  fit,
56
- radius: opts.radius,
59
+ radius: opts.radius !== undefined ? toRadius(opts.radius) : undefined,
57
60
  alt: opts.alt,
58
- });
61
+ })
62
+ .withAlignSelf(opts.alignSelf)
63
+ .withOrder(opts.order)
64
+ .withFlexShrink(opts.flexShrink);
59
65
  }
@@ -1,3 +1,5 @@
1
+ import type { Radii } from "../ir/display-list.js";
2
+ import type { CrossAlign } from "../layout/alignment.js";
1
3
  /**
2
4
  * Size input for a Box's `width` / `height` (relative sizing). Either a number of PDF points (a
3
5
  * fixed size) or a percentage string like `"50%"` - a fraction of the space the parent offers on
@@ -18,3 +20,57 @@ export interface Dimension {
18
20
  * boundary so `resolveExtent` and the layout never see a negative size.
19
21
  */
20
22
  export declare function toDimension(value: SizeInput): Dimension;
23
+ /** The bound and ratio options every sized factory accepts, in the public `SizeInput` form. */
24
+ export interface BoundsInput {
25
+ /**
26
+ * This element's own cross-axis alignment inside its Column / Row (CSS `align-self`), overriding the
27
+ * container's `align`. On a `Spacer` / `Expanded` it is a no-op - a flex child fills the main axis and
28
+ * has no natural cross size to align.
29
+ */
30
+ alignSelf?: CrossAlign;
31
+ /**
32
+ * Where this child sits among its siblings (CSS `order`), lowest first, source order deciding ties.
33
+ * It moves the child in the LAYOUT only - the element tree is untouched, so a tagged PDF still
34
+ * exposes the source reading order. Default 0.
35
+ */
36
+ order?: number;
37
+ /**
38
+ * How willingly this child gives up main-axis space when the line overflows (CSS `flex-shrink`),
39
+ * weighted by its own size. **Default 0, deliberately unlike CSS's 1** - shrinking changes what a
40
+ * document looks like, so it is opted into per child rather than applied to everything at once.
41
+ */
42
+ flexShrink?: number;
43
+ minWidth?: SizeInput;
44
+ maxWidth?: SizeInput;
45
+ minHeight?: SizeInput;
46
+ maxHeight?: SizeInput;
47
+ /**
48
+ * width / height (CSS `aspect-ratio`). Fills in whichever axis you leave open, and sizes the box
49
+ * from the offered width when you leave both open. An explicit `min`/`max` still wins over it.
50
+ */
51
+ aspectRatio?: number;
52
+ }
53
+ /** Split `BoundsInput` into the points-and-factors form the elements take. */
54
+ export declare function toBounds(o: BoundsInput): {
55
+ minWidth: number | undefined;
56
+ maxWidth: number | undefined;
57
+ minHeight: number | undefined;
58
+ maxHeight: number | undefined;
59
+ minWidthFactor: number | undefined;
60
+ maxWidthFactor: number | undefined;
61
+ minHeightFactor: number | undefined;
62
+ maxHeightFactor: number | undefined;
63
+ aspectRatio: number | undefined;
64
+ };
65
+ /**
66
+ * Corner radius input. A single number rounds all four corners; the object names them; the tuple is
67
+ * CSS order - `[topLeft, topRight, bottomRight, bottomLeft]`, clockwise from the top left.
68
+ */
69
+ export type RadiusInput = number | {
70
+ topLeft?: number;
71
+ topRight?: number;
72
+ bottomRight?: number;
73
+ bottomLeft?: number;
74
+ } | [number, number, number, number];
75
+ /** Normalizes a `RadiusInput` to the engine's short corner names. */
76
+ export declare function toRadius(r: RadiusInput): number | Radii;
@@ -14,3 +14,30 @@ export function toDimension(value) {
14
14
  return { factor: parseFloat(m[1]) / 100 };
15
15
  throw new Error(`Invalid size "${value}": use a number of points or a percentage like "50%".`);
16
16
  }
17
+ /** Split `BoundsInput` into the points-and-factors form the elements take. */
18
+ export function toBounds(o) {
19
+ const d = (v) => (v !== undefined ? toDimension(v) : undefined);
20
+ const [minW, maxW, minH, maxH] = [d(o.minWidth), d(o.maxWidth), d(o.minHeight), d(o.maxHeight)];
21
+ if (o.aspectRatio !== undefined && !(o.aspectRatio > 0)) {
22
+ throw new Error(`Invalid aspectRatio ${o.aspectRatio}: it is width / height and must be above 0.`);
23
+ }
24
+ return {
25
+ minWidth: minW?.points,
26
+ maxWidth: maxW?.points,
27
+ minHeight: minH?.points,
28
+ maxHeight: maxH?.points,
29
+ minWidthFactor: minW?.factor,
30
+ maxWidthFactor: maxW?.factor,
31
+ minHeightFactor: minH?.factor,
32
+ maxHeightFactor: maxH?.factor,
33
+ aspectRatio: o.aspectRatio,
34
+ };
35
+ }
36
+ /** Normalizes a `RadiusInput` to the engine's short corner names. */
37
+ export function toRadius(r) {
38
+ if (typeof r === "number")
39
+ return r;
40
+ if (Array.isArray(r))
41
+ return { tl: r[0], tr: r[1], br: r[2], bl: r[3] };
42
+ return { tl: r.topLeft, tr: r.topRight, br: r.bottomRight, bl: r.bottomLeft };
43
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Loading a font over the network for `doc.addFontFromUrl(...)`.
3
+ *
4
+ * `addFont` resolves its source AT REGISTRATION - a path is read there and then - and this keeps the
5
+ * same contract, differing only in being async. So the failure lands where you asked for the font,
6
+ * not somewhere inside a later render.
7
+ *
8
+ * `fetch` is used directly: it is in every browser and in Node since 18, so this needs no platform
9
+ * port, unlike the filesystem read beside it.
10
+ */
11
+ /** One font file by URL, or a styled family of them. Mirrors `FontSource`, with strings meaning URLs. */
12
+ export type UrlFontSource = string | {
13
+ normal: string;
14
+ bold?: string;
15
+ italic?: string;
16
+ boldItalic?: string;
17
+ };
18
+ /** Thrown when a font could not be fetched or is not one we can parse. Always says which URL. */
19
+ export declare class FontUrlError extends Error {
20
+ constructor(message: string);
21
+ }
22
+ /** Fetch a whole `UrlFontSource`, in parallel for a family. */
23
+ export declare function loadFontFromUrl(source: UrlFontSource): Promise<Uint8Array | {
24
+ normal: Uint8Array;
25
+ bold?: Uint8Array;
26
+ italic?: Uint8Array;
27
+ boldItalic?: Uint8Array;
28
+ }>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Loading a font over the network for `doc.addFontFromUrl(...)`.
3
+ *
4
+ * `addFont` resolves its source AT REGISTRATION - a path is read there and then - and this keeps the
5
+ * same contract, differing only in being async. So the failure lands where you asked for the font,
6
+ * not somewhere inside a later render.
7
+ *
8
+ * `fetch` is used directly: it is in every browser and in Node since 18, so this needs no platform
9
+ * port, unlike the filesystem read beside it.
10
+ */
11
+ /** Thrown when a font could not be fetched or is not one we can parse. Always says which URL. */
12
+ export class FontUrlError extends Error {
13
+ constructor(message) {
14
+ super(`@jasy/pdf: ${message}`);
15
+ }
16
+ }
17
+ /**
18
+ * What the first four bytes of an sfnt file say it is. Checked here because `TTFParser` does not look
19
+ * at them: it goes straight for the table directory, so an HTML error page or a WOFF would fail deep
20
+ * inside with `missing required table "head"` instead of saying what the file actually is.
21
+ */
22
+ function describeSignature(bytes) {
23
+ const tag = String.fromCharCode(...bytes.subarray(0, 4));
24
+ const v = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
25
+ if (v === 0x00010000 || tag === "true")
26
+ return { ok: true, what: "TrueType" };
27
+ if (tag === "OTTO")
28
+ return { ok: false, what: "an OpenType/CFF font, which jasy does not parse yet" };
29
+ if (tag === "wOFF")
30
+ return { ok: true, what: "WOFF" };
31
+ if (tag === "wOF2")
32
+ return { ok: false, what: "a WOFF2 font, which jasy does not parse yet" };
33
+ if (tag === "ttcf")
34
+ return { ok: false, what: "a TrueType Collection, which jasy does not parse yet" };
35
+ if (tag.startsWith("<") || tag === "%PDF")
36
+ return { ok: false, what: "not a font at all" };
37
+ return { ok: false, what: "not a font we recognise" };
38
+ }
39
+ /** How long one font may take to arrive before we give up. A hung server must not hang a render. */
40
+ const TIMEOUT_MS = 15000;
41
+ /**
42
+ * How large a font file may be. Generous on purpose - a full CJK face is tens of megabytes and is a
43
+ * legitimate thing to embed - but not unbounded: a URL pointing at the wrong file (a video, a disk
44
+ * image) would otherwise be read into memory in full before anyone notices it is not a font.
45
+ */
46
+ const MAX_BYTES = 32 * 1024 * 1024;
47
+ /** Read the body with a running ceiling, so an oversized response is dropped WHILE it arrives. */
48
+ async function readBounded(response, url) {
49
+ // `Content-Length` is the cheap early exit; a server may omit or misstate it, so it is a hint only
50
+ // and the real ceiling is enforced below while reading.
51
+ const declared = Number(response.headers?.get?.("content-length") ?? NaN);
52
+ if (Number.isFinite(declared) && declared > MAX_BYTES) {
53
+ throw new FontUrlError(`the font at ${url} is ${declared} bytes; the limit is ${MAX_BYTES}`);
54
+ }
55
+ const body = response.body;
56
+ if (!body?.getReader) {
57
+ // No streaming body (an older runtime, or a stubbed response): fall back to reading it whole and
58
+ // checking afterwards. Weaker, but never worse than not checking at all.
59
+ const whole = new Uint8Array(await response.arrayBuffer());
60
+ if (whole.length > MAX_BYTES) {
61
+ throw new FontUrlError(`the font at ${url} is larger than the ${MAX_BYTES} byte limit`);
62
+ }
63
+ return whole;
64
+ }
65
+ const reader = body.getReader();
66
+ const chunks = [];
67
+ let total = 0;
68
+ for (;;) {
69
+ const { done, value } = await reader.read();
70
+ if (done)
71
+ break;
72
+ total += value.length;
73
+ if (total > MAX_BYTES) {
74
+ await reader.cancel();
75
+ throw new FontUrlError(`the font at ${url} is larger than the ${MAX_BYTES} byte limit`);
76
+ }
77
+ chunks.push(value);
78
+ }
79
+ if (chunks.length === 1)
80
+ return chunks[0];
81
+ const out = new Uint8Array(total);
82
+ let at = 0;
83
+ for (const c of chunks) {
84
+ out.set(c, at);
85
+ at += c.length;
86
+ }
87
+ return out;
88
+ }
89
+ /** Fetch one font file and hand back its bytes, refusing anything we could not embed. */
90
+ async function fetchFont(url) {
91
+ let response;
92
+ try {
93
+ response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
94
+ }
95
+ catch (e) {
96
+ const why = e?.name === "TimeoutError" || e?.name === "AbortError"
97
+ ? `it did not respond within ${TIMEOUT_MS} ms`
98
+ : String(e?.message ?? e);
99
+ throw new FontUrlError(`could not fetch the font at ${url}: ${why}`);
100
+ }
101
+ if (!response.ok) {
102
+ throw new FontUrlError(`the font at ${url} could not be fetched: HTTP ${response.status}`);
103
+ }
104
+ // Everything past here still talks to the network, so a failure mid-body must not escape as a raw
105
+ // TypeError - a caller catching FontUrlError would miss it.
106
+ let bytes;
107
+ try {
108
+ bytes = await readBounded(response, url);
109
+ }
110
+ catch (e) {
111
+ if (e instanceof FontUrlError)
112
+ throw e;
113
+ throw new FontUrlError(`the font at ${url} could not be read: ${String(e?.message ?? e)}`);
114
+ }
115
+ if (bytes.length < 4) {
116
+ throw new FontUrlError(`the font at ${url} is empty`);
117
+ }
118
+ const { ok, what } = describeSignature(bytes);
119
+ if (!ok) {
120
+ throw new FontUrlError(`the file at ${url} is ${what}. jasy embeds TrueType-flavoured fonts (.ttf and .woff)`);
121
+ }
122
+ return bytes;
123
+ }
124
+ /** Fetch a whole `UrlFontSource`, in parallel for a family. */
125
+ export async function loadFontFromUrl(source) {
126
+ if (typeof source === "string")
127
+ return fetchFont(source);
128
+ const styles = ["normal", "bold", "italic", "boldItalic"];
129
+ const wanted = styles.filter((s) => source[s] !== undefined);
130
+ const loaded = await Promise.all(wanted.map((s) => fetchFont(source[s])));
131
+ const family = { normal: new Uint8Array() };
132
+ wanted.forEach((s, i) => (family[s] = loaded[i]));
133
+ return family;
134
+ }
@@ -0,0 +1,53 @@
1
+ import { type ColorInput } from "./color.js";
2
+ import type { Gradient } from "../ir/display-list.js";
3
+ /**
4
+ * Gradients for a box background.
5
+ *
6
+ * The public form is BOX-RELATIVE - an angle and some stops - because that is what an author knows
7
+ * when writing the document. The engine's `Gradient` wants absolute page coordinates, so the renderer
8
+ * resolves one against the box it is painting; see `resolveGradient`.
9
+ */
10
+ /** A stop as written: a colour, optionally pinned to a position 0..1 along the axis. */
11
+ export type StopInput = ColorInput | {
12
+ color: ColorInput;
13
+ at: number;
14
+ };
15
+ export interface LinearGradientInput {
16
+ kind: "linear";
17
+ /** Direction in degrees, CSS convention: 0 = to the top, 90 = to the right (default 180, downwards). */
18
+ angle: number;
19
+ stops: StopInput[];
20
+ }
21
+ export interface RadialGradientInput {
22
+ kind: "radial";
23
+ /** Centre as a fraction of the box, `[0.5, 0.5]` being the middle (the default). */
24
+ center: [number, number];
25
+ /** Outer radius as a fraction of the box's larger side (default 0.5, i.e. it touches the edges). */
26
+ radius: number;
27
+ stops: StopInput[];
28
+ }
29
+ export type GradientInput = LinearGradientInput | RadialGradientInput;
30
+ export declare const isGradientInput: (v: unknown) => v is GradientInput;
31
+ /**
32
+ * A linear gradient. `linearGradient("#fff", "#000")` runs top to bottom; pass `{ angle }` for any
33
+ * other direction, and `{ color, at }` for a stop that is not evenly spaced.
34
+ */
35
+ export declare function linearGradient(...args: [...StopInput[]] | [{
36
+ angle?: number;
37
+ stops: StopInput[];
38
+ }]): LinearGradientInput;
39
+ /** A radial gradient, centred by default and reaching the edges. */
40
+ export declare function radialGradient(...args: [...StopInput[]] | [{
41
+ center?: [number, number];
42
+ radius?: number;
43
+ stops: StopInput[];
44
+ }]): RadialGradientInput;
45
+ /**
46
+ * Resolve a box-relative gradient against the box it paints, into the absolute page coordinates the
47
+ * IR wants. Called by the renderer, which is the first place the box's geometry is known.
48
+ *
49
+ * The angle follows CSS: 0 points to the top and it turns clockwise, so 90 goes to the right. The
50
+ * axis runs through the box centre and is scaled so it spans the box on that diagonal - which is why
51
+ * a 45-degree gradient reaches the corners rather than stopping short of them.
52
+ */
53
+ export declare function resolveGradient(g: GradientInput, x: number, y: number, width: number, height: number): Gradient;
@@ -0,0 +1,97 @@
1
+ import { toColor } from "./color.js";
2
+ export const isGradientInput = (v) => typeof v === "object" && v !== null && "kind" in v && "stops" in v;
3
+ /**
4
+ * A linear gradient. `linearGradient("#fff", "#000")` runs top to bottom; pass `{ angle }` for any
5
+ * other direction, and `{ color, at }` for a stop that is not evenly spaced.
6
+ */
7
+ export function linearGradient(...args) {
8
+ const first = args[0];
9
+ if (args.length === 1 && typeof first === "object" && first !== null && "stops" in first) {
10
+ return { kind: "linear", angle: first.angle ?? 180, stops: first.stops };
11
+ }
12
+ return { kind: "linear", angle: 180, stops: args };
13
+ }
14
+ /** A radial gradient, centred by default and reaching the edges. */
15
+ export function radialGradient(...args) {
16
+ const first = args[0];
17
+ if (args.length === 1 && typeof first === "object" && first !== null && "stops" in first) {
18
+ return {
19
+ kind: "radial",
20
+ center: first.center ?? [0.5, 0.5],
21
+ radius: first.radius ?? 0.5,
22
+ stops: first.stops,
23
+ };
24
+ }
25
+ return { kind: "radial", center: [0.5, 0.5], radius: 0.5, stops: args };
26
+ }
27
+ /**
28
+ * Spread the stops that carry no `at` evenly, first to last, the way CSS does - and check the pinned
29
+ * ones, because they end up in a PDF stitching function's `/Bounds`, which the format requires to be
30
+ * strictly increasing inside the domain. A bad offset there is not a wrong-looking gradient, it is a
31
+ * malformed file, so it is refused by name here rather than emitted.
32
+ */
33
+ function toStops(stops) {
34
+ if (stops.length < 2) {
35
+ throw new Error("A gradient needs at least two colour stops.");
36
+ }
37
+ const last = stops.length - 1;
38
+ const out = stops.map((s, i) => {
39
+ const pinned = typeof s === "object" && s !== null && "at" in s;
40
+ const at = pinned ? s.at : i / last;
41
+ if (!Number.isFinite(at) || at < 0 || at > 1) {
42
+ throw new Error(`Invalid gradient stop position ${at}: it must be a number between 0 and 1.`);
43
+ }
44
+ return {
45
+ offset: at,
46
+ color: toColor(pinned ? s.color : s),
47
+ };
48
+ });
49
+ for (let i = 1; i < out.length; i++) {
50
+ if (out[i].offset <= out[i - 1].offset) {
51
+ throw new Error(`Gradient stops must move forward: stop ${i} sits at ${out[i].offset}, which is not after ` +
52
+ `${out[i - 1].offset}. (Two stops at the SAME position would be a hard colour edge in CSS; ` +
53
+ `PDF cannot express that in one shading, so it is refused rather than drawn wrongly.)`);
54
+ }
55
+ }
56
+ // A pinned first or last stop would otherwise be IGNORED - the shading's domain is always 0..1 and
57
+ // only the INTERIOR offsets reach /Bounds. Carrying the edge colour outwards is what CSS does.
58
+ if (out[0].offset > 0)
59
+ out.unshift({ offset: 0, color: out[0].color });
60
+ if (out[out.length - 1].offset < 1)
61
+ out.push({ offset: 1, color: out[out.length - 1].color });
62
+ return out;
63
+ }
64
+ /**
65
+ * Resolve a box-relative gradient against the box it paints, into the absolute page coordinates the
66
+ * IR wants. Called by the renderer, which is the first place the box's geometry is known.
67
+ *
68
+ * The angle follows CSS: 0 points to the top and it turns clockwise, so 90 goes to the right. The
69
+ * axis runs through the box centre and is scaled so it spans the box on that diagonal - which is why
70
+ * a 45-degree gradient reaches the corners rather than stopping short of them.
71
+ */
72
+ export function resolveGradient(g, x, y, width, height) {
73
+ const stops = toStops(g.stops);
74
+ if (g.kind === "radial") {
75
+ const cx = x + g.center[0] * width;
76
+ const cy = y + g.center[1] * height;
77
+ const r = g.radius * Math.max(width, height);
78
+ return { type: "radial", x0: cx, y0: cy, r0: 0, x1: cx, y1: cy, r1: r, stops, extend: "pad" };
79
+ }
80
+ const rad = ((g.angle % 360) * Math.PI) / 180;
81
+ // CSS angles: 0 = up, clockwise. The engine lays out top-left down, so "up" is -y here.
82
+ const dx = Math.sin(rad);
83
+ const dy = -Math.cos(rad);
84
+ // Half the box's extent along the axis, so the line covers the whole box for any angle.
85
+ const half = (Math.abs(dx) * width + Math.abs(dy) * height) / 2;
86
+ const cx = x + width / 2;
87
+ const cy = y + height / 2;
88
+ return {
89
+ type: "linear",
90
+ x0: cx - dx * half,
91
+ y0: cy - dy * half,
92
+ x1: cx + dx * half,
93
+ y1: cy + dy * half,
94
+ stops,
95
+ extend: "pad",
96
+ };
97
+ }
@@ -1,5 +1,7 @@
1
1
  export * from "./color.js";
2
2
  export * from "./insets.js";
3
+ export * from "./gradient.js";
4
+ export * from "./font-url.js";
3
5
  export * from "./dimension.js";
4
6
  export * from "./text.js";
5
7
  export * from "./layout.js";
package/dist/api/index.js CHANGED
@@ -3,6 +3,8 @@
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 "./gradient.js";
7
+ export * from "./font-url.js";
6
8
  export * from "./dimension.js";
7
9
  export * from "./text.js";
8
10
  export * from "./layout.js";
@@ -1,16 +1,23 @@
1
+ import { SizeInput } from "./dimension.js";
2
+ import type { EdgeSpecs } from "../layout/insets.js";
1
3
  /**
2
- * Spacing input for padding / margin (locked design §3). Units are PDF points. Every form
3
- * normalizes to the engine's `[top, right, bottom, left]` via `toEdges`, so you can reach
4
+ * Spacing input for padding / margin (locked design §3). Points, or a percentage string like `"5%"`.
5
+ * Every form normalizes to the engine's `[top, right, bottom, left]` via `toEdges`, so you can reach
4
6
  * for whichever shape fits and never think about the engine order.
7
+ *
8
+ * **A percentage resolves against the available WIDTH - on all four sides, top and bottom included.**
9
+ * That is the CSS rule, and Yoga's, so react-pdf behaves the same. It reads as wrong the first time and
10
+ * is what makes `padding-bottom: 56.25%` a 16:9 box on the web. A percentage needs a bounded width; in
11
+ * an unbounded region it resolves to 0, the same no-op a percentage SIZE gives there.
5
12
  */
6
- export type Insets = number | {
7
- x?: number;
8
- y?: number;
13
+ export type Insets = SizeInput | {
14
+ x?: SizeInput;
15
+ y?: SizeInput;
9
16
  } | {
10
- top?: number;
11
- right?: number;
12
- bottom?: number;
13
- left?: number;
14
- } | [number, number, number, number];
15
- /** Normalizes any `Insets` to the engine's `[top, right, bottom, left]`. */
16
- export declare function toEdges(i: Insets): [number, number, number, number];
17
+ top?: SizeInput;
18
+ right?: SizeInput;
19
+ bottom?: SizeInput;
20
+ left?: SizeInput;
21
+ } | [SizeInput, SizeInput, SizeInput, SizeInput];
22
+ /** Normalizes any `Insets` to the engine's `[top, right, bottom, left]`, percentages still unresolved. */
23
+ export declare function toEdges(i: Insets): EdgeSpecs;
@@ -1,17 +1,28 @@
1
- /** Normalizes any `Insets` to the engine's `[top, right, bottom, left]`. */
1
+ import { toDimension } from "./dimension.js";
2
+ // A fixed side stays a plain NUMBER, so the output is unchanged for every input that was legal before
3
+ // this file learned percentages - `toEdges` is publicly exported and did not need to break.
4
+ const spec = (v) => {
5
+ if (v === undefined)
6
+ return 0;
7
+ const d = toDimension(v);
8
+ return d.points ?? { factor: d.factor };
9
+ };
10
+ /** Normalizes any `Insets` to the engine's `[top, right, bottom, left]`, percentages still unresolved. */
2
11
  export function toEdges(i) {
3
- if (typeof i === "number")
4
- return [i, i, i, i];
12
+ if (typeof i === "number" || typeof i === "string") {
13
+ const s = spec(i);
14
+ return [s, s, s, s];
15
+ }
5
16
  if (Array.isArray(i))
6
- return i;
17
+ return [spec(i[0]), spec(i[1]), spec(i[2]), spec(i[3])];
7
18
  // Axis form ({x, y}) and per-side form ({top,...}) are both all-optional objects (TS
8
19
  // can't discriminate them), so read through one shape: an axis key present picks the
9
20
  // axis interpretation (an empty object is all-zero either way).
10
21
  const o = i;
11
22
  if (o.x !== undefined || o.y !== undefined) {
12
- const x = o.x ?? 0;
13
- const y = o.y ?? 0;
23
+ const x = spec(o.x);
24
+ const y = spec(o.y);
14
25
  return [y, x, y, x];
15
26
  }
16
- return [o.top ?? 0, o.right ?? 0, o.bottom ?? 0, o.left ?? 0];
27
+ return [spec(o.top), spec(o.right), spec(o.bottom), spec(o.left)];
17
28
  }