stimulus-pdf-viewer-rails 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 838e9d480a11d8bbf5a590bad2ab618624c1ec95284a1b7aa28b2c40468447ab
4
- data.tar.gz: 7296bfffca896a43fb16a50a3d271a790eab5541d3744aef8df90ae336e2767a
3
+ metadata.gz: 651ed5f4fcb8152f9716cdee8eaa0e69481c9ac0f44534331fa6650fbbf30a76
4
+ data.tar.gz: c045dc496a804a77b19f6d5ef81b9657cc1f5321c857bc04a87103ed0431d9a4
5
5
  SHA512:
6
- metadata.gz: 32804a93259414ccee6b961564c6587204c6deadcbf5bf07de69d1872751eb125c89099ec5e69125934f7a4ca85974b9bdf5b2b620912725bccb599ce27e082e
7
- data.tar.gz: '099ea9e6b131bfba5f48b4a63818c03841e05b5da782f51adca84418bc9ee3538b8cc47982e169e76386acaa7c125278bb3794f53ac15a021efd152c1bbf9c37'
6
+ metadata.gz: 28a1ad29124fd3cc7d30a470e9774dc0d043aac56b0df4d81f1ae187ea474b9009db8cbd2dedee4b73f4a38edb9ff97ff0ecfe021f402b37df23a0a73694e797
7
+ data.tar.gz: 28302f66b536695dd0fca27167a72d6b111e3290dd5fb2f102db0b710bb07a059353d9bad7cac1580ca68729eef02f337a96c3f20c4d0da72124f58391c6bf61
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.6.0] - 2026-09-22
6
+
7
+ ### Fixed
8
+ - Updated stimulus-pdf-viewer to 0.6.0
9
+
10
+ Annotated downloads now include everything shown on screen. Underlines were
11
+ never exported, because the download code expected an `annotation_type` of
12
+ `underline` while the underline tool created `line`. Comments on highlights,
13
+ underlines, and drawings are now exported too. Curly quotes, em dashes, and
14
+ non-Latin text in comments and notes no longer come out garbled. See the
15
+ upstream
16
+ [0.6.0 release notes](https://github.com/jhubert/stimulus-pdf-viewer/releases/tag/v0.6.0)
17
+ for details.
18
+
19
+ ### Upgrade notes
20
+ - New underlines are saved with `annotation_type: "underline"` instead of
21
+ `"line"`. Make sure your `Annotation` model accepts `underline`; with an
22
+ integer enum, rename the `line` key in place. Stored `"line"` records keep
23
+ working, but they are deprecated. The upstream release notes give the SQL to
24
+ migrate them.
25
+
5
26
  ## [0.5.0] - 2026-08-11
6
27
 
7
28
  ### Added
@@ -1,7 +1,7 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
  import * as pdfjsLib from 'pdfjs-dist';
3
3
  import { FetchRequest } from '@rails/request.js';
4
- import { PDFDocument, StandardFonts, degrees, rgb, PDFName, PDFString, PDFArray } from 'pdf-lib';
4
+ import { PDFDocument, StandardFonts, degrees, rgb, PDFName, PDFArray, PDFString, PDFHexString } from 'pdf-lib';
5
5
 
6
6
  /**
7
7
  * EventBus - Internal event system for the PDF viewer.
@@ -1694,6 +1694,115 @@ class MemoryAnnotationStore extends AnnotationStore {
1694
1694
  }
1695
1695
  }
1696
1696
 
1697
+ // Canonical annotation vocabulary.
1698
+ //
1699
+ // `annotation_type` travels over the REST API and is stored in the consuming
1700
+ // application's database, so inbound records may still carry legacy values that
1701
+ // this library no longer emits. Records are normalized once on the way in (see
1702
+ // AnnotationManager); everything downstream may assume the canonical names.
1703
+ //
1704
+ // These are the annotation types, not the editing modes -- see ToolMode in
1705
+ // index.js, which adds SELECT and otherwise uses the same strings.
1706
+
1707
+ const AnnotationType = {
1708
+ HIGHLIGHT: "highlight",
1709
+ UNDERLINE: "underline",
1710
+ NOTE: "note",
1711
+ INK: "ink"
1712
+ };
1713
+
1714
+ // Legacy `annotation_type` values accepted on read and rewritten to canonical
1715
+ // names. "line" was the original spelling of underline; it is also actively
1716
+ // misleading, naming the PDF spec's Line annotation (subtype 4) -- a different
1717
+ // annotation that this viewer does not support.
1718
+ const LEGACY_TYPE_ALIASES = {
1719
+ line: AnnotationType.UNDERLINE
1720
+ };
1721
+
1722
+ // Marks an ink annotation drawn with the highlighter rather than the pen. This
1723
+ // lives on `subject` rather than being its own type because both are genuinely
1724
+ // Ink annotations once exported to PDF.
1725
+ const FREE_HIGHLIGHT_SUBJECT = "Free Highlight";
1726
+
1727
+ // PDF annotation subtype each canonical type is written as by DownloadManager
1728
+ // when baking annotations into the file. See PDF 32000-1:2008 section 12.5.6.
1729
+ const PDF_SUBTYPES = {
1730
+ [AnnotationType.HIGHLIGHT]: "Highlight",
1731
+ [AnnotationType.UNDERLINE]: "Underline",
1732
+ [AnnotationType.NOTE]: "Text",
1733
+ [AnnotationType.INK]: "Ink"
1734
+ };
1735
+
1736
+ // Legacy values already warned about, so a document full of them produces one
1737
+ // warning per type rather than one per annotation.
1738
+ const warnedLegacyTypes = new Set();
1739
+
1740
+ /**
1741
+ * Map a possibly-legacy annotation type onto the canonical vocabulary.
1742
+ *
1743
+ * Warns once per legacy value encountered. The aliases exist only to carry
1744
+ * records written by earlier versions, and are slated for removal; the warning
1745
+ * is how a consuming application finds out it still has rows to migrate while
1746
+ * that support is still in place.
1747
+ *
1748
+ * @param {string} type
1749
+ * @returns {string}
1750
+ */
1751
+ function normalizeAnnotationType(type) {
1752
+ const canonical = LEGACY_TYPE_ALIASES[type];
1753
+ if (!canonical) return type
1754
+
1755
+ if (!warnedLegacyTypes.has(type)) {
1756
+ warnedLegacyTypes.add(type);
1757
+ console.warn(
1758
+ `[stimulus-pdf-viewer] Deprecated annotation_type "${type}" was read and ` +
1759
+ `treated as "${canonical}". Support for the old value will be removed in a ` +
1760
+ `future release. Migrate stored annotations: ` +
1761
+ `UPDATE annotations SET annotation_type = '${canonical}' WHERE annotation_type = '${type}'.`
1762
+ );
1763
+ }
1764
+
1765
+ return canonical
1766
+ }
1767
+
1768
+ /**
1769
+ * Rewrite a record's annotation_type in place. Mutates rather than clones so
1770
+ * that references already held elsewhere (selectedAnnotation, rendered sidebar
1771
+ * rows) observe the same canonical value.
1772
+ * @param {Object} annotation
1773
+ * @returns {Object} the same annotation
1774
+ */
1775
+ function normalizeAnnotation(annotation) {
1776
+ if (annotation && annotation.annotation_type) {
1777
+ annotation.annotation_type = normalizeAnnotationType(annotation.annotation_type);
1778
+ }
1779
+ return annotation
1780
+ }
1781
+
1782
+ /** An ink annotation drawn with the highlighter tool. */
1783
+ function isFreeHighlight(annotation) {
1784
+ return annotation?.annotation_type === AnnotationType.INK &&
1785
+ annotation?.subject === FREE_HIGHLIGHT_SUBJECT
1786
+ }
1787
+
1788
+ /** Highlights and highlighter-drawn ink both render as highlights. */
1789
+ function isHighlightLike(annotation) {
1790
+ return annotation?.annotation_type === AnnotationType.HIGHLIGHT ||
1791
+ isFreeHighlight(annotation)
1792
+ }
1793
+
1794
+ /** Ink drawn with the pen, as opposed to the highlighter. */
1795
+ function isDrawing(annotation) {
1796
+ return annotation?.annotation_type === AnnotationType.INK &&
1797
+ !isFreeHighlight(annotation)
1798
+ }
1799
+
1800
+ /** Types whose contents are editable as a comment via the note dialog. */
1801
+ function supportsComment(annotation) {
1802
+ return [AnnotationType.HIGHLIGHT, AnnotationType.UNDERLINE, AnnotationType.INK]
1803
+ .includes(annotation?.annotation_type)
1804
+ }
1805
+
1697
1806
  // Custom event types for error handling
1698
1807
  const AnnotationErrorType = {
1699
1808
  LOAD_FAILED: "load_failed",
@@ -1767,6 +1876,7 @@ class AnnotationManager {
1767
1876
  this.annotationsByPage.clear();
1768
1877
 
1769
1878
  for (const annotation of annotationsData) {
1879
+ normalizeAnnotation(annotation);
1770
1880
  this.annotations.set(this._key(annotation.id), annotation);
1771
1881
 
1772
1882
  if (!this.annotationsByPage.has(annotation.page)) {
@@ -1868,6 +1978,8 @@ class AnnotationManager {
1868
1978
  }
1869
1979
 
1870
1980
  _addAnnotation(annotation) {
1981
+ normalizeAnnotation(annotation);
1982
+
1871
1983
  this.annotations.set(this._key(annotation.id), annotation);
1872
1984
 
1873
1985
  if (!this.annotationsByPage.has(annotation.page)) {
@@ -1877,6 +1989,8 @@ class AnnotationManager {
1877
1989
  }
1878
1990
 
1879
1991
  _updateAnnotation(annotation) {
1992
+ normalizeAnnotation(annotation);
1993
+
1880
1994
  const oldAnnotation = this.getAnnotation(annotation.id);
1881
1995
  if (!oldAnnotation) {
1882
1996
  this._addAnnotation(annotation);
@@ -2065,16 +2179,16 @@ class DownloadManager {
2065
2179
  _applyAnnotationsToPage(pdfDoc, page, annotations, pageHeight) {
2066
2180
  for (const annotation of annotations) {
2067
2181
  switch (annotation.annotation_type) {
2068
- case "highlight":
2182
+ case AnnotationType.HIGHLIGHT:
2069
2183
  this._applyHighlight(pdfDoc, page, annotation, pageHeight);
2070
2184
  break
2071
- case "underline":
2185
+ case AnnotationType.UNDERLINE:
2072
2186
  this._applyUnderline(pdfDoc, page, annotation, pageHeight);
2073
2187
  break
2074
- case "ink":
2188
+ case AnnotationType.INK:
2075
2189
  this._applyInk(pdfDoc, page, annotation, pageHeight);
2076
2190
  break
2077
- case "note":
2191
+ case AnnotationType.NOTE:
2078
2192
  this._applyNote(pdfDoc, page, annotation, pageHeight);
2079
2193
  break
2080
2194
  }
@@ -2113,7 +2227,7 @@ class DownloadManager {
2113
2227
 
2114
2228
  const annotationDict = pdfDoc.context.obj({
2115
2229
  Type: PDFName.of("Annot"),
2116
- Subtype: PDFName.of("Highlight"),
2230
+ Subtype: PDFName.of(PDF_SUBTYPES[AnnotationType.HIGHLIGHT]),
2117
2231
  Rect: [minX, minY, maxX, maxY],
2118
2232
  QuadPoints: quadPoints,
2119
2233
  C: [rgba.r, rgba.g, rgba.b],
@@ -2154,7 +2268,7 @@ class DownloadManager {
2154
2268
 
2155
2269
  const annotationDict = pdfDoc.context.obj({
2156
2270
  Type: PDFName.of("Annot"),
2157
- Subtype: PDFName.of("Underline"),
2271
+ Subtype: PDFName.of(PDF_SUBTYPES[AnnotationType.UNDERLINE]),
2158
2272
  Rect: [minX, minY, maxX, maxY],
2159
2273
  QuadPoints: quadPoints,
2160
2274
  C: [rgba.r, rgba.g, rgba.b],
@@ -2167,7 +2281,7 @@ class DownloadManager {
2167
2281
 
2168
2282
  _applyInk(pdfDoc, page, annotation, pageHeight) {
2169
2283
  // Freehand highlights need different rendering (thick, semi-transparent strokes)
2170
- if (annotation.subject === "Free Highlight") {
2284
+ if (isFreeHighlight(annotation)) {
2171
2285
  this._applyFreehandHighlight(pdfDoc, page, annotation, pageHeight);
2172
2286
  return
2173
2287
  }
@@ -2239,7 +2353,7 @@ class DownloadManager {
2239
2353
 
2240
2354
  const annotationDict = pdfDoc.context.obj({
2241
2355
  Type: PDFName.of("Annot"),
2242
- Subtype: PDFName.of("Ink"),
2356
+ Subtype: PDFName.of(PDF_SUBTYPES[AnnotationType.INK]),
2243
2357
  Rect: [minX, minY, maxX, maxY],
2244
2358
  InkList: inkList,
2245
2359
  C: [rgba.r, rgba.g, rgba.b],
@@ -2335,7 +2449,7 @@ class DownloadManager {
2335
2449
 
2336
2450
  const annotationDict = pdfDoc.context.obj({
2337
2451
  Type: PDFName.of("Annot"),
2338
- Subtype: PDFName.of("Ink"),
2452
+ Subtype: PDFName.of(PDF_SUBTYPES[AnnotationType.INK]),
2339
2453
  Rect: [minX, minY, maxX, maxY],
2340
2454
  InkList: inkList,
2341
2455
  C: [rgba.r, rgba.g, rgba.b],
@@ -2361,9 +2475,8 @@ class DownloadManager {
2361
2475
 
2362
2476
  const annotationDict = pdfDoc.context.obj({
2363
2477
  Type: PDFName.of("Annot"),
2364
- Subtype: PDFName.of("Text"),
2478
+ Subtype: PDFName.of(PDF_SUBTYPES[AnnotationType.NOTE]),
2365
2479
  Rect: [x, pdfY - iconSize, x + iconSize, pdfY],
2366
- Contents: PDFString.of(contents),
2367
2480
  C: [rgba.r, rgba.g, rgba.b],
2368
2481
  Name: PDFName.of("Comment"),
2369
2482
  Open: false,
@@ -2427,6 +2540,13 @@ class DownloadManager {
2427
2540
  metadata.T = PDFString.of(this.userName);
2428
2541
  }
2429
2542
 
2543
+ // Contents - a note's body, or the comment on any other annotation type.
2544
+ // fromText encodes as UTF-16BE when needed; PDFString.of only covers
2545
+ // PDFDocEncoding and mangles curly quotes, dashes, and the like.
2546
+ if (annotation.contents) {
2547
+ metadata.Contents = PDFHexString.fromText(annotation.contents);
2548
+ }
2549
+
2430
2550
  // Modification date (M) - use annotation's updated_at or created_at
2431
2551
  const dateStr = annotation.updated_at || annotation.created_at;
2432
2552
  if (dateStr) {
@@ -2487,12 +2607,17 @@ class DownloadManager {
2487
2607
  .replace(/\s+/g, " ") // Normalize whitespace
2488
2608
  .trim();
2489
2609
 
2610
+ // Check for emptiness before appending the extension. A name made only of
2611
+ // illegal characters is empty at this point, and appending first would
2612
+ // produce a hidden ".pdf" file instead of falling back.
2613
+ if (!sanitized) return "document.pdf"
2614
+
2490
2615
  // Ensure it ends with .pdf
2491
2616
  if (!sanitized.toLowerCase().endsWith(".pdf")) {
2492
2617
  sanitized += ".pdf";
2493
2618
  }
2494
2619
 
2495
- return sanitized || "document.pdf"
2620
+ return sanitized
2496
2621
  }
2497
2622
 
2498
2623
  _triggerDownload(bytes, filename) {
@@ -2822,8 +2947,8 @@ class AnnotationEditToolbar {
2822
2947
  }
2823
2948
  } else if (e.key === "c" || e.key === "C") {
2824
2949
  // Comment shortcut for highlight/underline/ink annotations
2825
- const supportsComment = ["highlight", "line", "ink"].includes(this.currentAnnotation?.annotation_type);
2826
- if (supportsComment && this.onComment) {
2950
+ const canComment = supportsComment(this.currentAnnotation);
2951
+ if (canComment && this.onComment) {
2827
2952
  e.preventDefault();
2828
2953
  this.onComment(this.currentAnnotation);
2829
2954
  }
@@ -2885,14 +3010,14 @@ class AnnotationEditToolbar {
2885
3010
 
2886
3011
  // Show/hide buttons based on annotation type
2887
3012
  const isNote = annotation.annotation_type === "note";
2888
- const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
3013
+ const canComment = supportsComment(annotation);
2889
3014
 
2890
3015
  // Comment button for highlight/underline/ink, edit button for notes
2891
- this.commentBtn.classList.toggle("hidden", !supportsComment);
3016
+ this.commentBtn.classList.toggle("hidden", !canComment);
2892
3017
  this.editBtn.classList.toggle("hidden", !isNote);
2893
3018
 
2894
3019
  // Update comment button title based on whether contents exists
2895
- if (supportsComment) {
3020
+ if (canComment) {
2896
3021
  const hasComment = annotation.contents && annotation.contents.trim();
2897
3022
  this.commentBtn.title = hasComment ? "Edit Comment (C)" : "Add Comment (C)";
2898
3023
  }
@@ -3098,13 +3223,13 @@ class AnnotationDetailPanel {
3098
3223
  this.onDelete(this.currentAnnotation);
3099
3224
  }
3100
3225
  } else if (e.key === "e" || e.key === "E") {
3101
- if (this.currentAnnotation?.annotation_type === "note" && this.onEdit) {
3226
+ if (this.currentAnnotation?.annotation_type === AnnotationType.NOTE && this.onEdit) {
3102
3227
  e.preventDefault();
3103
3228
  this.onEdit(this.currentAnnotation);
3104
3229
  }
3105
3230
  } else if (e.key === "c" || e.key === "C") {
3106
- const supportsComment = ["highlight", "line", "ink"].includes(this.currentAnnotation?.annotation_type);
3107
- if (supportsComment && this.onComment) {
3231
+ const canComment = supportsComment(this.currentAnnotation);
3232
+ if (canComment && this.onComment) {
3108
3233
  e.preventDefault();
3109
3234
  this.onComment(this.currentAnnotation);
3110
3235
  }
@@ -3156,10 +3281,10 @@ class AnnotationDetailPanel {
3156
3281
 
3157
3282
  _getTypeLabel(annotationType) {
3158
3283
  const labels = {
3159
- highlight: "Highlight",
3160
- line: "Underline",
3161
- note: "Note",
3162
- ink: "Drawing"
3284
+ [AnnotationType.HIGHLIGHT]: "Highlight",
3285
+ [AnnotationType.UNDERLINE]: "Underline",
3286
+ [AnnotationType.NOTE]: "Note",
3287
+ [AnnotationType.INK]: "Drawing"
3163
3288
  };
3164
3289
  return labels[annotationType] || "Annotation"
3165
3290
  }
@@ -3188,11 +3313,11 @@ class AnnotationDetailPanel {
3188
3313
 
3189
3314
  // Show/hide buttons based on annotation type
3190
3315
  const isNote = annotation.annotation_type === "note";
3191
- const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
3192
- this.commentBtn.classList.toggle("hidden", !supportsComment);
3316
+ const canComment = supportsComment(annotation);
3317
+ this.commentBtn.classList.toggle("hidden", !canComment);
3193
3318
  this.editBtn.classList.toggle("hidden", !isNote);
3194
3319
 
3195
- if (supportsComment) {
3320
+ if (canComment) {
3196
3321
  const hasComment = annotation.contents && annotation.contents.trim();
3197
3322
  this.commentBtn.title = hasComment ? "Edit Comment (C)" : "Add Comment (C)";
3198
3323
  }
@@ -4014,7 +4139,7 @@ const ANNOTATION_ICONS = {
4014
4139
  ink: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4015
4140
  <path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
4016
4141
  </svg>`,
4017
- line: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4142
+ underline: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4018
4143
  <path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/>
4019
4144
  <line x1="4" y1="21" x2="20" y2="21" stroke-width="3"/>
4020
4145
  </svg>`
@@ -4301,13 +4426,13 @@ class AnnotationSidebar {
4301
4426
 
4302
4427
  switch (this.filterType) {
4303
4428
  case FilterType.HIGHLIGHT:
4304
- return type === "highlight" || (type === "ink" && annotation.subject === "Free Highlight")
4429
+ return isHighlightLike(annotation)
4305
4430
  case FilterType.NOTE:
4306
- return type === "note"
4431
+ return type === AnnotationType.NOTE
4307
4432
  case FilterType.DRAWING:
4308
- return type === "ink" && annotation.subject !== "Free Highlight"
4433
+ return isDrawing(annotation)
4309
4434
  case FilterType.UNDERLINE:
4310
- return type === "line"
4435
+ return type === AnnotationType.UNDERLINE
4311
4436
  default:
4312
4437
  return true
4313
4438
  }
@@ -4462,23 +4587,23 @@ class AnnotationSidebar {
4462
4587
  const type = annotation.annotation_type;
4463
4588
  let icon, label, typeLabel;
4464
4589
 
4465
- if (type === "highlight" || (type === "ink" && annotation.subject === "Free Highlight")) {
4590
+ if (isHighlightLike(annotation)) {
4466
4591
  icon = ANNOTATION_ICONS.highlight;
4467
4592
  typeLabel = "Highlight";
4468
4593
  // Extract highlighted text if available
4469
4594
  label = annotation.title || annotation.contents || "Freehand Highlight";
4470
4595
  label = this._truncate(label, 80);
4471
- } else if (type === "note") {
4596
+ } else if (type === AnnotationType.NOTE) {
4472
4597
  icon = ANNOTATION_ICONS.note;
4473
4598
  typeLabel = "Note";
4474
4599
  label = annotation.contents || "Empty note";
4475
4600
  label = this._truncate(label, 80);
4476
- } else if (type === "ink") {
4601
+ } else if (type === AnnotationType.INK) {
4477
4602
  icon = ANNOTATION_ICONS.ink;
4478
4603
  typeLabel = "Drawing";
4479
4604
  label = "Ink drawing";
4480
- } else if (type === "line") {
4481
- icon = ANNOTATION_ICONS.line;
4605
+ } else if (type === AnnotationType.UNDERLINE) {
4606
+ icon = ANNOTATION_ICONS.underline;
4482
4607
  typeLabel = "Underline";
4483
4608
  label = annotation.title || "Underlined text";
4484
4609
  label = this._truncate(label, 80);
@@ -4778,6 +4903,17 @@ class FindController {
4778
4903
  // If new matches were added, re-sort and fix current index
4779
4904
  if (this.matches.length > matchCountBefore) {
4780
4905
  this._sortMatchesAndFixIndex();
4906
+
4907
+ // The first search on a document runs before any text has been
4908
+ // extracted, so find() finds nothing and leaves no current match.
4909
+ // Select the first match as soon as one appears; otherwise the UI
4910
+ // sits at "0 of N" with nothing highlighted until the user presses
4911
+ // Next.
4912
+ if (this.currentMatchIndex === -1) {
4913
+ this.currentMatchIndex = 0;
4914
+ this.state = FindState.FOUND;
4915
+ this._scrollToMatch(0);
4916
+ }
4781
4917
  }
4782
4918
 
4783
4919
  this._updateHighlights(pageNum);
@@ -6692,7 +6828,7 @@ class HighlightTool extends TextSelectionTool {
6692
6828
  rect: [minX, minY, maxX - minX, maxY - minY],
6693
6829
  color: colorWithAlpha,
6694
6830
  thickness: this.freehandThickness / scale,
6695
- subject: "Free Highlight"
6831
+ subject: FREE_HIGHLIGHT_SUBJECT
6696
6832
  });
6697
6833
  }
6698
6834
 
@@ -6724,7 +6860,7 @@ class UnderlineTool extends TextSelectionTool {
6724
6860
 
6725
6861
  async createAnnotationFromSelection(selectedText, pageNumber, quads, rect) {
6726
6862
  await this.annotationManager.createAnnotation({
6727
- annotation_type: "line",
6863
+ annotation_type: AnnotationType.UNDERLINE,
6728
6864
  page: pageNumber,
6729
6865
  quads: quads,
6730
6866
  rect: rect,
@@ -7628,6 +7764,11 @@ class PdfViewer {
7628
7764
  }
7629
7765
 
7630
7766
  _setupEventListeners() {
7767
+ // _initializeComponents() bails out when .pdf-pages-container is missing,
7768
+ // having logged what the host got wrong. Without this guard the
7769
+ // constructor then throws a TypeError here that buries that message.
7770
+ if (!this.pagesContainer) return
7771
+
7631
7772
  const signal = this._abortController.signal;
7632
7773
 
7633
7774
  // Handle visibility change for time tracking
@@ -7928,15 +8069,14 @@ class PdfViewer {
7928
8069
 
7929
8070
  _onAnnotationEdit(annotation) {
7930
8071
  // For notes, show the edit popup
7931
- if (annotation.annotation_type === "note") {
8072
+ if (annotation.annotation_type === AnnotationType.NOTE) {
7932
8073
  this.tools[ToolMode.NOTE].editNote(annotation);
7933
8074
  }
7934
8075
  }
7935
8076
 
7936
8077
  _onAnnotationComment(annotation) {
7937
8078
  // For highlight/underline/ink, use the note tool's edit dialog to edit contents
7938
- const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
7939
- if (supportsComment) {
8079
+ if (supportsComment(annotation)) {
7940
8080
  this.tools[ToolMode.NOTE].editNote(annotation);
7941
8081
  }
7942
8082
  }
@@ -7956,15 +8096,15 @@ class PdfViewer {
7956
8096
 
7957
8097
  /**
7958
8098
  * Get human-readable label for annotation type.
7959
- * @param {string} type - Annotation type (highlight, note, ink, line)
8099
+ * @param {string} type - Canonical annotation type (see AnnotationType)
7960
8100
  * @returns {string} Human-readable label
7961
8101
  */
7962
8102
  _getAnnotationTypeLabel(type) {
7963
8103
  switch (type) {
7964
- case "highlight": return "Highlight"
7965
- case "note": return "Note"
7966
- case "ink": return "Drawing"
7967
- case "line": return "Underline"
8104
+ case AnnotationType.HIGHLIGHT: return "Highlight"
8105
+ case AnnotationType.NOTE: return "Note"
8106
+ case AnnotationType.INK: return "Drawing"
8107
+ case AnnotationType.UNDERLINE: return "Underline"
7968
8108
  default: return "Annotation"
7969
8109
  }
7970
8110
  }
@@ -8029,9 +8169,8 @@ class PdfViewer {
8029
8169
 
8030
8170
  // Render each annotation using percentage-based positioning
8031
8171
  for (const annotation of annotations) {
8032
- const isHighlight = annotation.annotation_type === "highlight" ||
8033
- (annotation.annotation_type === "ink" && annotation.subject === "Free Highlight");
8034
- const isUnderline = annotation.annotation_type === "line";
8172
+ const isHighlight = isHighlightLike(annotation);
8173
+ const isUnderline = annotation.annotation_type === AnnotationType.UNDERLINE;
8035
8174
 
8036
8175
  if (isHighlight) {
8037
8176
  // Render colored SVG in the highlight layer (has mix-blend-mode for text visibility)
@@ -8121,7 +8260,7 @@ class PdfViewer {
8121
8260
  // Render highlight as SVG in the blend layer (for mix-blend-mode to work)
8122
8261
  // Uses unscaled PDF coordinates - SVG viewBox handles scaling
8123
8262
  _renderHighlightSvg(annotation, svgLayer) {
8124
- if (annotation.annotation_type === "ink") {
8263
+ if (annotation.annotation_type === AnnotationType.INK) {
8125
8264
  this._renderFreehandHighlightSvg(annotation, svgLayer);
8126
8265
  return
8127
8266
  }
@@ -8270,7 +8409,7 @@ class PdfViewer {
8270
8409
  container.className = "annotation annotation-highlight";
8271
8410
  container.dataset.annotationId = annotation.id;
8272
8411
 
8273
- if (annotation.annotation_type === "ink") {
8412
+ if (annotation.annotation_type === AnnotationType.INK) {
8274
8413
  // Freehand highlight bounds
8275
8414
  const strokes = annotation.ink_strokes || [];
8276
8415
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -8316,11 +8455,11 @@ class PdfViewer {
8316
8455
 
8317
8456
  _createAnnotationElement(annotation, pageWidth, pageHeight) {
8318
8457
  switch (annotation.annotation_type) {
8319
- case "highlight":
8458
+ case AnnotationType.HIGHLIGHT:
8320
8459
  return this._createHighlightElement(annotation, pageWidth, pageHeight)
8321
- case "note":
8460
+ case AnnotationType.NOTE:
8322
8461
  return this._createNoteElement(annotation, pageWidth, pageHeight)
8323
- case "ink":
8462
+ case AnnotationType.INK:
8324
8463
  return this._createInkElement(annotation, pageWidth, pageHeight)
8325
8464
  default:
8326
8465
  return null
@@ -8603,7 +8742,7 @@ class PdfViewer {
8603
8742
  async _onAnnotationColorChange(annotation, color) {
8604
8743
  try {
8605
8744
  // Preserve the existing opacity when changing color (default to 0.4 for highlights/ink, 1 for others)
8606
- const defaultOpacity = (annotation.annotation_type === "highlight" || annotation.annotation_type === "ink") ? 0.4 : 1;
8745
+ const defaultOpacity = (annotation.annotation_type === AnnotationType.HIGHLIGHT || annotation.annotation_type === AnnotationType.INK) ? 0.4 : 1;
8607
8746
  const opacity = annotation.opacity ?? defaultOpacity;
8608
8747
 
8609
8748
  // Encode opacity into color string as alpha channel (#RRGGBBAA)
@@ -1714,6 +1714,115 @@
1714
1714
  }
1715
1715
  }
1716
1716
 
1717
+ // Canonical annotation vocabulary.
1718
+ //
1719
+ // `annotation_type` travels over the REST API and is stored in the consuming
1720
+ // application's database, so inbound records may still carry legacy values that
1721
+ // this library no longer emits. Records are normalized once on the way in (see
1722
+ // AnnotationManager); everything downstream may assume the canonical names.
1723
+ //
1724
+ // These are the annotation types, not the editing modes -- see ToolMode in
1725
+ // index.js, which adds SELECT and otherwise uses the same strings.
1726
+
1727
+ const AnnotationType = {
1728
+ HIGHLIGHT: "highlight",
1729
+ UNDERLINE: "underline",
1730
+ NOTE: "note",
1731
+ INK: "ink"
1732
+ };
1733
+
1734
+ // Legacy `annotation_type` values accepted on read and rewritten to canonical
1735
+ // names. "line" was the original spelling of underline; it is also actively
1736
+ // misleading, naming the PDF spec's Line annotation (subtype 4) -- a different
1737
+ // annotation that this viewer does not support.
1738
+ const LEGACY_TYPE_ALIASES = {
1739
+ line: AnnotationType.UNDERLINE
1740
+ };
1741
+
1742
+ // Marks an ink annotation drawn with the highlighter rather than the pen. This
1743
+ // lives on `subject` rather than being its own type because both are genuinely
1744
+ // Ink annotations once exported to PDF.
1745
+ const FREE_HIGHLIGHT_SUBJECT = "Free Highlight";
1746
+
1747
+ // PDF annotation subtype each canonical type is written as by DownloadManager
1748
+ // when baking annotations into the file. See PDF 32000-1:2008 section 12.5.6.
1749
+ const PDF_SUBTYPES = {
1750
+ [AnnotationType.HIGHLIGHT]: "Highlight",
1751
+ [AnnotationType.UNDERLINE]: "Underline",
1752
+ [AnnotationType.NOTE]: "Text",
1753
+ [AnnotationType.INK]: "Ink"
1754
+ };
1755
+
1756
+ // Legacy values already warned about, so a document full of them produces one
1757
+ // warning per type rather than one per annotation.
1758
+ const warnedLegacyTypes = new Set();
1759
+
1760
+ /**
1761
+ * Map a possibly-legacy annotation type onto the canonical vocabulary.
1762
+ *
1763
+ * Warns once per legacy value encountered. The aliases exist only to carry
1764
+ * records written by earlier versions, and are slated for removal; the warning
1765
+ * is how a consuming application finds out it still has rows to migrate while
1766
+ * that support is still in place.
1767
+ *
1768
+ * @param {string} type
1769
+ * @returns {string}
1770
+ */
1771
+ function normalizeAnnotationType(type) {
1772
+ const canonical = LEGACY_TYPE_ALIASES[type];
1773
+ if (!canonical) return type
1774
+
1775
+ if (!warnedLegacyTypes.has(type)) {
1776
+ warnedLegacyTypes.add(type);
1777
+ console.warn(
1778
+ `[stimulus-pdf-viewer] Deprecated annotation_type "${type}" was read and ` +
1779
+ `treated as "${canonical}". Support for the old value will be removed in a ` +
1780
+ `future release. Migrate stored annotations: ` +
1781
+ `UPDATE annotations SET annotation_type = '${canonical}' WHERE annotation_type = '${type}'.`
1782
+ );
1783
+ }
1784
+
1785
+ return canonical
1786
+ }
1787
+
1788
+ /**
1789
+ * Rewrite a record's annotation_type in place. Mutates rather than clones so
1790
+ * that references already held elsewhere (selectedAnnotation, rendered sidebar
1791
+ * rows) observe the same canonical value.
1792
+ * @param {Object} annotation
1793
+ * @returns {Object} the same annotation
1794
+ */
1795
+ function normalizeAnnotation(annotation) {
1796
+ if (annotation && annotation.annotation_type) {
1797
+ annotation.annotation_type = normalizeAnnotationType(annotation.annotation_type);
1798
+ }
1799
+ return annotation
1800
+ }
1801
+
1802
+ /** An ink annotation drawn with the highlighter tool. */
1803
+ function isFreeHighlight(annotation) {
1804
+ return annotation?.annotation_type === AnnotationType.INK &&
1805
+ annotation?.subject === FREE_HIGHLIGHT_SUBJECT
1806
+ }
1807
+
1808
+ /** Highlights and highlighter-drawn ink both render as highlights. */
1809
+ function isHighlightLike(annotation) {
1810
+ return annotation?.annotation_type === AnnotationType.HIGHLIGHT ||
1811
+ isFreeHighlight(annotation)
1812
+ }
1813
+
1814
+ /** Ink drawn with the pen, as opposed to the highlighter. */
1815
+ function isDrawing(annotation) {
1816
+ return annotation?.annotation_type === AnnotationType.INK &&
1817
+ !isFreeHighlight(annotation)
1818
+ }
1819
+
1820
+ /** Types whose contents are editable as a comment via the note dialog. */
1821
+ function supportsComment(annotation) {
1822
+ return [AnnotationType.HIGHLIGHT, AnnotationType.UNDERLINE, AnnotationType.INK]
1823
+ .includes(annotation?.annotation_type)
1824
+ }
1825
+
1717
1826
  // Custom event types for error handling
1718
1827
  const AnnotationErrorType = {
1719
1828
  LOAD_FAILED: "load_failed",
@@ -1787,6 +1896,7 @@
1787
1896
  this.annotationsByPage.clear();
1788
1897
 
1789
1898
  for (const annotation of annotationsData) {
1899
+ normalizeAnnotation(annotation);
1790
1900
  this.annotations.set(this._key(annotation.id), annotation);
1791
1901
 
1792
1902
  if (!this.annotationsByPage.has(annotation.page)) {
@@ -1888,6 +1998,8 @@
1888
1998
  }
1889
1999
 
1890
2000
  _addAnnotation(annotation) {
2001
+ normalizeAnnotation(annotation);
2002
+
1891
2003
  this.annotations.set(this._key(annotation.id), annotation);
1892
2004
 
1893
2005
  if (!this.annotationsByPage.has(annotation.page)) {
@@ -1897,6 +2009,8 @@
1897
2009
  }
1898
2010
 
1899
2011
  _updateAnnotation(annotation) {
2012
+ normalizeAnnotation(annotation);
2013
+
1900
2014
  const oldAnnotation = this.getAnnotation(annotation.id);
1901
2015
  if (!oldAnnotation) {
1902
2016
  this._addAnnotation(annotation);
@@ -2085,16 +2199,16 @@
2085
2199
  _applyAnnotationsToPage(pdfDoc, page, annotations, pageHeight) {
2086
2200
  for (const annotation of annotations) {
2087
2201
  switch (annotation.annotation_type) {
2088
- case "highlight":
2202
+ case AnnotationType.HIGHLIGHT:
2089
2203
  this._applyHighlight(pdfDoc, page, annotation, pageHeight);
2090
2204
  break
2091
- case "underline":
2205
+ case AnnotationType.UNDERLINE:
2092
2206
  this._applyUnderline(pdfDoc, page, annotation, pageHeight);
2093
2207
  break
2094
- case "ink":
2208
+ case AnnotationType.INK:
2095
2209
  this._applyInk(pdfDoc, page, annotation, pageHeight);
2096
2210
  break
2097
- case "note":
2211
+ case AnnotationType.NOTE:
2098
2212
  this._applyNote(pdfDoc, page, annotation, pageHeight);
2099
2213
  break
2100
2214
  }
@@ -2133,7 +2247,7 @@
2133
2247
 
2134
2248
  const annotationDict = pdfDoc.context.obj({
2135
2249
  Type: pdfLib.PDFName.of("Annot"),
2136
- Subtype: pdfLib.PDFName.of("Highlight"),
2250
+ Subtype: pdfLib.PDFName.of(PDF_SUBTYPES[AnnotationType.HIGHLIGHT]),
2137
2251
  Rect: [minX, minY, maxX, maxY],
2138
2252
  QuadPoints: quadPoints,
2139
2253
  C: [rgba.r, rgba.g, rgba.b],
@@ -2174,7 +2288,7 @@
2174
2288
 
2175
2289
  const annotationDict = pdfDoc.context.obj({
2176
2290
  Type: pdfLib.PDFName.of("Annot"),
2177
- Subtype: pdfLib.PDFName.of("Underline"),
2291
+ Subtype: pdfLib.PDFName.of(PDF_SUBTYPES[AnnotationType.UNDERLINE]),
2178
2292
  Rect: [minX, minY, maxX, maxY],
2179
2293
  QuadPoints: quadPoints,
2180
2294
  C: [rgba.r, rgba.g, rgba.b],
@@ -2187,7 +2301,7 @@
2187
2301
 
2188
2302
  _applyInk(pdfDoc, page, annotation, pageHeight) {
2189
2303
  // Freehand highlights need different rendering (thick, semi-transparent strokes)
2190
- if (annotation.subject === "Free Highlight") {
2304
+ if (isFreeHighlight(annotation)) {
2191
2305
  this._applyFreehandHighlight(pdfDoc, page, annotation, pageHeight);
2192
2306
  return
2193
2307
  }
@@ -2259,7 +2373,7 @@
2259
2373
 
2260
2374
  const annotationDict = pdfDoc.context.obj({
2261
2375
  Type: pdfLib.PDFName.of("Annot"),
2262
- Subtype: pdfLib.PDFName.of("Ink"),
2376
+ Subtype: pdfLib.PDFName.of(PDF_SUBTYPES[AnnotationType.INK]),
2263
2377
  Rect: [minX, minY, maxX, maxY],
2264
2378
  InkList: inkList,
2265
2379
  C: [rgba.r, rgba.g, rgba.b],
@@ -2355,7 +2469,7 @@
2355
2469
 
2356
2470
  const annotationDict = pdfDoc.context.obj({
2357
2471
  Type: pdfLib.PDFName.of("Annot"),
2358
- Subtype: pdfLib.PDFName.of("Ink"),
2472
+ Subtype: pdfLib.PDFName.of(PDF_SUBTYPES[AnnotationType.INK]),
2359
2473
  Rect: [minX, minY, maxX, maxY],
2360
2474
  InkList: inkList,
2361
2475
  C: [rgba.r, rgba.g, rgba.b],
@@ -2381,9 +2495,8 @@
2381
2495
 
2382
2496
  const annotationDict = pdfDoc.context.obj({
2383
2497
  Type: pdfLib.PDFName.of("Annot"),
2384
- Subtype: pdfLib.PDFName.of("Text"),
2498
+ Subtype: pdfLib.PDFName.of(PDF_SUBTYPES[AnnotationType.NOTE]),
2385
2499
  Rect: [x, pdfY - iconSize, x + iconSize, pdfY],
2386
- Contents: pdfLib.PDFString.of(contents),
2387
2500
  C: [rgba.r, rgba.g, rgba.b],
2388
2501
  Name: pdfLib.PDFName.of("Comment"),
2389
2502
  Open: false,
@@ -2447,6 +2560,13 @@
2447
2560
  metadata.T = pdfLib.PDFString.of(this.userName);
2448
2561
  }
2449
2562
 
2563
+ // Contents - a note's body, or the comment on any other annotation type.
2564
+ // fromText encodes as UTF-16BE when needed; PDFString.of only covers
2565
+ // PDFDocEncoding and mangles curly quotes, dashes, and the like.
2566
+ if (annotation.contents) {
2567
+ metadata.Contents = pdfLib.PDFHexString.fromText(annotation.contents);
2568
+ }
2569
+
2450
2570
  // Modification date (M) - use annotation's updated_at or created_at
2451
2571
  const dateStr = annotation.updated_at || annotation.created_at;
2452
2572
  if (dateStr) {
@@ -2507,12 +2627,17 @@
2507
2627
  .replace(/\s+/g, " ") // Normalize whitespace
2508
2628
  .trim();
2509
2629
 
2630
+ // Check for emptiness before appending the extension. A name made only of
2631
+ // illegal characters is empty at this point, and appending first would
2632
+ // produce a hidden ".pdf" file instead of falling back.
2633
+ if (!sanitized) return "document.pdf"
2634
+
2510
2635
  // Ensure it ends with .pdf
2511
2636
  if (!sanitized.toLowerCase().endsWith(".pdf")) {
2512
2637
  sanitized += ".pdf";
2513
2638
  }
2514
2639
 
2515
- return sanitized || "document.pdf"
2640
+ return sanitized
2516
2641
  }
2517
2642
 
2518
2643
  _triggerDownload(bytes, filename) {
@@ -2842,8 +2967,8 @@
2842
2967
  }
2843
2968
  } else if (e.key === "c" || e.key === "C") {
2844
2969
  // Comment shortcut for highlight/underline/ink annotations
2845
- const supportsComment = ["highlight", "line", "ink"].includes(this.currentAnnotation?.annotation_type);
2846
- if (supportsComment && this.onComment) {
2970
+ const canComment = supportsComment(this.currentAnnotation);
2971
+ if (canComment && this.onComment) {
2847
2972
  e.preventDefault();
2848
2973
  this.onComment(this.currentAnnotation);
2849
2974
  }
@@ -2905,14 +3030,14 @@
2905
3030
 
2906
3031
  // Show/hide buttons based on annotation type
2907
3032
  const isNote = annotation.annotation_type === "note";
2908
- const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
3033
+ const canComment = supportsComment(annotation);
2909
3034
 
2910
3035
  // Comment button for highlight/underline/ink, edit button for notes
2911
- this.commentBtn.classList.toggle("hidden", !supportsComment);
3036
+ this.commentBtn.classList.toggle("hidden", !canComment);
2912
3037
  this.editBtn.classList.toggle("hidden", !isNote);
2913
3038
 
2914
3039
  // Update comment button title based on whether contents exists
2915
- if (supportsComment) {
3040
+ if (canComment) {
2916
3041
  const hasComment = annotation.contents && annotation.contents.trim();
2917
3042
  this.commentBtn.title = hasComment ? "Edit Comment (C)" : "Add Comment (C)";
2918
3043
  }
@@ -3118,13 +3243,13 @@
3118
3243
  this.onDelete(this.currentAnnotation);
3119
3244
  }
3120
3245
  } else if (e.key === "e" || e.key === "E") {
3121
- if (this.currentAnnotation?.annotation_type === "note" && this.onEdit) {
3246
+ if (this.currentAnnotation?.annotation_type === AnnotationType.NOTE && this.onEdit) {
3122
3247
  e.preventDefault();
3123
3248
  this.onEdit(this.currentAnnotation);
3124
3249
  }
3125
3250
  } else if (e.key === "c" || e.key === "C") {
3126
- const supportsComment = ["highlight", "line", "ink"].includes(this.currentAnnotation?.annotation_type);
3127
- if (supportsComment && this.onComment) {
3251
+ const canComment = supportsComment(this.currentAnnotation);
3252
+ if (canComment && this.onComment) {
3128
3253
  e.preventDefault();
3129
3254
  this.onComment(this.currentAnnotation);
3130
3255
  }
@@ -3176,10 +3301,10 @@
3176
3301
 
3177
3302
  _getTypeLabel(annotationType) {
3178
3303
  const labels = {
3179
- highlight: "Highlight",
3180
- line: "Underline",
3181
- note: "Note",
3182
- ink: "Drawing"
3304
+ [AnnotationType.HIGHLIGHT]: "Highlight",
3305
+ [AnnotationType.UNDERLINE]: "Underline",
3306
+ [AnnotationType.NOTE]: "Note",
3307
+ [AnnotationType.INK]: "Drawing"
3183
3308
  };
3184
3309
  return labels[annotationType] || "Annotation"
3185
3310
  }
@@ -3208,11 +3333,11 @@
3208
3333
 
3209
3334
  // Show/hide buttons based on annotation type
3210
3335
  const isNote = annotation.annotation_type === "note";
3211
- const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
3212
- this.commentBtn.classList.toggle("hidden", !supportsComment);
3336
+ const canComment = supportsComment(annotation);
3337
+ this.commentBtn.classList.toggle("hidden", !canComment);
3213
3338
  this.editBtn.classList.toggle("hidden", !isNote);
3214
3339
 
3215
- if (supportsComment) {
3340
+ if (canComment) {
3216
3341
  const hasComment = annotation.contents && annotation.contents.trim();
3217
3342
  this.commentBtn.title = hasComment ? "Edit Comment (C)" : "Add Comment (C)";
3218
3343
  }
@@ -4034,7 +4159,7 @@
4034
4159
  ink: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4035
4160
  <path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
4036
4161
  </svg>`,
4037
- line: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4162
+ underline: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
4038
4163
  <path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/>
4039
4164
  <line x1="4" y1="21" x2="20" y2="21" stroke-width="3"/>
4040
4165
  </svg>`
@@ -4321,13 +4446,13 @@
4321
4446
 
4322
4447
  switch (this.filterType) {
4323
4448
  case FilterType.HIGHLIGHT:
4324
- return type === "highlight" || (type === "ink" && annotation.subject === "Free Highlight")
4449
+ return isHighlightLike(annotation)
4325
4450
  case FilterType.NOTE:
4326
- return type === "note"
4451
+ return type === AnnotationType.NOTE
4327
4452
  case FilterType.DRAWING:
4328
- return type === "ink" && annotation.subject !== "Free Highlight"
4453
+ return isDrawing(annotation)
4329
4454
  case FilterType.UNDERLINE:
4330
- return type === "line"
4455
+ return type === AnnotationType.UNDERLINE
4331
4456
  default:
4332
4457
  return true
4333
4458
  }
@@ -4482,23 +4607,23 @@
4482
4607
  const type = annotation.annotation_type;
4483
4608
  let icon, label, typeLabel;
4484
4609
 
4485
- if (type === "highlight" || (type === "ink" && annotation.subject === "Free Highlight")) {
4610
+ if (isHighlightLike(annotation)) {
4486
4611
  icon = ANNOTATION_ICONS.highlight;
4487
4612
  typeLabel = "Highlight";
4488
4613
  // Extract highlighted text if available
4489
4614
  label = annotation.title || annotation.contents || "Freehand Highlight";
4490
4615
  label = this._truncate(label, 80);
4491
- } else if (type === "note") {
4616
+ } else if (type === AnnotationType.NOTE) {
4492
4617
  icon = ANNOTATION_ICONS.note;
4493
4618
  typeLabel = "Note";
4494
4619
  label = annotation.contents || "Empty note";
4495
4620
  label = this._truncate(label, 80);
4496
- } else if (type === "ink") {
4621
+ } else if (type === AnnotationType.INK) {
4497
4622
  icon = ANNOTATION_ICONS.ink;
4498
4623
  typeLabel = "Drawing";
4499
4624
  label = "Ink drawing";
4500
- } else if (type === "line") {
4501
- icon = ANNOTATION_ICONS.line;
4625
+ } else if (type === AnnotationType.UNDERLINE) {
4626
+ icon = ANNOTATION_ICONS.underline;
4502
4627
  typeLabel = "Underline";
4503
4628
  label = annotation.title || "Underlined text";
4504
4629
  label = this._truncate(label, 80);
@@ -4798,6 +4923,17 @@
4798
4923
  // If new matches were added, re-sort and fix current index
4799
4924
  if (this.matches.length > matchCountBefore) {
4800
4925
  this._sortMatchesAndFixIndex();
4926
+
4927
+ // The first search on a document runs before any text has been
4928
+ // extracted, so find() finds nothing and leaves no current match.
4929
+ // Select the first match as soon as one appears; otherwise the UI
4930
+ // sits at "0 of N" with nothing highlighted until the user presses
4931
+ // Next.
4932
+ if (this.currentMatchIndex === -1) {
4933
+ this.currentMatchIndex = 0;
4934
+ this.state = FindState.FOUND;
4935
+ this._scrollToMatch(0);
4936
+ }
4801
4937
  }
4802
4938
 
4803
4939
  this._updateHighlights(pageNum);
@@ -6712,7 +6848,7 @@
6712
6848
  rect: [minX, minY, maxX - minX, maxY - minY],
6713
6849
  color: colorWithAlpha,
6714
6850
  thickness: this.freehandThickness / scale,
6715
- subject: "Free Highlight"
6851
+ subject: FREE_HIGHLIGHT_SUBJECT
6716
6852
  });
6717
6853
  }
6718
6854
 
@@ -6744,7 +6880,7 @@
6744
6880
 
6745
6881
  async createAnnotationFromSelection(selectedText, pageNumber, quads, rect) {
6746
6882
  await this.annotationManager.createAnnotation({
6747
- annotation_type: "line",
6883
+ annotation_type: AnnotationType.UNDERLINE,
6748
6884
  page: pageNumber,
6749
6885
  quads: quads,
6750
6886
  rect: rect,
@@ -7648,6 +7784,11 @@
7648
7784
  }
7649
7785
 
7650
7786
  _setupEventListeners() {
7787
+ // _initializeComponents() bails out when .pdf-pages-container is missing,
7788
+ // having logged what the host got wrong. Without this guard the
7789
+ // constructor then throws a TypeError here that buries that message.
7790
+ if (!this.pagesContainer) return
7791
+
7651
7792
  const signal = this._abortController.signal;
7652
7793
 
7653
7794
  // Handle visibility change for time tracking
@@ -7948,15 +8089,14 @@
7948
8089
 
7949
8090
  _onAnnotationEdit(annotation) {
7950
8091
  // For notes, show the edit popup
7951
- if (annotation.annotation_type === "note") {
8092
+ if (annotation.annotation_type === AnnotationType.NOTE) {
7952
8093
  this.tools[ToolMode.NOTE].editNote(annotation);
7953
8094
  }
7954
8095
  }
7955
8096
 
7956
8097
  _onAnnotationComment(annotation) {
7957
8098
  // For highlight/underline/ink, use the note tool's edit dialog to edit contents
7958
- const supportsComment = ["highlight", "line", "ink"].includes(annotation.annotation_type);
7959
- if (supportsComment) {
8099
+ if (supportsComment(annotation)) {
7960
8100
  this.tools[ToolMode.NOTE].editNote(annotation);
7961
8101
  }
7962
8102
  }
@@ -7976,15 +8116,15 @@
7976
8116
 
7977
8117
  /**
7978
8118
  * Get human-readable label for annotation type.
7979
- * @param {string} type - Annotation type (highlight, note, ink, line)
8119
+ * @param {string} type - Canonical annotation type (see AnnotationType)
7980
8120
  * @returns {string} Human-readable label
7981
8121
  */
7982
8122
  _getAnnotationTypeLabel(type) {
7983
8123
  switch (type) {
7984
- case "highlight": return "Highlight"
7985
- case "note": return "Note"
7986
- case "ink": return "Drawing"
7987
- case "line": return "Underline"
8124
+ case AnnotationType.HIGHLIGHT: return "Highlight"
8125
+ case AnnotationType.NOTE: return "Note"
8126
+ case AnnotationType.INK: return "Drawing"
8127
+ case AnnotationType.UNDERLINE: return "Underline"
7988
8128
  default: return "Annotation"
7989
8129
  }
7990
8130
  }
@@ -8049,9 +8189,8 @@
8049
8189
 
8050
8190
  // Render each annotation using percentage-based positioning
8051
8191
  for (const annotation of annotations) {
8052
- const isHighlight = annotation.annotation_type === "highlight" ||
8053
- (annotation.annotation_type === "ink" && annotation.subject === "Free Highlight");
8054
- const isUnderline = annotation.annotation_type === "line";
8192
+ const isHighlight = isHighlightLike(annotation);
8193
+ const isUnderline = annotation.annotation_type === AnnotationType.UNDERLINE;
8055
8194
 
8056
8195
  if (isHighlight) {
8057
8196
  // Render colored SVG in the highlight layer (has mix-blend-mode for text visibility)
@@ -8141,7 +8280,7 @@
8141
8280
  // Render highlight as SVG in the blend layer (for mix-blend-mode to work)
8142
8281
  // Uses unscaled PDF coordinates - SVG viewBox handles scaling
8143
8282
  _renderHighlightSvg(annotation, svgLayer) {
8144
- if (annotation.annotation_type === "ink") {
8283
+ if (annotation.annotation_type === AnnotationType.INK) {
8145
8284
  this._renderFreehandHighlightSvg(annotation, svgLayer);
8146
8285
  return
8147
8286
  }
@@ -8290,7 +8429,7 @@
8290
8429
  container.className = "annotation annotation-highlight";
8291
8430
  container.dataset.annotationId = annotation.id;
8292
8431
 
8293
- if (annotation.annotation_type === "ink") {
8432
+ if (annotation.annotation_type === AnnotationType.INK) {
8294
8433
  // Freehand highlight bounds
8295
8434
  const strokes = annotation.ink_strokes || [];
8296
8435
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -8336,11 +8475,11 @@
8336
8475
 
8337
8476
  _createAnnotationElement(annotation, pageWidth, pageHeight) {
8338
8477
  switch (annotation.annotation_type) {
8339
- case "highlight":
8478
+ case AnnotationType.HIGHLIGHT:
8340
8479
  return this._createHighlightElement(annotation, pageWidth, pageHeight)
8341
- case "note":
8480
+ case AnnotationType.NOTE:
8342
8481
  return this._createNoteElement(annotation, pageWidth, pageHeight)
8343
- case "ink":
8482
+ case AnnotationType.INK:
8344
8483
  return this._createInkElement(annotation, pageWidth, pageHeight)
8345
8484
  default:
8346
8485
  return null
@@ -8623,7 +8762,7 @@
8623
8762
  async _onAnnotationColorChange(annotation, color) {
8624
8763
  try {
8625
8764
  // Preserve the existing opacity when changing color (default to 0.4 for highlights/ink, 1 for others)
8626
- const defaultOpacity = (annotation.annotation_type === "highlight" || annotation.annotation_type === "ink") ? 0.4 : 1;
8765
+ const defaultOpacity = (annotation.annotation_type === AnnotationType.HIGHLIGHT || annotation.annotation_type === AnnotationType.INK) ? 0.4 : 1;
8627
8766
  const opacity = annotation.opacity ?? defaultOpacity;
8628
8767
 
8629
8768
  // Encode opacity into color string as alpha channel (#RRGGBBAA)
@@ -1,7 +1,7 @@
1
1
  module StimulusPdfViewer
2
2
  module Rails
3
- VERSION = '0.5.0'
3
+ VERSION = '0.6.0'
4
4
  # This should match the npm package version
5
- STIMULUS_PDF_VIEWER_VERSION = "0.5.0"
5
+ STIMULUS_PDF_VIEWER_VERSION = "0.6.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stimulus-pdf-viewer-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremy Baker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties