@uurtech/jdf 0.1.26 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jdfjs.cjs +10 -3
- package/dist/jdfjs.cjs.map +1 -1
- package/dist/jdfjs.css +2 -0
- package/dist/jdfjs.d.cts +12 -1
- package/dist/jdfjs.d.ts +12 -1
- package/dist/jdfjs.js +10 -3
- package/dist/jdfjs.js.map +1 -1
- package/package.json +1 -1
- package/src/jdfjs.css +2 -0
- package/src/jdfx.ts +12 -5
- package/src/renderers/element.ts +69 -0
- package/src/viewer.ts +55 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uurtech/jdf",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Render JDF (JSON Document Format) documents in the browser. Drop-in replacement for embedding PDFs — point at a .jdf file and it appears on the page, fully styled, searchable, with a clickable TOC.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ugur Kazdal <ugur@uurtech.com>",
|
package/src/jdfjs.css
CHANGED
package/src/jdfx.ts
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
JDFX_DOCUMENT_PATH,
|
|
4
4
|
JDFX_MANIFEST_PATH,
|
|
5
5
|
JDFX_ASSET_DIR,
|
|
6
|
+
isVideoMime,
|
|
7
|
+
mimeOf,
|
|
6
8
|
type JdfDocument,
|
|
7
9
|
type JdfxManifest,
|
|
8
10
|
} from "@jdf/core";
|
|
@@ -67,11 +69,16 @@ export async function unpackJdfxToDocument(bytes: ArrayBuffer | Uint8Array): Pro
|
|
|
67
69
|
const file = zip.file(entry.path);
|
|
68
70
|
if (!file) continue;
|
|
69
71
|
const data = await file.async("uint8array");
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
const mimeType = entry.mimeType || mimeOf(entry.path);
|
|
73
|
+
const res = { src: "embedded" as const, mimeType, data: uint8ToBase64(data) };
|
|
74
|
+
// Videos bind into resources.videos, everything else into resources.images
|
|
75
|
+
// — the renderers look up `resource` ids in both.
|
|
76
|
+
if (isVideoMime(mimeType)) {
|
|
77
|
+
if (!doc.resources.videos) doc.resources.videos = {};
|
|
78
|
+
doc.resources.videos[entry.id] = res;
|
|
79
|
+
} else {
|
|
80
|
+
doc.resources.images[entry.id] = res;
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
package/src/renderers/element.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ShapeElement, CollapsibleElement, TocElement, RichTextRun, ListItem, TableCellValue, ImageResource,
|
|
5
5
|
FormInputElement, FormTextareaElement, FormCheckboxElement, FormSelectElement, FormSignatureElement,
|
|
6
6
|
TextAlign,
|
|
7
|
+
VideoElement,
|
|
7
8
|
} from "@jdf/core";
|
|
8
9
|
import { unitToPx } from "@jdf/core";
|
|
9
10
|
import { resolveStyle, styleToCss, applyStyle } from "../utils/style";
|
|
@@ -35,6 +36,7 @@ export function renderElement(el: Element, ctx: RenderContext): HTMLElement | nu
|
|
|
35
36
|
case "text": inner = renderText(el, ctx); break;
|
|
36
37
|
case "richtext": inner = renderRichText(el, ctx); break;
|
|
37
38
|
case "image": inner = renderImage(el, ctx); break;
|
|
39
|
+
case "video": inner = renderVideo(el, ctx); break;
|
|
38
40
|
case "table": inner = renderTable(el, ctx); break;
|
|
39
41
|
case "list": inner = renderList(el, ctx); break;
|
|
40
42
|
case "shape": inner = renderShape(el); break;
|
|
@@ -141,9 +143,76 @@ function lookupResource(resources: Resources | undefined, key: string): ImageRes
|
|
|
141
143
|
if (direct && typeof direct === "object" && "data" in direct) return direct as ImageResource;
|
|
142
144
|
const inImages = resources.images?.[key];
|
|
143
145
|
if (inImages) return inImages;
|
|
146
|
+
// Videos live in resources.videos; a .jdfx unpacker binds by MIME type.
|
|
147
|
+
const inVideos = resources.videos?.[key];
|
|
148
|
+
if (inVideos) return inVideos;
|
|
144
149
|
return undefined;
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
function mediaSrc(el: { src?: string; resource?: string }, resources: Resources | undefined, fallbackMime: string): string {
|
|
153
|
+
if (el.src?.startsWith("data:") || el.src?.startsWith("http")) return el.src;
|
|
154
|
+
if (el.resource) {
|
|
155
|
+
const res = lookupResource(resources, el.resource);
|
|
156
|
+
if (res?.data) {
|
|
157
|
+
const mime = res.mimeType || fallbackMime;
|
|
158
|
+
if (res.data.startsWith("data:")) return res.data;
|
|
159
|
+
return `data:${mime};base64,${res.data}`;
|
|
160
|
+
}
|
|
161
|
+
if (res?.path) return res.path;
|
|
162
|
+
}
|
|
163
|
+
return el.src || "";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── video ───────────────────────────────────────────────────────────────────
|
|
167
|
+
function renderVideo(el: VideoElement, ctx: RenderContext): HTMLElement {
|
|
168
|
+
const wrap = document.createElement("div");
|
|
169
|
+
wrap.className = "jdfjs-video";
|
|
170
|
+
wrap.style.width = "100%";
|
|
171
|
+
wrap.style.height = "100%";
|
|
172
|
+
const video = document.createElement("video");
|
|
173
|
+
video.src = mediaSrc(el, ctx.resources, "video/mp4");
|
|
174
|
+
if (el.poster) {
|
|
175
|
+
// Poster may be an image resource id or a URL / data URL.
|
|
176
|
+
const res = lookupResource(ctx.resources, el.poster);
|
|
177
|
+
video.poster = res?.data ? (res.data.startsWith("data:") ? res.data : `data:${res.mimeType || "image/png"};base64,${res.data}`) : el.poster;
|
|
178
|
+
}
|
|
179
|
+
if (el.title) { video.title = el.title; video.setAttribute("aria-label", el.title); }
|
|
180
|
+
video.controls = el.controls !== false;
|
|
181
|
+
video.autoplay = !!el.autoplay;
|
|
182
|
+
video.loop = !!el.loop;
|
|
183
|
+
// Browsers only allow autoplay when muted.
|
|
184
|
+
video.muted = !!el.muted || !!el.autoplay;
|
|
185
|
+
video.playsInline = true;
|
|
186
|
+
video.preload = "metadata";
|
|
187
|
+
video.style.display = "block";
|
|
188
|
+
video.style.width = "100%";
|
|
189
|
+
video.style.height = "100%";
|
|
190
|
+
video.style.background = "#000";
|
|
191
|
+
switch (el.fit) {
|
|
192
|
+
case "cover": video.style.objectFit = "cover"; break;
|
|
193
|
+
case "fill": video.style.objectFit = "fill"; break;
|
|
194
|
+
case "none": video.style.objectFit = "none"; break;
|
|
195
|
+
default: video.style.objectFit = "contain";
|
|
196
|
+
}
|
|
197
|
+
applyStyle(video, resolveStyle(el.style, ctx.styles));
|
|
198
|
+
if (el.id) video.setAttribute("data-jdf-video", el.id);
|
|
199
|
+
// Transcript → WebVTT captions, so playback shows the very text RAG indexed.
|
|
200
|
+
const segs = el.transcript?.segments;
|
|
201
|
+
if (segs && segs.length) {
|
|
202
|
+
const ts = (t: number) => { const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), sec = t % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${sec.toFixed(3).padStart(6, "0")}`; };
|
|
203
|
+
const body = segs.map((sg, i) => `${i + 1}\n${ts(sg.t0)} --> ${ts(Math.max(sg.t1, sg.t0 + 0.2))}\n${sg.speaker ? sg.speaker + ": " : ""}${sg.text}`).join("\n\n");
|
|
204
|
+
const track = document.createElement("track");
|
|
205
|
+
track.kind = "captions";
|
|
206
|
+
track.label = "Transcript";
|
|
207
|
+
track.srclang = el.transcript?.language || "en";
|
|
208
|
+
track.src = `data:text/vtt;charset=utf-8,${encodeURIComponent(`WEBVTT\n\n${body}\n`)}`;
|
|
209
|
+
track.default = true;
|
|
210
|
+
video.appendChild(track);
|
|
211
|
+
}
|
|
212
|
+
wrap.appendChild(video);
|
|
213
|
+
return wrap;
|
|
214
|
+
}
|
|
215
|
+
|
|
147
216
|
function imageSrc(el: ImageElement, resources?: Resources): string {
|
|
148
217
|
if (el.src?.startsWith("data:") || el.src?.startsWith("http")) return el.src;
|
|
149
218
|
if (el.resource) {
|
package/src/viewer.ts
CHANGED
|
@@ -52,6 +52,13 @@ export interface JDFViewerInstance {
|
|
|
52
52
|
getCurrentPage: () => number;
|
|
53
53
|
/** Replace the document */
|
|
54
54
|
setDocument: (doc: JdfDocument) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Jump to a moment in a video element and start playing — the retrieval-side
|
|
57
|
+
* counterpart of `jdf chunk`'s `media: { element, t0 }`. `elementId` is the
|
|
58
|
+
* video element's `id`; `seconds` the offset. Scrolls the page into view.
|
|
59
|
+
* Returns false when no such video exists.
|
|
60
|
+
*/
|
|
61
|
+
seek: (elementId: string, seconds: number) => boolean;
|
|
55
62
|
/** Tear down — removes DOM and event listeners */
|
|
56
63
|
destroy: () => void;
|
|
57
64
|
/**
|
|
@@ -175,6 +182,13 @@ export class JDFViewer {
|
|
|
175
182
|
// Window resize fallback for fit-width / fit-page when the host element's
|
|
176
183
|
// own size doesn't change but the viewport's does (flex re-layout, etc).
|
|
177
184
|
private windowResizeListener: (() => void) | null = null;
|
|
185
|
+
// Zoom the host asked for (option or toolbar). In "manual" fit mode the
|
|
186
|
+
// effective zoom is capped so a page never renders wider than its
|
|
187
|
+
// container — a 794 px A4 page inside a 390 px phone viewport used to be
|
|
188
|
+
// cut in half with a sideways scroll. Once the user zooms by hand we stop
|
|
189
|
+
// capping and let them scroll.
|
|
190
|
+
private requestedZoom = 1;
|
|
191
|
+
private userZoomed = false;
|
|
178
192
|
|
|
179
193
|
constructor(container: HTMLElement, doc: JdfDocument, options: JDFViewerOptions = {}) {
|
|
180
194
|
this.container = container;
|
|
@@ -189,6 +203,7 @@ export class JDFViewer {
|
|
|
189
203
|
...options,
|
|
190
204
|
};
|
|
191
205
|
this.zoom = this.options.zoom;
|
|
206
|
+
this.requestedZoom = this.options.zoom;
|
|
192
207
|
this.currentPage = this.options.initialPage;
|
|
193
208
|
this.applyContainerSize();
|
|
194
209
|
this.mount();
|
|
@@ -293,9 +308,8 @@ export class JDFViewer {
|
|
|
293
308
|
this.darkModeListener = listener;
|
|
294
309
|
}
|
|
295
310
|
|
|
296
|
-
/** Auto-zoom for fit modes. */
|
|
311
|
+
/** Auto-zoom for fit modes (and the responsive cap in manual mode). */
|
|
297
312
|
private applyFit() {
|
|
298
|
-
if (this.options.fit === "manual") return;
|
|
299
313
|
const firstPage = this.pagesEl.querySelector<HTMLElement>(".jdfjs-page");
|
|
300
314
|
if (!firstPage) return;
|
|
301
315
|
// Read intrinsic page size from the inline width/min-height in px (set in renderPage)
|
|
@@ -304,6 +318,17 @@ export class JDFViewer {
|
|
|
304
318
|
if (!pageWidth || !pageHeight) return;
|
|
305
319
|
const containerWidth = this.pagesEl.clientWidth - 32; // margin
|
|
306
320
|
const containerHeight = this.pagesEl.clientHeight - 32;
|
|
321
|
+
if (this.options.fit === "manual") {
|
|
322
|
+
if (containerWidth <= 0) return;
|
|
323
|
+
const maxFit = containerWidth / pageWidth;
|
|
324
|
+
const next = this.userZoomed ? this.zoom : Math.min(this.requestedZoom, maxFit);
|
|
325
|
+
if (Math.abs(next - this.zoom) > 0.001 || firstPage.style.transformOrigin !== "top left") {
|
|
326
|
+
this.zoom = Math.max(0.25, Math.min(3, next));
|
|
327
|
+
this.applyZoom();
|
|
328
|
+
this.updateIndicators();
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
307
332
|
if (this.options.fit === "fit-width") {
|
|
308
333
|
this.zoom = Math.max(0.25, Math.min(3, containerWidth / pageWidth));
|
|
309
334
|
} else if (this.options.fit === "fit-page") {
|
|
@@ -380,6 +405,19 @@ export class JDFViewer {
|
|
|
380
405
|
this.applyZoom();
|
|
381
406
|
}
|
|
382
407
|
|
|
408
|
+
/** Seek a video element (by its `id`) to `seconds` and play; scrolls it into view. */
|
|
409
|
+
seek(elementId: string, seconds: number): boolean {
|
|
410
|
+
const video = this.pagesEl.querySelector<HTMLVideoElement>(`video[data-jdf-video="${elementId.replace(/"/g, '\\"')}"]`);
|
|
411
|
+
if (!video) return false;
|
|
412
|
+
const pageWrap = video.closest<HTMLElement>(".jdfjs-page-wrapper");
|
|
413
|
+
const idx = pageWrap ? Number(pageWrap.getAttribute("data-page-index")) : NaN;
|
|
414
|
+
if (!Number.isNaN(idx)) this.goToPage(idx);
|
|
415
|
+
video.scrollIntoView({ block: "center" });
|
|
416
|
+
const go = () => { video.currentTime = Math.max(0, seconds); video.play().catch(() => { /* autoplay policy — user can press play */ }); };
|
|
417
|
+
if (video.readyState >= 1) go(); else video.addEventListener("loadedmetadata", go, { once: true });
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
383
421
|
private renderPage(page: Page, pageIndex: number, styles: Record<string, Style>): HTMLDivElement {
|
|
384
422
|
const dim = getPageDimensions(
|
|
385
423
|
page.pageSize ?? this.doc.meta?.pageSize ?? "A4",
|
|
@@ -508,14 +546,26 @@ export class JDFViewer {
|
|
|
508
546
|
|
|
509
547
|
private applyZoom() {
|
|
510
548
|
this.pagesEl.style.setProperty("--jdfjs-zoom", String(this.zoom));
|
|
511
|
-
this.pagesEl.querySelectorAll<HTMLElement>(".jdfjs-page").forEach((
|
|
549
|
+
this.pagesEl.querySelectorAll<HTMLElement>(".jdfjs-page-wrapper").forEach((wrapper) => {
|
|
550
|
+
const el = wrapper.querySelector<HTMLElement>(".jdfjs-page");
|
|
551
|
+
if (!el) return;
|
|
552
|
+
// `transform: scale()` does not take part in layout, so the wrapper
|
|
553
|
+
// used to keep the page's unscaled 794 px footprint: on narrow hosts
|
|
554
|
+
// the pages column scrolled sideways and the page sat off-centre.
|
|
555
|
+
// Size the wrapper to the *rendered* box and scale from its corner.
|
|
556
|
+
const w = parseFloat(el.style.width || "0") || el.offsetWidth;
|
|
557
|
+
const h = el.offsetHeight;
|
|
512
558
|
el.style.transform = `scale(${this.zoom})`;
|
|
513
|
-
el.style.transformOrigin = "top
|
|
559
|
+
el.style.transformOrigin = "top left";
|
|
560
|
+
wrapper.style.width = `${Math.round(w * this.zoom)}px`;
|
|
561
|
+
wrapper.style.height = `${Math.round(h * this.zoom)}px`;
|
|
514
562
|
});
|
|
515
563
|
}
|
|
516
564
|
|
|
517
565
|
setZoom(z: number) {
|
|
518
566
|
this.zoom = Math.max(0.25, Math.min(3, z));
|
|
567
|
+
this.requestedZoom = this.zoom;
|
|
568
|
+
this.userZoomed = true;
|
|
519
569
|
this.applyZoom();
|
|
520
570
|
this.updateIndicators();
|
|
521
571
|
}
|
|
@@ -680,6 +730,7 @@ export class JDFViewer {
|
|
|
680
730
|
setZoom: (z: number) => this.setZoom(z),
|
|
681
731
|
getZoom: () => this.getZoom(),
|
|
682
732
|
goToPage: (i: number) => this.goToPage(i),
|
|
733
|
+
seek: (elementId: string, seconds: number) => this.seek(elementId, seconds),
|
|
683
734
|
getCurrentPage: () => this.getCurrentPage(),
|
|
684
735
|
setDocument: (d: JdfDocument) => this.setDocument(d),
|
|
685
736
|
destroy: () => this.destroy(),
|