Skip to content

Commit 8fdc636

Browse files
committed
Fix group selection box and text annotation color
Ensure multi-select group outlines correctly enclose annotations that use noZoom and/or noRotate (including rotated pages and non-square noRotate annotations). Prefer a new strokeColor for text annotations (falling back to deprecated color) and explicitly set annotation opacity/color when creating annotations in the Pdfium engine. Add getContrastStrokeColor helper for choosing contrasting stroke colors. Regenerate/format PDFium vendor bindings and update related annotation UI components, default tools, and viewer configs to use the new behavior. Note: PdfAnnotationObject.color is now deprecated in favor of strokeColor (will be removed in the next major release).
1 parent 932a628 commit 8fdc636

12 files changed

Lines changed: 149 additions & 115 deletions

File tree

.changeset/fix-nozoom-group-outline.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@embedpdf/plugin-annotation': patch
33
---
44

5-
Fix group selection box outline for noZoom annotations. The multi-select group outline now correctly encloses noZoom annotations at all zoom levels instead of being too large when zoomed in or too small when zoomed out.
5+
Fix group selection box outline when selected annotations use `noZoom` and/or `noRotate` flags. The multi-select group outline now correctly encloses mixed selections (flagged + normal annotations), including rotated pages and non-square noRotate annotations.

packages/engines/src/lib/pdfium/engine.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2521,6 +2521,15 @@ export class PdfiumNative implements IPdfiumExecutor {
25212521
return false;
25222522
}
25232523

