kviewer 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.
Files changed (63) hide show
  1. package/README.md +3 -0
  2. package/dist/module.json +2 -2
  3. package/dist/runtime/annotation/engine/painter.d.ts +7 -0
  4. package/dist/runtime/annotation/engine/painter.js +14 -0
  5. package/dist/runtime/annotation/engine/store.d.ts +5 -0
  6. package/dist/runtime/annotation/engine/store.js +17 -0
  7. package/dist/runtime/annotation/engine/tools/free-text.js +3 -1
  8. package/dist/runtime/annotation/engine/types.d.ts +20 -0
  9. package/dist/runtime/annotation/pdf-export/export.d.ts +4 -1
  10. package/dist/runtime/annotation/pdf-export/export.js +101 -4
  11. package/dist/runtime/annotation/pdf-export/parse_freetext.js +106 -18
  12. package/dist/runtime/annotation/pdf-export/parse_highlight.js +22 -6
  13. package/dist/runtime/annotation/pdf-export/parse_ink.js +32 -3
  14. package/dist/runtime/annotation/pdf-export/parse_line.js +133 -13
  15. package/dist/runtime/annotation/pdf-import/decode.d.ts +21 -0
  16. package/dist/runtime/annotation/pdf-import/decode.js +134 -0
  17. package/dist/runtime/annotation/pdf-import/decode_circle.d.ts +3 -0
  18. package/dist/runtime/annotation/pdf-import/decode_circle.js +36 -0
  19. package/dist/runtime/annotation/pdf-import/decode_freetext.d.ts +3 -0
  20. package/dist/runtime/annotation/pdf-import/decode_freetext.js +87 -0
  21. package/dist/runtime/annotation/pdf-import/decode_highlight.d.ts +3 -0
  22. package/dist/runtime/annotation/pdf-import/decode_highlight.js +96 -0
  23. package/dist/runtime/annotation/pdf-import/decode_ink.d.ts +3 -0
  24. package/dist/runtime/annotation/pdf-import/decode_ink.js +48 -0
  25. package/dist/runtime/annotation/pdf-import/decode_line.d.ts +3 -0
  26. package/dist/runtime/annotation/pdf-import/decode_line.js +48 -0
  27. package/dist/runtime/annotation/pdf-import/decode_square.d.ts +3 -0
  28. package/dist/runtime/annotation/pdf-import/decode_square.js +39 -0
  29. package/dist/runtime/annotation/pdf-import/decode_stamp.d.ts +3 -0
  30. package/dist/runtime/annotation/pdf-import/decode_stamp.js +38 -0
  31. package/dist/runtime/annotation/pdf-import/decode_text.d.ts +3 -0
  32. package/dist/runtime/annotation/pdf-import/decode_text.js +33 -0
  33. package/dist/runtime/annotation/pdf-import/extract_stamp_appearance.d.ts +23 -0
  34. package/dist/runtime/annotation/pdf-import/extract_stamp_appearance.js +168 -0
  35. package/dist/runtime/annotation/pdf-import/types.d.ts +57 -0
  36. package/dist/runtime/annotation/pdf-import/types.js +25 -0
  37. package/dist/runtime/annotation/pdf-import/utils.d.ts +48 -0
  38. package/dist/runtime/annotation/pdf-import/utils.js +250 -0
  39. package/dist/runtime/assets/kviewer.css +1 -1
  40. package/dist/runtime/components/AnnotationToolbar.vue +1 -0
  41. package/dist/runtime/components/FloatingPageIndicator.vue +4 -1
  42. package/dist/runtime/components/PdfPage.vue +27 -1
  43. package/dist/runtime/components/Viewer.d.vue.ts +12 -1
  44. package/dist/runtime/components/Viewer.vue +114 -35
  45. package/dist/runtime/components/Viewer.vue.d.ts +12 -1
  46. package/dist/runtime/components/ViewerBar.vue +3 -14
  47. package/dist/runtime/components/ViewerTabs.d.vue.ts +16 -1
  48. package/dist/runtime/components/ViewerTabs.vue +42 -12
  49. package/dist/runtime/components/ViewerTabs.vue.d.ts +16 -1
  50. package/dist/runtime/components/form-fields/FormCheckbox.vue +37 -1
  51. package/dist/runtime/components/tools/ActionTools.vue +3 -0
  52. package/dist/runtime/components/tools/PageInfo.vue +1 -1
  53. package/dist/runtime/components/tools/SearchTool.vue +3 -1
  54. package/dist/runtime/components/tools/ZoomControls.vue +3 -0
  55. package/dist/runtime/composables/shape-detection-utils.d.ts +38 -0
  56. package/dist/runtime/composables/shape-detection-utils.js +50 -0
  57. package/dist/runtime/composables/useFormFields.d.ts +3 -1
  58. package/dist/runtime/composables/useFormFields.js +47 -0
  59. package/dist/runtime/composables/useShapeDetection.d.ts +24 -0
  60. package/dist/runtime/composables/useShapeDetection.js +235 -0
  61. package/dist/runtime/composables/useViewerState.d.ts +4 -0
  62. package/dist/runtime/composables/useViewerState.js +13 -1
  63. package/package.json +28 -5
