@uurtech/jdf 0.1.26 → 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uurtech/jdf",
3
- "version": "0.1.26",
3
+ "version": "0.2.0",
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
@@ -166,6 +166,8 @@
166
166
  .jdfjs-page-wrapper {
167
167
  display: block;
168
168
  scroll-margin-top: 16px;
169
+ flex-shrink: 0;
170
+ max-width: 100%;
169
171
  }
170
172
 
171
173
  .jdfjs-page {
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
- doc.resources.images[entry.id] = {
71
- src: "embedded",
72
- mimeType: entry.mimeType || "image/png",
73
- data: uint8ToBase64(data),
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
 
@@ -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((el) => {
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 center";
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(),