@embedpdf/plugin-selection 3.0.0-next.3 → 3.0.0-next.4

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.
package/dist/index.js CHANGED
@@ -1,251 +1,81 @@
1
1
  import { createCapabilityToken, definePlugin } from "@embedpdf/core";
2
2
  import { InteractionToken } from "@embedpdf/plugin-interaction";
3
- import { applyRect, boundsOfRects, pageGeometry } from "@embedpdf/core-geometry";
3
+ import { applyPoint, invert, pageGeometry, textQuadBounds } from "@embedpdf/core-geometry";
4
+ import { buildPageTextLayout, expandTextRangeToLine, expandTextRangeToWord, textGlyphAt, textGlyphQuad, textSegmentsForRange } from "@embedpdf/engine-core/runtime";
4
5
  //#region src/geometry.ts
5
6
  /**
6
- * The pure selection geometry: engine text geometry (PDF user space, y-up) →
7
- * viewer content space (y-down), glyph hit-testing, word/line expansion, and the
8
- * line-merge. No DOM, no engine, no React.
7
+ * The selection plugin's COORDINATE SEAM — and nothing else.
9
8
  *
10
- * The non-trivial bits are ports of battle-tested algorithms, adapted to v3's
11
- * content-space `Rect {x,y,width,height}`:
12
- * - `glyphAt` — PDFium `GetIndexAtPos` (exact tight box, then a tolerance pass).
13
- * - `rectsForRange` — Chromium `pdfium_range.cc` `MergeAdjacentRects` (one rect per line).
14
- * - `expandToWord/Line` — Chromium `OnMultipleClick` (double = word, triple = line).
15
- * The PDF↔content y-flip is the geometry package's `pageGeometry` (the one place
16
- * that math lives), never hand-rolled here.
9
+ * Segmentation, hit-testing, and word/line expansion live in the canonical
10
+ * engine-core text layout (`@embedpdf/engine-core` `text/layout`): a text
11
+ * range has exactly one segmentation whether it came from a drag or a
12
+ * search. This module only converts between the viewer's content space
13
+ * (y-down, crop-relative) and the engine's PDF space (y-up) — pointer
14
+ * points inward, canonical segments outward. The PDF↔content y-flip is the
15
+ * geometry package's `pageGeometry` (the one place that math lives), never
16
+ * hand-rolled here.
17
17
  */
18
- const FLAG_EMPTY = 2;
19
- const isBoundary = (flags) => (flags & 3) !== 0;
20
- const isEmpty = (flags) => (flags & FLAG_EMPTY) !== 0;
21
- const toContent = (m, b) => applyRect(m, {
22
- x: b.left,
23
- y: b.bottom,
24
- width: b.right - b.left,
25
- height: b.top - b.bottom
26
- });
27
18
  /**
28
- * Flatten a page's text geometry into content-space glyphs + runs. Rotation does
29
- * NOT enter here — the overlay rides the page's CSS rotation, so glyphs stay in
30
- * un-rotated content space (the crop-relative y-flip is the only conversion).
19
+ * Build a page's selection geometry. Page display rotation does NOT enter
20
+ * here — the overlay rides the page's CSS rotation, so content space stays
21
+ * un-rotated (the crop-relative y-flip is the only conversion; zoom = 1,
22
+ * the viewer's zoom is applied later by `PageTransform.pageToContent`).
31
23
  */