@@ -3,7 +3,7 @@
3
3
  <!-- Top navigation bar -->
4
4
  <div class="flex items-center h-[40px] px-3 gap-2 bg-elevated/50">
5
5
  <UPopover>
6
- <UButton icon="i-lucide-menu" variant="ghost" color="neutral" size="xs" />
6
+ <UButton icon="i-lucide-menu" variant="ghost" color="neutral" size="xs" data-testid="viewer-menu" />
7
7
  <template #content>
8
8
  <div class="p-1">
9
9
  <UButton
@@ -13,6 +13,7 @@
13
13
  color="neutral"
14
14
  size="xs"
15
15
  class="w-full justify-start"
16
+ data-testid="viewer-download"
16
17
  @click="onDownload"
17
18
  />
18
19
  </div>
@@ -57,9 +58,6 @@
57
58
  <script setup>
58
59
  import { ref } from "vue";
59
60
  import { useViewerState } from "../composables/useViewerState";
60
- import { getTimestampString } from "../annotation/engine/utils";
61
- import { downloadPdfBytes } from "../annotation/pdf-export/download";
62
- import { exportAnnotationsToPdf } from "../annotation/pdf-export/export";
63
61
  import PageSettings from "./tools/PageSettings.vue";
64
62
  import ZoomControls from "./tools/ZoomControls.vue";
65
63
  import HandTool from "./tools/HandTool.vue";
@@ -76,16 +74,7 @@ function bumpStyleVersion() {
76
74
  }