2524+
if (!this.setAnnotationOpacity(annotationPtr, annotation.opacity ?? 1)) {
2525+
return false;
2526+
}
2527+
// Prefer strokeColor, fall back to deprecated color
2528+
const strokeColor = annotation.strokeColor ?? annotation.color ?? '#FFFF00';
2529+
if (!this.setAnnotationColor(annotationPtr, strokeColor, PdfAnnotationColorType.Color)) {
2530+
return false;
2531+
}
2532+
25242533
// Text annotations have default flags if not specified
25252534
if (!annotation.flags) {
25262535
if (!this.setAnnotationFlags(annotationPtr, ['print', 'noZoom', 'noRotate'])) {
@@ -5984,6 +5993,7 @@ export class PdfiumNative implements IPdfiumExecutor {
59845993
id: index,
59855994
type: PdfAnnotationSubtype.TEXT,
59865995
rect,
5996+
strokeColor: color ?? '#FFFF00',
59875997
color: color ?? '#FFFF00',
59885998
opacity,
59895999
state,

packages/models/src/color.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ export function webColorToPdfColor(color: WebColor): PdfColor {
5151
};
5252
}
5353

54+
/**
55+
* Return `#fff` or `#000` as a contrasting stroke color for the given fill color,
56+
* based on relative luminance (threshold 0.45).
57+
*/
58+
export function getContrastStrokeColor(fillColor: string): string {
59+
try {
60+
const { red, green, blue } = webColorToPdfColor(fillColor);
61+
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
62+
return luminance < 0.45 ? '#fff' : '#000';
63+
} catch {
64+
return '#000';
65+
}
66+
}
67+
5468
// === Alpha utility functions ===
5569

5670
/**

packages/models/src/pdf.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1188,7 +1188,12 @@ export interface PdfTextAnnoObject extends PdfAnnotationObjectBase {
11881188
contents: string;
11891189

11901190
/**
1191-
* color of text annotation
1191+
* Color of the text annotation (preferred over deprecated `color`)
1192+
*/
1193+
strokeColor?: string;
1194+
1195+
/**
1196+
* @deprecated Use strokeColor instead. Will be removed in next major version.
11921197
*/
11931198
color?: string;
11941199

207 Bytes
Binary file not shown.

packages/plugin-annotation/src/lib/tools/default-tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ export const defaultTools = [
384384
},
385385
defaults: {
386386
type: PdfAnnotationSubtype.TEXT,
387-
color: '#facc15',
387+
strokeColor: '#FFCD45',
388388
opacity: 1,
389389
},
390390
behavior: {

packages/plugin-annotation/src/shared/components/annotations/text.tsx

Lines changed: 8 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getContrastStrokeColor } from '@embedpdf/models';
12
import { MouseEvent } from '@framework';
23

34
interface TextProps {
@@ -8,53 +9,6 @@ interface TextProps {
89
appearanceActive?: boolean;
910
}
1011

11-
function parseColor(color: string): { r: number; g: number; b: number } | null {
12-
const normalized = color.trim().toLowerCase();
13-
14-
if (normalized === 'black') {
15-
return { r: 0, g: 0, b: 0 };
16-
}
17-
18-
const hex = normalized.startsWith('#') ? normalized.slice(1) : normalized;
19-
if (/^[0-9a-f]{3}$/.test(hex)) {
20-
return {
21-
r: parseInt(hex[0] + hex[0], 16),
22-
g: parseInt(hex[1] + hex[1], 16),
23-
b: parseInt(hex[2] + hex[2], 16),
24-
};
25-
}
26-
if (/^[0-9a-f]{6}$/.test(hex)) {
27-
return {
28-
r: parseInt(hex.slice(0, 2), 16),
29-
g: parseInt(hex.slice(2, 4), 16),
30-
b: parseInt(hex.slice(4, 6), 16),
31-
};
32-
}
33-
34-
const rgbMatch = normalized.match(
35-
/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/,
36-
);
37-
if (rgbMatch) {
38-
return {
39-
r: Math.min(255, Number(rgbMatch[1])),
40-
g: Math.min(255, Number(rgbMatch[2])),
41-
b: Math.min(255, Number(rgbMatch[3])),
42-
};
43-
}
44-
45-
return null;
46-
}
47-
48-
function getContrastStroke(fillColor: string): string {
49-
const rgb = parseColor(fillColor);
50-
if (!rgb) {
51-
return '#000';
52-
}
53-
54-
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
55-
return luminance < 0.45 ? '#fff' : '#000';
56-
}
57-
5812
/**
5913
* Renders a fallback sticky-note icon for PDF Text annotations when no
6014
* appearance stream image is available.
@@ -66,7 +20,7 @@ export function Text({
6620
onClick,
6721
appearanceActive = false,
6822
}: TextProps): JSX.Element {
69-
const lineColor = getContrastStroke(color);
23+
const lineColor = getContrastStrokeColor(color);
7024

7125
return (
7226
<div
@@ -86,46 +40,21 @@ export function Text({
8640
inset: 0,
8741
pointerEvents: 'none',
8842
}}
89-
viewBox="0 0 64 64"
43+
viewBox="0 0 20 20"
9044
width="100%"
9145
height="100%"
92-
fill="#facc15"
9346
>
9447
<path
95-
d="M8 8 H56 V48 H32 L26 56 L20 48 H8 Z"
48+
d="M 0.5 15.5 L 0.5 0.5 L 19.5 0.5 L 19.5 15.5 L 8.5 15.5 L 6.5 19.5 L 4.5 15.5 Z"
9649
fill={color}
9750
opacity={opacity}
9851
stroke={lineColor}
99-
strokeWidth="4"
52+
strokeWidth="1"
10053
strokeLinejoin="miter"
10154
/>
102-
<line
103-
x1="20"
104-
y1="18"
105-
x2="44"
106-
y2="18"
107-
stroke={lineColor}
108-
strokeWidth="2"
109-
strokeLinecap="square"
110-
/>
111-
<line
112-
x1="20"
113-
y1="28"
114-
x2="44"
115-
y2="28"
116-
stroke={lineColor}
117-
strokeWidth="2"
118-
strokeLinecap="square"
119-
/>
120-
<line
121-
x1="20"
122-
y1="38"
123-
x2="44"
124-
y2="38"
125-
stroke={lineColor}
126-
strokeWidth="2"
127-
strokeLinecap="square"
128-
/>
55+
<line x1="2.5" y1="4.25" x2="17.5" y2="4.25" stroke={lineColor} strokeWidth="1" />
56+
<line x1="2.5" y1="8" x2="17.5" y2="8" stroke={lineColor} strokeWidth="1" />
57+
<line x1="2.5" y1="11.75" x2="17.5" y2="11.75" stroke={lineColor} strokeWidth="1" />
12958
</svg>
13059
)}
13160
</div>

packages/plugin-annotation/src/shared/components/built-in-renderers.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,11 @@ export const builtInRenderers: BoxedAnnotationRenderer[] = [
224224

225225
createRenderer<PdfTextAnnoObject>({
226226
id: 'text',
227-
matches: (a): a is PdfTextAnnoObject => a.type === PdfAnnotationSubtype.TEXT,
227+
matches: (a): a is PdfTextAnnoObject => a.type === PdfAnnotationSubtype.TEXT && !a.inReplyToId,
228228
render: ({ currentObject, isSelected, onClick, appearanceActive }) => (
229229
<Text
230230
isSelected={isSelected}
231-
color={currentObject.color}
231+
color={currentObject.strokeColor ?? currentObject.color}
232232
opacity={currentObject.opacity}
233233
onClick={onClick}
234234
appearanceActive={appearanceActive}

packages/plugin-annotation/src/shared/components/group-selection-box.tsx

Lines changed: 102 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Rect, boundingRectOrEmpty } from '@embedpdf/models';
1+
import { Rect, boundingRectOrEmpty, Rotation } from '@embedpdf/models';
22
import { useInteractionHandles, CounterRotate } from '@embedpdf/utils/@framework';
33
import { TrackedAnnotation } from '@embedpdf/plugin-annotation';
44
import { useState, useMemo, useCallback, useRef, useEffect, createPortal } from '@framework';
@@ -12,6 +12,82 @@ import {
1212
SelectionOutline,
1313
} from './types';
1414

15+
interface ScreenBounds {
16+
left: number;
17+
top: number;
18+
right: number;
19+
bottom: number;
20+
}
21+
22+
function mapCounterRotatePoint(
23+
x: number,
24+
y: number,
25+
width: number,
26+
height: number,
27+
rotation: Rotation,
28+
): { x: number; y: number } {
29+
switch (rotation) {
30+
case 1:
31+
return { x: y, y: height - x };
32+
case 2:
33+
return { x: width - x, y: height - y };
34+
case 3:
35+
return { x: width - y, y: x };
36+
default:
37+
return { x, y };
38+
}
39+
}
40+
41+
function getAnnotationScreenBounds(
42+
annotation: TrackedAnnotation,
43+
scale: number,
44+
rotation: Rotation,
45+
): ScreenBounds {
46+
const flags = annotation.object.flags ?? [];
47+
const hasNoZoom = flags.includes('noZoom');
48+
const hasNoRotate = flags.includes('noRotate');
49+
50+
const left = annotation.object.rect.origin.x * scale;
51+
const top = annotation.object.rect.origin.y * scale;
52+
const width = annotation.object.rect.size.width * (hasNoZoom ? 1 : scale);
53+
const height = annotation.object.rect.size.height * (hasNoZoom ? 1 : scale);
54+
55+
if (!hasNoRotate || rotation === 0) {
56+
return {
57+
left,
58+
top,
59+
right: left + width,
60+
bottom: top + height,
61+
};
62+
}
63+
64+
const corners = [
65+
mapCounterRotatePoint(0, 0, width, height, rotation),
66+
mapCounterRotatePoint(width, 0, width, height, rotation),
67+
mapCounterRotatePoint(0, height, width, height, rotation),
68+
mapCounterRotatePoint(width, height, width, height, rotation),
69+
];
70+
71+
let minX = Infinity;
72+
let minY = Infinity;
73+
let maxX = -Infinity;
74+
let maxY = -Infinity;
75+
76+
for (const corner of corners) {
77+
if (corner.x < minX) minX = corner.x;
78+
if (corner.y < minY) minY = corner.y;
79+
if (corner.x > maxX) maxX = corner.x;
80+
if (corner.y > maxY) maxY = corner.y;
81+
}
82+
83+
return {
84+
left: left + minX,
85+
top: top + minY,
86+
right: left + maxX,
87+
bottom: top + maxY,
88+
};
89+
}
90+
1591
interface GroupSelectionBoxProps {
1692
documentId: string;
1793
pageIndex: number;
@@ -305,31 +381,31 @@ export function GroupSelectionBox({
305381
return null;
306382
}
307383

308-
// Compute visual right/bottom edges in screen pixels, accounting for noZoom annotations.
309-
// noZoom annotations have a fixed pixel size independent of zoom level.
384+
// Compute visual bounds in screen pixels, including mixed noZoom/noRotate selections.
385+
let visualLeft = Infinity;
386+
let visualTop = Infinity;
310387
let visualRight = -Infinity;
311388
let visualBottom = -Infinity;
312389
for (const ta of selectedAnnotations) {
313-
const rect = ta.object.rect;
314-
const isNoZoom = (ta.object.flags ?? []).includes('noZoom');
315-
visualRight = Math.max(
316-
visualRight,
317-
isNoZoom
318-
? rect.origin.x * scale + rect.size.width
319-
: (rect.origin.x + rect.size.width) * scale,
320-
);
321-
visualBottom = Math.max(
322-
visualBottom,
323-
isNoZoom
324-
? rect.origin.y * scale + rect.size.height
325-
: (rect.origin.y + rect.size.height) * scale,
326-
);
390+
const bounds = getAnnotationScreenBounds(ta, scale, rotation as Rotation);
391+
visualLeft = Math.min(visualLeft, bounds.left);
392+
visualTop = Math.min(visualTop, bounds.top);
393+
visualRight = Math.max(visualRight, bounds.right);
394+
visualBottom = Math.max(visualBottom, bounds.bottom);
327395
}
396+
const initialLogicalLeft = groupBox.origin.x * scale;
397+
const initialLogicalTop = groupBox.origin.y * scale;
328398
const initialLogicalRight = (groupBox.origin.x + groupBox.size.width) * scale;
329399
const initialLogicalBottom = (groupBox.origin.y + groupBox.size.height) * scale;
330-
const groupBoxWidth = previewGroupBox.size.width * scale + (visualRight - initialLogicalRight);
331-
const groupBoxHeight =
332-
previewGroupBox.size.height * scale + (visualBottom - initialLogicalBottom);
400+
const leftCorrection = visualLeft - initialLogicalLeft;
401+
const topCorrection = visualTop - initialLogicalTop;
402+
const rightCorrection = visualRight - initialLogicalRight;
403+
const bottomCorrection = visualBottom - initialLogicalBottom;
404+
405+
const groupBoxLeft = previewGroupBox.origin.x * scale + leftCorrection;
406+
const groupBoxTop = previewGroupBox.origin.y * scale + topCorrection;
407+
const groupBoxWidth = previewGroupBox.size.width * scale + (rightCorrection - leftCorrection);
408+
const groupBoxHeight = previewGroupBox.size.height * scale + (bottomCorrection - topCorrection);
333409
const groupCenterX = groupBoxWidth / 2;
334410
const groupCenterY = groupBoxHeight / 2;
335411
const groupGuideLength = Math.max(300, Math.max(groupBoxWidth, groupBoxHeight) + 80);
@@ -340,8 +416,8 @@ export function GroupSelectionBox({
340416
<div
341417
style={{
342418
position: 'absolute',
343-
left: previewGroupBox.origin.x * scale,
344-
top: previewGroupBox.origin.y * scale,
419+
left: groupBoxLeft,
420+
top: groupBoxTop,
345421
width: groupBoxWidth,
346422
height: groupBoxHeight,
347423
pointerEvents: 'none',
@@ -554,12 +630,12 @@ export function GroupSelectionBox({
554630
<CounterRotate
555631
rect={{
556632
origin: {
557-
x: previewGroupBox.origin.x * scale,
558-
y: previewGroupBox.origin.y * scale,
633+
x: groupBoxLeft,
634+
y: groupBoxTop,
559635
},
560636
size: {
561-
width: previewGroupBox.size.width * scale,
562-
height: previewGroupBox.size.height * scale,
637+
width: groupBoxWidth,
638+
height: groupBoxHeight,
563639
},
564640
}}
565641
rotation={rotation}

viewers/snippet/src/components/annotation-sidebar/property-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export const PROPERTY_CONFIGS: Record<string, PropertyConfig> = {
144144
*/
145145
export const ANNOTATION_PROPERTIES: Partial<Record<PdfAnnotationSubtype, string[]>> = {
146146
// Text comments: fill color drives the icon color, opacity affects the whole icon
147-
[PdfAnnotationSubtype.TEXT]: ['color', 'opacity'],
147+
[PdfAnnotationSubtype.TEXT]: ['strokeColor', 'opacity'],
148148

149149
// Ink uses strokeColor (was: color)
150150
[PdfAnnotationSubtype.INK]: ['strokeColor', 'opacity', 'strokeWidth', 'rotation'],

0 commit comments

Comments
 (0)