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
@@ -0,0 +1,250 @@
1
+ import Konva from "konva";
2
+ import { annotationDefinitions } from "../engine/config.js";
3
+ import { SHAPE_GROUP_NAME } from "../engine/const.js";
4
+ import {
5
+ PdfjsAnnotationEditorType
6
+ } from "../engine/types.js";
7
+ import { formatTimestamp } from "../engine/utils.js";
8
+ import {
9
+ asBorderStyle,
10
+ asPoint,
11
+ asTextObject,
12
+ isNumber,
13
+ isRecord,
14
+ isRawPdfAnnotation,
15
+ isString
16
+ } from "./types.js";
17
+ const UNKNOWN_USER = "Unknown user";
18
+ function clampColor(value) {
19
+ if (!Number.isFinite(value)) return 0;
20
+ return Math.max(0, Math.min(255, Math.round(value)));
21
+ }
22
+ function normalizeColorComponent(value) {
23
+ if (value >= 0 && value <= 1) return clampColor(value * 255);
24
+ return clampColor(value);
25
+ }
26
+ function getDefinition(type) {
27
+ return annotationDefinitions.find((def) => def.type === type) ?? null;
28
+ }
29
+ function getIndexedNumber(input, index) {
30
+ if (Array.isArray(input) || ArrayBuffer.isView(input)) {
31
+ const value = input[index];
32
+ return isNumber(value) ? value : null;
33
+ }
34
+ if (isRecord(input)) {
35
+ const value = input[String(index)];
36
+ return isNumber(value) ? value : null;
37
+ }
38
+ return null;
39
+ }
40
+ function toNumericTuple(input, size) {
41
+ const tuple = [];
42
+ for (let index = 0; index < size; index++) {
43
+ const value = getIndexedNumber(input, index);
44
+ if (!isNumber(value)) return null;
45
+ tuple.push(value);
46
+ }
47
+ return tuple;
48
+ }
49
+ function toCoordinateList(input) {
50
+ if (!Array.isArray(input) && !ArrayBuffer.isView(input)) return null;
51
+ const list = input;
52
+ const values = [];
53
+ for (let index = 0; index < list.length; index++) {
54
+ const value = list[index];
55
+ if (!isNumber(value)) return null;
56
+ values.push(value);
57
+ }
58
+ return values;
59
+ }
60
+ export function colorArrayToRgb(input) {
61
+ const tuple = toNumericTuple(input, 3);
62
+ if (!tuple) return null;
63
+ const [r, g, b] = tuple;
64
+ return `rgb(${normalizeColorComponent(r)}, ${normalizeColorComponent(g)}, ${normalizeColorComponent(b)})`;
65
+ }
66
+ export function getAnnotationColor(annotation, type) {
67
+ const decoded = colorArrayToRgb(annotation.color);
68
+ if (decoded) return decoded;
69
+ const fallback = getDefinition(type)?.style?.color;
70
+ return fallback ?? "rgb(255, 0, 0)";
71
+ }
72
+ export function getAnnotationTitle(annotation) {
73
+ const title = asTextObject(annotation.titleObj)?.str;
74
+ if (isString(title) && title.length > 0) return title;
75
+ return UNKNOWN_USER;
76
+ }
77
+ export function getAnnotationText(annotation) {
78
+ const text = asTextObject(annotation.contentsObj)?.str;
79
+ if (isString(text)) return text;
80
+ return "";
81
+ }
82
+ export function getFreeTextContent(annotation) {
83
+ if (isString(annotation.textContent)) return annotation.textContent;
84
+ if (Array.isArray(annotation.textContent)) {
85
+ const lines = annotation.textContent.filter((line) => isString(line));
86
+ if (lines.length > 0) return lines.join("\n");
87
+ }
88
+ return getAnnotationText(annotation);
89
+ }
90
+ export function getAnnotationDate(annotation) {
91
+ if (isString(annotation.modificationDate) && annotation.modificationDate.length > 0) {
92
+ return annotation.modificationDate;
93
+ }
94
+ return formatTimestamp(Date.now());
95
+ }
96
+ export function getComments(annotation, allAnnotations) {
97
+ const comments = [];
98
+ for (const candidate of allAnnotations) {
99
+ if (!isRawPdfAnnotation(candidate)) continue;
100
+ if (candidate.annotationType !== 1) continue;
101
+ if (candidate.inReplyTo !== annotation.id) continue;
102
+ comments.push({
103
+ id: candidate.id,
104
+ title: getAnnotationTitle(candidate),
105
+ date: getAnnotationDate(candidate),
106
+ content: getAnnotationText(candidate)
107
+ });
108
+ }
109
+ return comments;
110
+ }
111
+ export function rectFromPdfRect(input, pageHeight) {
112
+ if (!Array.isArray(input) || input.length < 4) return null;
113
+ const [x1, y1, x2, y2] = input;
114
+ if (!isNumber(x1) || !isNumber(y1) || !isNumber(x2) || !isNumber(y2)) return null;
115
+ return {
116
+ x: x1,
117
+ y: pageHeight - y2,
118
+ width: x2 - x1,
119
+ height: y2 - y1
120
+ };
121
+ }
122
+ export function pointFromPdfPoint(input, pageHeight) {
123
+ const pair = toNumericTuple(input, 2);
124
+ if (pair) return { x: pair[0], y: pageHeight - pair[1] };
125
+ const point = asPoint(input);
126
+ if (!point || !isNumber(point.x) || !isNumber(point.y)) return null;
127
+ return { x: point.x, y: pageHeight - point.y };
128
+ }
129
+ export function localPointFromPdfPoint(input) {
130
+ const pair = toNumericTuple(input, 2);
131
+ if (pair) return { x: pair[0], y: pair[1] };
132
+ const point = asPoint(input);
133
+ if (!point || !isNumber(point.x) || !isNumber(point.y)) return null;
134
+ return { x: point.x, y: point.y };
135
+ }
136
+ export function rectFromQuadPoints(input, pageHeight) {
137
+ const numeric = toCoordinateList(input);
138
+ if (numeric && numeric.length >= 8) {
139
+ const p02 = pointFromPdfPoint([numeric[0], numeric[1]], pageHeight);
140
+ const p12 = pointFromPdfPoint([numeric[2], numeric[3]], pageHeight);
141
+ const p32 = pointFromPdfPoint([numeric[6], numeric[7]], pageHeight);
142
+ if (!p02 || !p12 || !p32) return null;
143
+ return {
144
+ x: p02.x,
145
+ y: p02.y,
146
+ width: p12.x - p02.x,
147
+ height: p12.y - p32.y
148
+ };
149
+ }
150
+ if (!Array.isArray(input) || input.length < 4) return null;
151
+ const p0 = pointFromPdfPoint(input[0], pageHeight);
152
+ const p1 = pointFromPdfPoint(input[1], pageHeight);
153
+ const p3 = pointFromPdfPoint(input[3], pageHeight);
154
+ if (!p0 || !p1 || !p3) return null;
155
+ return {
156
+ x: p0.x,
157
+ y: p0.y,
158
+ width: p1.x - p0.x,
159
+ height: p1.y - p3.y
160
+ };
161
+ }
162
+ export function pointsFromInkList(input, pageHeight) {
163
+ const numeric = toCoordinateList(input);
164
+ if (numeric) {
165
+ const points2 = [];
166
+ for (let index = 0; index + 1 < numeric.length; index += 2) {
167
+ points2.push(numeric[index], pageHeight - numeric[index + 1]);
168
+ }
169
+ return points2;
170
+ }
171
+ if (!Array.isArray(input)) return [];
172
+ const points = [];
173
+ for (const item of input) {
174
+ const converted = pointFromPdfPoint(item, pageHeight);
175
+ if (!converted) continue;
176
+ points.push(converted.x, converted.y);
177
+ }
178
+ return points;
179
+ }
180
+ export function linePointsFromCoordinates(input, pageHeight) {
181
+ const tuple = toNumericTuple(input, 4);
182
+ if (!tuple) return null;
183
+ const [x, y, x1, y1] = tuple;
184
+ return [x, pageHeight - y, x1, pageHeight - y1];
185
+ }
186
+ export function normalizedStrokeWidth(annotation, fallback = 1, options = {}) {
187
+ const borderStyle = asBorderStyle(annotation.borderStyle);
188
+ const width = isNumber(borderStyle?.width) && borderStyle.width > 0 ? borderStyle.width : null;
189
+ const rawWidth = isNumber(borderStyle?.rawWidth) && borderStyle.rawWidth > 0 ? borderStyle.rawWidth : null;
190
+ const preferRawWidth = options.preferRawWidth === true;
191
+ const resolvedWidth = preferRawWidth && isNumber(rawWidth) && (!isNumber(width) || rawWidth > width) ? rawWidth : width ?? rawWidth;
192
+ if (!isNumber(resolvedWidth)) return fallback;
193
+ return resolvedWidth === 1 ? 2 : Math.max(0.1, resolvedWidth);
194
+ }
195
+ export function dashedBorder(annotation) {
196
+ const borderStyle = asBorderStyle(annotation.borderStyle);
197
+ if (!borderStyle) return [];
198
+ const style = borderStyle.style;
199
+ const dashArray = toCoordinateList(borderStyle.dashArray);
200
+ if (style !== 2 || !dashArray) return [];
201
+ return dashArray;
202
+ }
203
+ export function hasArrowEnding(value) {
204
+ if (!isString(value)) return false;
205
+ return value.toLowerCase() !== "none";
206
+ }
207
+ export function createGhostGroup(id) {
208
+ return new Konva.Group({
209
+ draggable: false,
210
+ name: SHAPE_GROUP_NAME,
211
+ id
212
+ });
213
+ }
214
+ export function createAnnotationStore(params) {
215
+ const {
216
+ annotation,
217
+ allAnnotations,
218
+ pageNumber,
219
+ type,
220
+ group,
221
+ color,
222
+ fontSize,
223
+ contentsText,
224
+ contentsImage,
225
+ overrideSubtype
226
+ } = params;
227
+ const def = getDefinition(type);
228
+ const pdfjsType = isNumber(annotation.annotationType) ? annotation.annotationType : def?.pdfjsAnnotationType ?? 0;
229
+ return {
230
+ id: annotation.id,
231
+ pageNumber,
232
+ konvaString: group.toJSON(),
233
+ konvaClientRect: group.getClientRect(),
234
+ title: getAnnotationTitle(annotation),
235
+ type,
236
+ color: color ?? def?.style?.color ?? null,
237
+ subtype: overrideSubtype ?? (isString(annotation.subtype) ? annotation.subtype : def?.subtype ?? "Text"),
238
+ fontSize: fontSize ?? def?.style?.fontSize ?? null,
239
+ pdfjsType,
240
+ pdfjsEditorType: def?.pdfjsEditorType ?? PdfjsAnnotationEditorType.NONE,
241
+ date: getAnnotationDate(annotation),
242
+ contentsObj: {
243
+ text: contentsText ?? getAnnotationText(annotation),
244
+ image: contentsImage
245
+ },
246
+ comments: getComments(annotation, allAnnotations),
247
+ resizable: def?.resizable ?? false,
248
+ draggable: def?.draggable ?? false
249
+ };
250
+ }
@@ -1 +1 @@
1
- .kviewer-text-layer{left:0;line-height:1;overflow:hidden;position:absolute;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;z-index:1}.kviewer-text-layer--interactive{cursor:text;-webkit-user-select:text;-moz-user-select:text;user-select:text}.KViewer_is_painting.KViewer_painting_type_12 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_12 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_13 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_13 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_5 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_5 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_6 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_6 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_7 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_7 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_8 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_8 .kviewer-annotation-layer *{cursor:crosshair!important}.KViewer_is_painting.KViewer_painting_type_4 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_4 .kviewer-annotation-layer *{cursor:text!important}.KViewer_is_painting.KViewer_painting_type_11 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_11 .kviewer-annotation-layer *{cursor:crosshair!important}.KViewer_selector_hover{cursor:pointer!important}.kviewer-form-layer{left:0;overflow:hidden;position:absolute;top:0}.kviewer-form-field{box-sizing:border-box;position:absolute}.kviewer-form-input{background-color:rgba(224,232,255,.6);border:1px solid transparent;box-sizing:border-box;color:#000;font-family:inherit;height:100%;line-height:normal;margin:0;outline:none;padding:1px 2px;resize:none;width:100%}.kviewer-form-input:hover{background-color:rgba(224,232,255,.8);border-color:rgba(0,0,0,.2)}.kviewer-form-input:focus{background-color:rgba(224,232,255,.9);border-color:var(--color-primary,#3b82f6);outline:2px solid var(--color-primary,#3b82f6)}.kviewer-form-select{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto;cursor:pointer}.kviewer-form-listbox{overflow-y:auto;padding:0}.kviewer-form-listbox option{padding:1px 3px}.kviewer-form-editable-combo{height:100%;position:relative;width:100%}.kviewer-form-editable-combo input{height:100%;width:100%}.kviewer-form-checkbox,.kviewer-form-radio{align-items:center;background:#fff;display:flex;height:100%;justify-content:center;width:100%}.kviewer-form-checkbox input,.kviewer-form-radio input{accent-color:var(--color-primary,#3b82f6);cursor:pointer;height:80%;margin:0;max-height:18px;max-width:18px;width:80%}.kviewer-form-button{background:linear-gradient(180deg,#f0f0f0,#d0d0d0);border:1px solid rgba(0,0,0,.3);box-sizing:border-box;cursor:pointer;font-family:inherit;font-weight:600;height:100%;padding:2px 6px;text-align:center;width:100%}.kviewer-form-button:hover{background:linear-gradient(180deg,#e8e8e8,#c8c8c8)}.kviewer-form-button:active{background:linear-gradient(180deg,#c8c8c8,#d0d0d0)}.kviewer-form-signature{align-items:center;background:rgba(255,255,200,.15);border:1px dashed rgba(0,0,0,.2);cursor:pointer;display:flex;height:100%;justify-content:center;width:100%}.kviewer-form-signature:hover{background:rgba(59,130,246,.08);border-color:var(--color-primary,#3b82f6)}.kviewer-form-signature--filled{background:transparent;border-color:transparent;border-style:solid}.kviewer-form-signature__preview{max-height:100%;max-width:100%;-o-object-fit:contain;object-fit:contain}.kviewer-form-signature__placeholder{color:rgba(0,0,0,.4);font-size:10px;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}
1
+ .kviewer-text-layer{left:0;line-height:1;overflow:hidden;position:absolute;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none;z-index:1}.kviewer-text-layer--interactive{cursor:text;-webkit-user-select:text;-moz-user-select:text;user-select:text}.KViewer_is_painting.KViewer_painting_type_12 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_12 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_13 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_13 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_5 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_5 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_6 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_6 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_7 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_7 .kviewer-annotation-layer *,.KViewer_is_painting.KViewer_painting_type_8 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_8 .kviewer-annotation-layer *{cursor:crosshair!important}.KViewer_is_painting.KViewer_painting_type_4 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_4 .kviewer-annotation-layer *{cursor:text!important}.KViewer_is_painting.KViewer_painting_type_11 .kviewer-annotation-layer,.KViewer_is_painting.KViewer_painting_type_11 .kviewer-annotation-layer *{cursor:crosshair!important}.KViewer_selector_hover{cursor:pointer!important}.kviewer-form-layer{left:0;overflow:hidden;position:absolute;top:0}.kviewer-form-field{box-sizing:border-box;position:absolute}.kviewer-form-input{background-color:rgba(224,232,255,.6);border:1px solid transparent;box-sizing:border-box;color:#000;font-family:inherit;height:100%;line-height:normal;margin:0;outline:none;padding:1px 2px;resize:none;width:100%}.kviewer-form-input:hover{background-color:rgba(224,232,255,.8);border-color:rgba(0,0,0,.2)}.kviewer-form-input:focus{background-color:rgba(224,232,255,.9);border-color:var(--color-primary,#3b82f6);outline:2px solid var(--color-primary,#3b82f6)}.kviewer-form-select{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto;cursor:pointer}.kviewer-form-listbox{overflow-y:auto;padding:0}.kviewer-form-listbox option{padding:1px 3px}.kviewer-form-editable-combo{height:100%;position:relative;width:100%}.kviewer-form-editable-combo input{height:100%;width:100%}.kviewer-form-checkbox,.kviewer-form-radio{align-items:center;background:#fff;box-sizing:border-box;cursor:pointer;display:flex;height:100%;justify-content:center;width:100%}.kviewer-form-checkbox input,.kviewer-form-radio input{accent-color:var(--color-primary,#3b82f6);cursor:pointer;height:80%;margin:0;max-height:18px;max-width:18px;width:80%}.kviewer-form-checkbox--detected{background:transparent}.kviewer-form-checkbox--circle{border-radius:50%}.kviewer-form-checkbox__icon{color:#1a1a1a;height:65%;width:65%}.kviewer-form-checkbox--circle .kviewer-form-checkbox__icon{height:50%;width:50%}.kviewer-form-radio input{accent-color:var(--color-primary,#3b82f6);cursor:pointer;height:80%;margin:0;max-height:18px;max-width:18px;width:80%}.kviewer-form-button{background:linear-gradient(180deg,#f0f0f0,#d0d0d0);border:1px solid rgba(0,0,0,.3);box-sizing:border-box;cursor:pointer;font-family:inherit;font-weight:600;height:100%;padding:2px 6px;text-align:center;width:100%}.kviewer-form-button:hover{background:linear-gradient(180deg,#e8e8e8,#c8c8c8)}.kviewer-form-button:active{background:linear-gradient(180deg,#c8c8c8,#d0d0d0)}.kviewer-form-signature{align-items:center;background:rgba(255,255,200,.15);border:1px dashed rgba(0,0,0,.2);cursor:pointer;display:flex;height:100%;justify-content:center;width:100%}.kviewer-form-signature:hover{background:rgba(59,130,246,.08);border-color:var(--color-primary,#3b82f6)}.kviewer-form-signature--filled{background:transparent;border-color:transparent;border-style:solid}.kviewer-form-signature__preview{max-height:100%;max-width:100%;-o-object-fit:contain;object-fit:contain}.kviewer-form-signature__placeholder{color:rgba(0,0,0,.4);font-size:10px;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}
@@ -48,6 +48,7 @@
48
48
  <button
49
49
  class="flex items-center justify-center w-7 h-7 rounded-md hover:bg-default cursor-pointer"
50
50
  title="Delete"
51
+ data-testid="annotation-delete"
51
52
  @click="onDelete"
52
53
  >
53
54
  <UIcon name="i-lucide-trash-2" class="text-sm text-error" />
@@ -11,16 +11,18 @@
11
11
  class="relative z-10 flex items-center gap-1 bg-elevated rounded-full px-1 py-0.5 shadow-md
12
12
  transition-opacity duration-300"
13
13
  :class="visible ? 'opacity-100' : 'opacity-0 pointer-events-none'"
14
+ data-testid="page-indicator"
14
15
  >
15
16
  <UButton
16
17
  icon="i-lucide-chevron-left"
17
18
  variant="ghost"
18
19
  color="neutral"
19
20
  size="xs"
21
+ data-testid="page-prev"
20
22
  :disabled="state.currentPage.value <= 1"
21
23
  @click="goToPreviousPage"
22
24
  />
23
- <span class="text-sm text-muted tabular-nums px-1 select-none">
25
+ <span class="text-sm text-muted tabular-nums px-1 select-none" data-testid="page-indicator-text">
24
26
  {{ state.currentPage.value }} / {{ state.totalPages.value }}
25
27
  </span>
26
28
  <UButton
@@ -28,6 +30,7 @@
28
30
  variant="ghost"
29
31
  color="neutral"
30
32
  size="xs"
33
+ data-testid="page-next"
31
34
  :disabled="state.currentPage.value >= state.totalPages.value"
32
35
  @click="goToNextPage"
33
36
  />
@@ -56,8 +56,10 @@ import { useViewerState } from "../composables/useViewerState";
56
56
  import { useViewerSearch } from "../composables/useViewerSearch";
57
57
  import { usePageProxyCache } from "../composables/usePageProxyCache";
58
58
  import { useFormFields } from "../composables/useFormFields";
59
+ import { useShapeDetection } from "../composables/useShapeDetection";
59
60
  import { AnnotationType } from "../annotation/engine/types";
60
61
  import FormFieldLayer from "./FormFieldLayer.vue";
62
+ const PDFJS_ANNOTATION_MODE_DISABLE = 0;
61
63
  const props = defineProps({
62
64
  pageNumber: { type: Number, required: true },
63
65
  pageMeta: { type: Object, required: true },
@@ -70,6 +72,7 @@ const state = useViewerState();
70
72
  const search = useViewerSearch();
71
73
  const proxyCache = usePageProxyCache();
72
74
  const formFields = useFormFields();
75
+ const shapeDetection = useShapeDetection();
73
76
  const pageProxy = shallowRef(null);
74
77
  let currentRenderTask = null;
75
78
  let textLayer = null;
@@ -104,17 +107,38 @@ onMounted(async () => {
104
107
  const rawAnnotations = await pageProxy.value.getAnnotations();
105
108
  formFields.parsePageFields(props.pageNumber, rawAnnotations);
106
109
  }
110
+ if (state.shapeDetection.value && pageProxy.value) {
111
+ const viewport = pageProxy.value.getViewport({ scale: props.scale });
112
+ const shapes = await shapeDetection.preprocessShapesForPage(
113
+ props.pageNumber,
114
+ pageProxy.value,
115
+ viewport
116
+ );
117
+ if (shapes.length > 0) {
118
+ formFields.registerDetectedCheckboxes(
119
+ props.pageNumber,
120
+ shapes,
121
+ props.scale,
122
+ baseHeight
123
+ );
124
+ }
125
+ }
107
126
  });
108
127
  watch(
109
128
  () => props.scale,
110
129
  async () => {
111
130
  if (!pageProxy.value) return;
131
+ shapeDetection.clearShapeCache(props.pageNumber);
112
132
  await renderCanvas();
113
133
  updateTextLayer();
114
134
  if (state.painter.value) {
115
135
  state.painter.value.destroyCanvasForPage(props.pageNumber);
116
136
  initKonva();
117
137
  }
138
+ if (pageProxy.value) {
139
+ const viewport = pageProxy.value.getViewport({ scale: props.scale });
140
+ shapeDetection.preprocessShapesForPage(props.pageNumber, pageProxy.value, viewport);
141
+ }
118
142
  }
119
143
  );
120
144
  async function renderCanvas() {
@@ -134,7 +158,8 @@ async function renderCanvas() {
134
158
  try {
135
159
  currentRenderTask = proxy.render({
136
160
  canvasContext: canvas.getContext("2d"),
137
- viewport
161
+ viewport,
162
+ annotationMode: PDFJS_ANNOTATION_MODE_DISABLE
138
163
  });
139
164
  await currentRenderTask.promise;
140
165
  } catch (e) {
@@ -200,6 +225,7 @@ onBeforeUnmount(() => {
200
225
  textLayer?.cancel();
201
226
  textLayer = null;
202
227
  search.unregisterPageLayer(props.pageNumber);
228
+ shapeDetection.clearShapeCache(props.pageNumber);
203
229
  state.painter.value?.destroyCanvasForPage(props.pageNumber);
204
230
  });
205
231
  </script>
@@ -11,6 +11,8 @@ type __VLS_Props = {
11
11
  zoom?: number;
12
12
  /** When true, the viewer is in view-only mode: annotations, drawing tools, and form editing are disabled. */
13
13
  readonly?: boolean;
14
+ /** When true, auto-detect checkbox-like shapes (squares, circles) and create interactive form field checkboxes. */
15
+ shapeDetection?: boolean;
14
16
  /** When false, global keyboard shortcuts (e.g. Cmd+F) are suppressed. Used by ViewerTabs to prevent hidden viewers from capturing input. */
15
17
  active?: boolean;
16
18
  };
@@ -36,7 +38,16 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
36
38
  getKonvaCanvasState: typeof getKonvaCanvasState;
37
39
  getFormFieldValues: () => import("../annotation/engine/types.js").FormFieldValue[];
38
40
  setFormFieldValue: (fieldName: string, value: string | boolean | string[]) => void;
39
- }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
41
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
42
+ userName: string;
43
+ zoom: number;
44
+ readonly: boolean;
45
+ active: boolean;
46
+ stamps: StampDefinition[];
47
+ signatureHandlers: SignatureHandlers;
48
+ viewMode: ViewMode;
49
+ shapeDetection: boolean;
50
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
40
51
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
41
52
  declare const _default: typeof __VLS_export;
42
53
  export default _default;
@@ -10,8 +10,35 @@
10
10
  ref="scrollContainer"
11
11
  class="absolute inset-0 overflow-auto bg-muted/50"
12
12
  :class="{ 'overflow-hidden': isSinglePageMode }"
13
+ data-testid="viewer-scroll"
13
14
  >
14
- <ClientOnly>
15
+ <!-- Error state -->
16
+ <div
17
+ v-if="viewerState.error.value"
18
+ class="absolute inset-0 flex items-center justify-center"
19
+ >
20
+ <div class="flex flex-col items-center gap-3 text-center px-6">
21
+ <svg
22
+ xmlns="http://www.w3.org/2000/svg"
23
+ class="size-10 text-red-500"
24
+ viewBox="0 0 24 24"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ stroke-width="1.5"
28
+ stroke-linecap="round"
29
+ stroke-linejoin="round"
30
+ >
31
+ <circle cx="12" cy="12" r="10" />
32
+ <line x1="12" y1="8" x2="12" y2="12" />
33
+ <line x1="12" y1="16" x2="12.01" y2="16" />
34
+ </svg>
35
+ <p class="text-base font-medium text-foreground">
36
+ {{ viewerState.error.value }}
37
+ </p>
38
+ </div>
39
+ </div>
40
+
41
+ <ClientOnly v-else>
15
42
  <!-- Single-page-at-a-time mode -->
16
43
  <div
17
44
  v-if="isSinglePageMode && singlePageMeta"
@@ -135,12 +162,17 @@ import { mapViewModeToFitMode, normalizeZoom, provideViewerState } from "../comp
135
162
  import { provideViewerSearch } from "../composables/useViewerSearch";
136
163
  import { createAnnotationEngine } from "../composables/useAnnotationEngine";
137
164
  import { normalizeImportedAnnotations } from "../annotation/engine/import-normalize";
165
+ import {
166
+ decodePdfAnnotationsFromDocument,
167
+ filterCollidingAnnotations
168
+ } from "../annotation/pdf-import/decode";
138
169
  import { getTimestampString } from "../annotation/engine/utils";
139
170
  import { downloadPdfBytes } from "../annotation/pdf-export/download";
140
171
  import { exportAnnotationsToPdf } from "../annotation/pdf-export/export";
141
172
  import { createPageVirtualization } from "../composables/usePageVirtualization";
142
173
  import { createPageProxyCache } from "../composables/usePageProxyCache";
143
174
  import { createSearchIndex } from "../composables/useSearchIndex";
175
+ import { createShapeDetection } from "../composables/useShapeDetection";
144
176
  import { provideFormFields } from "../composables/useFormFields";
145
177
  import { isIPad, getTouchDistance, getTouchCenter, clampScale } from "../annotation/engine/input-device";
146
178
  const props = defineProps({
@@ -152,14 +184,18 @@ const props = defineProps({
152
184
  viewMode: { type: String, required: false, default: "fit-width" },
153
185
  zoom: { type: Number, required: false, default: 1 },
154
186
  readonly: { type: Boolean, required: false, default: false },
187
+ shapeDetection: { type: Boolean, required: false, default: false },
155
188
  active: { type: Boolean, required: false, default: true }
156
189
  });
157
190
  const viewerRoot = ref(null);
158
191
  const scrollContainer = ref(null);
159
- const { state: viewerState, setScrollToPageFn } = provideViewerState();
192
+ const { state: viewerState, setScrollToPageFn, setDownloadPdfFn } = provideViewerState();
160
193
  watchEffect(() => {
161
194
  viewerState.readonly.value = props.readonly ?? false;
162
195
  });
196
+ watchEffect(() => {
197
+ viewerState.shapeDetection.value = props.shapeDetection ?? false;
198
+ });
163
199
  const viewerSearch = provideViewerSearch();
164
200
  const formFieldsState = provideFormFields();
165
201
  const pageSettings = providePageSettings(viewerRoot);
@@ -173,6 +209,7 @@ setScrollToPageFn((pageNumber) => {
173
209
  });
174
210
  const proxyCache = createPageProxyCache();
175
211
  const searchIndex = createSearchIndex();
212
+ const shapeDetectionCache = createShapeDetection();
176
213
  const doc = shallowRef(null);
177
214
  let painter = null;
178
215
  const pageMetas = virtualization.pageMetas;
@@ -287,37 +324,73 @@ function onFreeTextCancel() {
287
324
  freeTextResolve = null;
288
325
  }
289
326
  async function loadDocument() {
290
- const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
291
- if (!pdfjs.GlobalWorkerOptions.workerSrc) {
292
- const workerModule = await import("pdfjs-dist/legacy/build/pdf.worker.min.mjs?url");
293
- pdfjs.GlobalWorkerOptions.workerSrc = workerModule.default;
294
- }
295
- const source = props.source;
296
- const loadingTask = pdfjs.getDocument(
297
- source
298
- );
299
- const pdfDoc = await loadingTask.promise;
300
- doc.value = pdfDoc;
301
- viewerState.doc.value = pdfDoc;
302
- viewerState.totalPages.value = pdfDoc.numPages;
303
- proxyCache.setDocument(pdfDoc);
304
- await virtualization.init(pdfDoc, scrollContainer.value);
305
- const metas = virtualization.pageMetas.value;
306
- if (metas.length > 0) {
307
- viewerState.pageDimensions.value = {
308
- width: metas[0].width,
309
- height: metas[0].height
310
- };
311
- }
312
- viewerSearch.setScrollToPage((pageNumber) => {
313
- virtualization.scrollToPage(pageNumber);
314
- });
315
- searchIndex.buildIndex(pdfDoc).then(() => {
316
- viewerSearch.setSearchIndex({
317
- getPageText: searchIndex.getPageText,
318
- totalPages: pdfDoc.numPages
327
+ viewerState.error.value = null;
328
+ viewerState.isLoading.value = true;
329
+ try {
330
+ const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
331
+ if (!pdfjs.GlobalWorkerOptions.workerSrc) {
332
+ const workerModule = await import("pdfjs-dist/legacy/build/pdf.worker.min.mjs?url");
333
+ pdfjs.GlobalWorkerOptions.workerSrc = workerModule.default;
334
+ }
335
+ const source = props.source;
336
+ const loadingTask = pdfjs.getDocument(
337
+ source
338
+ );
339
+ const pdfDoc = await loadingTask.promise;
340
+ doc.value = pdfDoc;
341
+ viewerState.doc.value = pdfDoc;
342
+ viewerState.totalPages.value = pdfDoc.numPages;
343
+ proxyCache.setDocument(pdfDoc);
344
+ await virtualization.init(pdfDoc, scrollContainer.value);
345
+ const metas = virtualization.pageMetas.value;
346
+ if (metas.length > 0) {
347
+ viewerState.pageDimensions.value = {
348
+ width: metas[0].width,
349
+ height: metas[0].height
350
+ };
351
+ }
352
+ const nativeDecoded = await decodePdfAnnotationsFromDocument(pdfDoc);
353
+ let collisionSkipped = 0;
354
+ let nativeMergedCount = 0;
355
+ if (painter) {
356
+ const existingIds = new Set(painter.getData().map((item) => item.id));
357
+ const filtered = filterCollidingAnnotations(nativeDecoded.annotations, existingIds);
358
+ const nativeToMerge = filtered.annotations;
359
+ collisionSkipped = filtered.skipped;
360
+ if (nativeToMerge.length > 0) {
361
+ painter.mergeAnnotations(nativeToMerge);
362
+ nativeMergedCount = nativeToMerge.length;
363
+ viewerState.history.reset();
364
+ }
365
+ viewerState.annotations.value = new Map(
366
+ painter.getData().map((annotation) => [annotation.id, annotation])
367
+ );
368
+ triggerRef(viewerState.annotations);
369
+ viewerState.selectedAnnotation.value = null;
370
+ viewerState.selectedAnnotations.value = [];
371
+ viewerState.selectionRect.value = null;
372
+ }
373
+ const totalSkipped = nativeDecoded.skipped + collisionSkipped;
374
+ if (nativeMergedCount > 0 || totalSkipped > 0 || nativeDecoded.errors > 0 || nativeDecoded.pageErrors > 0 || nativeDecoded.stampExtracted > 0 || nativeDecoded.stampSkipped > 0) {
375
+ console.info(
376
+ `[KViewer] Native annotation import: loaded=${nativeMergedCount}, skipped=${totalSkipped}, decodeErrors=${nativeDecoded.errors}, pageErrors=${nativeDecoded.pageErrors}, stampExtracted=${nativeDecoded.stampExtracted}, stampSkipped=${nativeDecoded.stampSkipped}`
377
+ );
378
+ }
379
+ viewerSearch.setScrollToPage((pageNumber) => {
380
+ virtualization.scrollToPage(pageNumber);
319
381
  });
320
- });
382
+ searchIndex.buildIndex(pdfDoc).then(() => {
383
+ viewerSearch.setSearchIndex({
384
+ getPageText: searchIndex.getPageText,
385
+ totalPages: pdfDoc.numPages
386
+ });
387
+ });
388
+ } catch (err) {
389
+ viewerState.error.value = "Failed to load document";
390
+ console.error("[KViewer] Document load error:", err);
391
+ } finally {
392
+ viewerState.isLoading.value = false;
393
+ }
321
394
  }
322
395
  let resizeObserver = null;
323
396
  function ensurePdfExtension(fileName) {
@@ -361,13 +434,16 @@ async function exportPdf(options = {}) {
361
434
  ...options
362
435
  };
363
436
  const pdfData = await doc.value.getData();
364
- const annotations = painter.getData();
437
+ const exportContext = painter.getExportContext();
365
438
  const formFieldValues = formFieldsState.getAllFieldValues();
366
439
  const bytes = await exportAnnotationsToPdf({
367
440
  pdfData,
368
- annotations,
441
+ annotations: exportContext.annotations,
369
442
  options: mergedOptions,
370
- formFieldValues
443
+ formFieldValues,
444
+ originalAnnotationIds: exportContext.originalIds,
445
+ modifiedAnnotationIds: exportContext.modifiedIds,
446
+ deletedAnnotationIds: exportContext.deletedIds
371
447
  });
372
448
  if (mergedOptions.download) {
373
449
  downloadPdfBytes(bytes, resolveExportFileName(mergedOptions.fileName));
@@ -564,6 +640,7 @@ onMounted(() => {
564
640
  if (isIPad()) {
565
641
  viewerState.setStylusMode(true);
566
642
  }
643
+ setDownloadPdfFn(() => exportPdf({ download: true }));
567
644
  loadDocument();
568
645
  window.addEventListener("keydown", onGlobalKeydown);
569
646
  });
@@ -572,6 +649,7 @@ watch(
572
649
  () => {
573
650
  viewerSearch.reset();
574
651
  formFieldsState.reset();
652
+ shapeDetectionCache.clearAllShapeCache();
575
653
  searchIndex.destroy();
576
654
  virtualization.destroy();
577
655
  proxyCache.clear();
@@ -605,6 +683,7 @@ onBeforeUnmount(() => {
605
683
  scrollContainer.value?.removeEventListener("touchcancel", onPinchTouchEnd);
606
684
  resizeObserver?.disconnect();
607
685
  viewerSearch.reset();
686
+ shapeDetectionCache.clearAllShapeCache();
608
687
  searchIndex.destroy();
609
688
  virtualization.destroy();
610
689
  proxyCache.clear();
@@ -11,6 +11,8 @@ type __VLS_Props = {
11
11
  zoom?: number;
12
12
  /** When true, the viewer is in view-only mode: annotations, drawing tools, and form editing are disabled. */
13
13
  readonly?: boolean;
14
+ /** When true, auto-detect checkbox-like shapes (squares, circles) and create interactive form field checkboxes. */
15
+ shapeDetection?: boolean;
14
16
  /** When false, global keyboard shortcuts (e.g. Cmd+F) are suppressed. Used by ViewerTabs to prevent hidden viewers from capturing input. */
15
17
  active?: boolean;
16
18
  };
@@ -36,7 +38,16 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {
36
38
  getKonvaCanvasState: typeof getKonvaCanvasState;
37
39
  getFormFieldValues: () => import("../annotation/engine/types.js").FormFieldValue[];
38
40
  setFormFieldValue: (fieldName: string, value: string | boolean | string[]) => void;
39
- }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
41
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
42
+ userName: string;
43
+ zoom: number;
44
+ readonly: boolean;
45
+ active: boolean;
46
+ stamps: StampDefinition[];
47
+ signatureHandlers: SignatureHandlers;
48
+ viewMode: ViewMode;
49
+ shapeDetection: boolean;
50
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
40
51
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
41
52
  declare const _default: typeof __VLS_export;
42
53
  export default _default;