77
75
  async function onDownload() {
78
76
  try {
79
- if (!state.doc.value || !state.painter.value) return;
80
- const bytes = await exportAnnotationsToPdf({
81
- pdfData: await state.doc.value.getData(),
82
- annotations: state.painter.value.getData(),
83
- options: {
84
- flatten: false,
85
- preserveOriginalAnnotations: false
86
- }
87
- });
88
- downloadPdfBytes(bytes, `annotated_${getTimestampString()}.pdf`);
77
+ await state.downloadPdf();
89
78
  } catch (error) {
90
79
  console.error("Failed to export PDF from menu action", error);
91
80
  }
@@ -16,6 +16,8 @@ export interface ViewerTabItem {
16
16
  viewMode?: ViewMode;
17
17
  /** Zoom override for this document. */
18
18
  zoom?: number;
19
+ /** Enable shape detection for this document. */
20
+ shapeDetection?: boolean;
19
21
  }
20
22
  export interface AddTabOptions {
21
23
  /** Insert at specific index. Defaults to end. */
@@ -38,6 +40,8 @@ type __VLS_Props = {
38
40
  textLayer?: boolean;
39
41
  /** When true, all viewers are in view-only mode. */
40
42
  readonly?: boolean;
43
+ /** Enable shape detection on all Viewer instances. Can be overridden per tab. */
44
+ shapeDetection?: boolean;
41
45
  /** Default view mode for tabs without a per-tab override. */
42
46
  viewMode?: ViewMode;
43
47
  /** Default zoom for tabs without a per-tab override. */
@@ -148,6 +152,7 @@ declare var __VLS_7: {}, __VLS_21: {}, __VLS_30: {
148
152
  closable?: boolean | undefined;
149
153
  viewMode?: ViewMode | undefined;
150
154
  zoom?: number | undefined;
155
+ shapeDetection?: boolean | undefined;
151
156
  };
152
157
  }, __VLS_33: {
153
158
  tab: {
@@ -246,6 +251,7 @@ declare var __VLS_7: {}, __VLS_21: {}, __VLS_30: {
246
251
  closable?: boolean | undefined;
247
252
  viewMode?: ViewMode | undefined;
248
253
  zoom?: number | undefined;
254
+ shapeDetection?: boolean | undefined;
249
255
  };
250
256
  }, __VLS_35: {};
251
257
  type __VLS_Slots = {} & {
@@ -361,6 +367,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
361
367
  closable?: boolean | undefined;
362
368
  viewMode?: ViewMode | undefined;
363
369
  zoom?: number | undefined;
370
+ shapeDetection?: boolean | undefined;
364
371
  }[];
365
372
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
366
373
  "update:activeTab": (id: string) => any;
@@ -372,7 +379,15 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
372
379
  "onTab-added"?: ((tab: ViewerTabItem) => any) | undefined;
373
380
  "onTab-close"?: ((id: string) => any) | undefined;
374
381
  "onTab-removed"?: ((id: string) => any) | undefined;
375
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
382
+ }>, {
383
+ userName: string;
384
+ zoom: number;
385
+ stamps: StampDefinition[];
386
+ signatureHandlers: SignatureHandlers;
387
+ viewMode: ViewMode;
388
+ defaultActiveTab: string;
389
+ minTabs: number;
390
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
376
391
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
377
392
  declare const _default: typeof __VLS_export;
378
393
  export default _default;
@@ -15,6 +15,7 @@
15
15
  :class="[
16
16
  tab.id === activeTabId ? 'bg-elevated text-highlighted border-b-2 border-b-primary' : 'text-muted hover:text-highlighted hover:bg-elevated/80'
17
17
  ]"
18
+ :data-testid="'tab-' + tab.id"
18
19
  @click="activateTab(tab.id)"
19
20
  >
20
21
  <UIcon
@@ -30,6 +31,7 @@
30
31
  color="neutral"
31
32
  class="opacity-0 group-hover:opacity-100 transition-opacity -mr-1"
32
33
  :class="{ 'opacity-100': tab.id === activeTabId }"
34
+ :data-testid="'tab-close-' + tab.id"
33
35
  @click.stop="removeTab(tab.id)"
34
36
  />
35
37
  </button>
@@ -38,31 +40,31 @@
38
40
  </div>
39
41
  </ClientOnly>
40
42
 
41
- <!-- Viewer container -->
43
+ <!-- Viewer container — only the active tab is rendered -->
42
44
  <div class="flex-1 relative overflow-hidden">
43
45
  <div
44
- v-for="tab in tabs"
45
- v-show="tab.id === activeTabId"
46
- :key="tab.id"
46
+ v-if="activeTab"
47
+ :key="activeTab.id"
47
48
  class="absolute inset-0"
48
49
  >
49
50
  <Viewer
50
- :ref="(el) => setViewerRef(tab.id, el)"
51
- :source="tab.source"
52
- :active="tab.id === activeTabId"
51
+ :ref="(el) => setViewerRef(activeTab.id, el)"
52
+ :source="activeTab.source"
53
+ :active="true"
53
54
  :stamps="stamps"
54
55
  :text-layer="textLayer"
55
56
  :user-name="userName"
56
57
  :signature-handlers="signatureHandlers"
57
- :view-mode="tab.viewMode ?? viewMode"
58
- :zoom="tab.zoom ?? zoom"
58
+ :view-mode="activeTab.viewMode ?? viewMode"
59
+ :zoom="activeTab.zoom ?? zoom"
59
60
  :readonly="readonly"
61
+ :shape-detection="activeTab.shapeDetection ?? shapeDetection"
60
62
  >
61
63
  <template v-if="$slots.header" #header>
62
- <slot name="header" :tab="tab" />
64
+ <slot name="header" :tab="activeTab" />
63
65
  </template>
64
66
  <template v-if="$slots.footer" #footer>
65
- <slot name="footer" :tab="tab" />
67
+ <slot name="footer" :tab="activeTab" />
66
68
  </template>
67
69
  </Viewer>
68
70
  </div>
@@ -76,7 +78,7 @@
76
78
  </template>
77
79
 
78
80
  <script setup>
79
- import { ref, watch } from "vue";
81
+ import { ref, computed, watch, nextTick } from "vue";
80
82
  import Viewer from "./Viewer.vue";
81
83
  const props = defineProps({
82
84
  items: { type: Array, required: true },
@@ -86,6 +88,7 @@ const props = defineProps({
86
88
  signatureHandlers: { type: Object, required: false, default: void 0 },
87
89
  textLayer: { type: Boolean, required: false },
88
90
  readonly: { type: Boolean, required: false },
91
+ shapeDetection: { type: Boolean, required: false },
89
92
  viewMode: { type: String, required: false, default: "fit-width" },
90
93
  zoom: { type: Number, required: false, default: 1 },
91
94
  minTabs: { type: Number, required: false, default: 0 }
@@ -99,14 +102,37 @@ const tabs = ref([...props.items]);
99
102
  const activeTabId = ref(
100
103
  props.defaultActiveTab ?? props.items[0]?.id ?? ""
101
104
  );
105
+ const activeTab = computed(
106
+ () => tabs.value.find((t) => t.id === activeTabId.value) ?? null
107
+ );
102
108
  const viewerRefs = /* @__PURE__ */ new Map();
109
+ const savedAnnotations = /* @__PURE__ */ new Map();
103
110
  function setViewerRef(id, el) {
104
111
  if (el) {
105
112
  viewerRefs.set(id, el);
113
+ const saved = savedAnnotations.get(id);
114
+ if (saved && saved.length > 0) {
115
+ nextTick(() => {
116
+ const viewer = el;
117
+ if (typeof viewer.importAnnotations === "function") {
118
+ viewer.importAnnotations(saved, { mode: "merge" }).catch(() => {
119
+ });
120
+ }
121
+ });
122
+ }
106
123
  } else {
107
124
  viewerRefs.delete(id);
108
125
  }
109
126
  }
127
+ function saveViewerState(id) {
128
+ const viewer = viewerRefs.get(id);
129
+ if (viewer && typeof viewer.getAnnotations === "function") {
130
+ const annotations = viewer.getAnnotations();
131
+ if (annotations.length > 0) {
132
+ savedAnnotations.set(id, annotations);
133
+ }
134
+ }
135
+ }
110
136
  watch(
111
137
  () => props.items,
112
138
  (newItems) => {
@@ -139,6 +165,7 @@ function removeTab(id) {
139
165
  emit("tab-close", id);
140
166
  tabs.value.splice(index, 1);
141
167
  viewerRefs.delete(id);
168
+ savedAnnotations.delete(id);
142
169
  if (activeTabId.value === id) {
143
170
  const newIndex = Math.min(index, tabs.value.length - 1);
144
171
  activeTabId.value = tabs.value[newIndex]?.id ?? "";
@@ -151,6 +178,9 @@ function removeTab(id) {
151
178
  }
152
179
  function activateTab(id) {
153
180
  if (tabs.value.some((t) => t.id === id)) {
181
+ if (activeTabId.value && activeTabId.value !== id) {
182
+ saveViewerState(activeTabId.value);
183
+ }
154
184
  activeTabId.value = id;
155
185
  emit("update:activeTab", id);
156
186
  }
@@ -16,6 +16,8 @@ export interface ViewerTabItem {
16
16
  viewMode?: ViewMode;
17
17
  /** Zoom override for this document. */
18
18
  zoom?: number;
19
+ /** Enable shape detection for this document. */
20
+ shapeDetection?: boolean;
19
21
  }
20
22
  export interface AddTabOptions {
21
23
  /** Insert at specific index. Defaults to end. */
@@ -38,6 +40,8 @@ type __VLS_Props = {
38
40
  textLayer?: boolean;
39
41
  /** When true, all viewers are in view-only mode. */
40
42
  readonly?: boolean;
43
+ /** Enable shape detection on all Viewer instances. Can be overridden per tab. */
44
+ shapeDetection?: boolean;
41
45
  /** Default view mode for tabs without a per-tab override. */
42
46
  viewMode?: ViewMode;
43
47
  /** Default zoom for tabs without a per-tab override. */
@@ -148,6 +152,7 @@ declare var __VLS_7: {}, __VLS_21: {}, __VLS_30: {
148
152
  closable?: boolean | undefined;
149
153
  viewMode?: ViewMode | undefined;
150
154
  zoom?: number | undefined;
155
+ shapeDetection?: boolean | undefined;
151
156
  };
152
157
  }, __VLS_33: {
153
158
  tab: {
@@ -246,6 +251,7 @@ declare var __VLS_7: {}, __VLS_21: {}, __VLS_30: {
246
251
  closable?: boolean | undefined;
247
252
  viewMode?: ViewMode | undefined;
248
253
  zoom?: number | undefined;
254
+ shapeDetection?: boolean | undefined;
249
255
  };
250
256
  }, __VLS_35: {};
251
257
  type __VLS_Slots = {} & {
@@ -361,6 +367,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
361
367
  closable?: boolean | undefined;
362
368
  viewMode?: ViewMode | undefined;
363
369
  zoom?: number | undefined;
370
+ shapeDetection?: boolean | undefined;
364
371
  }[];
365
372
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
366
373
  "update:activeTab": (id: string) => any;
@@ -372,7 +379,15 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
372
379
  "onTab-added"?: ((tab: ViewerTabItem) => any) | undefined;
373
380
  "onTab-close"?: ((id: string) => any) | undefined;
374
381
  "onTab-removed"?: ((id: string) => any) | undefined;
375
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
382
+ }>, {
383
+ userName: string;
384
+ zoom: number;
385
+ stamps: StampDefinition[];
386
+ signatureHandlers: SignatureHandlers;
387
+ viewMode: ViewMode;
388
+ defaultActiveTab: string;
389
+ minTabs: number;
390
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
376
391
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
377
392
  declare const _default: typeof __VLS_export;
378
393
  export default _default;
@@ -1,5 +1,36 @@
1
1
  <template>
2
- <div class="kviewer-form-checkbox">
2
+ <!-- Detected shape: custom × icon on transparent background -->
3
+ <div
4
+ v-if="isDetected"
5
+ class="kviewer-form-checkbox kviewer-form-checkbox--detected"
6
+ :class="{
7
+ 'kviewer-form-checkbox--circle': props.field.shapeType === 'circle',
8
+ 'kviewer-form-checkbox--checked': isChecked
9
+ }"
10
+ role="checkbox"
11
+ :aria-checked="isChecked"
12
+ :aria-disabled="isReadOnly"
13
+ tabindex="0"
14
+ @click="toggle"
15
+ @keydown.space.prevent="toggle"
16
+ @keydown.enter.prevent="toggle"
17
+ >
18
+ <svg
19
+ v-if="isChecked"
20
+ class="kviewer-form-checkbox__icon"
21
+ viewBox="0 0 16 16"
22
+ fill="none"
23
+ stroke="currentColor"
24
+ stroke-width="2.5"
25
+ stroke-linecap="round"
26
+ >
27
+ <line x1="3" y1="3" x2="13" y2="13" />
28
+ <line x1="13" y1="3" x2="3" y2="13" />
29
+ </svg>
30
+ </div>
31
+
32
+ <!-- Real PDF form field: native checkbox -->
33
+ <div v-else class="kviewer-form-checkbox">
3
34
  <input
4
35
  type="checkbox"
5
36
  :checked="isChecked"
@@ -19,11 +50,16 @@ const props = defineProps({
19
50
  });
20
51
  const formFields = useFormFields();
21
52
  const state = useViewerState();
53
+ const isDetected = computed(() => props.field.shapeType != null);
22
54
  const isReadOnly = computed(() => state.readonly.value || props.field.readOnly);
23
55
  const isChecked = computed(() => {
24
56
  const fv = formFields.getFieldValue(props.field.id);
25
57
  return fv?.value === true;
26
58
  });
59
+ function toggle() {
60
+ if (isReadOnly.value) return;
61
+ formFields.setFieldValue(props.field.id, !isChecked.value);
62
+ }
27
63
  function onChange(event) {
28
64
  const target = event.target;
29
65
  formFields.setFieldValue(props.field.id, target.checked);
@@ -5,6 +5,7 @@
5
5
  variant="ghost"
6
6
  color="neutral"
7
7
  size="sm"
8
+ data-testid="action-undo"
8
9
  :disabled="!state.history.canUndo.value"
9
10
  @click="state.history.undo"
10
11
  />
@@ -13,6 +14,7 @@
13
14
  variant="ghost"
14
15
  color="neutral"
15
16
  size="sm"
17
+ data-testid="action-redo"
16
18
  :disabled="!state.history.canRedo.value"
17
19
  @click="state.history.redo"
18
20
  />
@@ -21,6 +23,7 @@
21
23
  :variant="state.activeTool.value === 'eraser' ? 'soft' : 'ghost'"
22
24
  color="neutral"
23
25
  size="sm"
26
+ data-testid="action-eraser"
24
27
  @click="state.selectTool('eraser')"
25
28
  />
26
29
  </div>
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <span v-if="state.totalPages.value" class="text-xs text-muted">
2
+ <span v-if="state.totalPages.value" class="text-xs text-muted" data-testid="page-info">
3
3
  {{ state.totalPages.value }} pages
4
4
  </span>
5
5
  </template>
@@ -6,6 +6,7 @@
6
6
  color="neutral"
7
7
  size="xs"
8
8
  title="Search"
9
+ data-testid="search-toggle"
9
10
  @click="toggleOpen"
10
11
  />
11
12
 
@@ -21,11 +22,12 @@
21
22
  color="neutral"
22
23
  size="xs"
23
24
  class="w-56"
25
+ data-testid="search-input"
24
26
  @update:model-value="onQueryChange"
25
27
  @keydown="onInputKeydown"
26
28
  />
27
29
 
28
- <span class="min-w-16 text-center text-[11px] text-muted">
30
+ <span class="min-w-16 text-center text-[11px] text-muted" data-testid="search-status">
29
31
  {{ statusLabel }}
30
32
  </span>
31
33
 
@@ -7,6 +7,7 @@
7
7
  color="neutral"
8
8
  size="xs"
9
9
  class="w-24"
10
+ data-testid="zoom-select"
10
11
  @update:model-value="onZoomChange"
11
12
  />
12
13
  <UButton
@@ -14,6 +15,7 @@
14
15
  variant="ghost"
15
16
  color="neutral"
16
17
  size="xs"
18
+ data-testid="zoom-out"
17
19
  @click="state.zoomOut"
18
20
  />
19
21
  <UButton
@@ -21,6 +23,7 @@
21
23
  variant="ghost"
22
24
  color="neutral"
23
25
  size="xs"
26
+ data-testid="zoom-in"
24
27
  @click="state.zoomIn"
25
28
  />
26
29
  </div>
@@ -0,0 +1,38 @@
1
+ import type { PageViewport } from 'pdfjs-dist';
2
+ import type { DetectedShape, DetectedShapeType } from '../annotation/engine/types.js';
3
+ export interface ViewportRect {
4
+ left: number;
5
+ top: number;
6
+ right: number;
7
+ bottom: number;
8
+ w: number;
9
+ h: number;
10
+ }
11
+ /**
12
+ * Convert a PDF-space bounding box to viewport-space rect.
13
+ * PDF origin is bottom-left; viewport origin is top-left.
14
+ */
15
+ export declare function pdfRectToViewport(viewport: PageViewport, x1: number, y1: number, x2: number, y2: number): ViewportRect;
16
+ /**
17
+ * True if a viewport-space rect looks like a checkbox / radio button.
18
+ * Size: 15–120 px on each axis. Aspect ratio: 0.6–1.4 (roughly square).
19
+ */
20
+ export declare function isCheckboxLikeViewport(w: number, h: number): boolean;
21
+ /**
22
+ * True if PDF-space dimensions look checkbox-sized (5–40 pt, roughly square).
23
+ * Used for the operator-stream scan before coordinate conversion.
24
+ */
25
+ export declare function isCheckboxLikePdf(w: number, h: number): boolean;
26
+ /**
27
+ * Widget-specific size check (Layer 1).
28
+ *
29
+ * For actual PDF form fields (Widget annotations) we KNOW the shape is a
30
+ * real interactive element, so we use a more lenient minimum size (8 px
31
+ * instead of 15 px) to avoid rejecting small checkboxes at lower zoom levels.
32
+ * Wide text-input widgets are still excluded via the aspect-ratio guard.
33
+ */
34
+ export declare function isCheckboxLikeWidget(w: number, h: number): boolean;
35
+ /** Build a DetectedShape record from viewport-space left / top / w / h. */
36
+ export declare function makeShape(id: string, left: number, top: number, w: number, h: number, shapeType?: DetectedShapeType): DetectedShape;
37
+ /** Characters that indicate checkbox / radio icon-font glyphs. */
38
+ export declare const SYMBOL_CHARS: Set<string>;
@@ -0,0 +1,50 @@
1
+ export function pdfRectToViewport(viewport, x1, y1, x2, y2) {
2
+ const [vx1, vy1] = viewport.convertToViewportPoint(x1, y1);
3
+ const [vx2, vy2] = viewport.convertToViewportPoint(x2, y2);
4
+ const left = Math.min(vx1, vx2);
5
+ const top = Math.min(vy1, vy2);
6
+ const right = Math.max(vx1, vx2);
7
+ const bottom = Math.max(vy1, vy2);
8
+ return { left, top, right, bottom, w: right - left, h: bottom - top };
9
+ }
10
+ export function isCheckboxLikeViewport(w, h) {
11
+ if (w < 15 || w > 120 || h < 15 || h > 120) return false;
12
+ const ratio = w / h;
13
+ return ratio > 0.6 && ratio < 1.4;
14
+ }
15
+ export function isCheckboxLikePdf(w, h) {
16
+ const aw = Math.abs(w);
17
+ const ah = Math.abs(h);
18
+ if (aw < 5 || aw > 40 || ah < 5 || ah > 40) return false;
19
+ const ratio = aw / ah;
20
+ return ratio >= 0.8 && ratio <= 1.25;
21
+ }
22
+ export function isCheckboxLikeWidget(w, h) {
23
+ if (w < 8 || w > 200 || h < 8 || h > 200) return false;
24
+ const ratio = w / h;
25
+ return ratio > 0.4 && ratio < 2.5;
26
+ }
27
+ export function makeShape(id, left, top, w, h, shapeType) {
28
+ return {
29
+ id,
30
+ rect: { x: left, y: top, w, h },
31
+ center: { x: left + w / 2, y: top + h / 2 },
32
+ size: Math.max(w, h),
33
+ shapeType
34
+ };
35
+ }
36
+ export const SYMBOL_CHARS = /* @__PURE__ */ new Set([
37
+ "\u25CB",
38
+ "\u25CF",
39
+ "\u25EF",
40
+ "\u25C9",
41
+ "\u2299",
42
+ "\u25CE",
43
+ "\u2B1C",
44
+ "\u2B1B",
45
+ "\u2610",
46
+ "\u2611",
47
+ "\u2612",
48
+ "\u25FB",
49
+ "\u25FC"
50
+ ]);
@@ -1,5 +1,5 @@
1
1
  import { type ShallowRef } from 'vue';
2
- import type { FormFieldDefinition, FormFieldValue } from '../annotation/engine/types.js';
2
+ import type { DetectedShape, FormFieldDefinition, FormFieldValue } from '../annotation/engine/types.js';
3
3
  export interface FormFieldsState {
4
4
  /** Field definitions grouped by page number */
5
5
  fieldDefinitions: ShallowRef<Map<number, FormFieldDefinition[]>>;
@@ -19,6 +19,8 @@ export interface FormFieldsState {
19
19
  setFieldValueByName: (fieldName: string, value: string | boolean | string[]) => void;
20
20
  /** Reset all field values to their defaults (keeps definitions) */
21
21
  resetValues: () => void;
22
+ /** Create synthetic checkbox form fields from detected shapes */
23
+ registerDetectedCheckboxes: (pageNumber: number, shapes: DetectedShape[], scale: number, pageHeight: number) => void;
22
24
  /** Clear all values and definitions (for document change) */
23
25
  reset: () => void;
24
26
  }
@@ -69,6 +69,52 @@ export function provideFormFields() {
69
69
  }
70
70
  return "";
71
71
  }
72
+ function registerDetectedCheckboxes(pageNumber, shapes, scale, pageHeight) {
73
+ const existingDefs = fieldDefinitions.value.get(pageNumber) ?? [];
74
+ const existingCenters = existingDefs.map((d) => ({
75
+ cx: (d.rect[0] + d.rect[2]) / 2,
76
+ cy: (d.rect[1] + d.rect[3]) / 2,
77
+ hw: (d.rect[2] - d.rect[0]) / 2,
78
+ hh: (d.rect[3] - d.rect[1]) / 2
79
+ }));
80
+ const newDefs = [];
81
+ for (let i = 0; i < shapes.length; i++) {
82
+ const s = shapes[i];
83
+ const pdfX1 = s.rect.x / scale;
84
+ const pdfX2 = (s.rect.x + s.rect.w) / scale;
85
+ const pdfY1 = pageHeight - (s.rect.y + s.rect.h) / scale;
86
+ const pdfY2 = pageHeight - s.rect.y / scale;
87
+ const overlaps = existingCenters.some(
88
+ (ec) => ec.cx >= pdfX1 && ec.cx <= pdfX2 && ec.cy >= pdfY1 && ec.cy <= pdfY2
89
+ );
90
+ if (overlaps) continue;
91
+ const fieldId = `detected-chk-p${pageNumber}-${i}`;
92
+ newDefs.push({
93
+ id: fieldId,
94
+ pageNumber,
95
+ fieldType: "checkbox",
96
+ fieldName: `DetectedCheckbox_p${pageNumber}_${i}`,
97
+ rect: [pdfX1, pdfY1, pdfX2, pdfY2],
98
+ readOnly: false,
99
+ required: false,
100
+ exportValue: "Yes",
101
+ defaultValue: "Off",
102
+ shapeType: s.shapeType
103
+ });
104
+ }
105
+ if (newDefs.length === 0) return;
106
+ fieldDefinitions.value.set(pageNumber, [...existingDefs, ...newDefs]);
107
+ triggerRef(fieldDefinitions);
108
+ for (const def of newDefs) {
109
+ fieldValues.value.set(def.id, {
110
+ fieldId: def.id,
111
+ fieldName: def.fieldName,
112
+ fieldType: "checkbox",
113
+ value: false
114
+ });
115
+ }
116
+ triggerRef(fieldValues);
117
+ }
72
118
  function resetValues() {
73
119
  for (const [, defs] of fieldDefinitions.value.entries()) {
74
120
  for (const def of defs) {
@@ -95,6 +141,7 @@ export function provideFormFields() {
95
141
  getFieldsForPage,
96
142
  getAllFieldValues,
97
143
  setFieldValueByName,
144
+ registerDetectedCheckboxes,
98
145
  resetValues,
99
146
  reset
100
147
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shape Detection Composable
3
+ *
4
+ * Detects checkbox-like shapes on each PDF page using three layers:
5
+ * 1. PDF annotations (Widget, Btn, Square, Circle form fields)
6
+ * 2. Graphical shapes from the operator stream (vector rectangles / circles)
7
+ * 3. Icon-font glyphs from the text layer (Unicode symbols, Private Use Area)
8
+ *
9
+ * Used by the auto-stamp feature to snap stamps onto existing form checkboxes.
10
+ */
11
+ import type { PDFPageProxy, PageViewport } from 'pdfjs-dist';
12
+ import type { DetectedShape } from '../annotation/engine/types.js';
13
+ export interface ShapeDetection {
14
+ /** Run all three detection layers on a page and cache the results. */
15
+ preprocessShapesForPage: (pageNumber: number, pdfPage: PDFPageProxy, viewport: PageViewport) => Promise<DetectedShape[]>;
16
+ /** Fast O(n) lookup: first shape whose rect contains (x, y). */
17
+ findShapeAtPoint: (pageNumber: number, x: number, y: number) => DetectedShape | null;
18
+ /** Clear cached shapes for one page (e.g. after a zoom change). */
19
+ clearShapeCache: (pageNumber: number) => void;
20
+ /** Clear the entire shape cache (e.g. when a new PDF is loaded). */
21
+ clearAllShapeCache: () => void;
22
+ }
23
+ export declare function createShapeDetection(): ShapeDetection;
24
+ export declare function useShapeDetection(): ShapeDetection;