@epam/pdf-highlighter-kit 0.0.4 → 0.0.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.
@@ -1,9 +1,14 @@
1
+ import { ZoomMode, } from './types';
1
2
  import { PDFEngine } from './core/pdf-engine';
2
3
  import { ViewportManager } from './core/viewport-manager';
3
4
  import { UnifiedLayerBuilder } from './core/unified-layer-builder';
4
5
  import { UnifiedInteractionHandler } from './core/interaction-handler';
5
6
  import { PerformanceOptimizer } from './core/performance-optimizer';
6
7
  import { buildHighlightsIndex } from './utils/highlight-adapter';
8
+ import { applyLabelStyle, applyIconStyle, normalizeSize } from './utils/label-style';
9
+ import { sanitizeIconHtml } from './utils/sanitize-icon-html';
10
+ const CONTAINER_PADDING = 40;
11
+ const ZOOM_STEP = 1.2;
7
12
  export class PDFHighlightViewer {
8
13
  constructor() {
9
14
  this.container = null;
@@ -123,6 +128,7 @@ export class PDFHighlightViewer {
123
128
  interactionMode: 'hybrid',
124
129
  performanceMode: false,
125
130
  accessibility: true,
131
+ bboxOrigin: 'bottom-right',
126
132
  };
127
133
  this.pdfEngine = new PDFEngine(this.options);
128
134
  this.viewportManager = new ViewportManager(this.options.bufferPages, this.options.maxCachedPages);
@@ -407,22 +413,16 @@ export class PDFHighlightViewer {
407
413
  const cached = this.pageDimensions.get(pageNumber);
408
414
  if (cached)
409
415
  return cached;
410
- try {
411
- // Get PDF page and its viewport at current scale
412
- const page = await this.pdfEngine.getPage(pageNumber);
413
- const viewport = page.getViewport({ scale: this.currentScale });
414
- const dimensions = {
415
- width: viewport.width,
416
- height: viewport.height,
417
- };
418
- // Cache the dimensions
419
- this.pageDimensions.set(pageNumber, dimensions);
420
- return dimensions;
421
- }
422
- catch (error) {
423
- console.error(`Failed to get dimensions for page ${pageNumber}:`, error);
424
- return { width: 600, height: this.defaultPageHeight };
425
- }
416
+ // Get PDF page and its viewport at current scale
417
+ const page = await this.pdfEngine.getPage(pageNumber);
418
+ const viewport = page.getViewport({ scale: this.currentScale });
419
+ const dimensions = {
420
+ width: viewport.width,
421
+ height: viewport.height,
422
+ };
423
+ // Cache the dimensions
424
+ this.pageDimensions.set(pageNumber, dimensions);
425
+ return dimensions;
426
426
  }
427
427
  /**
428
428
  * Setup accessibility features
@@ -462,11 +462,11 @@ export class PDFHighlightViewer {
462
462
  break;
463
463
  case '+':
464
464
  case '=':
465
- this.setZoom(this.currentScale * 1.2);
465
+ this.setZoom(this.currentScale * ZOOM_STEP);
466
466
  event.preventDefault();
467
467
  break;
468
468
  case '-':
469
- this.setZoom(this.currentScale / 1.2);
469
+ this.setZoom(this.currentScale / ZOOM_STEP);
470
470
  event.preventDefault();
471
471
  break;
472
472
  }
@@ -508,31 +508,17 @@ export class PDFHighlightViewer {
508
508
  // Clear existing containers
509
509
  this.pdfContainer.innerHTML = '';
510
510
  this.pageContainers.clear();
511
- // Get dimensions for the first page to estimate all others
512
- let avgPageHeight = this.defaultPageHeight;
513
- try {
514
- const firstPageDimensions = await this.getPageDimensions(1);
515
- avgPageHeight = firstPageDimensions.height;
516
- }
517
- catch (_error) {
518
- console.warn('Could not get first page dimensions, using default');
519
- }
511
+ const firstPageDimensions = await this.getPageDimensions(1);
512
+ const avgPageHeight = firstPageDimensions.height;
520
513
  for (let pageNumber = 1; pageNumber <= this.totalPages; pageNumber++) {
521
514
  const pageContainer = document.createElement('div');
522
515
  pageContainer.className = 'pdf-page-container';
523
516
  pageContainer.setAttribute('data-page-number', pageNumber.toString());
524
517
  pageContainer.style.marginBottom = '20px';
525
518
  pageContainer.style.position = 'relative';
526
- // Try to set real dimensions, or use estimated height
527
- try {
528
- const dimensions = await this.getPageDimensions(pageNumber);
529
- pageContainer.style.height = `${dimensions.height}px`;
530
- pageContainer.style.width = `${dimensions.width}px`;
531
- }
532
- catch (_error) {
533
- // Use average height as fallback
534
- pageContainer.style.height = `${avgPageHeight}px`;
535
- }
519
+ const dimensions = await this.getPageDimensions(pageNumber);
520
+ pageContainer.style.height = `${dimensions.height}px`;
521
+ pageContainer.style.width = `${dimensions.width}px`;
536
522
  this.pdfContainer.appendChild(pageContainer);
537
523
  this.pageContainers.set(pageNumber, pageContainer);
538
524
  }
@@ -638,21 +624,50 @@ export class PDFHighlightViewer {
638
624
  getZoom() {
639
625
  return this.currentScale;
640
626
  }
641
- setZoom(scale) {
627
+ setZoom(value) {
628
+ if (value === ZoomMode.AUTO) {
629
+ void this.setAutoZoom();
630
+ }
631
+ else if (value === ZoomMode.PAGE_FIT) {
632
+ void this.setPageFitZoom();
633
+ }
634
+ else {
635
+ this.applyZoom(value);
636
+ }
637
+ }
638
+ applyZoom(scale) {
642
639
  const previousScale = this.currentScale;
643
640
  this.currentScale = Math.max(0.5, Math.min(5.0, scale));
644
641
  // Re-render visible pages with new scale
645
642
  this.reRenderVisiblePages();
646
643
  this.emit('zoomChanged', { scale: this.currentScale, previousScale });
647
644
  }
645
+ async setAutoZoom() {
646
+ const scales = await this.computeFitScales();
647
+ this.applyZoom(scales.scaleX);
648
+ }
649
+ async setPageFitZoom() {
650
+ const scales = await this.computeFitScales();
651
+ this.applyZoom(Math.min(scales.scaleX, scales.scaleY));
652
+ }
653
+ async computeFitScales() {
654
+ if (!this.container) {
655
+ return { scaleX: 1, scaleY: 1 };
656
+ }
657
+ const page = await this.pdfEngine.getPage(this.currentPage || 1);
658
+ const viewport = page.getViewport({ scale: 1 });
659
+ const scaleX = (this.container.clientWidth - CONTAINER_PADDING) / viewport.width;
660
+ const scaleY = (this.container.clientHeight - CONTAINER_PADDING) / viewport.height;
661
+ return { scaleX, scaleY };
662
+ }
648
663
  zoomIn() {
649
- this.setZoom(this.currentScale * 1.2);
664
+ this.applyZoom(this.currentScale * ZOOM_STEP);
650
665
  }
651
666
  zoomOut() {
652
- this.setZoom(this.currentScale / 1.2);
667
+ this.applyZoom(this.currentScale / ZOOM_STEP);
653
668
  }
654
669
  resetZoom() {
655
- this.setZoom(1.5);
670
+ this.applyZoom(1.5);
656
671
  }
657
672
  getCurrentPage() {
658
673
  return this.currentPage;
@@ -660,6 +675,19 @@ export class PDFHighlightViewer {
660
675
  getTotalPages() {
661
676
  return this.totalPages;
662
677
  }
678
+ async getThumbnails(pageNumbers, options) {
679
+ return this.pdfEngine.getThumbnails(pageNumbers, options);
680
+ }
681
+ async getThumbnailsDataUrl(pageNumbers, options) {
682
+ const canvases = await this.getThumbnails(pageNumbers, options);
683
+ const format = options?.format ?? 'image/webp';
684
+ const quality = options?.quality ?? 0.85;
685
+ const result = new Map();
686
+ canvases.forEach((canvas, pageNumber) => {
687
+ result.set(pageNumber, canvas.toDataURL(format, quality));
688
+ });
689
+ return result;
690
+ }
663
691
  // =============================================================================
664
692
  // Text Selection Management
665
693
  // =============================================================================
@@ -806,7 +834,20 @@ export class PDFHighlightViewer {
806
834
  const pageData = this.pdfEngine.getPageData(pageNumber);
807
835
  if (!pageData?.textContent)
808
836
  return;
809
- this.layerBuilder.updateHighlights(pageContainer, this.highlightsIndex.highlights, pageNumber, pageData.textContent, this.currentScale);
837
+ const normalizedHighlights = this.highlightsIndex.highlights.map((highlight) => ({
838
+ ...highlight,
839
+ bboxes: highlight.bboxes.map((bbox) => {
840
+ const normalized = this.normalizeBBoxForPage(bbox, bbox.page);
841
+ return {
842
+ ...bbox,
843
+ x1: normalized.x1,
844
+ y1: normalized.y1,
845
+ x2: normalized.x2,
846
+ y2: normalized.y2,
847
+ };
848
+ }),
849
+ }));
850
+ this.layerBuilder.updateHighlights(pageContainer, normalizedHighlights, pageNumber, pageData.textContent, this.currentScale);
810
851
  }
811
852
  /**
812
853
  * Update all unified layers
@@ -884,7 +925,14 @@ export class PDFHighlightViewer {
884
925
  for (const h of this.highlightsIndex.highlights) {
885
926
  for (let i = 0; i < h.bboxes.length; i++) {
886
927
  const b = h.bboxes[i];
887
- list.push({ termId: h.id, pageNumber: b.page, occurrenceIndex: i, x1: b.x1, y1: b.y1 });
928
+ const normalized = this.normalizeBBoxForPage(b, b.page);
929
+ list.push({
930
+ termId: h.id,
931
+ pageNumber: b.page,
932
+ occurrenceIndex: i,
933
+ x1: normalized.x1,
934
+ y1: normalized.y1,
935
+ });
888
936
  }
889
937
  }
890
938
  list.sort((a, b) => a.pageNumber - b.pageNumber || a.y1 - b.y1 || a.x1 - b.x1);
@@ -898,6 +946,7 @@ export class PDFHighlightViewer {
898
946
  if (!bbox)
899
947
  return;
900
948
  const page = bbox.page;
949
+ const normalizedBBox = this.normalizeBBoxForPage(bbox, page);
901
950
  this.highlightSelectedTerm(termId);
902
951
  this.setPage(page);
903
952
  void this.renderPage(page)
@@ -908,7 +957,7 @@ export class PDFHighlightViewer {
908
957
  return;
909
958
  }
910
959
  const pageTop = pageContainer.offsetTop;
911
- const y = bbox.y1 * this.currentScale;
960
+ const y = normalizedBBox.y1 * this.currentScale;
912
961
  this.container.scrollTop = Math.max(0, pageTop + y - 60);
913
962
  this.emit('navigationComplete', { termId, pageNumber: page, occurrenceIndex });
914
963
  })
@@ -938,7 +987,14 @@ export class PDFHighlightViewer {
938
987
  if (!this.container)
939
988
  return;
940
989
  const pageTop = this.getPageScrollTop(pageNumber);
941
- const targetY = pageTop + y * this.currentScale - this.container.clientHeight * 0.3;
990
+ const origin = this.options.bboxOrigin ?? 'bottom-right';
991
+ const pixelDimensions = this.pageDimensions.get(pageNumber);
992
+ if (!pixelDimensions) {
993
+ throw new Error(`Page dimensions for page ${pageNumber} are not available`);
994
+ }
995
+ const dimensions = this.toPageCoordinateDimensions(pixelDimensions);
996
+ const normalizedY = origin.startsWith('bottom') ? dimensions.height - y : y;
997
+ const targetY = pageTop + normalizedY * this.currentScale - this.container.clientHeight * 0.3;
942
998
  this.container.scrollTop = Math.max(0, targetY);
943
999
  this.emit('coordinateNavigation', { pageNumber, x, y });
944
1000
  }
@@ -1125,15 +1181,16 @@ export class PDFHighlightViewer {
1125
1181
  const bbox = highlight.bboxes[bboxIndex];
1126
1182
  if (bbox.page !== pageNumber)
1127
1183
  continue;
1184
+ const normalizedBBox = this.normalizeBBoxForPage(bbox, pageNumber);
1128
1185
  const highlightDiv = document.createElement('div');
1129
1186
  highlightDiv.className = 'highlight';
1130
1187
  highlightDiv.setAttribute('data-term-id', highlight.id);
1131
1188
  highlightDiv.setAttribute('data-page', String(pageNumber));
1132
1189
  highlightDiv.setAttribute('data-bbox-index', String(bboxIndex));
1133
- const left = bbox.x1 * scale;
1134
- const top = bbox.y1 * scale;
1135
- const width = (bbox.x2 - bbox.x1) * scale;
1136
- const height = (bbox.y2 - bbox.y1) * scale;
1190
+ const left = normalizedBBox.x1 * scale;
1191
+ const top = normalizedBBox.y1 * scale;
1192
+ const width = (normalizedBBox.x2 - normalizedBBox.x1) * scale;
1193
+ const height = (normalizedBBox.y2 - normalizedBBox.y1) * scale;
1137
1194
  highlightDiv.style.position = 'absolute';
1138
1195
  highlightDiv.style.left = `${left}px`;
1139
1196
  highlightDiv.style.top = `${top}px`;
@@ -1147,27 +1204,32 @@ export class PDFHighlightViewer {
1147
1204
  highlightDiv.style.userSelect = 'none';
1148
1205
  highlightDiv.style.mixBlendMode = 'multiply';
1149
1206
  const baseOpacity = typeof style?.opacity === 'number' ? style.opacity : 0.3;
1150
- const overlappingCount = this.countOverlappingHighlights(highlightLayer, bbox, scale);
1207
+ const overlappingCount = this.countOverlappingHighlights(highlightLayer, normalizedBBox, scale);
1151
1208
  const effectiveOpacity = Math.max(0.05, baseOpacity / Math.max(1, overlappingCount * 0.7));
1152
1209
  highlightDiv.style.opacity = effectiveOpacity.toString();
1153
1210
  highlightDiv.dataset.originalOpacity = effectiveOpacity.toString();
1154
1211
  const hoverOpacity = typeof style?.hoverOpacity === 'number'
1155
1212
  ? style.hoverOpacity
1156
1213
  : Math.min(0.6, effectiveOpacity + 0.2);
1214
+ const labelColor = style?.borderColor ?? style?.backgroundColor ?? '#666666';
1157
1215
  highlightDiv.addEventListener('mouseenter', () => {
1158
1216
  if (this.options.highlightsConfig?.enableMultilineHover) {
1159
- const same = highlightLayer.querySelectorAll(`[data-term-id="${highlight.id}"]`);
1217
+ const same = highlightLayer.querySelectorAll(`.highlight[data-term-id="${highlight.id}"]`);
1160
1218
  same.forEach((el) => (el.style.opacity = String(hoverOpacity)));
1161
- const other = highlightLayer.querySelectorAll(`div[data-term-id]:not([data-term-id="${highlight.id}"])`);
1219
+ const other = highlightLayer.querySelectorAll(`.highlight[data-term-id]:not([data-term-id="${highlight.id}"])`);
1162
1220
  other.forEach((el) => (el.style.opacity = '0.1'));
1163
1221
  }
1164
1222
  else {
1165
1223
  highlightDiv.style.opacity = String(hoverOpacity);
1166
1224
  }
1225
+ const labelEl = highlightDiv.querySelector('.highlight-label');
1226
+ if (labelEl) {
1227
+ labelEl.style.borderColor = borderColor;
1228
+ }
1167
1229
  });
1168
1230
  highlightDiv.addEventListener('mouseleave', () => {
1169
1231
  if (this.options.highlightsConfig?.enableMultilineHover) {
1170
- const all = highlightLayer.querySelectorAll(`div[data-term-id]`);
1232
+ const all = highlightLayer.querySelectorAll('.highlight[data-term-id]');
1171
1233
  all.forEach((el) => {
1172
1234
  const original = el.dataset.originalOpacity ?? '0.3';
1173
1235
  el.style.opacity = original;
@@ -1176,8 +1238,50 @@ export class PDFHighlightViewer {
1176
1238
  else {
1177
1239
  highlightDiv.style.opacity = highlightDiv.dataset.originalOpacity ?? '0.3';
1178
1240
  }
1241
+ const labelEl = highlightDiv.querySelector('.highlight-label');
1242
+ if (labelEl) {
1243
+ labelEl.style.borderColor = labelColor;
1244
+ }
1179
1245
  });
1180
1246
  highlightLayer.appendChild(highlightDiv);
1247
+ if (highlight.label || highlight.beforeIcon) {
1248
+ const labelEl = document.createElement('span');
1249
+ labelEl.className = 'highlight-label';
1250
+ labelEl.setAttribute('data-term-id', highlight.id);
1251
+ labelEl.style.position = 'absolute';
1252
+ labelEl.style.left = '0';
1253
+ labelEl.style.transform = 'translateX(-100%)';
1254
+ labelEl.style.top = '-1px';
1255
+ labelEl.style.display = 'flex';
1256
+ labelEl.style.alignItems = 'center';
1257
+ labelEl.style.justifyContent = 'flex-end';
1258
+ labelEl.style.gap = '4px';
1259
+ labelEl.style.pointerEvents = 'auto';
1260
+ labelEl.style.cursor = 'pointer';
1261
+ labelEl.style.whiteSpace = 'nowrap';
1262
+ labelEl.style.border = `1px solid ${labelColor}`;
1263
+ applyLabelStyle(labelEl, highlight.labelStyle);
1264
+ if (highlight.beforeIcon) {
1265
+ const iconWrap = document.createElement('span');
1266
+ iconWrap.className = 'highlight-label-icon';
1267
+ iconWrap.innerHTML = sanitizeIconHtml(highlight.beforeIcon);
1268
+ const svg = iconWrap.querySelector('svg');
1269
+ if (svg) {
1270
+ svg.removeAttribute('width');
1271
+ svg.removeAttribute('height');
1272
+ }
1273
+ const iconSize = highlight.labelStyle?.iconSize;
1274
+ const size = normalizeSize(iconSize);
1275
+ iconWrap.style.width = size;
1276
+ iconWrap.style.height = size;
1277
+ applyIconStyle(iconWrap, highlight.labelStyle);
1278
+ labelEl.appendChild(iconWrap);
1279
+ }
1280
+ if (highlight.label) {
1281
+ labelEl.appendChild(document.createTextNode(highlight.label));
1282
+ }
1283
+ highlightDiv.appendChild(labelEl);
1284
+ }
1181
1285
  }
1182
1286
  }
1183
1287
  pageContainer.appendChild(highlightLayer);
@@ -1201,6 +1305,12 @@ export class PDFHighlightViewer {
1201
1305
  getHighlightStyle(termId) {
1202
1306
  return this.getHighlightById(termId)?.style;
1203
1307
  }
1308
+ getHighlightElements(root, termId) {
1309
+ const selector = termId
1310
+ ? `.highlight[data-term-id="${termId}"], .highlight-wrapper[data-term-id="${termId}"]`
1311
+ : '.highlight, .highlight-wrapper';
1312
+ return Array.from(root.querySelectorAll(selector));
1313
+ }
1204
1314
  /**
1205
1315
  * Update highlights colors for specified page
1206
1316
  * */
@@ -1248,7 +1358,44 @@ export class PDFHighlightViewer {
1248
1358
  */
1249
1359
  buildSpatialIndexForPage(pageNumber) {
1250
1360
  const refs = this.highlightsIndex.pages[String(pageNumber)] ?? [];
1251
- this.performanceOptimizer.buildSpatialIndex(refs, pageNumber);
1361
+ const normalizedRefs = refs.map((ref) => ({
1362
+ ...ref,
1363
+ bbox: this.normalizeBBoxForPage({ ...ref.bbox, page: ref.page }, ref.page),
1364
+ }));
1365
+ this.performanceOptimizer.buildSpatialIndex(normalizedRefs, pageNumber);
1366
+ }
1367
+ toPageCoordinateDimensions(pixelDimensions) {
1368
+ const scale = this.currentScale > 0 ? this.currentScale : 1;
1369
+ return {
1370
+ width: pixelDimensions.width / scale,
1371
+ height: pixelDimensions.height / scale,
1372
+ };
1373
+ }
1374
+ normalizeBBoxForPage(bbox, pageNumber) {
1375
+ const origin = this.options.bboxOrigin ?? 'bottom-right';
1376
+ const pixelDimensions = this.pageDimensions.get(pageNumber);
1377
+ if (!pixelDimensions) {
1378
+ throw new Error(`Page dimensions for page ${pageNumber} are not available`);
1379
+ }
1380
+ const { width: pageWidth, height: pageHeight } = this.toPageCoordinateDimensions(pixelDimensions);
1381
+ let x1 = bbox.x1;
1382
+ let x2 = bbox.x2;
1383
+ let y1 = bbox.y1;
1384
+ let y2 = bbox.y2;
1385
+ if (origin.endsWith('right')) {
1386
+ x1 = pageWidth - bbox.x1;
1387
+ x2 = pageWidth - bbox.x2;
1388
+ }
1389
+ if (origin.startsWith('bottom')) {
1390
+ y1 = pageHeight - bbox.y1;
1391
+ y2 = pageHeight - bbox.y2;
1392
+ }
1393
+ return {
1394
+ x1: Math.min(x1, x2),
1395
+ y1: Math.min(y1, y2),
1396
+ x2: Math.max(x1, x2),
1397
+ y2: Math.max(y1, y2),
1398
+ };
1252
1399
  }
1253
1400
  /**
1254
1401
  * Build spatial indices for all pages
@@ -1297,9 +1444,9 @@ export class PDFHighlightViewer {
1297
1444
  // Store the selected term ID for persistence across page renders
1298
1445
  this.selectedTermId = termId;
1299
1446
  // Remove previous selection highlighting
1300
- this.clearSelectedTermHighlighting();
1447
+ this.clearSelectedTermHighlighting(false);
1301
1448
  // Add selected class to all instances of this term
1302
- const termElements = this.container.querySelectorAll(`[data-term-id="${termId}"]`);
1449
+ const termElements = this.getHighlightElements(this.container, termId);
1303
1450
  termElements.forEach((element) => {
1304
1451
  element.classList.add('selected-term');
1305
1452
  // Override inline styles for selected term
@@ -1314,7 +1461,7 @@ export class PDFHighlightViewer {
1314
1461
  htmlElement.style.transition = 'all 0.3s ease';
1315
1462
  });
1316
1463
  // Also dim all other highlights
1317
- const allHighlights = this.container.querySelectorAll('.highlight, .highlight-wrapper');
1464
+ const allHighlights = this.getHighlightElements(this.container);
1318
1465
  allHighlights.forEach((element) => {
1319
1466
  const elementTermId = element.getAttribute('data-term-id');
1320
1467
  if (!elementTermId || elementTermId !== termId) {
@@ -1330,12 +1477,13 @@ export class PDFHighlightViewer {
1330
1477
  /**
1331
1478
  * Clear selected term highlighting
1332
1479
  */
1333
- clearSelectedTermHighlighting() {
1480
+ clearSelectedTermHighlighting(clearStoredSelection = true) {
1334
1481
  if (!this.container)
1335
1482
  return;
1336
- // Clear the selected term ID
1337
- this.selectedTermId = null;
1338
- const selectedElements = this.container.querySelectorAll('.selected-term');
1483
+ if (clearStoredSelection) {
1484
+ this.selectedTermId = null;
1485
+ }
1486
+ const selectedElements = this.container.querySelectorAll('.highlight.selected-term, .highlight-wrapper.selected-term');
1339
1487
  selectedElements.forEach((element) => {
1340
1488
  element.classList.remove('selected-term');
1341
1489
  // Reset inline styles for selected elements
@@ -1346,7 +1494,7 @@ export class PDFHighlightViewer {
1346
1494
  htmlElement.style.borderWidth = '';
1347
1495
  // Keep original opacity as it was set by the original rendering
1348
1496
  });
1349
- const dimmedElements = this.container.querySelectorAll('.dimmed-highlight');
1497
+ const dimmedElements = this.container.querySelectorAll('.highlight.dimmed-highlight, .highlight-wrapper.dimmed-highlight');
1350
1498
  dimmedElements.forEach((element) => {
1351
1499
  element.classList.remove('dimmed-highlight');
1352
1500
  // Reset inline styles for dimmed elements