From 858938599edd34fc1d7d60b2327cedb6a8bc4bd8 Mon Sep 17 00:00:00 2001 From: Andrii Yusyp Date: Wed, 15 Jul 2026 13:00:49 +0200 Subject: [PATCH 1/2] fix: add new quads property to annotation and rely on it for direction detection --- packages/engines/src/lib/pdfium/engine.ts | 210 +++++++++++++----- packages/models/src/geometry.test.ts | 75 +++++++ packages/models/src/geometry.ts | 184 +++++++++++++++ packages/models/src/pdf.ts | 47 +++- .../src/lib/handlers/insert-text.handler.ts | 3 +- .../src/lib/handlers/replace-text.handler.ts | 4 +- .../src/lib/handlers/selection-utils.ts | 15 +- .../src/lib/handlers/text-markup.handler.ts | 7 +- .../src/shared/components/text-markup.tsx | 13 +- .../components/text-markup/highlight.tsx | 49 ++-- .../components/text-markup/quad-geometry.ts | 100 +++++++++ .../components/text-markup/squiggly.tsx | 101 +++++---- .../components/text-markup/strikeout.tsx | 89 +++++--- .../components/text-markup/underline.tsx | 90 +++++--- .../src/svelte/components/TextMarkup.svelte | 8 +- .../components/text-markup/Highlight.svelte | 24 +- .../components/text-markup/Squiggly.svelte | 57 ++--- .../components/text-markup/Strikeout.svelte | 44 ++-- .../components/text-markup/Underline.svelte | 45 ++-- .../src/vue/components/text-markup.vue | 8 +- .../vue/components/text-markup/highlight.vue | 24 +- .../vue/components/text-markup/squiggly.vue | 60 +++-- .../vue/components/text-markup/strikeout.vue | 44 ++-- .../vue/components/text-markup/underline.vue | 43 ++-- packages/plugin-selection/src/lib/actions.ts | 14 +- packages/plugin-selection/src/lib/reducer.ts | 15 +- .../src/lib/selection-plugin.ts | 17 +- .../plugin-selection/src/lib/selectors.ts | 16 +- packages/plugin-selection/src/lib/types.ts | 13 +- .../src/lib/utils.quads.test.ts | 68 ++++++ packages/plugin-selection/src/lib/utils.ts | 183 +++++++++++---- 31 files changed, 1274 insertions(+), 396 deletions(-) create mode 100644 packages/plugin-annotation/src/shared/components/text-markup/quad-geometry.ts create mode 100644 packages/plugin-selection/src/lib/utils.quads.test.ts diff --git a/packages/engines/src/lib/pdfium/engine.ts b/packages/engines/src/lib/pdfium/engine.ts index a6d58fdf5..e1a96c326 100644 --- a/packages/engines/src/lib/pdfium/engine.ts +++ b/packages/engines/src/lib/pdfium/engine.ts @@ -87,6 +87,10 @@ import { PageTextSlice, stripPdfUnwantedMarkers, rectToQuad, + orientedQuadFromPageBoxAndMatrix, + pdfAttachmentPointsToQuad, + quadToPdfAttachmentPoints, + quadsToRects, dateToPdfDate, pdfDateToDate, PdfAnnotationColorType, @@ -4011,7 +4015,15 @@ export class PdfiumNative implements IPdfiumExecutor { | PdfSquigglyAnnoObject, ) { // Type-specific properties - if (!this.syncQuadPointsAnno(doc, page, annotationPtr, annotation.segmentRects)) { + if ( + !this.syncQuadPointsAnno( + doc, + page, + annotationPtr, + annotation.segmentRects, + annotation.segmentQuads, + ) + ) { return false; } if (!this.setAnnotationOpacity(annotationPtr, annotation.opacity ?? 1)) { @@ -5027,6 +5039,9 @@ export class PdfiumNative implements IPdfiumExecutor { flags: g.isEmpty ? 2 : g.isSpace ? 1 : 0, ...(g.tightOrigin && { tightX: g.tightOrigin.x, tightY: g.tightOrigin.y }), ...(g.tightSize && { tightWidth: g.tightSize.width, tightHeight: g.tightSize.height }), + ...(g.matrix && { matrix: g.matrix }), + ...(g.pageOrigin && { pageOrigin: g.pageOrigin }), + ...(g.quad && { quad: g.quad }), }); /* 4 — expand the run's bounding rect */ @@ -5298,6 +5313,9 @@ export class PdfiumNative implements IPdfiumExecutor { const tRightPtr = this.memoryManager.malloc(8); const tBottomPtr = this.memoryManager.malloc(8); const tTopPtr = this.memoryManager.malloc(8); + const matrixPtr = this.memoryManager.malloc(24); + const originXPtr = this.memoryManager.malloc(8); + const originYPtr = this.memoryManager.malloc(8); const allPtrs = [ rectPtr, @@ -5309,6 +5327,9 @@ export class PdfiumNative implements IPdfiumExecutor { tRightPtr, tBottomPtr, tTopPtr, + matrixPtr, + originXPtr, + originYPtr, ]; let x = 0, @@ -5318,15 +5339,22 @@ export class PdfiumNative implements IPdfiumExecutor { isSpace = false; let tightOrigin: { x: number; y: number } | undefined; let tightSize: { width: number; height: number } | undefined; + let matrix: PdfTransformMatrix | undefined; + let pageOrigin: { x: number; y: number } | undefined; + let quad: Quad | undefined; + let pageLeft = 0; + let pageTop = 0; + let pageRight = 0; + let pageBottom = 0; // ── 1) loose glyph bbox (FPDFText_GetLooseCharBox) ────────── if (this.pdfiumModule.FPDFText_GetLooseCharBox(textPagePtr, charIndex, rectPtr)) { - const left = this.pdfiumModule.pdfium.getValue(rectPtr, 'float'); - const top = this.pdfiumModule.pdfium.getValue(rectPtr + 4, 'float'); - const right = this.pdfiumModule.pdfium.getValue(rectPtr + 8, 'float'); - const bottom = this.pdfiumModule.pdfium.getValue(rectPtr + 12, 'float'); + pageLeft = this.pdfiumModule.pdfium.getValue(rectPtr, 'float'); + pageTop = this.pdfiumModule.pdfium.getValue(rectPtr + 4, 'float'); + pageRight = this.pdfiumModule.pdfium.getValue(rectPtr + 8, 'float'); + pageBottom = this.pdfiumModule.pdfium.getValue(rectPtr + 12, 'float'); - if (left === right || top === bottom) { + if (pageLeft === pageRight || pageTop === pageBottom) { allPtrs.forEach((p) => this.memoryManager.free(p)); return { @@ -5344,8 +5372,8 @@ export class PdfiumNative implements IPdfiumExecutor { page.size.width, page.size.height, 0, - left, - top, + pageLeft, + pageTop, dx1Ptr, dy1Ptr, ); @@ -5356,8 +5384,8 @@ export class PdfiumNative implements IPdfiumExecutor { page.size.width, page.size.height, 0, - right, - bottom, + pageRight, + pageBottom, dx2Ptr, dy2Ptr, ); @@ -5425,7 +5453,67 @@ export class PdfiumNative implements IPdfiumExecutor { }; } - // ── 4) extra flags ──────────────────────────────────────── + // ── 4) glyph matrix + origin ────────────────────────────── + if (this.pdfiumModule.FPDFText_GetMatrix(textPagePtr, charIndex, matrixPtr)) { + matrix = { + a: this.pdfiumModule.pdfium.getValue(matrixPtr, 'float'), + b: this.pdfiumModule.pdfium.getValue(matrixPtr + 4, 'float'), + c: this.pdfiumModule.pdfium.getValue(matrixPtr + 8, 'float'), + d: this.pdfiumModule.pdfium.getValue(matrixPtr + 12, 'float'), + e: this.pdfiumModule.pdfium.getValue(matrixPtr + 16, 'float'), + f: this.pdfiumModule.pdfium.getValue(matrixPtr + 20, 'float'), + }; + } + + if ( + this.pdfiumModule.FPDFText_GetCharOrigin( + textPagePtr, + charIndex, + originXPtr, + originYPtr, + ) + ) { + pageOrigin = { + x: this.pdfiumModule.pdfium.getValue(originXPtr, 'double'), + y: this.pdfiumModule.pdfium.getValue(originYPtr, 'double'), + }; + } + + if (matrix) { + const pageQuad = orientedQuadFromPageBoxAndMatrix( + pageLeft, + pageTop, + pageRight, + pageBottom, + matrix, + ); + const pageToDevice = (point: Position): Position => { + this.pdfiumModule.FPDF_PageToDevice( + pagePtr, + 0, + 0, + page.size.width, + page.size.height, + 0, + point.x, + point.y, + dx1Ptr, + dy1Ptr, + ); + return { + x: this.pdfiumModule.pdfium.getValue(dx1Ptr, 'i32'), + y: this.pdfiumModule.pdfium.getValue(dy1Ptr, 'i32'), + }; + }; + quad = { + p1: pageToDevice(pageQuad.p1), + p2: pageToDevice(pageQuad.p2), + p3: pageToDevice(pageQuad.p3), + p4: pageToDevice(pageQuad.p4), + }; + } + + // ── 5) extra flags ──────────────────────────────────────── const uc = this.pdfiumModule.FPDFText_GetUnicode(textPagePtr, charIndex); isSpace = uc === 32; } @@ -5436,6 +5524,9 @@ export class PdfiumNative implements IPdfiumExecutor { return { origin: { x, y }, size: { width, height }, + ...(matrix && { matrix }), + ...(pageOrigin && { pageOrigin }), + ...(quad && { quad }), ...(tightOrigin && { tightOrigin }), ...(tightSize && { tightSize }), ...(isSpace && { isSpace }), @@ -6874,9 +6965,9 @@ export class PdfiumNative implements IPdfiumExecutor { doc: PdfDocumentObject, page: PdfPageObject, annotationPtr: number, - ): Rect[] { + ): { quads: Quad[]; rects: Rect[] } { const quadCount = this.pdfiumModule.FPDFAnnot_CountAttachmentPoints(annotationPtr); - if (quadCount === 0) return []; + if (quadCount === 0) return { quads: [], rects: [] }; const FS_QUADPOINTSF_SIZE = 8 * 4; // eight floats, 32 bytes const quads: Quad[] = []; @@ -6887,7 +6978,6 @@ export class PdfiumNative implements IPdfiumExecutor { const ok = this.pdfiumModule.FPDFAnnot_GetAttachmentPoints(annotationPtr, qi, quadPtr); if (ok) { - // read the eight floats const xs: number[] = []; const ys: number[] = []; for (let i = 0; i < 4; i++) { @@ -6896,19 +6986,18 @@ export class PdfiumNative implements IPdfiumExecutor { ys.push(this.pdfiumModule.pdfium.getValue(base + 4, 'float')); } - // convert to device-space - const p1 = this.convertPagePointToDevicePoint(doc, page, { x: xs[0], y: ys[0] }); - const p2 = this.convertPagePointToDevicePoint(doc, page, { x: xs[1], y: ys[1] }); - const p3 = this.convertPagePointToDevicePoint(doc, page, { x: xs[2], y: ys[2] }); - const p4 = this.convertPagePointToDevicePoint(doc, page, { x: xs[3], y: ys[3] }); + const bl = this.convertPagePointToDevicePoint(doc, page, { x: xs[0], y: ys[0] }); + const br = this.convertPagePointToDevicePoint(doc, page, { x: xs[1], y: ys[1] }); + const tl = this.convertPagePointToDevicePoint(doc, page, { x: xs[2], y: ys[2] }); + const tr = this.convertPagePointToDevicePoint(doc, page, { x: xs[3], y: ys[3] }); - quads.push({ p1, p2, p3, p4 }); + quads.push(pdfAttachmentPointsToQuad(bl, br, tl, tr)); } this.memoryManager.free(quadPtr); } - return quads.map(quadToRect); + return { quads, rects: quadsToRects(quads) }; } /** @@ -6927,48 +7016,47 @@ export class PdfiumNative implements IPdfiumExecutor { page: PdfPageObject, annotPtr: number, rects: Rect[], + quads?: Quad[], ): boolean { const FS_QUADPOINTSF_SIZE = 8 * 4; // eight floats, 32 bytes const pdf = this.pdfiumModule.pdfium; const count = this.pdfiumModule.FPDFAnnot_CountAttachmentPoints(annotPtr); const buf = this.memoryManager.malloc(FS_QUADPOINTSF_SIZE); + const segments = quads && quads.length > 0 ? quads : rects.map(rectToQuad); /** write one quad into `buf` in annotation space */ - const writeQuad = (r: Rect) => { - const q = rectToQuad(r); // TL, TR, BR, BL - const p1 = this.convertDevicePointToPagePoint(doc, page, q.p1); - const p2 = this.convertDevicePointToPagePoint(doc, page, q.p2); - const p3 = this.convertDevicePointToPagePoint(doc, page, q.p3); // BR - const p4 = this.convertDevicePointToPagePoint(doc, page, q.p4); // BL - - // PDF QuadPoints order: BL, BR, TL, TR (bottom-left, bottom-right, top-left, top-right) - pdf.setValue(buf + 0, p1.x, 'float'); // BL (bottom-left) - pdf.setValue(buf + 4, p1.y, 'float'); - - pdf.setValue(buf + 8, p2.x, 'float'); // BR (bottom-right) - pdf.setValue(buf + 12, p2.y, 'float'); - - pdf.setValue(buf + 16, p4.x, 'float'); // TL (top-left) - pdf.setValue(buf + 20, p4.y, 'float'); - - pdf.setValue(buf + 24, p3.x, 'float'); // TR (top-right) - pdf.setValue(buf + 28, p3.y, 'float'); + const writeQuad = (q: Quad) => { + const [bl, br, tl, tr] = quadToPdfAttachmentPoints(q); + const pBl = this.convertDevicePointToPagePoint(doc, page, bl); + const pBr = this.convertDevicePointToPagePoint(doc, page, br); + const pTl = this.convertDevicePointToPagePoint(doc, page, tl); + const pTr = this.convertDevicePointToPagePoint(doc, page, tr); + + // PDF QuadPoints order: BL, BR, TL, TR + pdf.setValue(buf + 0, pBl.x, 'float'); + pdf.setValue(buf + 4, pBl.y, 'float'); + pdf.setValue(buf + 8, pBr.x, 'float'); + pdf.setValue(buf + 12, pBr.y, 'float'); + pdf.setValue(buf + 16, pTl.x, 'float'); + pdf.setValue(buf + 20, pTl.y, 'float'); + pdf.setValue(buf + 24, pTr.x, 'float'); + pdf.setValue(buf + 28, pTr.y, 'float'); }; /* ----------------------------------------------------------------------- */ /* 1. overwrite the quads that already exist */ - const min = Math.min(count, rects.length); + const min = Math.min(count, segments.length); for (let i = 0; i < min; i++) { - writeQuad(rects[i]); + writeQuad(segments[i]); if (!this.pdfiumModule.FPDFAnnot_SetAttachmentPoints(annotPtr, i, buf)) { this.memoryManager.free(buf); return false; } } - /* 2. append new quads if rects.length > count */ - for (let i = count; i < rects.length; i++) { - writeQuad(rects[i]); + /* 2. append new quads if segments.length > count */ + for (let i = count; i < segments.length; i++) { + writeQuad(segments[i]); if (!this.pdfiumModule.FPDFAnnot_AppendAttachmentPoints(annotPtr, buf)) { this.memoryManager.free(buf); return false; @@ -8000,7 +8088,11 @@ export class PdfiumNative implements IPdfiumExecutor { const rect = this.convertPageRectToDeviceRect(doc, page, pageRect); // Type-specific properties - const segmentRects = this.getQuadPointsAnno(doc, page, annotationPtr); + const { quads: segmentQuads, rects: segmentRects } = this.getQuadPointsAnno( + doc, + page, + annotationPtr, + ); const strokeColor = this.getAnnotationColor(annotationPtr) ?? '#FFFF00'; const opacity = this.getAnnotationOpacity(annotationPtr); @@ -8010,6 +8102,7 @@ export class PdfiumNative implements IPdfiumExecutor { type: PdfAnnotationSubtype.HIGHLIGHT, rect, segmentRects, + ...(segmentQuads.length > 0 && { segmentQuads }), strokeColor, color: strokeColor, // deprecated alias opacity, @@ -8036,7 +8129,11 @@ export class PdfiumNative implements IPdfiumExecutor { const rect = this.convertPageRectToDeviceRect(doc, page, pageRect); // Type-specific properties - const segmentRects = this.getQuadPointsAnno(doc, page, annotationPtr); + const { quads: segmentQuads, rects: segmentRects } = this.getQuadPointsAnno( + doc, + page, + annotationPtr, + ); const strokeColor = this.getAnnotationColor(annotationPtr) ?? '#FF0000'; const opacity = this.getAnnotationOpacity(annotationPtr); @@ -8046,6 +8143,7 @@ export class PdfiumNative implements IPdfiumExecutor { type: PdfAnnotationSubtype.UNDERLINE, rect, segmentRects, + ...(segmentQuads.length > 0 && { segmentQuads }), strokeColor, color: strokeColor, // deprecated alias opacity, @@ -8072,7 +8170,11 @@ export class PdfiumNative implements IPdfiumExecutor { const rect = this.convertPageRectToDeviceRect(doc, page, pageRect); // Type-specific properties - const segmentRects = this.getQuadPointsAnno(doc, page, annotationPtr); + const { quads: segmentQuads, rects: segmentRects } = this.getQuadPointsAnno( + doc, + page, + annotationPtr, + ); const strokeColor = this.getAnnotationColor(annotationPtr) ?? '#FF0000'; const opacity = this.getAnnotationOpacity(annotationPtr); @@ -8082,6 +8184,7 @@ export class PdfiumNative implements IPdfiumExecutor { type: PdfAnnotationSubtype.STRIKEOUT, rect, segmentRects, + ...(segmentQuads.length > 0 && { segmentQuads }), strokeColor, color: strokeColor, // deprecated alias opacity, @@ -8108,7 +8211,11 @@ export class PdfiumNative implements IPdfiumExecutor { const rect = this.convertPageRectToDeviceRect(doc, page, pageRect); // Type-specific properties - const segmentRects = this.getQuadPointsAnno(doc, page, annotationPtr); + const { quads: segmentQuads, rects: segmentRects } = this.getQuadPointsAnno( + doc, + page, + annotationPtr, + ); const strokeColor = this.getAnnotationColor(annotationPtr) ?? '#FF0000'; const opacity = this.getAnnotationOpacity(annotationPtr); @@ -8118,6 +8225,7 @@ export class PdfiumNative implements IPdfiumExecutor { type: PdfAnnotationSubtype.SQUIGGLY, rect, segmentRects, + ...(segmentQuads.length > 0 && { segmentQuads }), strokeColor, color: strokeColor, // deprecated alias opacity, @@ -8185,8 +8293,8 @@ export class PdfiumNative implements IPdfiumExecutor { const pageRect = this.readPageAnnoRect(annotationPtr); const rect = this.convertPageRectToDeviceRect(doc, page, pageRect); - // QuadPoints for redaction areas - const segmentRects = this.getQuadPointsAnno(doc, page, annotationPtr); + // QuadPoints for redaction areas (rect-only; no oriented segmentQuads) + const { rects: segmentRects } = this.getQuadPointsAnno(doc, page, annotationPtr); // Colors: IC = interior/preview, OC = overlay, C = stroke const color = this.getAnnotationColor(annotationPtr, PdfAnnotationColorType.InteriorColor); diff --git a/packages/models/src/geometry.test.ts b/packages/models/src/geometry.test.ts index bb91ad1be..14eb7395a 100644 --- a/packages/models/src/geometry.test.ts +++ b/packages/models/src/geometry.test.ts @@ -6,6 +6,14 @@ import { Rotation, transformRect, restoreRect, + orientedQuadFromPageBoxAndMatrix, + pdfAttachmentPointsToQuad, + quadToPdfAttachmentPoints, + buildSegmentQuadFromGlyphQuads, + rectToQuad, + quadToRect, + getQuadBottomEdge, + getQuadMidline, } from './geometry'; describe('Geometry', () => { @@ -164,3 +172,70 @@ describe('Geometry', () => { ).toStrictEqual(rect); }); }); + +describe('Oriented quad helpers', () => { + test('orientedQuadFromPageBoxAndMatrix preserves horizontal glyph quad', () => { + const quad = orientedQuadFromPageBoxAndMatrix(10, 20, 30, 0, { + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, + }); + + expect(quad.p1).toEqual({ x: 10, y: 20 }); + expect(quad.p2).toEqual({ x: 30, y: 20 }); + expect(quad.p3).toEqual({ x: 30, y: 0 }); + expect(quad.p4).toEqual({ x: 10, y: 0 }); + }); + + test('orientedQuadFromPageBoxAndMatrix handles 90 degree rotation', () => { + const quad = orientedQuadFromPageBoxAndMatrix(0, 20, 10, 0, { + a: 0, + b: 1, + c: -1, + d: 0, + e: 0, + f: 0, + }); + + expect(quadToRect(quad).size.width).toBeCloseTo(20, 5); + expect(quadToRect(quad).size.height).toBeCloseTo(10, 5); + }); + + test('pdf attachment points round-trip internal quad convention', () => { + const quad = rectToQuad({ + origin: { x: 5, y: 10 }, + size: { width: 20, height: 8 }, + }); + const [bl, br, tl, tr] = quadToPdfAttachmentPoints(quad); + const restored = pdfAttachmentPointsToQuad(bl, br, tl, tr); + + expect(restored).toEqual(quad); + }); + + test('buildSegmentQuadFromGlyphQuads spans first and last glyph quads', () => { + const first = rectToQuad({ origin: { x: 0, y: 0 }, size: { width: 5, height: 10 } }); + const last = rectToQuad({ origin: { x: 20, y: 0 }, size: { width: 5, height: 10 } }); + const segment = buildSegmentQuadFromGlyphQuads(first, last); + + expect(segment.p1).toEqual(first.p1); + expect(segment.p2).toEqual(last.p2); + expect(segment.p3).toEqual(last.p3); + expect(segment.p4).toEqual(first.p4); + expect(quadToRect(segment).size.width).toBe(25); + }); + + test('quad edge helpers follow reading-order convention', () => { + const quad = rectToQuad({ origin: { x: 0, y: 0 }, size: { width: 10, height: 4 } }); + expect(getQuadBottomEdge(quad)).toEqual({ + start: { x: 0, y: 4 }, + end: { x: 10, y: 4 }, + }); + expect(getQuadMidline(quad)).toEqual({ + start: { x: 0, y: 2 }, + end: { x: 10, y: 2 }, + }); + }); +}); diff --git a/packages/models/src/geometry.ts b/packages/models/src/geometry.ts index 1d4404b91..04981720a 100644 --- a/packages/models/src/geometry.ts +++ b/packages/models/src/geometry.ts @@ -806,3 +806,187 @@ export function fitSizeWithin(size: Size, bounds: Size): Size { const scale = Math.min(bounds.width / size.width, bounds.height / size.height, 1); return { width: size.width * scale, height: size.height * scale }; } + +/** + * Apply a PDF transformation matrix to a point. + * + * @public + */ +export function transformPointByMatrix(m: Matrix, p: Position): Position { + return { + x: m.a * p.x + m.c * p.y + m.e, + y: m.b * p.x + m.d * p.y + m.f, + }; +} + +/** + * Invert a PDF transformation matrix. + * + * @public + */ +export function invertMatrix(m: Matrix): Matrix { + const det = m.a * m.d - m.b * m.c; + if (Math.abs(det) < 1e-10) { + return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + } + const invDet = 1 / det; + return { + a: m.d * invDet, + b: -m.b * invDet, + c: -m.c * invDet, + d: m.a * invDet, + e: (m.c * m.f - m.d * m.e) * invDet, + f: (m.b * m.e - m.a * m.f) * invDet, + }; +} + +/** + * Build an oriented quadrilateral in page space from a loose char box and glyph matrix. + * Internal convention: p1→p2 is the top edge and p4→p3 is the bottom edge in reading order. + * + * @public + */ +export function orientedQuadFromPageBoxAndMatrix( + left: number, + top: number, + right: number, + bottom: number, + matrix: Matrix, +): Quad { + const inv = invertMatrix(matrix); + const pageCorners = [ + { x: left, y: bottom }, + { x: right, y: bottom }, + { x: right, y: top }, + { x: left, y: top }, + ]; + const local = pageCorners.map((p) => transformPointByMatrix(inv, p)); + const xs = local.map((p) => p.x); + const ys = local.map((p) => p.y); + const l = Math.min(...xs); + const r = Math.max(...xs); + const b = Math.min(...ys); + const t = Math.max(...ys); + + const localQuad = [ + { x: l, y: t }, + { x: r, y: t }, + { x: r, y: b }, + { x: l, y: b }, + ]; + + const [p1, p2, p3, p4] = localQuad.map((p) => transformPointByMatrix(matrix, p)); + return { p1, p2, p3, p4 }; +} + +/** + * Convert an internal quad to PDF attachment-point order: BL, BR, TL, TR. + * + * @public + */ +export function quadToPdfAttachmentPoints(q: Quad): [Position, Position, Position, Position] { + return [q.p4, q.p3, q.p1, q.p2]; +} + +/** + * Convert PDF attachment points to the internal quad convention. + * + * @public + */ +export function pdfAttachmentPointsToQuad( + bl: Position, + br: Position, + tl: Position, + tr: Position, +): Quad { + return { p1: tl, p2: tr, p3: br, p4: bl }; +} + +/** + * Merge the first and last glyph quads into a single text segment quad. + * + * @public + */ +export function buildSegmentQuadFromGlyphQuads(first: Quad, last: Quad): Quad { + return { p1: first.p1, p2: last.p2, p3: last.p3, p4: first.p4 }; +} + +/** + * Unit vector along the glyph baseline in the matrix's local +x direction. + * + * @public + */ +export function matrixBaselineDirection(m: Matrix): Position { + const len = Math.hypot(m.a, m.b); + if (len < 1e-10) return { x: 1, y: 0 }; + return { x: m.a / len, y: m.b / len }; +} + +/** + * Whether two glyph matrices share the same baseline direction. + * + * @public + */ +export function matricesCompatible(m1: Matrix, m2: Matrix, tolerance = 0.01): boolean { + const d1 = matrixBaselineDirection(m1); + const d2 = matrixBaselineDirection(m2); + const dot = d1.x * d2.x + d1.y * d2.y; + return Math.abs(dot) > 1 - tolerance; +} + +/** + * Project a point onto a direction vector from an origin. + * + * @public + */ +export function projectOnDirection(p: Position, origin: Position, dir: Position): number { + return (p.x - origin.x) * dir.x + (p.y - origin.y) * dir.y; +} + +/** + * CSS/SVG polygon string for a quad (`p1 p2 p3 p4`). + * + * @public + */ +export function quadPolygonPoints(q: Quad): string { + return `${q.p1.x},${q.p1.y} ${q.p2.x},${q.p2.y} ${q.p3.x},${q.p3.y} ${q.p4.x},${q.p4.y}`; +} + +/** + * Bottom edge of a quad in reading order. + * + * @public + */ +export function getQuadBottomEdge(q: Quad): { start: Position; end: Position } { + return { start: q.p4, end: q.p3 }; +} + +/** + * Midline between the top and bottom edges of a quad. + * + * @public + */ +export function getQuadMidline(q: Quad): { start: Position; end: Position } { + return { + start: { x: (q.p1.x + q.p4.x) / 2, y: (q.p1.y + q.p4.y) / 2 }, + end: { x: (q.p2.x + q.p3.x) / 2, y: (q.p2.y + q.p3.y) / 2 }, + }; +} + +/** + * Baseline endpoint at the trailing edge of a segment quad. + * + * @public + */ +export function getQuadBaselineEnd(q: Quad): Position { + return { x: (q.p2.x + q.p3.x) / 2, y: (q.p2.y + q.p3.y) / 2 }; +} + +/** + * Derive axis-aligned rectangles from quads. + * + * @public + */ +export function quadsToRects(quads: Quad[]): Rect[] { + return quads.map(quadToRect); +} diff --git a/packages/models/src/pdf.ts b/packages/models/src/pdf.ts index 52293eb9e..fcae6eb1d 100644 --- a/packages/models/src/pdf.ts +++ b/packages/models/src/pdf.ts @@ -1,4 +1,4 @@ -import { Size, Rect, Position, Rotation, Box } from './geometry'; +import { Size, Rect, Position, Rotation, Box, Quad } from './geometry'; import { Task, TaskError } from './task'; /** @@ -1972,6 +1972,12 @@ export interface PdfHighlightAnnoObject extends PdfAnnotationObjectBase { * quads of highlight area */ segmentRects: Rect[]; + + /** + * Authoritative oriented quads for highlight segments. When present, used for + * persistence and rendering; {@link segmentRects} are derived for compatibility. + */ + segmentQuads?: Quad[]; } /** @@ -2230,6 +2236,11 @@ export interface PdfSquigglyAnnoObject extends PdfAnnotationObjectBase { * quads of squiggly area */ segmentRects: Rect[]; + + /** + * Authoritative oriented quads for squiggly segments. + */ + segmentQuads?: Quad[]; } /** @@ -2261,6 +2272,11 @@ export interface PdfUnderlineAnnoObject extends PdfAnnotationObjectBase { * quads of underline area */ segmentRects: Rect[]; + + /** + * Authoritative oriented quads for underline segments. + */ + segmentQuads?: Quad[]; } /** @@ -2294,6 +2310,11 @@ export interface PdfStrikeOutAnnoObject extends PdfAnnotationObjectBase { * quads of strikeout area */ segmentRects: Rect[]; + + /** + * Authoritative oriented quads for strikeout segments. + */ + segmentQuads?: Quad[]; } /** @@ -2738,6 +2759,18 @@ export interface PdfGlyphObject { * Size of the glyph (loose bounds from FPDFText_GetLooseCharBox) */ size: { width: number; height: number }; + /** + * Effective glyph transform matrix in page space (from FPDFText_GetMatrix) + */ + matrix?: PdfTransformMatrix; + /** + * Character origin in page space (from FPDFText_GetCharOrigin) + */ + pageOrigin?: { x: number; y: number }; + /** + * Oriented device-space quad for the glyph (p1→p2 top, p4→p3 bottom) + */ + quad?: Quad; /** * Tight bounds origin (from FPDFText_GetCharBox, closely surrounds the actual glyph shape). * Used for hit-testing to match Chrome's FPDFText_GetCharIndexAtPos behaviour. @@ -2800,6 +2833,18 @@ export interface PdfGlyphSlim { * Tight height (from FPDFText_GetCharBox) */ tightHeight?: number; + /** + * Effective glyph transform matrix in page space (from FPDFText_GetMatrix) + */ + matrix?: PdfTransformMatrix; + /** + * Character origin in page space (from FPDFText_GetCharOrigin) + */ + pageOrigin?: { x: number; y: number }; + /** + * Oriented device-space quad for the glyph (p1→p2 top, p4→p3 bottom) + */ + quad?: Quad; } /** diff --git a/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts b/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts index f81347d09..85cb30163 100644 --- a/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts +++ b/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts @@ -21,9 +21,10 @@ export const insertTextSelectionHandler: SelectionHandlerFactory>([]); + const [quads, setQuads] = useState>([]); const [boundingRect, setBoundingRect] = useState(null); const [activeTool, setActiveTool] = useState(null); @@ -26,8 +27,10 @@ export function TextMarkup({ documentId, pageIndex, scale }: TextMarkupProps) { if (!selectionProvides) return; return selectionProvides.forDocument(documentId).onSelectionChange(() => { - setRects(selectionProvides.forDocument(documentId).getHighlightRectsForPage(pageIndex)); - setBoundingRect(selectionProvides.forDocument(documentId).getBoundingRectForPage(pageIndex)); + const scope = selectionProvides.forDocument(documentId); + setRects(scope.getHighlightRectsForPage(pageIndex)); + setQuads(scope.getHighlightQuadsForPage(pageIndex)); + setBoundingRect(scope.getBoundingRectForPage(pageIndex)); }); }, [selectionProvides, documentId, pageIndex]); @@ -60,6 +63,7 @@ export function TextMarkup({ documentId, pageIndex, scale }: TextMarkupProps) { strokeColor={activeTool.defaults?.strokeColor} opacity={activeTool.defaults?.opacity} segmentRects={rects} + segmentQuads={quads} scale={scale} /> @@ -78,6 +82,7 @@ export function TextMarkup({ documentId, pageIndex, scale }: TextMarkupProps) { strokeColor={activeTool.defaults?.strokeColor} opacity={activeTool.defaults?.opacity} segmentRects={rects} + segmentQuads={quads} scale={scale} /> @@ -96,6 +101,7 @@ export function TextMarkup({ documentId, pageIndex, scale }: TextMarkupProps) { strokeColor={activeTool.defaults?.strokeColor} opacity={activeTool.defaults?.opacity} segmentRects={rects} + segmentQuads={quads} scale={scale} /> @@ -114,6 +120,7 @@ export function TextMarkup({ documentId, pageIndex, scale }: TextMarkupProps) { strokeColor={activeTool.defaults?.strokeColor} opacity={activeTool.defaults?.opacity} segmentRects={rects} + segmentQuads={quads} scale={scale} /> diff --git a/packages/plugin-annotation/src/shared/components/text-markup/highlight.tsx b/packages/plugin-annotation/src/shared/components/text-markup/highlight.tsx index dac874251..0dfca7e80 100644 --- a/packages/plugin-annotation/src/shared/components/text-markup/highlight.tsx +++ b/packages/plugin-annotation/src/shared/components/text-markup/highlight.tsx @@ -1,11 +1,14 @@ import { CSSProperties, MouseEvent } from '@framework'; -import { Rect } from '@embedpdf/models'; +import { Quad, Rect } from '@embedpdf/models'; + +import { quadBoundsRelativeToContainer, quadClipPath, resolveTextMarkupSegments } from './quad-geometry'; type HighlightProps = { /** Stroke/markup color */ strokeColor?: string; opacity?: number; segmentRects: Rect[]; + segmentQuads?: Quad[]; rect?: Rect; scale: number; onClick?: (e: MouseEvent) => void; @@ -18,6 +21,7 @@ export function Highlight({ strokeColor, opacity = 0.5, segmentRects, + segmentQuads, rect, scale, onClick, @@ -25,28 +29,33 @@ export function Highlight({ appearanceActive = false, }: HighlightProps) { const resolvedColor = strokeColor ?? '#FFFF00'; + const segments = resolveTextMarkupSegments(segmentRects, segmentQuads); return ( <> - {segmentRects.map((b, i) => ( -
- ))} + {segments.map((segment, i) => { + const bounds = quadBoundsRelativeToContainer(segment, rect, scale); + return ( +
+ ); + })} ); } diff --git a/packages/plugin-annotation/src/shared/components/text-markup/quad-geometry.ts b/packages/plugin-annotation/src/shared/components/text-markup/quad-geometry.ts new file mode 100644 index 000000000..cbf01042b --- /dev/null +++ b/packages/plugin-annotation/src/shared/components/text-markup/quad-geometry.ts @@ -0,0 +1,100 @@ +import { + getQuadBottomEdge, + getQuadMidline, + Quad, + quadPolygonPoints, + quadToRect, + Rect, + rectToQuad, +} from '@embedpdf/models'; + +export function resolveTextMarkupSegments( + segmentRects: Rect[], + segmentQuads?: Quad[], +): Quad[] { + if (segmentQuads && segmentQuads.length > 0) return segmentQuads; + return segmentRects.map(rectToQuad); +} + +function mapQuadToLocalSpace(quad: Quad, container: Rect | undefined, scale: number) { + const rect = quadToRect(quad); + const offset = container?.origin ?? { x: 0, y: 0 }; + const baseX = (rect.origin.x - offset.x) * scale; + const baseY = (rect.origin.y - offset.y) * scale; + const mapPoint = (p: { x: number; y: number }) => ({ + x: (p.x - offset.x) * scale - baseX, + y: (p.y - offset.y) * scale - baseY, + }); + return { + quad: { + p1: mapPoint(quad.p1), + p2: mapPoint(quad.p2), + p3: mapPoint(quad.p3), + p4: mapPoint(quad.p4), + }, + bounds: { + left: baseX, + top: baseY, + width: rect.size.width * scale, + height: rect.size.height * scale, + }, + }; +} + +export function quadBoundsRelativeToContainer(quad: Quad, container?: Rect, scale = 1) { + const { bounds } = mapQuadToLocalSpace(quad, container, scale); + return bounds; +} + +export function scaleQuad(quad: Quad, scale: number, container?: Rect): Quad { + return mapQuadToLocalSpace(quad, container, scale).quad; +} + +export function quadClipPath(quad: Quad, container?: Rect, scale = 1): string { + const { quad: localQuad } = mapQuadToLocalSpace(quad, container, scale); + return `polygon(${quadPolygonPoints(localQuad)})`; +} + +export function underlineSegmentPath(quad: Quad, container?: Rect, scale = 1): string { + const { quad: localQuad } = mapQuadToLocalSpace(quad, container, scale); + const { start, end } = getQuadBottomEdge(localQuad); + return `M ${start.x} ${start.y} L ${end.x} ${end.y}`; +} + +export function strikeoutSegmentPath(quad: Quad, container?: Rect, scale = 1): string { + const { quad: localQuad } = mapQuadToLocalSpace(quad, container, scale); + const { start, end } = getQuadMidline(localQuad); + return `M ${start.x} ${start.y} L ${end.x} ${end.y}`; +} + +export function squigglySegmentPath( + quad: Quad, + container?: Rect, + scale = 1, + amplitude = 2, + wavelength = 6, +): string { + const { quad: localQuad } = mapQuadToLocalSpace(quad, container, scale); + const { start, end } = getQuadBottomEdge(localQuad); + const dx = end.x - start.x; + const dy = end.y - start.y; + const length = Math.hypot(dx, dy); + if (length < 1) return `M ${start.x} ${start.y}`; + + const ux = dx / length; + const uy = dy / length; + const px = -uy; + const py = ux; + const steps = Math.max(2, Math.ceil(length / wavelength)); + const step = length / steps; + + let path = `M ${start.x} ${start.y}`; + for (let i = 1; i <= steps; i++) { + const t = i * step; + const wave = i % 2 === 0 ? -amplitude : amplitude; + const x = start.x + ux * t + px * wave; + const y = start.y + uy * t + py * wave; + path += ` L ${x} ${y}`; + } + return path; +} diff --git a/packages/plugin-annotation/src/shared/components/text-markup/squiggly.tsx b/packages/plugin-annotation/src/shared/components/text-markup/squiggly.tsx index 143fa4fa2..07983f1ae 100644 --- a/packages/plugin-annotation/src/shared/components/text-markup/squiggly.tsx +++ b/packages/plugin-annotation/src/shared/components/text-markup/squiggly.tsx @@ -1,11 +1,18 @@ import { CSSProperties, MouseEvent } from '@framework'; -import { Rect } from '@embedpdf/models'; +import { Quad, Rect } from '@embedpdf/models'; + +import { + quadBoundsRelativeToContainer, + resolveTextMarkupSegments, + squigglySegmentPath, +} from './quad-geometry'; type SquigglyProps = { /** Stroke/markup color */ strokeColor?: string; opacity?: number; segmentRects: Rect[]; + segmentQuads?: Quad[]; rect?: Rect; scale: number; onClick?: (e: MouseEvent) => void; @@ -18,6 +25,7 @@ export function Squiggly({ strokeColor, opacity = 0.5, segmentRects, + segmentQuads, rect, scale, onClick, @@ -25,55 +33,54 @@ export function Squiggly({ appearanceActive = false, }: SquigglyProps) { const resolvedColor = strokeColor ?? '#FFFF00'; - const amplitude = 2 * scale; // wave height - const period = 6 * scale; // wave length - - const svg = ` - - `; - - // Completely escape the SVG markup - const svgDataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; + const thickness = 2 * scale; + const segments = resolveTextMarkupSegments(segmentRects, segmentQuads); return ( <> - {segmentRects.map((r, i) => ( -
- {/* Visual -- hidden when AP active, never interactive */} - {!appearanceActive && ( -
- )} -
- ))} + {segments.map((segment, i) => { + const bounds = quadBoundsRelativeToContainer(segment, rect, scale); + return ( +
+ {!appearanceActive && ( + + + + )} +
+ ); + })} ); } diff --git a/packages/plugin-annotation/src/shared/components/text-markup/strikeout.tsx b/packages/plugin-annotation/src/shared/components/text-markup/strikeout.tsx index d8ac7de3e..cef9dfd3d 100644 --- a/packages/plugin-annotation/src/shared/components/text-markup/strikeout.tsx +++ b/packages/plugin-annotation/src/shared/components/text-markup/strikeout.tsx @@ -1,11 +1,18 @@ import { CSSProperties, MouseEvent } from '@framework'; -import { Rect } from '@embedpdf/models'; +import { Quad, Rect } from '@embedpdf/models'; + +import { + quadBoundsRelativeToContainer, + resolveTextMarkupSegments, + strikeoutSegmentPath, +} from './quad-geometry'; type StrikeoutProps = { /** Stroke/markup color */ strokeColor?: string; opacity?: number; segmentRects: Rect[]; + segmentQuads?: Quad[]; rect?: Rect; scale: number; onClick?: (e: MouseEvent) => void; @@ -18,6 +25,7 @@ export function Strikeout({ strokeColor, opacity = 0.5, segmentRects, + segmentQuads, rect, scale, onClick, @@ -26,44 +34,53 @@ export function Strikeout({ }: StrikeoutProps) { const resolvedColor = strokeColor ?? '#FFFF00'; const thickness = 2 * scale; + const segments = resolveTextMarkupSegments(segmentRects, segmentQuads); return ( <> - {segmentRects.map((r, i) => ( -
- {/* Visual -- hidden when AP active, never interactive */} - {!appearanceActive && ( -
- )} -
- ))} + {segments.map((segment, i) => { + const bounds = quadBoundsRelativeToContainer(segment, rect, scale); + return ( +
+ {!appearanceActive && ( + + + + )} +
+ ); + })} ); } diff --git a/packages/plugin-annotation/src/shared/components/text-markup/underline.tsx b/packages/plugin-annotation/src/shared/components/text-markup/underline.tsx index 2621948ac..448c34002 100644 --- a/packages/plugin-annotation/src/shared/components/text-markup/underline.tsx +++ b/packages/plugin-annotation/src/shared/components/text-markup/underline.tsx @@ -1,11 +1,18 @@ import { CSSProperties, MouseEvent } from '@framework'; -import { Rect } from '@embedpdf/models'; +import { Quad, Rect } from '@embedpdf/models'; + +import { + quadBoundsRelativeToContainer, + resolveTextMarkupSegments, + underlineSegmentPath, +} from './quad-geometry'; type UnderlineProps = { /** Stroke/markup color */ strokeColor?: string; opacity?: number; segmentRects: Rect[]; + segmentQuads?: Quad[]; rect?: Rect; scale: number; onClick?: (e: MouseEvent) => void; @@ -18,6 +25,7 @@ export function Underline({ strokeColor, opacity = 0.5, segmentRects, + segmentQuads, rect, scale, onClick, @@ -25,44 +33,54 @@ export function Underline({ appearanceActive = false, }: UnderlineProps) { const resolvedColor = strokeColor ?? '#FFFF00'; - const thickness = 2 * scale; // 2 CSS px at 100 % zoom + const thickness = 2 * scale; + const segments = resolveTextMarkupSegments(segmentRects, segmentQuads); return ( <> - {segmentRects.map((r, i) => ( -
- {/* Visual -- hidden when AP active, never interactive */} - {!appearanceActive && ( -
- )} -
- ))} + {segments.map((segment, i) => { + const bounds = quadBoundsRelativeToContainer(segment, rect, scale); + return ( +
+ {!appearanceActive && ( + + + + )} +
+ ); + })} ); } diff --git a/packages/plugin-annotation/src/svelte/components/TextMarkup.svelte b/packages/plugin-annotation/src/svelte/components/TextMarkup.svelte index 2eb2187fd..896574343 100644 --- a/packages/plugin-annotation/src/svelte/components/TextMarkup.svelte +++ b/packages/plugin-annotation/src/svelte/components/TextMarkup.svelte @@ -1,5 +1,5 @@ -{#each segmentRects as b, i (i)} +{#each segments as segment, i (i)} + {@const bounds = quadBoundsRelativeToContainer(segment, rect, scale)}
- import type { Rect } from '@embedpdf/models'; + import type { Quad, Rect } from '@embedpdf/models'; + import { + quadBoundsRelativeToContainer, + resolveTextMarkupSegments, + squigglySegmentPath, + } from '../../../shared/components/text-markup/quad-geometry'; interface SquigglyProps { - /** Stroke/markup color */ strokeColor?: string; opacity?: number; segmentRects: Rect[]; + segmentQuads?: Quad[]; rect?: Rect; scale: number; onClick?: (e: MouseEvent) => void; style?: Record; - /** When true, AP image provides the visual; only render hit area */ appearanceActive?: boolean; } @@ -18,6 +22,7 @@ strokeColor, opacity = 0.5, segmentRects, + segmentQuads, rect, scale, onClick, @@ -26,49 +31,45 @@ }: SquigglyProps = $props(); const resolvedColor = $derived(strokeColor ?? '#FFFF00'); - const amplitude = $derived(2 * scale); // wave height - const period = $derived(6 * scale); // wave length - - const svg = - $derived(` - - `); - - // Completely escape the SVG markup - const svgDataUri = $derived(`url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`); + const thickness = $derived(2 * scale); + const segments = $derived(resolveTextMarkupSegments(segmentRects, segmentQuads)); -{#each segmentRects as r, i (i)} +{#each segments as segment, i (i)} + {@const bounds = quadBoundsRelativeToContainer(segment, rect, scale)}
[`style:${k}`, v])) : {}} > - {#if !appearanceActive} -
+ > + + {/if}
{/each} diff --git a/packages/plugin-annotation/src/svelte/components/text-markup/Strikeout.svelte b/packages/plugin-annotation/src/svelte/components/text-markup/Strikeout.svelte index ba882f292..c427eed92 100644 --- a/packages/plugin-annotation/src/svelte/components/text-markup/Strikeout.svelte +++ b/packages/plugin-annotation/src/svelte/components/text-markup/Strikeout.svelte @@ -1,16 +1,20 @@ -{#each segmentRects as r, i (i)} +{#each segments as segment, i (i)} + {@const bounds = quadBoundsRelativeToContainer(segment, rect, scale)}
[`style:${k}`, v])) : {}} > - {#if !appearanceActive} -
+ > + + {/if}
{/each} diff --git a/packages/plugin-annotation/src/svelte/components/text-markup/Underline.svelte b/packages/plugin-annotation/src/svelte/components/text-markup/Underline.svelte index bb91a34d4..5f58534ad 100644 --- a/packages/plugin-annotation/src/svelte/components/text-markup/Underline.svelte +++ b/packages/plugin-annotation/src/svelte/components/text-markup/Underline.svelte @@ -1,16 +1,20 @@ -{#each segmentRects as r, i (i)} +{#each segments as segment, i (i)} + {@const bounds = quadBoundsRelativeToContainer(segment, rect, scale)}
[`style:${k}`, v])) : {}} > - {#if !appearanceActive} -
+ > + + {/if}
{/each} diff --git a/packages/plugin-annotation/src/vue/components/text-markup.vue b/packages/plugin-annotation/src/vue/components/text-markup.vue index d21fe97a8..3411cba87 100644 --- a/packages/plugin-annotation/src/vue/components/text-markup.vue +++ b/packages/plugin-annotation/src/vue/components/text-markup.vue @@ -13,6 +13,7 @@ :strokeColor="activeTool.defaults.strokeColor" :opacity="activeTool.defaults.opacity" :segmentRects="rects" + :segmentQuads="quads" :scale="scale" />
@@ -41,7 +45,7 @@ diff --git a/packages/plugin-annotation/src/vue/components/text-markup/squiggly.vue b/packages/plugin-annotation/src/vue/components/text-markup/squiggly.vue index 2baddf6cb..9a557dcc9 100644 --- a/packages/plugin-annotation/src/vue/components/text-markup/squiggly.vue +++ b/packages/plugin-annotation/src/vue/components/text-markup/squiggly.vue @@ -1,36 +1,40 @@ @@ -40,18 +44,22 @@ export default { inheritAttrs: false }; diff --git a/packages/plugin-annotation/src/vue/components/text-markup/strikeout.vue b/packages/plugin-annotation/src/vue/components/text-markup/strikeout.vue index 867a974e7..49d794896 100644 --- a/packages/plugin-annotation/src/vue/components/text-markup/strikeout.vue +++ b/packages/plugin-annotation/src/vue/components/text-markup/strikeout.vue @@ -1,35 +1,40 @@ @@ -39,18 +44,22 @@ export default { inheritAttrs: false }; diff --git a/packages/plugin-annotation/src/vue/components/text-markup/underline.vue b/packages/plugin-annotation/src/vue/components/text-markup/underline.vue index 8be24c7d8..895916fcc 100644 --- a/packages/plugin-annotation/src/vue/components/text-markup/underline.vue +++ b/packages/plugin-annotation/src/vue/components/text-markup/underline.vue @@ -1,34 +1,40 @@ @@ -38,18 +44,22 @@ export default { inheritAttrs: false }; diff --git a/packages/plugin-selection/src/lib/actions.ts b/packages/plugin-selection/src/lib/actions.ts index ef2d914af..70de8253b 100644 --- a/packages/plugin-selection/src/lib/actions.ts +++ b/packages/plugin-selection/src/lib/actions.ts @@ -1,5 +1,5 @@ import { Action } from '@embedpdf/core'; -import { PdfPageGeometry, Rect } from '@embedpdf/models'; +import { PdfPageGeometry, Quad, Rect } from '@embedpdf/models'; import { SelectionDocumentState, SelectionRangeX } from './types'; export const INIT_SELECTION_STATE = 'SELECTION/INIT_STATE'; @@ -10,6 +10,7 @@ export const START_SELECTION = 'SELECTION/START_SELECTION'; export const END_SELECTION = 'SELECTION/END_SELECTION'; export const CLEAR_SELECTION = 'SELECTION/CLEAR_SELECTION'; export const SET_RECTS = 'SELECTION/SET_RECTS'; +export const SET_QUADS = 'SELECTION/SET_QUADS'; export const SET_SLICES = 'SELECTION/SET_SLICES'; export const EVICT_PAGE_GEOMETRY = 'SELECTION/EVICT_PAGE_GEOMETRY'; export const RESET = 'SELECTION/RESET'; // This might be obsolete, but we'll keep it for now @@ -56,6 +57,11 @@ export interface SetRectsAction extends Action { payload: { documentId: string; rects: Record }; } +export interface SetQuadsAction extends Action { + type: typeof SET_QUADS; + payload: { documentId: string; quads: Record }; +} + export interface SetSlicesAction extends Action { type: typeof SET_SLICES; payload: { documentId: string; slices: Record }; @@ -80,6 +86,7 @@ export type SelectionAction = | EndSelectionAction | ClearSelectionAction | SetRectsAction + | SetQuadsAction | SetSlicesAction | EvictPageGeometryAction | ResetAction; @@ -134,6 +141,11 @@ export const setRects = (documentId: string, allRects: Record): payload: { documentId, rects: allRects }, }); +export const setQuads = (documentId: string, allQuads: Record): SetQuadsAction => ({ + type: SET_QUADS, + payload: { documentId, quads: allQuads }, +}); + export const setSlices = ( documentId: string, slices: Record, diff --git a/packages/plugin-selection/src/lib/reducer.ts b/packages/plugin-selection/src/lib/reducer.ts index e6ddae7e3..63610e8a1 100644 --- a/packages/plugin-selection/src/lib/reducer.ts +++ b/packages/plugin-selection/src/lib/reducer.ts @@ -9,6 +9,7 @@ import { RESET, SET_SLICES, SET_RECTS, + SET_QUADS, INIT_SELECTION_STATE, CLEANUP_SELECTION_STATE, EVICT_PAGE_GEOMETRY, @@ -17,6 +18,7 @@ import { export const initialSelectionDocumentState: SelectionDocumentState = { geometry: {}, rects: {}, + quads: {}, slices: {}, selection: null, active: false, @@ -85,6 +87,7 @@ export const selectionReducer = (state = initialState, action: SelectionAction): selecting: true, selection: null, rects: {}, + quads: {}, }); } @@ -104,6 +107,7 @@ export const selectionReducer = (state = initialState, action: SelectionAction): selecting: false, selection: null, rects: {}, + quads: {}, active: false, }); } @@ -115,6 +119,13 @@ export const selectionReducer = (state = initialState, action: SelectionAction): return updateDocState(state, documentId, { ...docState, rects }); } + case SET_QUADS: { + const { documentId, quads } = action.payload; + const docState = state.documents[documentId]; + if (!docState) return state; + return updateDocState(state, documentId, { ...docState, quads }); + } + case SET_SLICES: { const { documentId, slices } = action.payload; const docState = state.documents[documentId]; @@ -128,13 +139,15 @@ export const selectionReducer = (state = initialState, action: SelectionAction): if (!docState) return state; const geometry = { ...docState.geometry }; const rects = { ...docState.rects }; + const quads = { ...docState.quads }; const slices = { ...docState.slices }; for (const p of pages) { delete geometry[p]; delete rects[p]; + delete quads[p]; delete slices[p]; } - return updateDocState(state, documentId, { ...docState, geometry, rects, slices }); + return updateDocState(state, documentId, { ...docState, geometry, rects, quads, slices }); } case RESET: { diff --git a/packages/plugin-selection/src/lib/selection-plugin.ts b/packages/plugin-selection/src/lib/selection-plugin.ts index 11f2127f5..aa8ef24a1 100644 --- a/packages/plugin-selection/src/lib/selection-plugin.ts +++ b/packages/plugin-selection/src/lib/selection-plugin.ts @@ -16,6 +16,7 @@ import { PageTextSlice, Task, Position, + Quad, } from '@embedpdf/models'; import { InteractionManagerCapability, @@ -35,6 +36,7 @@ import { startSelection, clearSelection, setRects, + setQuads, setSlices, initSelectionState, cleanupSelectionState, @@ -66,7 +68,7 @@ import { EmptySpaceClickEvent, EmptySpaceClickScopeEvent, } from './types'; -import { sliceBounds, rectsWithinSlice, expandToWordBoundary, expandToLineBoundary } from './utils'; +import { sliceBounds, quadsWithinSlice, expandToWordBoundary, expandToLineBoundary } from './utils'; import { createTextSelectionHandler } from './handlers/text-selection.handler'; import { createMarqueeSelectionHandler } from './handlers/marquee-selection.handler'; @@ -285,6 +287,8 @@ export class SelectionPlugin extends BasePlugin< selector.getFormattedSelectionForPage(this.getDocumentState(getDocId(docId)), p), getHighlightRectsForPage: (p, docId) => selector.selectRectsForPage(this.getDocumentState(getDocId(docId)), p), + getHighlightQuadsForPage: (p, docId) => + selector.selectQuadsForPage(this.getDocumentState(getDocId(docId)), p), getHighlightRects: (docId) => this.getDocumentState(getDocId(docId)).rects, getBoundingRectForPage: (p, docId) => selector.selectBoundingRectForPage(this.getDocumentState(getDocId(docId)), p), @@ -328,6 +332,8 @@ export class SelectionPlugin extends BasePlugin< selector.getFormattedSelectionForPage(this.getDocumentState(documentId), p), getHighlightRectsForPage: (p) => selector.selectRectsForPage(this.getDocumentState(documentId), p), + getHighlightQuadsForPage: (p) => + selector.selectQuadsForPage(this.getDocumentState(documentId), p), getHighlightRects: () => this.getDocumentState(documentId).rects, getBoundingRectForPage: (p) => selector.selectBoundingRectForPage(this.getDocumentState(documentId), p), @@ -411,8 +417,9 @@ export class SelectionPlugin extends BasePlugin< const sb = sliceBounds(sel, geo, pageIndex); if (!sb) return; - const pageRects = rectsWithinSlice(geo, sb.from, sb.to); + const { quads: pageQuads, rects: pageRects } = quadsWithinSlice(geo, sb.from, sb.to); this.dispatch(setRects(documentId, { ...currentState.rects, [pageIndex]: pageRects })); + this.dispatch(setQuads(documentId, { ...currentState.quads, [pageIndex]: pageQuads })); this.dispatch( setSlices(documentId, { ...currentState.slices, @@ -872,6 +879,7 @@ export class SelectionPlugin extends BasePlugin< private updateRectsAndSlices(documentId: string, range: SelectionRangeX) { const docState = this.getDocumentState(documentId); const allRects: Record = {}; + const allQuads: Record = {}; const allSlices: Record = {}; for (let p = range.start.page; p <= range.end.page; p++) { @@ -879,11 +887,14 @@ export class SelectionPlugin extends BasePlugin< const sb = sliceBounds(range, geo, p); if (!sb) continue; - allRects[p] = rectsWithinSlice(geo!, sb.from, sb.to); + const { quads, rects } = quadsWithinSlice(geo!, sb.from, sb.to); + allRects[p] = rects; + allQuads[p] = quads; allSlices[p] = { start: sb.from, count: sb.to - sb.from + 1 }; } this.dispatch(setRects(documentId, allRects)); + this.dispatch(setQuads(documentId, allQuads)); this.dispatch(setSlices(documentId, allSlices)); } diff --git a/packages/plugin-selection/src/lib/selectors.ts b/packages/plugin-selection/src/lib/selectors.ts index 3ce0d8f70..835c7f37d 100644 --- a/packages/plugin-selection/src/lib/selectors.ts +++ b/packages/plugin-selection/src/lib/selectors.ts @@ -1,6 +1,10 @@ -import { Rect, boundingRect } from '@embedpdf/models'; +import { Rect, Quad, boundingRect } from '@embedpdf/models'; import { FormattedSelection, SelectionDocumentState } from './types'; +export function selectQuadsForPage(state: SelectionDocumentState, page: number) { + return state.quads[page] ?? []; +} + export function selectRectsForPage(state: SelectionDocumentState, page: number) { return state.rects[page] ?? []; } @@ -33,10 +37,16 @@ export function getFormattedSelectionForPage( page: number, ): FormattedSelection | null { const segmentRects = state.rects[page] || []; + const segmentQuads = state.quads[page] || []; if (segmentRects.length === 0) return null; const boundingRect = selectBoundingRectForPage(state, page); if (!boundingRect) return null; - return { pageIndex: page, rect: boundingRect, segmentRects }; + return { + pageIndex: page, + rect: boundingRect, + segmentRects, + ...(segmentQuads.length > 0 && { segmentQuads }), + }; } export function getFormattedSelection(state: SelectionDocumentState) { @@ -47,6 +57,7 @@ export function getFormattedSelection(state: SelectionDocumentState) { for (const pageIndex of pages) { const segmentRects = state.rects[pageIndex] || []; + const segmentQuads = state.quads[pageIndex] || []; if (segmentRects.length === 0) continue; @@ -58,6 +69,7 @@ export function getFormattedSelection(state: SelectionDocumentState) { pageIndex, rect: boundingRect, segmentRects, + ...(segmentQuads.length > 0 && { segmentQuads }), }); } } diff --git a/packages/plugin-selection/src/lib/types.ts b/packages/plugin-selection/src/lib/types.ts index 09358557f..1c1fa3ba5 100644 --- a/packages/plugin-selection/src/lib/types.ts +++ b/packages/plugin-selection/src/lib/types.ts @@ -1,5 +1,11 @@ import { BasePluginConfig, EventHook } from '@embedpdf/core'; -import { PdfPageGeometry, PdfTask, Rect, Size } from '@embedpdf/models'; +import { + PdfPageGeometry, + PdfTask, + Quad, + Rect, + Size, +} from '@embedpdf/models'; export interface MarqueeSelectionConfig { /** Minimum drag distance in pixels before considering it a marquee */ @@ -64,6 +70,8 @@ export interface SelectionDocumentState { geometry: Record; /** current selection or null */ rects: Record; + /** oriented segment quads per page (authoritative when present) */ + quads: Record; selection: SelectionRangeX | null; slices: Record; active: boolean; @@ -78,6 +86,7 @@ export interface FormattedSelection { pageIndex: number; rect: Rect; segmentRects: Rect[]; + segmentQuads?: Quad[]; } export interface SelectionRectsCallback { @@ -238,6 +247,7 @@ export interface SelectionScope { getFormattedSelection(): FormattedSelection[]; getFormattedSelectionForPage(page: number): FormattedSelection | null; getHighlightRectsForPage(page: number): Rect[]; + getHighlightQuadsForPage(page: number): Quad[]; getHighlightRects(): Record; getBoundingRectForPage(page: number): Rect | null; getBoundingRects(): { page: number; rect: Rect }[]; @@ -269,6 +279,7 @@ export interface SelectionCapability { getFormattedSelection(documentId?: string): FormattedSelection[]; getFormattedSelectionForPage(page: number, documentId?: string): FormattedSelection | null; getHighlightRectsForPage(page: number, documentId?: string): Rect[]; + getHighlightQuadsForPage(page: number, documentId?: string): Quad[]; getHighlightRects(documentId?: string): Record; getBoundingRectForPage(page: number, documentId?: string): Rect | null; getBoundingRects(documentId?: string): { page: number; rect: Rect }[]; diff --git a/packages/plugin-selection/src/lib/utils.quads.test.ts b/packages/plugin-selection/src/lib/utils.quads.test.ts new file mode 100644 index 000000000..6131c445c --- /dev/null +++ b/packages/plugin-selection/src/lib/utils.quads.test.ts @@ -0,0 +1,68 @@ +import { + buildSegmentQuadFromGlyphQuads, + PdfGlyphSlim, + PdfPageGeometry, + PdfRun, + rectToQuad, +} from '@embedpdf/models'; +import { quadsWithinSlice } from '../utils'; + +function makeGlyph( + index: number, + x: number, + width: number, + matrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, +): PdfGlyphSlim { + const quad = rectToQuad({ + origin: { x, y: 0 }, + size: { width, height: 10 }, + }); + return { + x, + y: 0, + width, + height: 10, + flags: 0, + matrix, + pageOrigin: { x, y: 0 }, + quad, + }; +} + +function makeGeometry(glyphs: PdfGlyphSlim[]): PdfPageGeometry { + const run: PdfRun = { + rect: { x: 0, y: 0, width: 100, height: 10 }, + charStart: 0, + glyphs, + fontSize: 10, + }; + return { runs: [run] }; +} + +describe('quadsWithinSlice', () => { + test('builds one oriented segment quad for horizontal selection', () => { + const geo = makeGeometry([makeGlyph(0, 0, 5), makeGlyph(1, 5, 5), makeGlyph(2, 10, 5)]); + const { quads, rects } = quadsWithinSlice(geo, 0, 2, false); + + expect(quads).toHaveLength(1); + expect(rects).toHaveLength(1); + expect(rects[0].size.width).toBe(15); + expect(quads[0].p1.x).toBe(0); + expect(quads[0].p2.x).toBe(15); + }); + + test('splits segments when baseline direction changes', () => { + const horizontal = makeGlyph(0, 0, 5); + const verticalMatrix = { a: 0, b: 1, c: -1, d: 0, e: 0, f: 0 }; + const vertical = makeGlyph(1, 20, 5, verticalMatrix); + vertical.quad = buildSegmentQuadFromGlyphQuads( + rectToQuad({ origin: { x: 20, y: 0 }, size: { width: 10, height: 5 } }), + rectToQuad({ origin: { x: 20, y: 0 }, size: { width: 10, height: 5 } }), + ); + + const geo = makeGeometry([horizontal, vertical]); + const { quads } = quadsWithinSlice(geo, 0, 1, false); + + expect(quads.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/plugin-selection/src/lib/utils.ts b/packages/plugin-selection/src/lib/utils.ts index 179caaad5..966436731 100644 --- a/packages/plugin-selection/src/lib/utils.ts +++ b/packages/plugin-selection/src/lib/utils.ts @@ -1,4 +1,16 @@ -import { PdfPageGeometry, PdfRun, Position, Rect } from '@embedpdf/models'; +import { + buildSegmentQuadFromGlyphQuads, + matricesCompatible, + matrixBaselineDirection, + PdfPageGeometry, + PdfRun, + Position, + projectOnDirection, + Quad, + quadToRect, + quadsToRects, + Rect, +} from '@embedpdf/models'; import { SelectionRangeX } from './types'; /** @@ -143,19 +155,15 @@ export function sliceBounds( } /** - * Helper: build rects for a slice of the page - * @param geo - page geometry - * @param from - from index - * @param to - to index - * @param merge - whether to merge adjacent rects (default: true) - * @returns rects + * Helper: build oriented quads and derived rects for a slice of the page. */ -export function rectsWithinSlice( +export function quadsWithinSlice( geo: PdfPageGeometry, from: number, to: number, merge: boolean = true, -): Rect[] { +): { quads: Quad[]; rects: Rect[] } { + const segmentQuads: Quad[] = []; const textRuns: TextRunInfo[] = []; const CHAR_DISTANCE_FACTOR = 2.5; @@ -168,66 +176,153 @@ export function rectsWithinSlice( const sIdx = Math.max(from, runStart) - runStart; const eIdx = Math.min(to, runEnd) - runStart; - let minX = Infinity, - maxX = -Infinity; - let minY = Infinity, - maxY = -Infinity; - let charCount = 0; + let segmentGlyphs: Array<(typeof run.glyphs)[number]> = []; let widthSum = 0; - let prevRight = -Infinity; + let prevTrailing = -Infinity; + let segmentOrigin: Position | null = null; + let segmentDir: Position | null = null; + + const flushSegment = () => { + if (segmentGlyphs.length === 0) return; - const flushSubRun = () => { - if (minX !== Infinity && charCount > 0) { + const first = segmentGlyphs[0]; + const last = segmentGlyphs[segmentGlyphs.length - 1]; + + if (first.quad && last.quad) { + const quad = buildSegmentQuadFromGlyphQuads(first.quad, last.quad); + segmentQuads.push(quad); textRuns.push({ - rect: { - origin: { x: minX, y: minY }, - size: { width: maxX - minX, height: maxY - minY }, - }, - charCount, + rect: quadToRect(quad), + charCount: segmentGlyphs.length, fontSize: run.fontSize, }); + } else { + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + + for (const g of segmentGlyphs) { + minX = Math.min(minX, g.x); + maxX = Math.max(maxX, g.x + g.width); + minY = Math.min(minY, g.y); + maxY = Math.max(maxY, g.y + g.height); + } + + if (minX !== Infinity) { + const rect = { + origin: { x: minX, y: minY }, + size: { width: maxX - minX, height: maxY - minY }, + }; + textRuns.push({ + rect, + charCount: segmentGlyphs.length, + fontSize: run.fontSize, + }); + } } - minX = Infinity; - maxX = -Infinity; - minY = Infinity; - maxY = -Infinity; - charCount = 0; + + segmentGlyphs = []; widthSum = 0; - prevRight = -Infinity; + prevTrailing = -Infinity; + segmentOrigin = null; + segmentDir = null; }; for (let i = sIdx; i <= eIdx; i++) { const g = run.glyphs[i]; if (g.flags === 2) continue; - if (charCount > 0 && prevRight > -Infinity) { - const gap = Math.abs(g.x - prevRight); - const avgWidth = widthSum / charCount; - if (avgWidth > 0 && gap > CHAR_DISTANCE_FACTOR * avgWidth) { - flushSubRun(); + const dir = g.matrix ? matrixBaselineDirection(g.matrix) : { x: 1, y: 0 }; + const origin = g.pageOrigin ?? { x: g.x, y: g.y + g.height }; + + if (segmentGlyphs.length > 0) { + const prev = segmentGlyphs[segmentGlyphs.length - 1]; + const compatible = + !prev.matrix || !g.matrix || matricesCompatible(prev.matrix, g.matrix); + + if (!compatible) { + flushSegment(); + } else if (prevTrailing > -Infinity && segmentDir) { + const leading = projectOnDirection( + { x: g.x, y: g.y }, + segmentOrigin!, + segmentDir, + ); + const gap = Math.abs(leading - prevTrailing); + const avgWidth = widthSum / segmentGlyphs.length; + if (avgWidth > 0 && gap > CHAR_DISTANCE_FACTOR * avgWidth) { + flushSegment(); + } } } - minX = Math.min(minX, g.x); - maxX = Math.max(maxX, g.x + g.width); - minY = Math.min(minY, g.y); - maxY = Math.max(maxY, g.y + g.height); + if (segmentGlyphs.length === 0) { + segmentOrigin = origin; + segmentDir = dir; + } - charCount++; + segmentGlyphs.push(g); widthSum += g.width; - prevRight = g.x + g.width; + + if (g.quad) { + prevTrailing = Math.max( + projectOnDirection(g.quad.p2, segmentOrigin!, segmentDir!), + projectOnDirection(g.quad.p3, segmentOrigin!, segmentDir!), + ); + } else { + prevTrailing = projectOnDirection( + { x: g.x + g.width, y: g.y + g.height / 2 }, + segmentOrigin!, + segmentDir!, + ); + } } - flushSubRun(); + flushSegment(); } - // If merge is false, just return the individual rects if (!merge) { - return textRuns.map((run) => run.rect); + return { + quads: segmentQuads, + rects: textRuns.map((run) => run.rect), + }; + } + + const mergedRects = mergeAdjacentRects(textRuns); + if (segmentQuads.length === textRuns.length) { + return { quads: segmentQuads, rects: mergedRects }; } - // Otherwise merge adjacent rects - return mergeAdjacentRects(textRuns); + return { + quads: segmentQuads, + rects: quadsToRects(segmentQuads.length > 0 ? segmentQuads : textRuns.map((r) => ({ + p1: { x: r.rect.origin.x, y: r.rect.origin.y }, + p2: { x: r.rect.origin.x + r.rect.size.width, y: r.rect.origin.y }, + p3: { + x: r.rect.origin.x + r.rect.size.width, + y: r.rect.origin.y + r.rect.size.height, + }, + p4: { x: r.rect.origin.x, y: r.rect.origin.y + r.rect.size.height }, + }))), + }; +} + +/** + * Helper: build rects for a slice of the page + * @param geo - page geometry + * @param from - from index + * @param to - to index + * @param merge - whether to merge adjacent rects (default: true) + * @returns rects + */ +export function rectsWithinSlice( + geo: PdfPageGeometry, + from: number, + to: number, + merge: boolean = true, +): Rect[] { + return quadsWithinSlice(geo, from, to, merge).rects; } /** From 680c995e10baf97d6698b19de2a7ba87e1781f83 Mon Sep 17 00:00:00 2001 From: Andrii Yusyp Date: Wed, 15 Jul 2026 14:21:41 +0200 Subject: [PATCH 2/2] fix: "replace annotation" tool for rotated pages text selection --- packages/models/src/geometry.test.ts | 31 ++++++++++ packages/models/src/geometry.ts | 41 ++++++++++++- .../src/lib/handlers/insert-text.handler.ts | 10 +++- .../src/lib/handlers/replace-text.handler.ts | 10 +++- .../src/lib/handlers/selection-utils.test.ts | 50 ++++++++++++++++ .../src/lib/handlers/selection-utils.ts | 57 +++++++++++++++---- 6 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 packages/plugin-annotation/src/lib/handlers/selection-utils.test.ts diff --git a/packages/models/src/geometry.test.ts b/packages/models/src/geometry.test.ts index 14eb7395a..e84f0aeee 100644 --- a/packages/models/src/geometry.test.ts +++ b/packages/models/src/geometry.test.ts @@ -14,6 +14,10 @@ import { quadToRect, getQuadBottomEdge, getQuadMidline, + getQuadInkExtent, + getQuadBaselineAngleDegrees, + getQuadBaselineEnd, + getRectBottomCenter, } from './geometry'; describe('Geometry', () => { @@ -237,5 +241,32 @@ describe('Oriented quad helpers', () => { start: { x: 0, y: 2 }, end: { x: 10, y: 2 }, }); + expect(getQuadBaselineEnd(quad)).toEqual({ x: 10, y: 4 }); + expect(getQuadInkExtent(quad)).toBeCloseTo(4, 5); + expect(getQuadBaselineAngleDegrees(quad)).toBeCloseTo(0, 5); + }); + + test('ink extent and baseline angle follow oriented glyph quads', () => { + const quad = orientedQuadFromPageBoxAndMatrix(0, 20, 10, 0, { + a: 0, + b: 1, + c: -1, + d: 0, + e: 0, + f: 0, + }); + + expect(getQuadInkExtent(quad)).toBeCloseTo(10, 5); + expect(quadToRect(quad).size.height).toBeCloseTo(20, 5); + expect(getQuadBaselineAngleDegrees(quad)).toBeCloseTo(90, 5); + }); + + test('getRectBottomCenter returns the bottom edge midpoint', () => { + expect( + getRectBottomCenter({ + origin: { x: 4, y: 6 }, + size: { width: 8, height: 10 }, + }), + ).toEqual({ x: 8, y: 16 }); }); }); diff --git a/packages/models/src/geometry.ts b/packages/models/src/geometry.ts index 04981720a..2c3ae1199 100644 --- a/packages/models/src/geometry.ts +++ b/packages/models/src/geometry.ts @@ -974,12 +974,49 @@ export function getQuadMidline(q: Quad): { start: Position; end: Position } { } /** - * Baseline endpoint at the trailing edge of a segment quad. + * Trailing baseline corner of a segment quad (end of the bottom edge in reading order). * * @public */ export function getQuadBaselineEnd(q: Quad): Position { - return { x: (q.p2.x + q.p3.x) / 2, y: (q.p2.y + q.p3.y) / 2 }; + return q.p3; +} + +/** + * Perpendicular ink height of a quad (distance between top and bottom edges). + * + * @public + */ +export function getQuadInkExtent(q: Quad): number { + const midTop = { x: (q.p1.x + q.p2.x) / 2, y: (q.p1.y + q.p2.y) / 2 }; + const midBottom = { x: (q.p4.x + q.p3.x) / 2, y: (q.p4.y + q.p3.y) / 2 }; + return Math.hypot(midTop.x - midBottom.x, midTop.y - midBottom.y); +} + +/** + * Baseline direction angle in degrees, measured counter-clockwise from +X. + * Matches the rotation convention used by {@link calculateRotatedRectAABBAroundPoint}. + * + * @public + */ +export function getQuadBaselineAngleDegrees(q: Quad): number { + const { start, end } = getQuadBottomEdge(q); + const dx = end.x - start.x; + const dy = end.y - start.y; + if (Math.hypot(dx, dy) < 1e-10) return 0; + return (Math.atan2(dy, dx) * 180) / Math.PI; +} + +/** + * Bottom-center point of a rectangle (baseline attachment for carets). + * + * @public + */ +export function getRectBottomCenter(rect: Rect): Position { + return { + x: rect.origin.x + rect.size.width / 2, + y: rect.origin.y + rect.size.height, + }; } /** diff --git a/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts b/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts index 85cb30163..20dcaea1e 100644 --- a/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts +++ b/packages/plugin-annotation/src/lib/handlers/insert-text.handler.ts @@ -1,6 +1,6 @@ import { PdfAnnotationSubtype, PdfCaretAnnoObject, uuidV4 } from '@embedpdf/models'; import { SelectionHandlerFactory } from './types'; -import { computeCaretRect } from './selection-utils'; +import { computeCaretGeometry } from './selection-utils'; /** * Selection handler for the "Insert Text" tool. @@ -24,7 +24,7 @@ export const insertTextSelectionHandler: SelectionHandlerFactory { + test('uses true ink height and baseline anchor for horizontal text', () => { + const segmentRect = { origin: { x: 0, y: 0 }, size: { width: 20, height: 12 } }; + const segmentQuad = rectToQuad(segmentRect); + const geometry = computeCaretGeometry(segmentRect, segmentQuad); + const baselineEnd = getQuadBaselineEnd(segmentQuad); + + expect(geometry.rotation).toBeUndefined(); + expect(geometry.unrotatedRect).toBeUndefined(); + expect(geometry.rect.size).toEqual({ width: 6, height: 6 }); + expect(getRectBottomCenter(geometry.rect)).toEqual(baselineEnd); + }); + + test('derives rotation and AABB for vertically oriented text', () => { + const segmentQuad = orientedQuadFromPageBoxAndMatrix(0, 20, 10, 0, { + a: 0, + b: 1, + c: -1, + d: 0, + e: 0, + f: 0, + }); + const segmentRect = quadToRect(segmentQuad); + const geometry = computeCaretGeometry(segmentRect, segmentQuad); + const baselineEnd = getQuadBaselineEnd(segmentQuad); + + expect(geometry.rotation).toBeCloseTo(90, 5); + expect(geometry.unrotatedRect?.size).toEqual({ width: 5, height: 5 }); + expect(getRectBottomCenter(geometry.unrotatedRect!)).toEqual(baselineEnd); + expect(geometry.rect.size.width).toBeGreaterThan(geometry.unrotatedRect!.size.width); + expect(geometry.rect.size.height).toBeGreaterThan(geometry.unrotatedRect!.size.height); + }); + + test('falls back to axis-aligned quad when segment quads are missing', () => { + const segmentRect = { origin: { x: 5, y: 8 }, size: { width: 12, height: 4 } }; + const geometry = computeCaretGeometry(segmentRect); + + expect(geometry.rect.size).toEqual({ width: 2, height: 2 }); + expect(geometry.rotation).toBeUndefined(); + }); +}); diff --git a/packages/plugin-annotation/src/lib/handlers/selection-utils.ts b/packages/plugin-annotation/src/lib/handlers/selection-utils.ts index 890eb26ed..912c86412 100644 --- a/packages/plugin-annotation/src/lib/handlers/selection-utils.ts +++ b/packages/plugin-annotation/src/lib/handlers/selection-utils.ts @@ -1,21 +1,56 @@ -import { getQuadBaselineEnd, Quad, Rect, rectToQuad } from '@embedpdf/models'; +import { + calculateRotatedRectAABBAroundPoint, + getQuadBaselineAngleDegrees, + getQuadBaselineEnd, + getQuadInkExtent, + getRectBottomCenter, + Quad, + Rect, + rectToQuad, +} from '@embedpdf/models'; + +export interface CaretGeometry { + rect: Rect; + unrotatedRect?: Rect; + rotation?: number; +} /** - * Compute a caret annotation rect at the end of a text selection. - * Uses the oriented segment quad baseline when available. + * Compute caret annotation geometry at the end of a text selection. + * Uses oriented segment quads for size, anchor, and rotation on rotated pages. */ -export function computeCaretRect(lastSegRect: Rect, lastSegQuad?: Quad): Rect { +export function computeCaretGeometry(lastSegRect: Rect, lastSegQuad?: Quad): CaretGeometry { const quad = lastSegQuad ?? rectToQuad(lastSegRect); const baselineEnd = getQuadBaselineEnd(quad); - const lineHeight = lastSegRect.size.height; - const height = lineHeight / 2; - const width = height; + const inkExtent = getQuadInkExtent(quad); + const size = Math.max(inkExtent / 2, 0.5); + const rotation = getQuadBaselineAngleDegrees(quad); - return { + const unrotatedRect: Rect = { origin: { - x: baselineEnd.x - width / 2, - y: baselineEnd.y - height, + x: baselineEnd.x - size / 2, + y: baselineEnd.y - size, }, - size: { width, height }, + size: { width: size, height: size }, + }; + + if (Math.abs(rotation) < 1e-6 || Math.abs(Math.abs(rotation) - 360) < 1e-6) { + return { rect: unrotatedRect }; + } + + const pivot = getRectBottomCenter(unrotatedRect); + const rect = calculateRotatedRectAABBAroundPoint(unrotatedRect, rotation, pivot); + + return { + rect, + unrotatedRect, + rotation, }; } + +/** + * @deprecated Use {@link computeCaretGeometry} for oriented caret placement. + */ +export function computeCaretRect(lastSegRect: Rect, lastSegQuad?: Quad): Rect { + return computeCaretGeometry(lastSegRect, lastSegQuad).rect; +}