32
- function buildPageText(snapshot, crop, rotation, userUnit) {
24
+ function buildSelectionPageGeometry(snapshot, crop, rotation, userUnit) {
33
25
  const { pdfToContent } = pageGeometry({
34
26
  crop,
35
27
  rotation,
36
28
  userUnit
37
29
  }, 1);
38
- const glyphs = [];
39
- const runs = [];
40
- for (const run of snapshot.runs) {
41
- const start = glyphs.length;
42
- for (const g of run.glyphs) glyphs.push({
43
- loose: toContent(pdfToContent, g.looseBox),
44
- tight: g.tightBox ? toContent(pdfToContent, g.tightBox) : void 0,
45
- flags: g.flags
46
- });
47
- runs.push({
48
- start,
49
- count: run.glyphs.length,
50
- rect: toContent(pdfToContent, run.rect),
51
- fontSize: run.fontSize
52
- });
53
- }
54
30
  return {
55
- glyphs,
56
- runs
31
+ layout: buildPageTextLayout(snapshot),
32
+ toContent: pdfToContent,
33
+ fromContent: invert(pdfToContent)
57
34
  };
58
35
  }
59
- function avgGlyphHeight(text) {
60
- let total = 0;
61
- let count = 0;
62
- for (const g of text.glyphs) {
63
- if (isEmpty(g.flags)) continue;
64
- total += g.loose.height;
65
- count++;
66
- }
67
- return count === 0 ? 0 : total / count;
36
+ /** A content-space pointer point in the layout's PDF space. */
37
+ function contentPointToPdf(geom, p) {
38
+ return applyPoint(geom.fromContent, p);
68
39
  }
69
40
  /**
70
- * The glyph index at a content-space point, or null when nothing is near (so the
71
- * caller can show the pointer cursor off-text). PDFium `GetIndexAtPos`: exact
72
- * tight-box containment first, then a tolerance pass (closest by Manhattan
73
- * distance within `toleranceFactor × average glyph height`).
41
+ * A canonical PDF quad in content space. Engine quads carry frame-geometric
42
+ * slot semantics (`p1..p4` = upper-start, upper-end, lower-start,
43
+ * lower-end) — a trusted producer, so the y-flip maps corners straight onto
44
+ * their names; imported-annotation normalization does NOT apply here.
74
45
  */
75
- function glyphAt(text, p, toleranceFactor = 1.5) {
76
- for (const run of text.runs) {
77
- const r = run.rect;
78
- if (p.x < r.x || p.x > r.x + r.width || p.y < r.y || p.y > r.y + r.height) continue;
79
- for (let i = 0; i < run.count; i++) {
80
- const b = text.glyphs[run.start + i].tight ?? text.glyphs[run.start + i].loose;
81
- if (p.x >= b.x && p.x <= b.x + b.width && p.y >= b.y && p.y <= b.y + b.height) return run.start + i;
82
- }
83
- }
84
- if (toleranceFactor <= 0) return null;
85
- const half = avgGlyphHeight(text) * toleranceFactor / 2;
86
- let best = -1;
87
- let bestDist = Infinity;
88
- for (const run of text.runs) {
89
- const r = run.rect;
90
- if (p.y < r.y - half || p.y > r.y + r.height + half || p.x < r.x - half || p.x > r.x + r.width + half) continue;
91
- for (let i = 0; i < run.count; i++) {
92
- const g = text.glyphs[run.start + i];
93
- if (isEmpty(g.flags)) continue;
94
- const b = g.tight ?? g.loose;
95
- if (p.x < b.x - half || p.x > b.x + b.width + half || p.y < b.y - half || p.y > b.y + b.height + half) continue;
96
- const dx = Math.min(Math.abs(p.x - b.x), Math.abs(p.x - (b.x + b.width)));
97
- const dy = Math.min(Math.abs(p.y - b.y), Math.abs(p.y - (b.y + b.height)));
98
- if (dx + dy < bestDist) {
99
- bestDist = dx + dy;
100
- best = run.start + i;
101
- }
102
- }
103
- }
104
- return best >= 0 ? best : null;
105
- }
106
- /** Double-click: the word around `i` (walk to space/empty glyphs both ways). */
107
- function expandToWord(text, i) {
108
- const n = text.glyphs.length;
109
- if (i < 0 || i >= n) return [i, i];
110
- let from = i;
111
- while (from > 0 && !isBoundary(text.glyphs[from - 1].flags)) from--;
112
- let to = i;
113
- while (to < n - 1 && !isBoundary(text.glyphs[to + 1].flags)) to++;
114
- return [from, to];
115
- }
116
- /** Triple-click: the full visual line — runs whose vertical extent overlaps the anchor run's. */
117
- function expandToLine(text, i) {
118
- const ri = text.runs.findIndex((r) => i >= r.start && i < r.start + r.count);
119
- if (ri < 0) return [i, i];
120
- const anchor = text.runs[ri];
121
- const top = anchor.rect.y;
122
- const bottom = anchor.rect.y + anchor.rect.height;
123
- let from = anchor.start;
124
- let to = anchor.start + anchor.count - 1;
125
- for (let r = ri - 1; r >= 0; r--) {
126
- const run = text.runs[r];
127
- if (isZero(run.rect)) continue;
128
- if (!overlapV(run.rect.y, run.rect.y + run.rect.height, top, bottom)) break;
129
- from = run.start;
130
- }
131
- for (let r = ri + 1; r < text.runs.length; r++) {
132
- const run = text.runs[r];
133
- if (isZero(run.rect)) continue;
134
- if (!overlapV(run.rect.y, run.rect.y + run.rect.height, top, bottom)) break;
135
- to = run.start + run.count - 1;
136
- }
137
- return [from, to];
138
- }
139
- const CHAR_DISTANCE_FACTOR = 2.5;
140
- const FONT_SIZE_RATIO_THRESHOLD = 1.5;
141
- const VERTICAL_OVERLAP_THRESHOLD = .8;
142
- const LINE_OVERLAP_THRESHOLD = .5;
143
- function rectsForRange(text, from, to) {
144
- const lo = Math.max(0, Math.min(from, to));
145
- const hi = Math.min(text.glyphs.length - 1, Math.max(from, to));
146
- const subRuns = [];
147
- for (const run of text.runs) {
148
- const runEnd = run.start + run.count - 1;
149
- if (runEnd < lo || run.start > hi) continue;
150
- const s = Math.max(lo, run.start);
151
- const e = Math.min(hi, runEnd);
152
- let minX = Infinity;
153
- let maxX = -Infinity;
154
- let minY = Infinity;
155
- let maxY = -Infinity;
156
- let charCount = 0;
157
- let widthSum = 0;
158
- let prevRight = -Infinity;
159
- const flush = () => {
160
- if (minX !== Infinity && charCount > 0) subRuns.push({
161
- rect: {
162
- x: minX,
163
- y: minY,
164
- width: maxX - minX,
165
- height: maxY - minY
166
- },
167
- charCount,
168
- fontSize: run.fontSize
169
- });
170
- minX = Infinity;
171
- maxX = -Infinity;
172
- minY = Infinity;
173
- maxY = -Infinity;
174
- charCount = 0;
175
- widthSum = 0;
176
- prevRight = -Infinity;
177
- };
178
- for (let gi = s; gi <= e; gi++) {
179
- const g = text.glyphs[gi];
180
- if (isEmpty(g.flags)) continue;
181
- const b = g.loose;
182
- if (charCount > 0 && prevRight > -Infinity) {
183
- const avg = widthSum / charCount;
184
- if (avg > 0 && Math.abs(b.x - prevRight) > CHAR_DISTANCE_FACTOR * avg) flush();
185
- }
186
- minX = Math.min(minX, b.x);
187
- maxX = Math.max(maxX, b.x + b.width);
188
- minY = Math.min(minY, b.y);
189
- maxY = Math.max(maxY, b.y + b.height);
190
- charCount++;
191
- widthSum += b.width;
192
- prevRight = b.x + b.width;
193
- }
194
- flush();
195
- }
196
- return mergeAdjacentRects(subRuns);
197
- }
198
- function mergeAdjacentRects(runs) {
199
- const out = [];
200
- let prev = null;
201
- let cur = null;
202
- for (const run of runs) {
203
- if (prev && cur && shouldMerge(prev, run)) cur = boundsOfRects([cur, run.rect]);
204
- else {
205
- if (cur) out.push(cur);
206
- cur = run.rect;
207
- }
208
- prev = run;
209
- }
210
- if (cur && cur.width > 0 && cur.height > 0) out.push(cur);
211
- return out;
212
- }
213
- function shouldMerge(a, b) {
214
- if (a.fontSize != null && b.fontSize != null && a.fontSize > 0 && b.fontSize > 0) {
215
- if (Math.max(a.fontSize, b.fontSize) / Math.min(a.fontSize, b.fontSize) > FONT_SIZE_RATIO_THRESHOLD) return false;
216
- }
217
- if (verticalOverlap(a.rect, b.rect) < VERTICAL_OVERLAP_THRESHOLD) return false;
218
- const aw = a.rect.width / a.charCount;
219
- const bw = b.rect.width / b.charCount;
220
- const aL = a.rect.x - aw;
221
- const aR = a.rect.x + a.rect.width + aw;
222
- const bL = b.rect.x - bw;
223
- return aL < b.rect.x + b.rect.width + bw && aR > bL;
224
- }
225
- function verticalOverlap(a, b) {
226
- if (a.height <= 0 || b.height <= 0) return 0;
227
- const u = Math.max(a.y + a.height, b.y + b.height) - Math.min(a.y, b.y);
228
- if (u === a.height || u === b.height) return 1;
229
- return Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)) / u;
46
+ function toContentTextQuad(geom, quad) {
47
+ const pt = (p) => applyPoint(geom.toContent, p);
48
+ return {
49
+ upperStart: pt(quad.p1),
50
+ upperEnd: pt(quad.p2),
51
+ lowerStart: pt(quad.p3),
52
+ lowerEnd: pt(quad.p4)
53
+ };
230
54
  }
231
- const isZero = (r) => r.width === 0 && r.height === 0;
232
- function overlapV(top1, bottom1, top2, bottom2) {
233
- const u = Math.max(bottom1, bottom2) - Math.min(top1, top2);
234
- if (u === 0) return false;
235
- return Math.max(0, Math.min(bottom1, bottom2) - Math.max(top1, top2)) / u >= LINE_OVERLAP_THRESHOLD;
55
+ /** A canonical segment in content space (`rect` recomputed from the mapped
56
+ * quad so the pair can never drift). */
57
+ function toContentSegment(geom, segment) {
58
+ const quad = toContentTextQuad(geom, segment.quad);
59
+ return {
60
+ quad,
61
+ rect: textQuadBounds(quad),
62
+ advance: segment.advance
63
+ };
236
64
  }
237
65
  //#endregion
238
66
  //#region src/capability.ts
239
- const EMPTY = [];
67
+ const EMPTY_SEGMENTS = [];
240
68
  /**
241
69
  * The selection capability. The reducer state holds the selection range, the
242
- * derived content-space rects (per page), and per-page loaded flags; the (large,
243
- * non-serializable) per-page text geometry is cached HERE in the closure.
70
+ * derived content-space segments (per page), and per-page loaded flags; the
71
+ * (large, non-serializable) per-page canonical layout is cached HERE in the
72
+ * closure. Segmentation itself is the engine-core text layout — selection
73
+ * owns gestures and state, never how glyphs become lines.
244
74
  *
245
- * Selection is cross-page: glyphs are ordered globally by (pageIndex, glyph), so
246
- * a drag from page 2 into page 4 selects the tail of 2, all of 3, and the head of
247
- * 4. `recompute` rebuilds the merged line rects for every loaded page in the span
248
- * and re-runs whenever a mid-span page finishes loading.
75
+ * Selection is cross-page: glyphs are ordered globally by (pageIndex, glyph),
76
+ * so a drag from page 2 into page 4 selects the tail of 2, all of 3, and the
77
+ * head of 4. `recompute` rebuilds the merged line segments for every loaded
78
+ * page in the span and re-runs whenever a mid-span page finishes loading.
249
79
  */
250
80
  function createSelectionCapability(ctx) {
251
81
  const cache = /* @__PURE__ */ new Map();
@@ -255,6 +85,7 @@ function createSelectionCapability(ctx) {
255
85
  const fireChange = () => changeCbs.forEach((cb) => cb());
256
86
  const pageIndexOf = (pon) => ctx.document()?.pages.findIndex((p) => p.pageObjectNumber === pon) ?? -1;
257
87
  const ponAtIndex = (i) => ctx.document()?.pages[i]?.pageObjectNumber;
88
+ const glyphAt = (geom, point) => textGlyphAt(geom.layout, contentPointToPdf(geom, point));
258
89
  function orderedEnds(sel) {
259
90
  const ai = pageIndexOf(sel.anchor.pon);
260
91
  const fi = pageIndexOf(sel.focus.pon);
@@ -269,18 +100,32 @@ function createSelectionCapability(ctx) {
269
100
  };
270
101
  }
271
102
  function endpointFor(ptr, which) {
272
- const rects = ctx.getState().rects[ptr.pon] ?? EMPTY;
273
- if (!rects.length) return null;
103
+ const segments = ctx.getState().segments[ptr.pon] ?? EMPTY_SEGMENTS;
104
+ if (!segments.length) return null;
105
+ const segment = which === "start" ? segments[0] : segments[segments.length - 1];
106
+ const geom = cache.get(ptr.pon);
107
+ const cell = geom ? textGlyphQuad(geom.layout, ptr.glyph) : null;
108
+ if (geom && cell) {
109
+ const glyphQuad = toContentTextQuad(geom, cell);
110
+ return {
111
+ pon: ptr.pon,
112
+ glyphQuad,
113
+ advance: segment.advance,
114
+ rect: textQuadBounds(glyphQuad)
115
+ };
116
+ }
274
117
  return {
275
118
  pon: ptr.pon,
276
- rect: which === "start" ? rects[0] : rects[rects.length - 1]
119
+ glyphQuad: segment.quad,
120
+ advance: segment.advance,
121
+ rect: segment.rect
277
122
  };
278
123
  }
279
124
  function snapshot() {
280
- const { selection, rects } = ctx.getState();
281
- const pages = Object.keys(rects).map(Number).filter((pon) => (rects[pon]?.length ?? 0) > 0).map((pon) => ({
125
+ const { selection, segments } = ctx.getState();
126
+ const pages = Object.keys(segments).map(Number).filter((pon) => (segments[pon]?.length ?? 0) > 0).map((pon) => ({
282
127
  pon,
283
- rects: rects[pon]
128
+ segments: segments[pon]
284
129
  }));
285
130
  if (!selection) return {
286
131
  pages,
@@ -304,7 +149,7 @@ function createSelectionCapability(ctx) {
304
149
  pending.add(pon);
305
150
  doc.page(pon).geometry.read().then((snapshot) => {
306
151
  pending.delete(pon);
307
- cache.set(pon, buildPageText(snapshot, layout.boxes.crop, layout.rotation, layout.userUnit));
152
+ cache.set(pon, buildSelectionPageGeometry(snapshot, layout.boxes.crop, layout.rotation, layout.userUnit));
308
153
  ctx.dispatch({
309
154
  type: "PAGE_LOADED",
310
155
  pon
@@ -320,30 +165,32 @@ function createSelectionCapability(ctx) {
320
165
  const si = pageIndexOf(start.pon);
321
166
  const ei = pageIndexOf(end.pon);
322
167
  if (si < 0 || ei < 0) return;
323
- const rects = {};
168
+ const segments = {};
324
169
  for (let i = si; i <= ei; i++) {
325
170
  const pon = ponAtIndex(i);
326
171
  if (pon == null) continue;
327
- const text = cache.get(pon);
328
- if (!text) {
172
+ const geom = cache.get(pon);
173
+ if (!geom) {
329
174
  ensurePage(pon);
330
175
  continue;
331
176
  }
332
- rects[pon] = rectsForRange(text, i === si ? start.glyph : 0, i === ei ? end.glyph : text.glyphs.length - 1);
177
+ const from = i === si ? start.glyph : 0;
178
+ const to = i === ei ? end.glyph : geom.layout.glyphs.length - 1;
179
+ segments[pon] = textSegmentsForRange(geom.layout, from, to - from + 1).map((s) => toContentSegment(geom, s));
333
180
  }
334
181
  ctx.dispatch({
335
182
  type: "SET",
336
183
  selection: sel,
337
- rects
184
+ segments
338
185
  });
339
186
  fireChange();
340
187
  }
341
188
  function selectSpan(pon, point, expand) {
342
- const text = cache.get(pon);
343
- if (!text) return;
344
- const i = glyphAt(text, point);
189
+ const geom = cache.get(pon);
190
+ if (!geom) return;
191
+ const i = glyphAt(geom, point);
345
192
  if (i == null) return;
346
- const [from, to] = expand === "word" ? expandToWord(text, i) : expandToLine(text, i);
193
+ const [from, to] = expand === "word" ? expandTextRangeToWord(geom.layout, i) : expandTextRangeToLine(geom.layout, i);
347
194
  recompute({
348
195
  anchor: {
349
196
  pon,
@@ -359,13 +206,13 @@ function createSelectionCapability(ctx) {
359
206
  ensurePage,
360
207
  isLoaded: (pon) => !!ctx.getState().loaded[pon],
361
208
  isOverText: (pon, point) => {
362
- const text = cache.get(pon);
363
- return text ? glyphAt(text, point) != null : false;
209
+ const geom = cache.get(pon);
210
+ return geom ? glyphAt(geom, point) != null : false;
364
211
  },
365
212
  beginAt: (pon, point) => {
366
- const text = cache.get(pon);
367
- if (!text) return false;
368
- const i = glyphAt(text, point);
213
+ const geom = cache.get(pon);
214
+ if (!geom) return false;
215
+ const i = glyphAt(geom, point);
369
216
  if (i == null) return false;
370
217
  recompute({
371
218
  anchor: {
@@ -384,12 +231,12 @@ function createSelectionCapability(ctx) {
384
231
  extendTo: (pon, point) => {
385
232
  const cur = ctx.getState().selection;
386
233
  if (!cur) return;
387
- const text = cache.get(pon);
388
- if (!text) {
234
+ const geom = cache.get(pon);
235
+ if (!geom) {
389
236
  ensurePage(pon);
390
237
  return;
391
238
  }
392
- const i = glyphAt(text, point);
239
+ const i = glyphAt(geom, point);
393
240
  if (i == null) return;
394
241
  recompute({
395
242
  anchor: cur.anchor,
@@ -405,11 +252,12 @@ function createSelectionCapability(ctx) {
405
252
  fireChange();
406
253
  },
407
254
  snapshot,
408
- rectsForPage: (pon) => ctx.getState().rects[pon] ?? EMPTY,
255
+ segmentsForPage: (pon) => ctx.getState().segments[pon] ?? EMPTY_SEGMENTS,
256
+ rectsForPage: (pon) => (ctx.getState().segments[pon] ?? EMPTY_SEGMENTS).map((s) => s.rect),
409
257
  hasSelection: () => ctx.getState().selection != null,
410
258
  selectedPages: () => {
411
- const { rects } = ctx.getState();
412
- return Object.keys(rects).map(Number).filter((pon) => (rects[pon]?.length ?? 0) > 0);
259
+ const { segments } = ctx.getState();
260
+ return Object.keys(segments).map(Number).filter((pon) => (segments[pon]?.length ?? 0) > 0);
413
261
  },
414
262
  onChange: (cb) => {
415
263
  changeCbs.add(cb);
@@ -503,7 +351,7 @@ function createTextSelectHandler(selection, interaction) {
503
351
  //#region src/reducer.ts
504
352
  const initialSelectionState = {
505
353
  selection: null,
506
- rects: {},
354
+ segments: {},
507
355
  loaded: {},
508
356
  highlightHidden: false
509
357
  };
@@ -519,12 +367,12 @@ const selectionReducer = (state, action) => {
519
367
  case "SET": return {
520
368
  ...state,
521
369
  selection: action.selection,
522
- rects: action.rects
370
+ segments: action.segments
523
371
  };
524
- case "CLEAR": return state.selection === null && Object.keys(state.rects).length === 0 ? state : {
372
+ case "CLEAR": return state.selection === null && Object.keys(state.segments).length === 0 ? state : {
525
373
  ...state,
526
374
  selection: null,
527
- rects: {}
375
+ segments: {}
528
376
  };
529
377
  case "SET_HIGHLIGHT_HIDDEN": return state.highlightHidden === action.hidden ? state : {
530
378
  ...state,
@@ -559,6 +407,6 @@ const selectionPlugin = () => definePlugin({
559
407
  }
560
408
  });
561
409
  //#endregion
562
- export { SelectionToken, buildPageText, createSelectionCapability, createTextSelectHandler, expandToLine, expandToWord, glyphAt, initialSelectionState, rectsForRange, selectionPlugin, selectionReducer };
410
+ export { SelectionToken, buildSelectionPageGeometry, contentPointToPdf, createSelectionCapability, createTextSelectHandler, initialSelectionState, selectionPlugin, selectionReducer, toContentSegment, toContentTextQuad };
563
411
 
564
412
  //# sourceMappingURL=index.js.map