@uurtech/jdf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,513 @@
1
+ import type {
2
+ Element, Style, Resources, JdfDocument,
3
+ TextElement, RichTextElement, ImageElement, TableElement, ListElement,
4
+ ShapeElement, CollapsibleElement, TocElement, RichTextRun, ListItem, TableCellValue, ImageResource,
5
+ } from "@jdf/core";
6
+ import { unitToPx } from "@jdf/core";
7
+ import { resolveStyle, styleToCss, applyStyle } from "../utils/style";
8
+ import { resolveLink, attachLinkBehaviour } from "../utils/link";
9
+
10
+ export interface RenderContext {
11
+ styles: Record<string, Style>;
12
+ resources?: Resources;
13
+ document: JdfDocument;
14
+ path: (string | number)[];
15
+ onNavigatePage?: (pageIndex: number) => void;
16
+ }
17
+
18
+ const NS_SVG = "http://www.w3.org/2000/svg";
19
+
20
+ export function renderElement(el: Element, ctx: RenderContext): HTMLElement | null {
21
+ const wrap = document.createElement("div");
22
+ applyPositionAndSize(wrap, el);
23
+ let inner: HTMLElement | null = null;
24
+ switch (el.type) {
25
+ case "text": inner = renderText(el, ctx); break;
26
+ case "richtext": inner = renderRichText(el, ctx); break;
27
+ case "image": inner = renderImage(el, ctx); break;
28
+ case "table": inner = renderTable(el, ctx); break;
29
+ case "list": inner = renderList(el, ctx); break;
30
+ case "shape": inner = renderShape(el); break;
31
+ case "collapsible": inner = renderCollapsible(el, ctx); break;
32
+ case "toc": inner = renderToc(el, ctx); break;
33
+ }
34
+ if (!inner) return null;
35
+ wrap.appendChild(inner);
36
+ return wrap;
37
+ }
38
+
39
+ function applyPositionAndSize(el: HTMLElement, e: Element) {
40
+ const any = e as any;
41
+ if (any.position) {
42
+ el.style.position = "absolute";
43
+ if (any.position.x != null) el.style.left = `${unitToPx(any.position.x)}px`;
44
+ if (any.position.y != null) el.style.top = `${unitToPx(any.position.y)}px`;
45
+ }
46
+ if (any.width != null) el.style.width = `${unitToPx(any.width)}px`;
47
+ if (any.height != null) el.style.height = `${unitToPx(any.height)}px`;
48
+ }
49
+
50
+ // ── text ────────────────────────────────────────────────────────────────────
51
+ function renderText(el: TextElement, ctx: RenderContext): HTMLElement {
52
+ const tag = textTag(el.heading);
53
+ const node = document.createElement(tag);
54
+ node.className = "jdfjs-text";
55
+ node.style.margin = "0";
56
+ node.style.whiteSpace = "pre-wrap";
57
+ applyStyle(node, resolveStyle(el.style, ctx.styles));
58
+ if (el.align) node.style.textAlign = el.align;
59
+
60
+ const link = resolveLink(el.link);
61
+ if (link) {
62
+ const a = document.createElement("a");
63
+ attachLinkBehaviour(a, link, ctx.onNavigatePage);
64
+ a.className = "jdfjs-link";
65
+ a.textContent = el.content || "";
66
+ node.appendChild(a);
67
+ } else {
68
+ node.textContent = el.content || "";
69
+ }
70
+ return node;
71
+ }
72
+
73
+ function textTag(h: TextElement["heading"]): keyof HTMLElementTagNameMap {
74
+ if (h === true) return "h1";
75
+ if (typeof h === "number" && h >= 1 && h <= 6) return ("h" + h) as keyof HTMLElementTagNameMap;
76
+ return "p";
77
+ }
78
+
79
+ // ── richtext ────────────────────────────────────────────────────────────────
80
+ function renderRichText(el: RichTextElement, ctx: RenderContext): HTMLElement {
81
+ const p = document.createElement("p");
82
+ p.className = "jdfjs-richtext";
83
+ p.style.margin = "0";
84
+ applyStyle(p, resolveStyle(el.style, ctx.styles));
85
+ for (const run of el.runs || []) {
86
+ p.appendChild(renderRun(run, ctx));
87
+ }
88
+ return p;
89
+ }
90
+
91
+ function runCss(run: RichTextRun, styles: Record<string, Style>): Record<string, string> {
92
+ const css: Record<string, string> = {};
93
+ if (run.style) {
94
+ if (typeof run.style === "string") Object.assign(css, styleToCss(styles[run.style] || {}));
95
+ else if (Array.isArray(run.style)) for (const s of run.style) Object.assign(css, styleToCss(styles[s] || {}));
96
+ else Object.assign(css, styleToCss(run.style));
97
+ }
98
+ if (run.bold) css["font-weight"] = "bold";
99
+ if (run.italic) css["font-style"] = "italic";
100
+ const decos: string[] = [];
101
+ if (run.underline) decos.push("underline");
102
+ if (run.strikethrough) decos.push("line-through");
103
+ if (decos.length) css["text-decoration"] = decos.join(" ");
104
+ if (run.color) css["color"] = run.color;
105
+ if (run.fontSize) css["font-size"] = `${run.fontSize * 1.333}px`;
106
+ if (run.fontFamily) css["font-family"] = run.fontFamily;
107
+ return css;
108
+ }
109
+
110
+ function renderRun(run: RichTextRun, ctx: RenderContext): HTMLElement {
111
+ const link = resolveLink(run.link);
112
+ const node: HTMLElement = link ? document.createElement("a") : document.createElement("span");
113
+ if (link) {
114
+ attachLinkBehaviour(node as HTMLAnchorElement, link, ctx.onNavigatePage);
115
+ node.classList.add("jdfjs-link");
116
+ }
117
+ applyStyle(node, runCss(run, ctx.styles));
118
+ node.textContent = run.text;
119
+ return node;
120
+ }
121
+
122
+ // ── image ───────────────────────────────────────────────────────────────────
123
+ function lookupResource(resources: Resources | undefined, key: string): ImageResource | undefined {
124
+ if (!resources) return undefined;
125
+ const direct = (resources as any)[key];
126
+ if (direct && typeof direct === "object" && "data" in direct) return direct as ImageResource;
127
+ const inImages = resources.images?.[key];
128
+ if (inImages) return inImages;
129
+ return undefined;
130
+ }
131
+
132
+ function imageSrc(el: ImageElement, resources?: Resources): string {
133
+ if (el.src?.startsWith("data:") || el.src?.startsWith("http")) return el.src;
134
+ if (el.resource) {
135
+ const res = lookupResource(resources, el.resource);
136
+ if (res?.data) {
137
+ const mime = res.mimeType || "image/png";
138
+ if (res.data.startsWith("data:")) return res.data;
139
+ return `data:${mime};base64,${res.data}`;
140
+ }
141
+ if (res?.path) return res.path;
142
+ }
143
+ return el.src || "";
144
+ }
145
+
146
+ function renderImage(el: ImageElement, ctx: RenderContext): HTMLElement {
147
+ const wrap = document.createElement("div");
148
+ wrap.className = "jdfjs-image";
149
+ wrap.style.width = "100%";
150
+ wrap.style.height = "100%";
151
+ const img = document.createElement("img");
152
+ img.src = imageSrc(el, ctx.resources);
153
+ img.alt = el.alt || "";
154
+ img.style.display = "block";
155
+ img.style.width = "100%";
156
+ img.style.height = "100%";
157
+ switch (el.fit) {
158
+ case "cover": img.style.objectFit = "cover"; break;
159
+ case "fill": img.style.objectFit = "fill"; break;
160
+ case "none": img.style.objectFit = "none"; break;
161
+ default: img.style.objectFit = "contain";
162
+ }
163
+ applyStyle(img, resolveStyle(el.style, ctx.styles));
164
+ wrap.appendChild(img);
165
+ return wrap;
166
+ }
167
+
168
+ // ── table ───────────────────────────────────────────────────────────────────
169
+ function cellText(c: TableCellValue): string {
170
+ return typeof c === "string" ? c : c.content;
171
+ }
172
+ function cellAttrs(c: TableCellValue): { colspan?: number; rowspan?: number } {
173
+ if (typeof c === "string") return {};
174
+ return { colspan: c.colspan, rowspan: c.rowspan };
175
+ }
176
+
177
+ function renderTable(el: TableElement, ctx: RenderContext): HTMLElement {
178
+ const wrap = document.createElement("div");
179
+ wrap.className = "jdfjs-table-wrap";
180
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
181
+ wrap.style.overflowX = "auto";
182
+
183
+ const headerCss = (() => {
184
+ const s = el.headerStyle;
185
+ if (!s) return {};
186
+ if (typeof s === "string") return styleToCss(ctx.styles[s] || {});
187
+ if (Array.isArray(s)) { let m = {}; for (const k of s) m = { ...m, ...styleToCss(ctx.styles[k] || {}) }; return m; }
188
+ return styleToCss(s);
189
+ })();
190
+ const rowCss = (() => {
191
+ const s = el.rowStyle;
192
+ if (!s) return {};
193
+ if (typeof s === "string") return styleToCss(ctx.styles[s] || {});
194
+ if (Array.isArray(s)) { let m = {}; for (const k of s) m = { ...m, ...styleToCss(ctx.styles[k] || {}) }; return m; }
195
+ return styleToCss(s);
196
+ })();
197
+ const altRowCss = (() => {
198
+ const s = el.alternateRowStyle;
199
+ if (!s) {
200
+ const c = el.alternatingRowColor;
201
+ return c ? { "background-color": c } : {};
202
+ }
203
+ if (typeof s === "string") return styleToCss(ctx.styles[s] || {});
204
+ if (Array.isArray(s)) { let m = {}; for (const k of s) m = { ...m, ...styleToCss(ctx.styles[k] || {}) }; return m; }
205
+ return styleToCss(s);
206
+ })();
207
+ const borders = (() => {
208
+ const b = el.borders;
209
+ if (b === false) return { outer: false, inner: false } as const;
210
+ if (b === true || b === undefined) return { outer: true, inner: true, color: "#e2e8f0", width: 1 } as const;
211
+ return { outer: true, inner: true, color: "#e2e8f0", width: 1, ...b } as const;
212
+ })();
213
+
214
+ const headers = el.headers ?? el.columns?.map((c) => c.header || "").filter((h) => h !== "");
215
+ const colAlign = (i: number) => el.columns?.[i]?.align;
216
+
217
+ const table = document.createElement("table");
218
+ table.style.width = "100%";
219
+ table.style.borderCollapse = "collapse";
220
+ table.style.fontSize = "14px";
221
+ if (borders.outer) table.style.border = `${borders.width || 1}px solid ${borders.color || "#e2e8f0"}`;
222
+
223
+ if (headers && headers.length > 0) {
224
+ const thead = document.createElement("thead");
225
+ const tr = document.createElement("tr");
226
+ applyStyle(tr, headerCss as any);
227
+ headers.forEach((h, i) => {
228
+ const th = document.createElement("th");
229
+ th.textContent = h;
230
+ th.style.padding = "8px 12px";
231
+ th.style.fontWeight = "600";
232
+ th.style.background = "#f8fafc";
233
+ th.style.textAlign = colAlign(i) || "left";
234
+ if (borders.inner) th.style.border = `${borders.width || 1}px solid ${borders.color || "#e2e8f0"}`;
235
+ tr.appendChild(th);
236
+ });
237
+ thead.appendChild(tr);
238
+ table.appendChild(thead);
239
+ }
240
+
241
+ const tbody = document.createElement("tbody");
242
+ el.rows.forEach((row, ri) => {
243
+ const tr = document.createElement("tr");
244
+ applyStyle(tr, rowCss as any);
245
+ if (ri % 2 === 1) applyStyle(tr, altRowCss as any);
246
+ row.forEach((cell, ci) => {
247
+ const td = document.createElement("td");
248
+ td.textContent = cellText(cell);
249
+ const attrs = cellAttrs(cell);
250
+ if (attrs.colspan) td.colSpan = attrs.colspan;
251
+ if (attrs.rowspan) td.rowSpan = attrs.rowspan;
252
+ td.style.padding = "8px 12px";
253
+ td.style.verticalAlign = "top";
254
+ td.style.textAlign = colAlign(ci) || "left";
255
+ if (borders.inner) td.style.border = `${borders.width || 1}px solid ${borders.color || "#e2e8f0"}`;
256
+ tr.appendChild(td);
257
+ });
258
+ tbody.appendChild(tr);
259
+ });
260
+ table.appendChild(tbody);
261
+ wrap.appendChild(table);
262
+ return wrap;
263
+ }
264
+
265
+ // ── list ────────────────────────────────────────────────────────────────────
266
+ function renderList(el: ListElement, ctx: RenderContext): HTMLElement {
267
+ const wrap = document.createElement("div");
268
+ wrap.className = "jdfjs-list-wrap";
269
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
270
+
271
+ const defaultType = el.listType ?? (el.ordered ? "ordered" : "unordered");
272
+ const root = listElementForType(defaultType);
273
+ buildItems(root, el.items || [], defaultType, ctx);
274
+ wrap.appendChild(root);
275
+ return wrap;
276
+ }
277
+
278
+ function listElementForType(t: "ordered" | "unordered") {
279
+ const node = document.createElement(t === "ordered" ? "ol" : "ul");
280
+ node.style.margin = "0";
281
+ node.style.paddingLeft = "20px";
282
+ node.style.listStyle = t === "ordered" ? "decimal" : "disc";
283
+ return node;
284
+ }
285
+
286
+ function buildItems(parent: HTMLElement, items: ListItem[], def: "ordered" | "unordered", ctx: RenderContext) {
287
+ for (const item of items) {
288
+ const li = document.createElement("li");
289
+ li.style.fontSize = "14px";
290
+ li.style.lineHeight = "1.6";
291
+ li.appendChild(document.createTextNode(item.content));
292
+ if (item.children?.length) {
293
+ const childType = item.listType || def;
294
+ const nested = listElementForType(childType);
295
+ nested.style.marginTop = "4px";
296
+ buildItems(nested, item.children, childType, ctx);
297
+ li.appendChild(nested);
298
+ }
299
+ parent.appendChild(li);
300
+ }
301
+ }
302
+
303
+ // ── shape ───────────────────────────────────────────────────────────────────
304
+ function renderShape(el: ShapeElement): HTMLElement {
305
+ const wrap = document.createElement("div");
306
+ wrap.className = "jdfjs-shape";
307
+ wrap.style.width = "100%";
308
+ wrap.style.height = "100%";
309
+
310
+ const w = el.width ?? 100;
311
+ const h = el.height ?? 100;
312
+ const fill = el.fill ?? "none";
313
+ const strokeColor = (() => {
314
+ const s = el.stroke;
315
+ if (typeof s === "string") return s;
316
+ if (s?.color) return s.color;
317
+ return "none";
318
+ })();
319
+ const strokeWidth = (() => {
320
+ const s = el.stroke;
321
+ if (typeof s === "object" && s?.width != null) return s.width;
322
+ return el.strokeWidth ?? 0;
323
+ })();
324
+
325
+ const svg = document.createElementNS(NS_SVG, "svg");
326
+ svg.setAttribute("width", "100%");
327
+ svg.setAttribute("height", "100%");
328
+ svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
329
+ svg.setAttribute("preserveAspectRatio", "none");
330
+ svg.style.display = "block";
331
+ svg.style.overflow = "visible";
332
+
333
+ let shapeNode: SVGElement | null = null;
334
+ switch (el.shape) {
335
+ case "rect": {
336
+ const r = document.createElementNS(NS_SVG, "rect");
337
+ r.setAttribute("x", "0");
338
+ r.setAttribute("y", "0");
339
+ r.setAttribute("width", String(w));
340
+ r.setAttribute("height", String(h));
341
+ if (el.borderRadius) r.setAttribute("rx", String(el.borderRadius));
342
+ shapeNode = r;
343
+ break;
344
+ }
345
+ case "circle": {
346
+ const c = document.createElementNS(NS_SVG, "circle");
347
+ c.setAttribute("cx", String(w / 2));
348
+ c.setAttribute("cy", String(h / 2));
349
+ c.setAttribute("r", String(Math.min(w, h) / 2));
350
+ shapeNode = c;
351
+ break;
352
+ }
353
+ case "ellipse": {
354
+ const e = document.createElementNS(NS_SVG, "ellipse");
355
+ e.setAttribute("cx", String(w / 2));
356
+ e.setAttribute("cy", String(h / 2));
357
+ e.setAttribute("rx", String(w / 2));
358
+ e.setAttribute("ry", String(h / 2));
359
+ shapeNode = e;
360
+ break;
361
+ }
362
+ case "line": {
363
+ const l = document.createElementNS(NS_SVG, "line");
364
+ l.setAttribute("x1", "0");
365
+ l.setAttribute("y1", "0");
366
+ l.setAttribute("x2", String(w));
367
+ l.setAttribute("y2", String(h));
368
+ l.setAttribute("stroke", strokeColor === "none" ? (fill !== "none" ? fill : "currentColor") : strokeColor);
369
+ l.setAttribute("stroke-width", String(strokeWidth || 0.3));
370
+ svg.appendChild(l);
371
+ wrap.appendChild(svg);
372
+ return wrap;
373
+ }
374
+ case "path": {
375
+ const p = document.createElementNS(NS_SVG, "path");
376
+ p.setAttribute("d", el.path || "");
377
+ p.setAttribute("fill-rule", "evenodd");
378
+ shapeNode = p;
379
+ break;
380
+ }
381
+ }
382
+ if (shapeNode) {
383
+ shapeNode.setAttribute("fill", fill);
384
+ shapeNode.setAttribute("stroke", strokeColor);
385
+ if (strokeWidth) shapeNode.setAttribute("stroke-width", String(strokeWidth));
386
+ svg.appendChild(shapeNode);
387
+ }
388
+ wrap.appendChild(svg);
389
+ return wrap;
390
+ }
391
+
392
+ // ── collapsible ─────────────────────────────────────────────────────────────
393
+ function renderCollapsible(el: CollapsibleElement, ctx: RenderContext): HTMLElement {
394
+ const wrap = document.createElement("div");
395
+ wrap.className = "jdfjs-collapsible";
396
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
397
+ wrap.style.border = "1px solid #e2e8f0";
398
+ wrap.style.borderRadius = "8px";
399
+ wrap.style.overflow = "hidden";
400
+ wrap.style.background = "#ffffff";
401
+
402
+ const header = document.createElement("button");
403
+ header.type = "button";
404
+ header.className = "jdfjs-collapsible-header";
405
+ header.style.width = "100%";
406
+ header.style.display = "flex";
407
+ header.style.alignItems = "center";
408
+ header.style.gap = "8px";
409
+ header.style.padding = "10px 16px";
410
+ header.style.fontSize = "14px";
411
+ header.style.fontWeight = "500";
412
+ header.style.background = "#f8fafc";
413
+ header.style.border = "0";
414
+ header.style.cursor = "pointer";
415
+ header.style.textAlign = "left";
416
+
417
+ const arrow = document.createElement("span");
418
+ arrow.textContent = "▶";
419
+ arrow.style.fontSize = "10px";
420
+ arrow.style.transition = "transform 0.15s ease";
421
+
422
+ const titleSpan = document.createElement("span");
423
+ titleSpan.textContent = el.title || "Section";
424
+ titleSpan.style.flex = "1";
425
+
426
+ header.appendChild(arrow);
427
+ header.appendChild(titleSpan);
428
+ wrap.appendChild(header);
429
+
430
+ const body = document.createElement("div");
431
+ body.className = "jdfjs-collapsible-body";
432
+ body.style.padding = "12px 16px";
433
+ body.style.position = "relative";
434
+
435
+ let expanded = el.expanded ?? false;
436
+ const apply = () => {
437
+ body.style.display = expanded ? "block" : "none";
438
+ arrow.style.transform = expanded ? "rotate(90deg)" : "rotate(0deg)";
439
+ };
440
+ apply();
441
+ header.addEventListener("click", () => { expanded = !expanded; apply(); });
442
+
443
+ for (const child of el.elements || []) {
444
+ const node = renderElement(child, { ...ctx, path: [...ctx.path, "elements"] });
445
+ if (node) body.appendChild(node);
446
+ }
447
+ wrap.appendChild(body);
448
+ return wrap;
449
+ }
450
+
451
+ // ── toc ─────────────────────────────────────────────────────────────────────
452
+ function renderToc(el: TocElement, ctx: RenderContext): HTMLElement {
453
+ const wrap = document.createElement("div");
454
+ wrap.className = "jdfjs-toc";
455
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
456
+
457
+ const depth = el.depth ?? 6;
458
+ const entries: { title: string; pageIndex: number; level: number }[] = [];
459
+ ctx.document.pages.forEach((page, pi) => {
460
+ for (const e of page.elements) {
461
+ if (e.type !== "text") continue;
462
+ const t = e as TextElement;
463
+ const title = t.tocEntry || (t.heading ? t.content : null);
464
+ if (!title) continue;
465
+ const level = typeof t.tocLevel === "number" ? t.tocLevel : (typeof t.heading === "number" ? t.heading : 1);
466
+ if (level > depth) continue;
467
+ entries.push({ title, pageIndex: pi, level });
468
+ }
469
+ });
470
+
471
+ for (const entry of entries) {
472
+ const btn = document.createElement("button");
473
+ btn.type = "button";
474
+ btn.className = "jdfjs-toc-entry";
475
+ btn.style.display = "flex";
476
+ btn.style.alignItems = "baseline";
477
+ btn.style.gap = "8px";
478
+ btn.style.width = "100%";
479
+ btn.style.background = "transparent";
480
+ btn.style.border = "0";
481
+ btn.style.borderBottom = "1px dotted #e2e8f0";
482
+ btn.style.padding = `6px 8px 6px ${(entry.level - 1) * 16 + 8}px`;
483
+ btn.style.cursor = "pointer";
484
+ btn.style.fontSize = "14px";
485
+ btn.style.color = "#334155";
486
+ btn.style.textAlign = "left";
487
+
488
+ const titleSpan = document.createElement("span");
489
+ titleSpan.textContent = entry.title;
490
+ titleSpan.style.flex = "1";
491
+
492
+ const filler = document.createElement("span");
493
+ filler.style.flex = "1";
494
+ filler.style.borderBottom = "1px dotted #cbd5e1";
495
+ filler.style.alignSelf = "end";
496
+ filler.style.marginBottom = "4px";
497
+
498
+ const num = document.createElement("span");
499
+ num.textContent = String(entry.pageIndex + 1);
500
+ num.style.fontFamily = "JetBrains Mono, monospace";
501
+ num.style.fontSize = "12px";
502
+ num.style.color = "#94a3b8";
503
+ num.style.flexShrink = "0";
504
+
505
+ btn.appendChild(titleSpan);
506
+ btn.appendChild(filler);
507
+ btn.appendChild(num);
508
+
509
+ btn.addEventListener("click", () => ctx.onNavigatePage?.(entry.pageIndex));
510
+ wrap.appendChild(btn);
511
+ }
512
+ return wrap;
513
+ }
@@ -0,0 +1,38 @@
1
+ import type { Link } from "@jdf/core";
2
+
3
+ export interface ResolvedLink {
4
+ href: string;
5
+ internal: boolean;
6
+ pageIndex?: number;
7
+ }
8
+
9
+ export function resolveLink(link: Link | undefined): ResolvedLink | null {
10
+ if (!link) return null;
11
+ const target = typeof link === "string" ? link : link.target;
12
+ const internal = typeof link === "string" ? link.startsWith("#") : link.type === "internal";
13
+ if (internal) {
14
+ const m = target.replace(/^#/, "").match(/^page-(\d+)$/i);
15
+ if (m) return { href: target, internal: true, pageIndex: Number(m[1]) - 1 };
16
+ return { href: target, internal: true };
17
+ }
18
+ return { href: target, internal: false };
19
+ }
20
+
21
+ export function attachLinkBehaviour(
22
+ el: HTMLAnchorElement,
23
+ resolved: ResolvedLink,
24
+ onNavigatePage?: (idx: number) => void
25
+ ) {
26
+ el.href = resolved.href;
27
+ if (resolved.internal) {
28
+ el.addEventListener("click", (e) => {
29
+ if (resolved.pageIndex != null && onNavigatePage) {
30
+ e.preventDefault();
31
+ onNavigatePage(resolved.pageIndex);
32
+ }
33
+ });
34
+ } else {
35
+ el.target = "_blank";
36
+ el.rel = "noopener noreferrer";
37
+ }
38
+ }
@@ -0,0 +1,53 @@
1
+ import type { Style, StyleRef } from "@jdf/core";
2
+
3
+ function paddingToCss(p: NonNullable<Style["padding"]>): string {
4
+ if (typeof p === "number") return `${p}px`;
5
+ if (typeof p === "string") return p;
6
+ const { top = 0, right = 0, bottom = 0, left = 0 } = p;
7
+ return `${top}px ${right}px ${bottom}px ${left}px`;
8
+ }
9
+
10
+ export function styleToCss(style: Style): Record<string, string> {
11
+ const css: Record<string, string> = {};
12
+ if (style.fontFamily) css["font-family"] = style.fontFamily;
13
+ if (style.fontSize) css["font-size"] = `${style.fontSize * 1.333}px`;
14
+ if (style.fontWeight) css["font-weight"] = String(style.fontWeight);
15
+ if (style.fontStyle) css["font-style"] = style.fontStyle;
16
+ if (style.color) css["color"] = style.color;
17
+ if (style.backgroundColor) css["background-color"] = style.backgroundColor;
18
+ if (style.textAlign) css["text-align"] = style.textAlign;
19
+ if (style.textDecoration) {
20
+ const td = style.textDecoration === "strikethrough" ? "line-through" : style.textDecoration;
21
+ css["text-decoration"] = td;
22
+ }
23
+ if (style.lineHeight) css["line-height"] = String(style.lineHeight);
24
+ if (style.letterSpacing != null) {
25
+ css["letter-spacing"] = typeof style.letterSpacing === "number" ? `${style.letterSpacing}px` : style.letterSpacing;
26
+ }
27
+ if (style.padding != null) css["padding"] = paddingToCss(style.padding);
28
+ if (style.margin != null) css["margin"] = paddingToCss(style.margin as any);
29
+ if (style.marginTop != null) css["margin-top"] = `${style.marginTop}px`;
30
+ if (style.marginBottom != null) css["margin-bottom"] = `${style.marginBottom}px`;
31
+ if (style.border) css["border"] = style.border;
32
+ if (style.borderRadius != null) {
33
+ css["border-radius"] = typeof style.borderRadius === "number" ? `${style.borderRadius}px` : style.borderRadius;
34
+ }
35
+ if (style.opacity != null) css["opacity"] = String(style.opacity);
36
+ return css;
37
+ }
38
+
39
+ export function resolveStyle(ref: StyleRef | undefined, styles: Record<string, Style>): Record<string, string> {
40
+ if (!ref) return {};
41
+ if (typeof ref === "string") return styleToCss(styles[ref] || {});
42
+ if (Array.isArray(ref)) {
43
+ let merged: Style = {};
44
+ for (const k of ref) merged = { ...merged, ...(styles[k] || {}) };
45
+ return styleToCss(merged);
46
+ }
47
+ return styleToCss(ref);
48
+ }
49
+
50
+ /** Apply a CSS object map to an HTMLElement. */
51
+ export function applyStyle(el: HTMLElement, css: Record<string, string>) {
52
+ for (const [k, v] of Object.entries(css)) el.style.setProperty(k, v);
53
+ }
@@ -0,0 +1,14 @@
1
+ export interface TemplateVars {
2
+ pageNumber: number;
3
+ totalPages: number;
4
+ title: string;
5
+ author: string;
6
+ }
7
+
8
+ export function resolveTemplate(text: string, vars: TemplateVars): string {
9
+ return text
10
+ .replace(/\{\{pageNumber\}\}/g, String(vars.pageNumber))
11
+ .replace(/\{\{totalPages\}\}/g, String(vars.totalPages))
12
+ .replace(/\{\{title\}\}/g, vars.title)
13
+ .replace(/\{\{author\}\}/g, vars.author);
14
+ }