From 5e1902c12524230c62f8eed3eac2f421fbad74bb Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Fri, 15 Oct 2021 23:48:38 +0900 Subject: [PATCH 01/51] chore: add pdfjs-dist typings --- package.json | 1 + .../components/PdfViewer/PdfViewer.tsx | 20 +++++++++---------- .../components/PdfViewer/typings.d.ts | 1 - yarn.lock | 5 +++++ 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index f558ed0cb..7ccfb06b0 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/lodash": "^4.14.141", "@types/mustache": "^0.8.32", "@types/node": "^12.7.3", + "@types/pdfjs-dist": "^2.7.5", "@types/react": "^16.9.2", "@types/react-dom": "^16.9.0", "@types/react-resize-detector": "^4.2.0", diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index 753f263e1..9d4357de1 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -1,5 +1,5 @@ -import React, { SFC, useEffect, useRef, useState, useMemo } from 'react'; -import PdfjsLib from 'pdfjs-dist'; +import React, { FC, useEffect, useRef, useState, useMemo } from 'react'; +import PdfjsLib, { PDFDocumentProxy, PDFPageProxy, PDFPageViewport, PDFSource } from 'pdfjs-dist'; import PdfjsWorkerAsText from 'pdfjs-dist/build/pdf.worker.min.js'; import { settings } from 'carbon-components'; @@ -35,7 +35,7 @@ interface Props { setHideToolbarControls?: (disabled: boolean) => void; } -const PdfViewer: SFC = ({ +const PdfViewer: FC = ({ file, page, scale, @@ -46,8 +46,8 @@ const PdfViewer: SFC = ({ const canvasRef = useRef(null); // In order to prevent unnecessary re-loading, loaded file and page are stored in state - const [loadedFile, setLoadedFile] = useState(null); - const [loadedPage, setLoadedPage] = useState(null); + const [loadedFile, setLoadedFile] = useState(null); + const [loadedPage, setLoadedPage] = useState(null); useEffect(() => { let didCancel = false; @@ -95,7 +95,7 @@ const PdfViewer: SFC = ({ }, [loadedPage, scale]); useEffect(() => { - if (loadedPage && !loadedPage.then && viewport && canvasInfo) { + if (loadedPage && !(loadedPage as any).then && viewport && canvasInfo) { _renderPage(loadedPage, canvasRef.current!, viewport, canvasInfo); setLoading(false); } @@ -127,14 +127,14 @@ function _loadPdf(data: string): Promise { return PdfjsLib.getDocument({ data }).promise; } -function _loadPage(file: any, page: number): Promise { +function _loadPage(file: PDFDocumentProxy, page: number) { return file.getPage(page); } function _renderPage( - pdfPage: any, + pdfPage: PDFPageProxy, canvas: HTMLCanvasElement, - viewport: any, + viewport: PDFPageViewport, canvasInfo: CanvasInfo ): void { const canvasContext = canvas.getContext('2d'); @@ -148,7 +148,7 @@ function _renderPage( function setupPdfjs(): void { if (typeof Worker !== 'undefined') { const blob = new Blob([PdfjsWorkerAsText], { type: 'text/javascript' }); - const pdfjsWorker = new Worker(URL.createObjectURL(blob)); + const pdfjsWorker = new Worker(URL.createObjectURL(blob)) as any; // TODO is this usage correct? PdfjsLib.GlobalWorkerOptions.workerPort = pdfjsWorker; } else { PdfjsLib.GlobalWorkerOptions.workerSrc = PdfjsWorkerAsText; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts index fa31151b5..21a04c613 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts @@ -1,2 +1 @@ -declare module 'pdfjs-dist'; declare module 'pdfjs-dist/build/pdf.worker.min.js'; diff --git a/yarn.lock b/yarn.lock index b99dd926d..c29f30314 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4615,6 +4615,11 @@ resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== +"@types/pdfjs-dist@^2.7.5": + version "2.7.5" + resolved "https://registry.yarnpkg.com/@types/pdfjs-dist/-/pdfjs-dist-2.7.5.tgz#53fc13e30b6bd18acdb3617fb6332a43225e0ffa" + integrity sha512-QdKChstEcREV+imniONC3JvSSFOeYv0RL12QOpzSfTpGg7xRAZcJXpWdJEtuH6FAPXX5V+sp9i/sBHJ9bsU7JA== + "@types/prop-types@*": version "15.7.3" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" From 824c2d7e16de6e333645921550be12c7f02a3679 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Tue, 9 Nov 2021 16:04:37 +0900 Subject: [PATCH 02/51] fix: control PDF rendering tasks properly --- .../components/PdfViewer/PdfViewer.tsx | 66 +++++++++++++++---- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index 9d4357de1..5ab2ee420 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -1,8 +1,15 @@ import React, { FC, useEffect, useRef, useState, useMemo } from 'react'; -import PdfjsLib, { PDFDocumentProxy, PDFPageProxy, PDFPageViewport, PDFSource } from 'pdfjs-dist'; +import PdfjsLib, { + PDFDocumentProxy, + PDFPageProxy, + PDFPageViewport, + PDFRenderTask +} from 'pdfjs-dist'; import PdfjsWorkerAsText from 'pdfjs-dist/build/pdf.worker.min.js'; import { settings } from 'carbon-components'; +const { RenderingCancelledException } = PdfjsLib as any; + setupPdfjs(); interface Props { @@ -88,18 +95,45 @@ const PdfViewer: FC = ({ }; }, [loadedFile, page]); - const [viewport, canvasInfo] = useMemo(() => { - const viewport = loadedPage?.getViewport({ scale }); - const canvasInfo = viewport ? getCanvasInfo(viewport) : undefined; - return [viewport, canvasInfo]; - }, [loadedPage, scale]); + const currentPage = useMemo(() => { + const isPageValid = !!loadedPage && loadedPage.pageNumber === page; + if (isPageValid) { + const viewport = loadedPage?.getViewport({ scale }); + const canvasInfo = viewport ? getCanvasInfo(viewport) : undefined; + return { loadedPage, viewport, canvasInfo }; + } + return null; + }, [loadedPage, page, scale]); useEffect(() => { + let didCancel = false; + let task: PDFRenderTask | null = null; + + const { loadedPage, viewport, canvasInfo } = currentPage || {}; if (loadedPage && !(loadedPage as any).then && viewport && canvasInfo) { - _renderPage(loadedPage, canvasRef.current!, viewport, canvasInfo); - setLoading(false); + const render = async () => { + try { + task = _renderPage(loadedPage, canvasRef.current!, viewport, canvasInfo); + await task?.promise; + } catch (e) { + if (e instanceof RenderingCancelledException) { + // ignore + } else { + throw e; // rethrow unknown exception + } + } finally { + if (!didCancel) { + setLoading(false); + } + } + }; + render(); } - }, [loadedPage, viewport, canvasInfo, setLoading]); + return () => { + didCancel = true; + task?.cancel(); + }; + }, [loadedPage, currentPage, setLoading]); useEffect(() => { if (setHideToolbarControls) { @@ -107,6 +141,7 @@ const PdfViewer: FC = ({ } }, [setHideToolbarControls]); + const { canvasInfo } = currentPage || {}; return ( Date: Wed, 20 Oct 2021 13:47:13 +0900 Subject: [PATCH 03/51] feat: add pdf text layer support --- .../PdfViewer/PdfViewer.stories.tsx | 18 +- .../components/PdfViewer/PdfViewer.tsx | 54 ++++- .../PdfViewer/PdfViewerTextLayer.tsx | 207 ++++++++++++++++++ .../components/PdfViewer/typings.d.ts | 53 +++++ .../_document-preview-pdf-viewer.scss | 58 +++++ 5 files changed, 379 insertions(+), 11 deletions(-) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx index e6cdc54b1..6e7b31d12 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { withKnobs, radios, number } from '@storybook/addon-knobs'; +import { withKnobs, radios, number, boolean } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; import PdfViewer from './PdfViewer'; import { document as doc } from 'components/DocumentPreview/__fixtures__/Art Effects.pdf'; @@ -32,6 +33,19 @@ storiesOf('DocumentPreview/components/PdfViewer', module) const zoom = radios(zoomKnob.label, zoomKnob.options, zoomKnob.defaultValue); const scale = parseFloat(zoom); + const showTextLayer = boolean('Show text layer', false); - return {}} />; + const setLoadingAction = action('setLoading'); + const setTextLayerInfoAction = action('setTextLayerInfo'); + + return ( + + ); }); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index 5ab2ee420..d0ee15607 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -1,4 +1,5 @@ import React, { FC, useEffect, useRef, useState, useMemo } from 'react'; +import cx from 'classnames'; import PdfjsLib, { PDFDocumentProxy, PDFPageProxy, @@ -7,12 +8,15 @@ import PdfjsLib, { } from 'pdfjs-dist'; import PdfjsWorkerAsText from 'pdfjs-dist/build/pdf.worker.min.js'; import { settings } from 'carbon-components'; +import PdfViewerTextLayer, { PdfTextLayerInfo } from './PdfViewerTextLayer'; const { RenderingCancelledException } = PdfjsLib as any; setupPdfjs(); interface Props { + className?: string; + /** * PDF file data as base64-encoded string */ @@ -28,6 +32,16 @@ interface Props { */ scale: number; + /** + * Render text layer + */ + showTextLayer?: boolean; + + /** + * Text layer class name. Only applicable when showTextLayer is true + */ + textLayerClassName?: string; + /** * Callback invoked with page count, once `file` has been parsed */ @@ -40,15 +54,24 @@ interface Props { * Callback which is invoked with whether to enable/disable toolbar controls */ setHideToolbarControls?: (disabled: boolean) => void; + /** + * Callback for text layer info + */ + setTextLayerInfo?: (info: PdfTextLayerInfo | null) => any; } const PdfViewer: FC = ({ + className, file, page, scale, + showTextLayer, + textLayerClassName, setPageCount, setLoading, - setHideToolbarControls + setHideToolbarControls, + setTextLayerInfo, + children }) => { const canvasRef = useRef(null); @@ -141,15 +164,28 @@ const PdfViewer: FC = ({ } }, [setHideToolbarControls]); - const { canvasInfo } = currentPage || {}; + const classNameBase = `${settings.prefix}--document-preview-pdf-viewer`; + const { loadedPage: currentLoadedPage, canvasInfo } = currentPage || {}; return ( - +
+ + {showTextLayer && ( + + )} + {children} +
); }; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx new file mode 100644 index 000000000..f6c38f08c --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx @@ -0,0 +1,207 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import cx from 'classnames'; +import PDFJSLib, { PDFPageProxy, PDFPageViewport, TextContent, TextContentItem } from 'pdfjs-dist'; +import { EventBus } from 'pdfjs-dist/lib/web/ui_utils'; +import { TextLayerBuilder } from 'pdfjs-dist/lib/web/text_layer_builder'; + +const { RenderingCancelledException } = PDFJSLib as any; + +interface Props { + className?: string; + + /** + * PDF page from pdfjs + */ + loadedPage: PDFPageProxy | null | undefined; + + /** + * Page number, starting at 1 + */ + page: number; + + /** + * Zoom factor, where `1` is equal to 100% + */ + scale: number; + + /** + * Callback for text layer info + */ + setTextLayerInfo?: (info: PdfTextLayerInfo | null) => any; +} + +export type PdfTextLayerInfo = { + /** + * PDF text content + */ + textContent: TextContent & { + styles: { [styleName: string]: CSSStyleDeclaration }; + }; + + /** + * Text span DOM elements rendered on the text layer + */ + textDivs: HTMLElement[]; + + /** + * Pdf page viewport used to render text items + */ + viewport: PDFPageViewport; + + /** + * Page number, starting at 1 + */ + page: number; +}; + +const PdfViewerTextLayer: FC = ({ + className, + loadedPage, + page = 1, + scale = 1, + setTextLayerInfo: setTextLayerInfoCallback = () => {} +}) => { + const textLayerRef = useRef(null); + const textLayerDiv = textLayerRef.current; + + const [textRenderInfo, setTextRenderInfo] = useState<{ + page: number; + scale: number; + textContent: TextContent; + viewport: PDFPageViewport; + } | null>(null); + + useEffect(() => { + async function loadTextInfo() { + const isPageReady = !!loadedPage && loadedPage.pageNumber === page; + if (isPageReady) { + const viewport = loadedPage.getViewport({ scale }); + const textContent = await loadedPage.getTextContent(); + setTextRenderInfo({ textContent, viewport, page, scale }); + } + } + loadTextInfo(); + }, [loadedPage, page, scale]); + + const textLayerBuilderRef = useRef(null); // ref for debugging purpose + const [textLayerInfo, setTextLayerInfo] = useState(null); + useEffect(() => { + let textLayerBuilder: TextLayerBuilder | null = null; + async function loadTextLayer() { + let textLayerInfo: PdfTextLayerInfo | null = null; + + if (textLayerDiv && textRenderInfo) { + const { textContent, viewport, scale, page } = textRenderInfo; + // prepare text layer + textLayerBuilder = textLayerBuilderRef.current = new TextLayerBuilder({ + textLayerDiv, + viewport, + eventBus: new EventBus(), + pageIndex: page - 1 + }); + textLayerBuilder.setTextContent(textContent); + + // render + textLayerDiv.innerHTML = ''; + try { + const deferredRenderEnd = (() => { + let resolve: null | Function = null; + const promise = new Promise(resolvePromise => { + resolve = resolvePromise; + }); + + const listener = () => { + resolve!(); + textLayerBuilder?.eventBus.off('textlayerrendered', listener); + }; + textLayerBuilder.eventBus.on('textlayerrendered', listener); + + return { promise }; + })(); + + textLayerBuilder.render(); + await deferredRenderEnd.promise; + + // fix text divs + _adjustTextDivs(textLayerBuilder.textDivs, textContent.items, scale); + + textLayerInfo = { + textContent, + textDivs: textLayerBuilder.textDivs, + viewport, + page + }; + } catch (e) { + if (e instanceof RenderingCancelledException) { + // ignore + return; + } else { + throw e; + } + } + } + setTextLayerInfo(textLayerInfo); + } + loadTextLayer(); + + return () => { + textLayerBuilder?.cancel(); + // should we set text items?? + }; + }, [textLayerDiv, textRenderInfo]); + + useEffect(() => { + setTextLayerInfoCallback(textLayerInfo); + }, [textLayerInfo, setTextLayerInfoCallback]); + + const rootClassName = cx(className, `textLayer`); + return ( +
+ ); +}; + +/** + * Adjust text span width + * @param textDivs + * @param textItems + * @param scale + */ +function _adjustTextDivs( + textDivs: HTMLElement[], + textItems: TextContentItem[] | null, + scale: number +): void { + const scaleXPattern = /scaleX\(([\d.]+)\)/; + (textDivs || []).forEach((textDivElm, index) => { + const textItem = textItems?.[index]; + if (!textItem) return; + + const expectedWidth = textItem.width * scale; + const actualWidth = textDivElm.getBoundingClientRect().width; + + function getScaleX(element: HTMLElement) { + const match = element.style.transform?.match(scaleXPattern); + if (match) { + return parseFloat(match[1]); + } + return null; + } + const currentScaleX = getScaleX(textDivElm); + if (currentScaleX && !isNaN(currentScaleX)) { + const newScale = `scaleX(${(expectedWidth / actualWidth) * currentScaleX})`; + textDivElm.style.transform = textDivElm.style.transform.replace(scaleXPattern, newScale); + } else { + const newScale = `scaleX(${expectedWidth / actualWidth})`; + textDivElm.style.transform = newScale; + } + }); +} + +export default PdfViewerTextLayer; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts index 21a04c613..44544e96f 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/typings.d.ts @@ -1 +1,54 @@ declare module 'pdfjs-dist/build/pdf.worker.min.js'; + +// +// Declare modules and their types that is referred from PDF text layer rendering. +// Unused properties are commented out +// +declare module 'pdfjs-dist/lib/web/ui_utils' { + export class EventBus { + on(eventName: string, listener: any): void; + off(eventName: string, listener: any): void; + dispatch(eventName: string, args?: any): void; + } + // export function getGlobalEventBus(): EventBus; +} + +declare module 'pdfjs-dist/lib/web/text_layer_builder' { + import { EventBus } from 'pdfjs-dist/lib/web/ui_utils'; + import PdfjsLib from 'pdfjs-dist'; + + export class TextLayerBuilder { + constructor(options: TextLayerBuilder.Options); + + textLayerDiv: HTMLElement; + eventBus: EventBus; + textContent: PdfjsLib.TextContent | null; + // textContentItemsStr: any[]; + renderingDone: boolean; + // pageIdx: number; + pageNumber: number; + // matches: any[]; + // viewport: PdfjsLib.PDFPageViewport; + textDivs: HTMLElement[]; + // findController: any; + textLayerRenderTask: TextLayerRenderTask; + // enhanceTextSelection: any; + + render(timeout?: number): void; + cancel(): void; + // setTextContentStream(readableStream: any): void; + setTextContent(textContent: PdfjsLib.TextContent): void; + } + export const DefaultTextLayerFactory; + + declare namespace TextLayerBuilder { + export interface Options { + textLayerDiv: HTMLElement; + eventBus: EventBus; + pageIndex: number; + viewport: PdfjsLib.PDFPageViewport; + // findController?: any; + // enhanceTextSelection?: any; + } + } +} diff --git a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss index 19169fe5f..f3bcda870 100644 --- a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss +++ b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss @@ -1,3 +1,61 @@ .#{$prefix}--document-preview-pdf-viewer { + position: relative; +} + +.#{$prefix}--document-preview-pdf-viewer--canvas { + transform-origin: left top 0px; +} + +.#{$prefix}--document-preview-pdf-viewer--text { transform-origin: left top 0px; + + // + // NOTE: import textLayer styles from ~pdfjs-dist/web/pdf_viewer.css + // @import "~pdfjs-dist/web/pdf_viewer" doesn't work for loading image + // + &.textLayer { + position: absolute; + text-align: initial; + left: 0; + top: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0.2; + line-height: 1; + } + + &.textLayer span, + &.textLayer br { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + transform-origin: 0% 0%; + } + + &.textLayer ::selection { + background: rgba(0, 0, 255, 1); + } + + /* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */ + &.textLayer br::selection { + background: transparent; + } + + &.textLayer .endOfContent { + display: block; + position: absolute; + left: 0; + top: 100%; + right: 0; + bottom: 0; + z-index: -1; + cursor: default; + user-select: none; + } + + &.textLayer .endOfContent.active { + top: 0; + } } From 9f0dcd527061452bcbe2f73449c8855a6ef5e77b Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Tue, 16 Nov 2021 22:33:28 +0900 Subject: [PATCH 04/51] fix: apply review comments --- .../components/PdfViewer/PdfViewer.tsx | 28 ++++++------- .../PdfViewer/PdfViewerTextLayer.tsx | 41 +++++++------------ .../_document-preview-pdf-viewer.scss | 3 +- 3 files changed, 30 insertions(+), 42 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index d0ee15607..209b371d1 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -79,6 +79,7 @@ const PdfViewer: FC = ({ const [loadedFile, setLoadedFile] = useState(null); const [loadedPage, setLoadedPage] = useState(null); + // load PDF file useEffect(() => { let didCancel = false; @@ -100,6 +101,7 @@ const PdfViewer: FC = ({ }; }, [file, setPageCount]); + // load page from PDF file useEffect(() => { let didCancel = false; @@ -118,21 +120,18 @@ const PdfViewer: FC = ({ }; }, [loadedFile, page]); - const currentPage = useMemo(() => { - const isPageValid = !!loadedPage && loadedPage.pageNumber === page; - if (isPageValid) { - const viewport = loadedPage?.getViewport({ scale }); - const canvasInfo = viewport ? getCanvasInfo(viewport) : undefined; - return { loadedPage, viewport, canvasInfo }; - } - return null; - }, [loadedPage, page, scale]); + // extract canvas size of the current page + const [viewport, canvasInfo] = useMemo(() => { + const viewport = loadedPage?.getViewport({ scale }); + const canvasInfo = viewport ? getCanvasInfo(viewport) : undefined; + return [viewport, canvasInfo]; + }, [loadedPage, scale]); + // render the current page useEffect(() => { let didCancel = false; let task: PDFRenderTask | null = null; - const { loadedPage, viewport, canvasInfo } = currentPage || {}; if (loadedPage && !(loadedPage as any).then && viewport && canvasInfo) { const render = async () => { try { @@ -140,7 +139,8 @@ const PdfViewer: FC = ({ await task?.promise; } catch (e) { if (e instanceof RenderingCancelledException) { - // ignore + // Ignore. Rendering is interrupted by the effect cleanup method + // and another rendering will be taken place soon } else { throw e; // rethrow unknown exception } @@ -156,7 +156,7 @@ const PdfViewer: FC = ({ didCancel = true; task?.cancel(); }; - }, [loadedPage, currentPage, setLoading]); + }, [loadedPage, viewport, canvasInfo, setLoading]); useEffect(() => { if (setHideToolbarControls) { @@ -165,7 +165,6 @@ const PdfViewer: FC = ({ }, [setHideToolbarControls]); const classNameBase = `${settings.prefix}--document-preview-pdf-viewer`; - const { loadedPage: currentLoadedPage, canvasInfo } = currentPage || {}; return (
= ({ {showTextLayer && ( diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx index f6c38f08c..e05a596ee 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx @@ -14,11 +14,6 @@ interface Props { */ loadedPage: PDFPageProxy | null | undefined; - /** - * Page number, starting at 1 - */ - page: number; - /** * Zoom factor, where `1` is equal to 100% */ @@ -57,7 +52,6 @@ export type PdfTextLayerInfo = { const PdfViewerTextLayer: FC = ({ className, loadedPage, - page = 1, scale = 1, setTextLayerInfo: setTextLayerInfoCallback = () => {} }) => { @@ -71,20 +65,21 @@ const PdfViewerTextLayer: FC = ({ viewport: PDFPageViewport; } | null>(null); + // load text content from the page useEffect(() => { async function loadTextInfo() { - const isPageReady = !!loadedPage && loadedPage.pageNumber === page; - if (isPageReady) { + if (loadedPage) { const viewport = loadedPage.getViewport({ scale }); const textContent = await loadedPage.getTextContent(); - setTextRenderInfo({ textContent, viewport, page, scale }); + setTextRenderInfo({ textContent, viewport, page: loadedPage.pageNumber, scale }); } } loadTextInfo(); - }, [loadedPage, page, scale]); + }, [loadedPage, scale]); const textLayerBuilderRef = useRef(null); // ref for debugging purpose const [textLayerInfo, setTextLayerInfo] = useState(null); + // render text content useEffect(() => { let textLayerBuilder: TextLayerBuilder | null = null; async function loadTextLayer() { @@ -104,23 +99,16 @@ const PdfViewerTextLayer: FC = ({ // render textLayerDiv.innerHTML = ''; try { - const deferredRenderEnd = (() => { - let resolve: null | Function = null; - const promise = new Promise(resolvePromise => { - resolve = resolvePromise; - }); - + const deferredRenderEndPromise = new Promise(resolve => { const listener = () => { - resolve!(); + resolve(undefined); textLayerBuilder?.eventBus.off('textlayerrendered', listener); }; - textLayerBuilder.eventBus.on('textlayerrendered', listener); - - return { promise }; - })(); + textLayerBuilder?.eventBus.on('textlayerrendered', listener); + }); textLayerBuilder.render(); - await deferredRenderEnd.promise; + await deferredRenderEndPromise; // fix text divs _adjustTextDivs(textLayerBuilder.textDivs, textContent.items, scale); @@ -133,20 +121,21 @@ const PdfViewerTextLayer: FC = ({ }; } catch (e) { if (e instanceof RenderingCancelledException) { - // ignore + // Ignore. Rendering is interrupted by useEffect cleanup method. + // Another rendering starts soon return; } else { - throw e; + throw e; // rethrow unknown exception } } } setTextLayerInfo(textLayerInfo); } + loadTextLayer(); return () => { textLayerBuilder?.cancel(); - // should we set text items?? }; }, [textLayerDiv, textRenderInfo]); @@ -168,7 +157,7 @@ const PdfViewerTextLayer: FC = ({ }; /** - * Adjust text span width + * Adjust text span width based on scale * @param textDivs * @param textItems * @param scale diff --git a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss index f3bcda870..bcce8911c 100644 --- a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss +++ b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss @@ -38,7 +38,8 @@ background: rgba(0, 0, 255, 1); } - /* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */ + // Avoid unexpected text selection box in Chrome + // see https://github.com/mozilla/pdf.js/issues/13840 &.textLayer br::selection { background: transparent; } From cc0644610267820f1a210f2f89c172f6439108d1 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 18 Nov 2021 11:48:22 +0900 Subject: [PATCH 05/51] refactor: extract text rendering hook --- .../PdfViewer/PdfViewerTextLayer.tsx | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx index e05a596ee..097a6d0ac 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx @@ -49,6 +49,20 @@ export type PdfTextLayerInfo = { page: number; }; +type PdfTextContentInfo = { + /** extracted PDF text content */ + textContent: TextContent; + + /** @see Props['scale'] */ + scale: number; + + /** @see PdfTextLayerInfo['viewport'] */ + viewport: PDFPageViewport; + + /** @see PdfTextLayerInfo['page'] */ + page: number; +}; + const PdfViewerTextLayer: FC = ({ className, loadedPage, @@ -58,27 +72,46 @@ const PdfViewerTextLayer: FC = ({ const textLayerRef = useRef(null); const textLayerDiv = textLayerRef.current; - const [textRenderInfo, setTextRenderInfo] = useState<{ - page: number; - scale: number; - textContent: TextContent; - viewport: PDFPageViewport; - } | null>(null); - // load text content from the page + const [textContentInfo, setTextContentInfo] = useState(null); useEffect(() => { async function loadTextInfo() { if (loadedPage) { const viewport = loadedPage.getViewport({ scale }); const textContent = await loadedPage.getTextContent(); - setTextRenderInfo({ textContent, viewport, page: loadedPage.pageNumber, scale }); + setTextContentInfo({ textContent, viewport, page: loadedPage.pageNumber, scale }); } } loadTextInfo(); }, [loadedPage, scale]); + // render text content + const [renderedTextInfo, setRenderedTextInfo] = useState(null); + useTextLayerRendering(textLayerDiv, textContentInfo, setRenderedTextInfo); + + useEffect(() => { + setTextLayerInfoCallback(renderedTextInfo); + }, [renderedTextInfo, setTextLayerInfoCallback]); + + const rootClassName = cx(className, `textLayer`); + return ( +
+ ); +}; + +function useTextLayerRendering( + textLayerDiv: HTMLDivElement | null, + textRenderInfo: PdfTextContentInfo | null, + setRenderedTextInfo?: (info: PdfTextLayerInfo | null) => any +) { const textLayerBuilderRef = useRef(null); // ref for debugging purpose - const [textLayerInfo, setTextLayerInfo] = useState(null); // render text content useEffect(() => { let textLayerBuilder: TextLayerBuilder | null = null; @@ -129,7 +162,9 @@ const PdfViewerTextLayer: FC = ({ } } } - setTextLayerInfo(textLayerInfo); + if (setRenderedTextInfo) { + setRenderedTextInfo(textLayerInfo); + } } loadTextLayer(); @@ -137,24 +172,8 @@ const PdfViewerTextLayer: FC = ({ return () => { textLayerBuilder?.cancel(); }; - }, [textLayerDiv, textRenderInfo]); - - useEffect(() => { - setTextLayerInfoCallback(textLayerInfo); - }, [textLayerInfo, setTextLayerInfoCallback]); - - const rootClassName = cx(className, `textLayer`); - return ( -
- ); -}; + }, [setRenderedTextInfo, textLayerDiv, textRenderInfo]); +} /** * Adjust text span width based on scale From de5731fad760918ea523ab3d6318bf5757d807da Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Fri, 19 Nov 2021 23:46:04 +0900 Subject: [PATCH 06/51] refactor: extract async func call hook --- .../PdfViewer/PdfViewer.stories.tsx | 4 +- .../components/PdfViewer/PdfViewer.tsx | 114 ++++--------- .../PdfViewer/PdfViewerTextLayer.tsx | 160 +++++++----------- .../PdfViewer/useAsyncFunctionCall.ts | 46 +++++ 4 files changed, 144 insertions(+), 180 deletions(-) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/useAsyncFunctionCall.ts diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx index 6e7b31d12..11a3d0f08 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx @@ -36,7 +36,7 @@ storiesOf('DocumentPreview/components/PdfViewer', module) const showTextLayer = boolean('Show text layer', false); const setLoadingAction = action('setLoading'); - const setTextLayerInfoAction = action('setTextLayerInfo'); + const setRenderedTextAction = action('setRenderedText'); return ( ); }); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index 209b371d1..4a0de76ef 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect, useRef, useState, useMemo } from 'react'; +import React, { FC, useEffect, useRef, useMemo, useCallback } from 'react'; import cx from 'classnames'; import PdfjsLib, { PDFDocumentProxy, @@ -8,9 +8,8 @@ import PdfjsLib, { } from 'pdfjs-dist'; import PdfjsWorkerAsText from 'pdfjs-dist/build/pdf.worker.min.js'; import { settings } from 'carbon-components'; -import PdfViewerTextLayer, { PdfTextLayerInfo } from './PdfViewerTextLayer'; - -const { RenderingCancelledException } = PdfjsLib as any; +import PdfViewerTextLayer, { PdfRenderedText } from './PdfViewerTextLayer'; +import useAsyncFunctionCall from './useAsyncFunctionCall'; setupPdfjs(); @@ -57,7 +56,7 @@ interface Props { /** * Callback for text layer info */ - setTextLayerInfo?: (info: PdfTextLayerInfo | null) => any; + setRenderedText?: (info: PdfRenderedText | null) => any; } const PdfViewer: FC = ({ @@ -70,93 +69,48 @@ const PdfViewer: FC = ({ setPageCount, setLoading, setHideToolbarControls, - setTextLayerInfo, + setRenderedText, children }) => { const canvasRef = useRef(null); - // In order to prevent unnecessary re-loading, loaded file and page are stored in state - const [loadedFile, setLoadedFile] = useState(null); - const [loadedPage, setLoadedPage] = useState(null); - - // load PDF file - useEffect(() => { - let didCancel = false; - - async function loadPdf(): Promise { - if (file) { - const newPdf = await _loadPdf(file); - if (!didCancel) { - setLoadedFile(newPdf); - if (setPageCount) { - setPageCount(newPdf.numPages); - } - } - } - } - loadPdf(); - - return (): void => { - didCancel = true; - }; - }, [file, setPageCount]); - - // load page from PDF file - useEffect(() => { - let didCancel = false; - - async function loadPage(): Promise { - if (loadedFile && page > 0) { - const newPage = await _loadPage(loadedFile, page); - if (!didCancel) { - setLoadedPage(newPage); - } - } - } - loadPage(); - - return (): void => { - didCancel = true; - }; - }, [loadedFile, page]); + const loadedFile = useAsyncFunctionCall( + useCallback(async () => (file ? await _loadPdf(file) : null), [file]) + ); + const loadedPage = useAsyncFunctionCall( + useCallback( + async () => (loadedFile && page > 0 ? await _loadPage(loadedFile, page) : null), + [loadedFile, page] + ) + ); - // extract canvas size of the current page const [viewport, canvasInfo] = useMemo(() => { const viewport = loadedPage?.getViewport({ scale }); const canvasInfo = viewport ? getCanvasInfo(viewport) : undefined; return [viewport, canvasInfo]; }, [loadedPage, scale]); - // render the current page - useEffect(() => { - let didCancel = false; - let task: PDFRenderTask | null = null; - - if (loadedPage && !(loadedPage as any).then && viewport && canvasInfo) { - const render = async () => { - try { - task = _renderPage(loadedPage, canvasRef.current!, viewport, canvasInfo); + // render page + useAsyncFunctionCall( + useCallback( + async (abortSignal: AbortSignal) => { + if (loadedPage && !(loadedPage as any).then && viewport && canvasInfo) { + const task = _renderPage(loadedPage, canvasRef.current!, viewport, canvasInfo); + abortSignal.addEventListener('abort', () => task?.cancel()); await task?.promise; - } catch (e) { - if (e instanceof RenderingCancelledException) { - // Ignore. Rendering is interrupted by the effect cleanup method - // and another rendering will be taken place soon - } else { - throw e; // rethrow unknown exception - } - } finally { - if (!didCancel) { - setLoading(false); - } + + setLoading(false); } - }; - render(); + }, + [canvasInfo, loadedPage, setLoading, viewport] + ) + ); + + useEffect(() => { + if (setPageCount && loadedFile) { + setPageCount(loadedFile.numPages); } - return () => { - didCancel = true; - task?.cancel(); - }; - }, [loadedPage, viewport, canvasInfo, setLoading]); + }, [loadedFile, setPageCount]); useEffect(() => { if (setHideToolbarControls) { @@ -179,7 +133,7 @@ const PdfViewer: FC = ({ className={cx(`${classNameBase}--text`, textLayerClassName)} loadedPage={loadedPage} scale={scale} - setTextLayerInfo={setTextLayerInfo} + setRenderedText={setRenderedText} /> )} {children} @@ -192,7 +146,7 @@ PdfViewer.defaultProps = { scale: 1 }; -function _loadPdf(data: string): Promise { +function _loadPdf(data: string): Promise { return PdfjsLib.getDocument({ data }).promise; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx index 097a6d0ac..e6f536076 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx @@ -1,10 +1,9 @@ -import React, { FC, useEffect, useRef, useState } from 'react'; +import React, { FC, useEffect, useRef, useCallback } from 'react'; import cx from 'classnames'; -import PDFJSLib, { PDFPageProxy, PDFPageViewport, TextContent, TextContentItem } from 'pdfjs-dist'; +import { PDFPageProxy, PDFPageViewport, TextContent, TextContentItem } from 'pdfjs-dist'; import { EventBus } from 'pdfjs-dist/lib/web/ui_utils'; import { TextLayerBuilder } from 'pdfjs-dist/lib/web/text_layer_builder'; - -const { RenderingCancelledException } = PDFJSLib as any; +import useAsyncFunctionCall from './useAsyncFunctionCall'; interface Props { className?: string; @@ -22,10 +21,10 @@ interface Props { /** * Callback for text layer info */ - setTextLayerInfo?: (info: PdfTextLayerInfo | null) => any; + setRenderedText?: (info: PdfRenderedText | null) => any; } -export type PdfTextLayerInfo = { +export type PdfRenderedText = { /** * PDF text content */ @@ -49,49 +48,56 @@ export type PdfTextLayerInfo = { page: number; }; -type PdfTextContentInfo = { - /** extracted PDF text content */ - textContent: TextContent; - - /** @see Props['scale'] */ - scale: number; - - /** @see PdfTextLayerInfo['viewport'] */ - viewport: PDFPageViewport; - - /** @see PdfTextLayerInfo['page'] */ - page: number; -}; - const PdfViewerTextLayer: FC = ({ className, loadedPage, scale = 1, - setTextLayerInfo: setTextLayerInfoCallback = () => {} + setRenderedText = () => {} }) => { const textLayerRef = useRef(null); const textLayerDiv = textLayerRef.current; // load text content from the page - const [textContentInfo, setTextContentInfo] = useState(null); - useEffect(() => { - async function loadTextInfo() { + const loadedText = useAsyncFunctionCall( + useCallback(async () => { if (loadedPage) { const viewport = loadedPage.getViewport({ scale }); const textContent = await loadedPage.getTextContent(); - setTextContentInfo({ textContent, viewport, page: loadedPage.pageNumber, scale }); + return { textContent, viewport, page: loadedPage.pageNumber, scale }; } - } - loadTextInfo(); - }, [loadedPage, scale]); + return null; + }, [loadedPage, scale]) + ); // render text content - const [renderedTextInfo, setRenderedTextInfo] = useState(null); - useTextLayerRendering(textLayerDiv, textContentInfo, setRenderedTextInfo); + const renderedText = useAsyncFunctionCall( + useCallback( + async (signal: AbortSignal) => { + if (textLayerDiv && loadedText) { + const { textContent, viewport, scale, page } = loadedText; + + const builder = new TextLayerBuilder({ + textLayerDiv, + viewport, + eventBus: new EventBus(), + pageIndex: page - 1 + }); + signal.addEventListener('abort', () => builder.cancel()); + + await _renderTextLayer(builder, textContent, textLayerDiv, scale); + return { textContent, viewport, page, textDivs: builder.textDivs }; + } + return undefined; + }, + [loadedText, textLayerDiv] + ) + ); useEffect(() => { - setTextLayerInfoCallback(renderedTextInfo); - }, [renderedTextInfo, setTextLayerInfoCallback]); + if (renderedText !== undefined) { + setRenderedText(renderedText); + } + }, [renderedText, setRenderedText]); const rootClassName = cx(className, `textLayer`); return ( @@ -99,80 +105,38 @@ const PdfViewerTextLayer: FC = ({ className={rootClassName} ref={textLayerRef} style={{ - width: `${textContentInfo?.viewport?.width ?? 0}px`, - height: `${textContentInfo?.viewport?.height ?? 0}px` + width: `${loadedText?.viewport?.width ?? 0}px`, + height: `${loadedText?.viewport?.height ?? 0}px` }} /> ); }; -function useTextLayerRendering( - textLayerDiv: HTMLDivElement | null, - textRenderInfo: PdfTextContentInfo | null, - setRenderedTextInfo?: (info: PdfTextLayerInfo | null) => any +/** + * Render text into DOM using the text layer builder + */ +async function _renderTextLayer( + builder: TextLayerBuilder, + textContent: TextContent, + textLayerDiv: HTMLDivElement, + scale: number ) { - const textLayerBuilderRef = useRef(null); // ref for debugging purpose - // render text content - useEffect(() => { - let textLayerBuilder: TextLayerBuilder | null = null; - async function loadTextLayer() { - let textLayerInfo: PdfTextLayerInfo | null = null; - - if (textLayerDiv && textRenderInfo) { - const { textContent, viewport, scale, page } = textRenderInfo; - // prepare text layer - textLayerBuilder = textLayerBuilderRef.current = new TextLayerBuilder({ - textLayerDiv, - viewport, - eventBus: new EventBus(), - pageIndex: page - 1 - }); - textLayerBuilder.setTextContent(textContent); - - // render - textLayerDiv.innerHTML = ''; - try { - const deferredRenderEndPromise = new Promise(resolve => { - const listener = () => { - resolve(undefined); - textLayerBuilder?.eventBus.off('textlayerrendered', listener); - }; - textLayerBuilder?.eventBus.on('textlayerrendered', listener); - }); - - textLayerBuilder.render(); - await deferredRenderEndPromise; - - // fix text divs - _adjustTextDivs(textLayerBuilder.textDivs, textContent.items, scale); - - textLayerInfo = { - textContent, - textDivs: textLayerBuilder.textDivs, - viewport, - page - }; - } catch (e) { - if (e instanceof RenderingCancelledException) { - // Ignore. Rendering is interrupted by useEffect cleanup method. - // Another rendering starts soon - return; - } else { - throw e; // rethrow unknown exception - } - } - } - if (setRenderedTextInfo) { - setRenderedTextInfo(textLayerInfo); - } - } + builder.setTextContent(textContent); + + // render + textLayerDiv.innerHTML = ''; + const deferredRenderEndPromise = new Promise(resolve => { + const listener = () => { + resolve(undefined); + builder?.eventBus.off('textlayerrendered', listener); + }; + builder?.eventBus.on('textlayerrendered', listener); + }); - loadTextLayer(); + builder.render(); + await deferredRenderEndPromise; - return () => { - textLayerBuilder?.cancel(); - }; - }, [setRenderedTextInfo, textLayerDiv, textRenderInfo]); + _adjustTextDivs(builder.textDivs, textContent.items, scale); } /** diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/useAsyncFunctionCall.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/useAsyncFunctionCall.ts new file mode 100644 index 000000000..6f3d5f929 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/useAsyncFunctionCall.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +type AsyncFunc = (signal: AbortSignal) => Promise; +type AsyncFuncReturnType = T extends AsyncFunc ? U : never; + +/** + * Call async function WRAPPED BY `useCallback` and return its result + * + * @param asyncFunction async function wrapped by `useCallback`. + * Take one parameter `setCancellable` to set _cancellable_ of the current async call. + * @returns the result of the async function + */ +function useAsyncFunctionCall, ReturnType = AsyncFuncReturnType>( + asyncFunction: Func +): ReturnType | undefined { + const [result, setResult] = useState(); + + useEffect(() => { + let state: 'pending' | 'fulfilled' | 'rejected' = 'pending'; + const abortController = new AbortController(); + + asyncFunction(abortController.signal) + .then((promiseResult: ReturnType) => { + state = 'fulfilled'; + if (!abortController.signal.aborted && promiseResult !== undefined) { + setResult(promiseResult); + } + }) + .catch(err => { + state = 'rejected'; + if (!abortController.signal.aborted) { + throw err; + } + }); + + return (): void => { + if (state === 'pending') { + abortController.abort(); + } + }; + }, [asyncFunction]); + + return result; +} + +export default useAsyncFunctionCall; From f64fa0774851b7aa2bb7aa375321d1937b283941 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 24 Nov 2021 10:51:53 +0900 Subject: [PATCH 07/51] fix: revise how to import css from pdfjs --- .../_document-preview-pdf-viewer.scss | 54 +----------- .../document-preview/_pdfjs_web_mixins.scss | 83 +++++++++++++++++++ 2 files changed, 86 insertions(+), 51 deletions(-) create mode 100644 packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss diff --git a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss index bcce8911c..dce987167 100644 --- a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss +++ b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss @@ -1,5 +1,8 @@ +@import './pdfjs_web_mixins'; + .#{$prefix}--document-preview-pdf-viewer { position: relative; + @include pdfjsTextLayer; } .#{$prefix}--document-preview-pdf-viewer--canvas { @@ -8,55 +11,4 @@ .#{$prefix}--document-preview-pdf-viewer--text { transform-origin: left top 0px; - - // - // NOTE: import textLayer styles from ~pdfjs-dist/web/pdf_viewer.css - // @import "~pdfjs-dist/web/pdf_viewer" doesn't work for loading image - // - &.textLayer { - position: absolute; - text-align: initial; - left: 0; - top: 0; - right: 0; - bottom: 0; - overflow: hidden; - opacity: 0.2; - line-height: 1; - } - - &.textLayer span, - &.textLayer br { - color: transparent; - position: absolute; - white-space: pre; - cursor: text; - transform-origin: 0% 0%; - } - - &.textLayer ::selection { - background: rgba(0, 0, 255, 1); - } - - // Avoid unexpected text selection box in Chrome - // see https://github.com/mozilla/pdf.js/issues/13840 - &.textLayer br::selection { - background: transparent; - } - - &.textLayer .endOfContent { - display: block; - position: absolute; - left: 0; - top: 100%; - right: 0; - bottom: 0; - z-index: -1; - cursor: default; - user-select: none; - } - - &.textLayer .endOfContent.active { - top: 0; - } } diff --git a/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss new file mode 100644 index 000000000..abac06472 --- /dev/null +++ b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss @@ -0,0 +1,83 @@ +@mixin pdfjsTextLayer { + // CSS from ~pdfjs-dist/web/pdf_viewer.css for scoped style + + // BEGIN-QUOTE --- awk '/^\/\*/,/\*\//' + /* Copyright 2014 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // END-QUOTE + + // BEGIN-QUOTE --- awk '/^\.textLayer/,/}/' + .textLayer { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0.2; + line-height: 1; + } + .textLayer > span { + color: transparent; + position: absolute; + white-space: pre; + cursor: text; + -webkit-transform-origin: 0% 0%; + transform-origin: 0% 0%; + } + .textLayer .highlight { + margin: -1px; + padding: 1px; + + background-color: rgb(180, 0, 170); + border-radius: 4px; + } + .textLayer .highlight.begin { + border-radius: 4px 0px 0px 4px; + } + .textLayer .highlight.end { + border-radius: 0px 4px 4px 0px; + } + .textLayer .highlight.middle { + border-radius: 0px; + } + .textLayer .highlight.selected { + background-color: rgb(0, 100, 0); + } + .textLayer ::-moz-selection { + background: rgb(0, 0, 255); + } + .textLayer ::selection { + background: rgb(0, 0, 255); + } + .textLayer .endOfContent { + display: block; + position: absolute; + left: 0px; + top: 100%; + right: 0px; + bottom: 0px; + z-index: -1; + cursor: default; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + .textLayer .endOfContent.active { + top: 0px; + } + // END-QUOTE +} // end mixin From 3c08df91e4938c7a7483b7c26ee4c52bca94b9f6 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 24 Nov 2021 11:29:44 +0900 Subject: [PATCH 08/51] fix: install @types/pdfjs-dist to yarn2 --- .../discovery-react-components/package.json | 1 + yarn.lock | 30 ++++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/discovery-react-components/package.json b/packages/discovery-react-components/package.json index 91f4d4191..49e64485a 100644 --- a/packages/discovery-react-components/package.json +++ b/packages/discovery-react-components/package.json @@ -43,6 +43,7 @@ "react-virtualized": "9.21.1" }, "devDependencies": { + "@types/pdfjs-dist": "^2.10.378", "cross-env": "^7.0.3", "css-loader": "^3.4.2", "madge": "^5.0.1", diff --git a/yarn.lock b/yarn.lock index f7460da53..513c03d4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2267,10 +2267,11 @@ __metadata: languageName: node linkType: hard -"@ibm-watson/discovery-react-components@^1.5.0-beta.1, @ibm-watson/discovery-react-components@workspace:packages/discovery-react-components": +"@ibm-watson/discovery-react-components@^1.5.0-beta.2, @ibm-watson/discovery-react-components@workspace:packages/discovery-react-components": version: 0.0.0-use.local resolution: "@ibm-watson/discovery-react-components@workspace:packages/discovery-react-components" dependencies: + "@types/pdfjs-dist": ^2.10.378 classnames: ^2.2.6 cross-env: ^7.0.3 css-loader: ^3.4.2 @@ -2298,7 +2299,7 @@ __metadata: languageName: unknown linkType: soft -"@ibm-watson/discovery-styles@^1.5.0-beta.1, @ibm-watson/discovery-styles@workspace:packages/discovery-styles": +"@ibm-watson/discovery-styles@^1.5.0-beta.2, @ibm-watson/discovery-styles@workspace:packages/discovery-styles": version: 0.0.0-use.local resolution: "@ibm-watson/discovery-styles@workspace:packages/discovery-styles" peerDependencies: @@ -4938,6 +4939,15 @@ __metadata: languageName: node linkType: hard +"@types/pdfjs-dist@npm:^2.10.378": + version: 2.10.378 + resolution: "@types/pdfjs-dist@npm:2.10.378" + dependencies: + pdfjs-dist: "*" + checksum: 36dd6010f7d23a995efdf11ea4ecb56f371f8bfb3e83a5c311666726e13238597ed1519701d0e2e6fb297270d01ad6aece9582b036fd4cb3aa301e61ea364978 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.3 resolution: "@types/prop-types@npm:15.7.3" @@ -10236,8 +10246,8 @@ __metadata: resolution: "discovery-search-app@workspace:examples/discovery-search-app" dependencies: "@carbon/icons": ^10.5.0 - "@ibm-watson/discovery-react-components": ^1.5.0-beta.1 - "@ibm-watson/discovery-styles": ^1.5.0-beta.1 + "@ibm-watson/discovery-react-components": ^1.5.0-beta.2 + "@ibm-watson/discovery-styles": ^1.5.0-beta.2 body-parser: ^1.19.0 carbon-components: ^10.6.0 carbon-components-react: ^7.7.0 @@ -19518,6 +19528,18 @@ __metadata: languageName: node linkType: hard +"pdfjs-dist@npm:*": + version: 2.11.338 + resolution: "pdfjs-dist@npm:2.11.338" + peerDependencies: + worker-loader: ^3.0.8 + peerDependenciesMeta: + worker-loader: + optional: true + checksum: 1b946a3eeb3312a79e12b4e0aa066bb2b98487b9ee329666edc840a194602595cf84de9a3f6dbb023b808699a6ebb0cd06e751314fc4c0ffa56f7be12855d296 + languageName: node + linkType: hard + "pdfjs-dist@npm:^2.2.228": version: 2.2.228 resolution: "pdfjs-dist@npm:2.2.228" From c72222159d280202a567f2d33291f121d8b1973c Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 24 Nov 2021 12:07:45 +0900 Subject: [PATCH 09/51] feat: add script to update style --- packages/discovery-styles/package.json | 1 + .../discovery-styles/scripts/update-styles.sh | 19 +++++++++++++++++++ .../document-preview/_pdfjs_web_mixins.scss | 8 ++++---- 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100755 packages/discovery-styles/scripts/update-styles.sh diff --git a/packages/discovery-styles/package.json b/packages/discovery-styles/package.json index 3bbdf2c64..7c6b9e05f 100644 --- a/packages/discovery-styles/package.json +++ b/packages/discovery-styles/package.json @@ -7,6 +7,7 @@ "repository": "https://github.com/watson-developer-cloud/discovery-components", "main": "scss/index.scss", "scripts": { + "prebuild": "scripts/update-styles.sh", "build": "node-sass --importer=../../node_modules/node-sass-tilde-importer --source-map=true scss/index.scss css/index.css", "prepublish": "yarn run build", "start": "yarn run build -- --watch", diff --git a/packages/discovery-styles/scripts/update-styles.sh b/packages/discovery-styles/scripts/update-styles.sh new file mode 100755 index 000000000..2c112101f --- /dev/null +++ b/packages/discovery-styles/scripts/update-styles.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +PDFJS_WEB_CSS=../../node_modules/pdfjs-dist/web/pdf_viewer.css +PDFJS_SCSS=scss/components/document-preview/_pdfjs_web_mixins.scss + +function update_pdfjs_scss() { + key=$1 + tmp=$PDFJS_SCSS.tmp + + sed -e "/BEGIN-QUOTE $key/q" $PDFJS_SCSS > $tmp + cat >> $tmp + sed -ne "/END-QUOTE $key/,\$p" $PDFJS_SCSS >> $tmp + cp $tmp $PDFJS_SCSS; + rm $tmp; +} + +cat $PDFJS_WEB_CSS | awk '/^\/\*/,/\*\//' | update_pdfjs_scss "COMMENT" +cat $PDFJS_WEB_CSS | awk '/^\.textLayer/,/}/' | update_pdfjs_scss "TEXT-LAYER" +../../node_modules/.bin/prettier --write $PDFJS_SCSS diff --git a/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss index abac06472..37e94e1c8 100644 --- a/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss +++ b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss @@ -1,7 +1,7 @@ @mixin pdfjsTextLayer { // CSS from ~pdfjs-dist/web/pdf_viewer.css for scoped style - // BEGIN-QUOTE --- awk '/^\/\*/,/\*\//' + // BEGIN-QUOTE COMMENT /* Copyright 2014 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,9 +16,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - // END-QUOTE + // END-QUOTE COMMENT - // BEGIN-QUOTE --- awk '/^\.textLayer/,/}/' + // BEGIN-QUOTE TEXT-LAYER .textLayer { position: absolute; left: 0; @@ -79,5 +79,5 @@ .textLayer .endOfContent.active { top: 0px; } - // END-QUOTE + // END-QUOTE TEXT-LAYER } // end mixin From 69ff0425f2a74f9b35ad7191cc50b945366fd319 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 24 Nov 2021 12:50:02 +0900 Subject: [PATCH 10/51] refactor: revise script for importing css --- .../discovery-styles/scripts/update-styles.sh | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/discovery-styles/scripts/update-styles.sh b/packages/discovery-styles/scripts/update-styles.sh index 2c112101f..49ab2c595 100755 --- a/packages/discovery-styles/scripts/update-styles.sh +++ b/packages/discovery-styles/scripts/update-styles.sh @@ -3,17 +3,18 @@ PDFJS_WEB_CSS=../../node_modules/pdfjs-dist/web/pdf_viewer.css PDFJS_SCSS=scss/components/document-preview/_pdfjs_web_mixins.scss -function update_pdfjs_scss() { - key=$1 - tmp=$PDFJS_SCSS.tmp +function replace_quote() { + file=$1 + key=$2 + tmp=$file.tmp - sed -e "/BEGIN-QUOTE $key/q" $PDFJS_SCSS > $tmp + sed -e "/BEGIN-QUOTE $key/q" $file > $tmp cat >> $tmp - sed -ne "/END-QUOTE $key/,\$p" $PDFJS_SCSS >> $tmp - cp $tmp $PDFJS_SCSS; + sed -ne "/END-QUOTE $key/,\$p" $file >> $tmp + cp $tmp $file; rm $tmp; } -cat $PDFJS_WEB_CSS | awk '/^\/\*/,/\*\//' | update_pdfjs_scss "COMMENT" -cat $PDFJS_WEB_CSS | awk '/^\.textLayer/,/}/' | update_pdfjs_scss "TEXT-LAYER" +cat $PDFJS_WEB_CSS | awk '/^\/\*/,/\*\//' | replace_quote $PDFJS_SCSS "COMMENT" +cat $PDFJS_WEB_CSS | awk '/^\.textLayer/,/}/' | replace_quote $PDFJS_SCSS "TEXT-LAYER" ../../node_modules/.bin/prettier --write $PDFJS_SCSS From c0771fc93a238dc3d18cebcefc656c301d32f7db Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 10 Nov 2021 15:19:39 +0900 Subject: [PATCH 11/51] feat: add types and common utilities --- .../components/PdfViewerHighlight/types.ts | 39 +++ .../utils/common/TextNormalizer.ts | 266 ++++++++++++++++++ .../common/__tests__/TextNormalizer.test.ts | 142 ++++++++++ .../utils/common/__tests__/bboxUtils.test.ts | 41 +++ .../common/__tests__/findLargestIndex.test.ts | 46 +++ .../common/__tests__/textSpanUtils.test.ts | 135 +++++++++ .../utils/common/bboxUtils.ts | 76 +++++ .../utils/common/documentUtils.ts | 53 ++++ .../utils/common/findLargestIndex.ts | 34 +++ .../utils/common/nonEmpty.ts | 3 + .../utils/common/textSpanUtils.ts | 58 ++++ 11 files changed, 893 insertions(+) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/TextNormalizer.test.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/findLargestIndex.test.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/findLargestIndex.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts new file mode 100644 index 000000000..5496895c1 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts @@ -0,0 +1,39 @@ +import { Bbox as DocumentPreviewBbox } from '../../../DocumentPreview/types'; +import { Location } from 'utils/document/processDoc'; + +// (re-)export useful types +export type Bbox = DocumentPreviewBbox; +export type TextSpan = [number, number]; + +/** + * A document. Same to QueryResult, but this more focuses on document fields + */ +export type DocumentFields = { [fieldName: string]: string[] | undefined }; + +/** + * Highlight on a document field + */ +export type DocumentFieldHighlight = { + field: string; + fieldIndex: number; + location: Location; + className?: string; +}; + +/** + * Highlight shape on a page, which consists of boundary boxes + */ +export interface HighlightShape { + boxes: HighlightShapeBox[]; + className?: string; +} + +/** + * Boundary box for a highlight + */ +export interface HighlightShapeBox { + bbox: Bbox; + dir?: string; // e.g. ltr, rtl. ltr by default + isStart?: boolean; + isEnd?: boolean; +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts new file mode 100644 index 000000000..8eab9cf07 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts @@ -0,0 +1,266 @@ +import { TextSpan } from '../../types'; +import { END, spanLen, START } from './textSpanUtils'; + +type SpanMapping = { rawSpan: TextSpan; normalizedSpan: TextSpan }; + +const SPACES = { + normal: () => ' ', + regexString: '\\s+' +}; + +const DOUBLE_QUOTE = { + normal: () => '"', + regexString: `[${[ + '«', // U+00AB + '»', // U+00BB + '“', // U+201C + '”', // U+201D + '„', // U+201E + '‟', // U+201F + '❝', // U+275D + '❞', // U+275E + '⹂', // U+2E42 + '〝', // U+301D + '〞', // U+301E + '〟', // U+301F + '"' // U+FF02 + ].join('')}]` +}; + +const QUOTE = { + normal: () => "'", + regexString: `[${[ + '‹', // U+2039 + '›', // U+203A + '’', // U+2019 + '❮', // U+276E + '❯', // U+276F + '‘', // U+2018 + '‚', // U+201A + '‛', // U+201B + '❛', // U+275B + '❜', // U+275C + '❟' // U+275F + ].join('')}]` +}; + +const SURROGATE_PAIR = { + normal: (_: string) => '_', + regexString: '[\uD800-\uDBFF][\uDC00-\uDFFF]' +}; + +// remove "Combining Diacritical Marks" from the string +// NOTE: we may have to do this after conversion again +// str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") +const DIACRITICAL_MARK = { + normal: () => '', + regexString: '[\u0300-\u036f]' +}; +const DIACRITICAL_MARK_REGEX = new RegExp(DIACRITICAL_MARK.regexString, 'g'); + +function normalizeDiacriticalMarks(text: string, keepLength = false) { + const r = text + .normalize('NFD') + .replace(DIACRITICAL_MARK_REGEX, DIACRITICAL_MARK.normal) + .normalize('NFC'); + if (keepLength && r.length !== text.length) { + // + // String.normalize may change length of a string. `keepLength` flag keeps string + // length after conversion by padding or truncating a string. + // + return r.substring(0, text.length).padEnd(text.length, ' '); + } + return r; +} + +const NORMALIZATIONS = [SPACES, DOUBLE_QUOTE, QUOTE, SURROGATE_PAIR, DIACRITICAL_MARK].map(n => ({ + ...n, + regex: new RegExp(n.regexString, 'g') +})); + +// regex to match all the chars to normalize. +// the regex is: /(\s+)|(["""])|(['''])|([\u8D..FF])|([\u03..6f])/g +const NORMALIZATIONS_REGEX = new RegExp( + NORMALIZATIONS.map(n => `(${n.regexString})`).join('|'), + 'g' +); + +/** + * Normalize text + * @param text text to normalize + * @returns normalized text @see TextNormalizer + */ +export function normalizeText(text: string) { + const r = NORMALIZATIONS.reduce((text, n) => { + return text.replace(n.regex, m => n.normal(m)); + }, text); + return normalizeDiacriticalMarks(r); +} + +/** + * Text normalizer with mapping between spans on original and normalized text + * + * Normalize the following in a text: + * - two or more consequent spaces + * - single or double quote + * - surrogate pairs + * - diacritical marks (accent) + */ +export class TextNormalizer { + readonly rawText: string; + readonly normalizedText: string; + private readonly normalizationMappings: SpanMapping[]; + + constructor(rawText: string) { + this.rawText = rawText; + + let normalizedText = ''; + const addNormalizedText = (text: string) => { + normalizedText += normalizeDiacriticalMarks(text, true); + }; + + const normalizationMappings: SpanMapping[] = []; + const re = NORMALIZATIONS_REGEX; + let cur = 0; + let match = re.exec(this.rawText); + while (match != null) { + const originalChar = match[0]; + let normalizedChar = match[0]; + for (let i = 0; i < match.length - 1; i += 1) { + if (match[i + 1] != null) { + normalizedChar = NORMALIZATIONS[i].normal(match[0]); + break; + } + } + const needNormalize = originalChar !== normalizedChar; + + if (match.index > cur) { + const newText = this.rawText.substring(cur, match.index); + if (needNormalize) { + const rawSpan: TextSpan = [cur, match.index]; + const normalizedSpan: TextSpan = [ + normalizedText.length, + normalizedText.length + newText.length + ]; + normalizationMappings.push({ rawSpan, normalizedSpan }); + addNormalizedText(newText); + cur += newText.length; + } + } + + if (needNormalize) { + const newText = normalizedChar; + const rawSpan: TextSpan = [match.index, match.index + match[0].length]; + const normalizedSpan: TextSpan = [ + normalizedText.length, + normalizedText.length + newText.length + ]; + normalizationMappings.push({ rawSpan, normalizedSpan }); + addNormalizedText(newText); + cur = re.lastIndex; + } + match = re.exec(this.rawText); + } + if (cur < this.rawText.length) { + const newText = this.rawText.substring(cur); + const rawSpan: TextSpan = [cur, cur + newText.length]; + const normalizedSpan: TextSpan = [ + normalizedText.length, + normalizedText.length + newText.length + ]; + normalizationMappings.push({ rawSpan, normalizedSpan }); + addNormalizedText(newText); + } + this.normalizedText = normalizedText; + this.normalizationMappings = optimizeSpanMappings(normalizationMappings); + } + + toNormalized(rawSpan: TextSpan): TextSpan { + const [rawBegin, rawEnd] = rawSpan; + + const normalizedIndex = (raw: number) => { + if (raw < 0) { + return raw; + } + const beginIndex = this.normalizationMappings.findIndex(({ rawSpan }) => raw < rawSpan[END]); + if (beginIndex >= 0) { + const { rawSpan, normalizedSpan } = this.normalizationMappings[beginIndex]; + return mapCharIndexOnSpans(raw, { from: rawSpan, to: normalizedSpan }); + } + const last = this.normalizationMappings[this.normalizationMappings.length - 1]; + return raw - last.rawSpan[END] + last.normalizedSpan[END]; + }; + return [normalizedIndex(rawBegin), normalizedIndex(rawEnd)]; + } + + toRaw(normalizedSpan: TextSpan): TextSpan { + const [normalizedBegin, normalizedEnd] = normalizedSpan; + + const rawIndex = (normalized: number) => { + if (normalized < 0) { + return normalized; + } + const beginIndex = this.normalizationMappings.findIndex( + ({ normalizedSpan }) => normalized < normalizedSpan[END] + ); + if (beginIndex >= 0) { + const { rawSpan, normalizedSpan } = this.normalizationMappings[beginIndex]; + return mapCharIndexOnSpans(normalized, { from: normalizedSpan, to: rawSpan }); + } + const last = this.normalizationMappings[this.normalizationMappings.length - 1]; + return normalized - last.normalizedSpan[END] + last.rawSpan[END]; + }; + return [rawIndex(normalizedBegin), rawIndex(normalizedEnd)]; + } + + normalize(text: string) { + return normalizeText(text); + } + + isBlank(text: string) { + return text.length === 0 || text.trim().length === 0 || text.match(/^\s*$/); + } +} + +/** + * Map charIndex on a 'from' span to index on 'to' span + * @param charIndex char index to map + * @param mapping {from: Span, to: Span} spans + * @returns + */ +function mapCharIndexOnSpans( + charIndex: number, + { from: fromSpan, to: toSpan }: { from: TextSpan; to: TextSpan } +): number { + if (spanLen(fromSpan) === spanLen(toSpan)) { + return toSpan[START] + (charIndex - fromSpan[START]); + } + return ( + toSpan[START] + + Math.round((charIndex - fromSpan[START]) * (spanLen(toSpan) / spanLen(fromSpan))) + ); +} + +function optimizeSpanMappings(mappings: SpanMapping[]) { + const sameLength = (mapping: SpanMapping) => + spanLen(mapping.normalizedSpan) === spanLen(mapping.rawSpan); + const isShifted = (a: SpanMapping, b: SpanMapping) => + b.normalizedSpan[START] - a.normalizedSpan[START] === b.rawSpan[START] - a.rawSpan[START]; + + return mappings.reduce((acc, mapping) => { + const lastMapping = acc.length > 0 ? acc[acc.length - 1] : null; + if ( + sameLength(mapping) && + lastMapping && + sameLength(lastMapping) && + isShifted(lastMapping, mapping) + ) { + // merge mappings + lastMapping.normalizedSpan[END] = mapping.normalizedSpan[END]; + lastMapping.rawSpan[END] = mapping.rawSpan[END]; + return acc; + } + acc.push(mapping); + return acc; + }, [] as SpanMapping[]); +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/TextNormalizer.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/TextNormalizer.test.ts new file mode 100644 index 000000000..029b1d7c4 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/TextNormalizer.test.ts @@ -0,0 +1,142 @@ +import { TextSpan } from '../../../types'; +import { TextNormalizer } from '../TextNormalizer'; + +describe('TextNormalizer', () => { + it('should do nothing with text that does not have any chars to normalize', () => { + const fieldText = 'This is a sample text content.'; + const expectedNormalizedText = fieldText; + + const matcher = new TextNormalizer(fieldText); + expect(matcher.rawText).toEqual(fieldText); + expect(matcher.normalizedText).toEqual(expectedNormalizedText); + expect(matcher.normalizationMappings).toHaveLength(1); + + let spans: TextSpan[] = [ + [0, 10], // start from beginning + [3, 10], // start from one before space + [4, 10], // start from space + [5, 10], // start from one character after space + [10, 20], // end at one char before space + [10, 21], // end at space + [10, 22], // end at one char after space, + [10, fieldText.length] + ]; + for (const span of spans) { + expect(matcher.toNormalized(span)).toEqual(span); + expect(matcher.toRaw(span)).toEqual(span); + } + }); + + it('should normalize text with one long blank', () => { + const fieldText = 'This is a sample text content.'; + const expectedNormalizedText = 'This is a sample text content.'; + + const matcher = new TextNormalizer(fieldText); + expect(matcher.rawText).toEqual(fieldText); + expect(matcher.normalizedText).toEqual(expectedNormalizedText); + expect(matcher.normalizationMappings).toHaveLength(3); + + // test begin + expect(matcher.toNormalized([0, 10])).toEqual([0, 7]); + expect(matcher.toNormalized([3, 10])).toEqual([3, 7]); // one before blank + expect(matcher.toNormalized([4, 10])).toEqual([4, 7]); + expect(matcher.toNormalized([5, 10])).toEqual([4, 7]); + expect(matcher.toNormalized([6, 10])).toEqual([5, 7]); + expect(matcher.toNormalized([7, 10])).toEqual([5, 7]); + expect(matcher.toNormalized([8, 10])).toEqual([5, 7]); + expect(matcher.toNormalized([9, 10])).toEqual([6, 7]); // one after blank + // test end + expect(matcher.toNormalized([0, 3])).toEqual([0, 3]); // one before blank + expect(matcher.toNormalized([0, 4])).toEqual([0, 4]); + expect(matcher.toNormalized([0, 5])).toEqual([0, 4]); + expect(matcher.toNormalized([0, 6])).toEqual([0, 5]); + expect(matcher.toNormalized([0, 7])).toEqual([0, 5]); + expect(matcher.toNormalized([0, 8])).toEqual([0, 5]); + expect(matcher.toNormalized([0, 9])).toEqual([0, 6]); // one after blank + // last + expect(matcher.toNormalized([20, fieldText.length])).toEqual([ + 17, + expectedNormalizedText.length + ]); + + // test begin + expect(matcher.toRaw([0, 7])).toEqual([0, 10]); + expect(matcher.toRaw([3, 7])).toEqual([3, 10]); // one before blank + expect(matcher.toRaw([4, 7])).toEqual([4, 10]); + expect(matcher.toRaw([5, 7])).toEqual([8, 10]); // one after blank + // test end + expect(matcher.toRaw([0, 3])).toEqual([0, 3]); // one before blank + expect(matcher.toRaw([0, 4])).toEqual([0, 4]); + expect(matcher.toRaw([0, 5])).toEqual([0, 8]); // one after blank + expect(matcher.toRaw([0, 6])).toEqual([0, 9]); // two after blank + // last + expect(matcher.toRaw([17, expectedNormalizedText.length])).toEqual([20, fieldText.length]); + }); + + it('should normalize text with multiple long blanks', () => { + const fieldText = 'This is a sample text content. '; + const expectedNormalizedText = 'This is a sample text content. '; + + const matcher = new TextNormalizer(fieldText); + expect(matcher.normalizedText).toEqual(expectedNormalizedText); + expect(matcher.toNormalized([9, 29] /* s a sample te */)).toEqual([6, 19]); + expect(matcher.toRaw([10, 16] /* sample */)).toEqual([17, 23]); + }); + + it('should normalize quotes', () => { + const fieldText = 'This is “double-quoted”. This is ‘single-quoted’.'; + const expectedNormalizedText = 'This is "double-quoted". This is \'single-quoted\'.'; + + const matcher = new TextNormalizer(fieldText); + expect(matcher.normalizedText).toEqual(expectedNormalizedText); + expect(matcher.toNormalized([9, 29])).toEqual([9, 29]); + expect(matcher.toRaw([10, 16])).toEqual([10, 16]); + for (let i = 0; i < fieldText.length; i += 1) { + expect(matcher.toNormalized([0, i + 1])).toEqual([0, i + 1]); + expect(matcher.toNormalized([i, fieldText.length])).toEqual([i, fieldText.length]); + expect(matcher.toRaw([0, i + 1])).toEqual([0, i + 1]); + expect(matcher.toRaw([i, fieldText.length])).toEqual([i, fieldText.length]); + } + }); + + it('should normalize surrogate pairs', () => { + const fieldText = 'This is emoji 😁.'; + const expectedNormalizedText = 'This is emoji _.'; + + const matcher = new TextNormalizer(fieldText); + expect(matcher.normalizedText).toEqual(expectedNormalizedText); + expect(matcher.toNormalized([14, 16])).toEqual([14, 15]); + expect(matcher.toRaw([14, 15])).toEqual([14, 16]); + }); + + it('should normalize diacritical marks', () => { + const fieldText = 'àáâãäåçèéêëìíîïñòóôõöùúûüýÿæœ'; + const expectedNormalizedText = 'aaaaaaceeeeiiiinooooouuuuyyæœ'; + + const matcher = new TextNormalizer(fieldText); + expect(matcher.normalizedText).toEqual(expectedNormalizedText); + + const fieldText2 = fieldText.normalize('NFD'); // à: U+00E0 -> U+0061 U+0300 + expect(fieldText2.length).toBe(fieldText.length * 2 - 2 /* æœ are not changed */); + const matcher2 = new TextNormalizer(fieldText2); + expect(matcher2.normalizedText).toEqual(expectedNormalizedText); + }); + + describe('range conversion', () => { + it('should return mapped indices for negative indices and greater indices than text length', () => { + const matcher = new TextNormalizer('1234567890'); + expect(matcher.toNormalized([-10, 20])).toEqual([-10, 20]); + expect(matcher.toNormalized([20, 30])).toEqual([20, 30]); + expect(matcher.toRaw([-10, 20])).toEqual([-10, 20]); + expect(matcher.toRaw([20, 30])).toEqual([20, 30]); + }); + + it('should return mapped indices for negative indices and greater indices than normalized text length', () => { + const matcher = new TextNormalizer(' '); + expect(matcher.toNormalized([-10, 20])).toEqual([-10, 11]); + expect(matcher.toNormalized([20, 30])).toEqual([11, 21]); + expect(matcher.toRaw([-10, 20])).toEqual([-10, 29]); + expect(matcher.toRaw([20, 30])).toEqual([29, 39]); + }); + }); +}); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts new file mode 100644 index 000000000..f67fc87fd --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts @@ -0,0 +1,41 @@ +import { bboxGetSpanByRatio, bboxIntersects, isSideBySideOnLine } from '../bboxUtils'; + +describe('bboxIntersects', () => { + it('should return true when boxes intersect', () => { + expect(bboxIntersects([10, 10, 20, 20], [15, 15, 25, 25])).toBeTruthy(); + }); + + it("should return false when boxes don't intersect", () => { + expect(bboxIntersects([10, 10, 20, 20], [15, 25, 25, 35])).toBeFalsy(); + }); + + it('should return false when one box is on another', () => { + expect(bboxIntersects([10, 10, 20, 20], [20, 10, 30, 20])).toBeFalsy(); + expect(bboxIntersects([10, 10, 20, 20], [0, 10, 10, 20])).toBeFalsy(); + expect(bboxIntersects([10, 10, 20, 20], [10, 20, 20, 30])).toBeFalsy(); + expect(bboxIntersects([10, 10, 20, 20], [10, 0, 20, 10])).toBeFalsy(); + }); +}); + +describe('bboxGetSpanByRatio', () => { + it('should return proper bbox for spans on text', () => { + // text: '0123456789' -> highlight: '0123456789' + expect(bboxGetSpanByRatio([0, 0, 10, 2], 10, [0, 10])).toEqual([0, 0, 10, 2]); + // text: '0123456789' -> highlight: '23' + expect(bboxGetSpanByRatio([0, 0, 10, 2], 10, [2, 4])).toEqual([2, 0, 4, 2]); + // text: '012345' -> highlight: '23' + expect(bboxGetSpanByRatio([0, 0, 10, 2], 5, [2, 4])).toEqual([4, 0, 8, 2]); + }); +}); + +describe('isSideBySideOnLine', () => { + it('should return true for side-by-side boxes', () => { + expect(isSideBySideOnLine([0, 0, 5, 2], [5, 0, 10, 2])).toBeTruthy(); + }); + it('should return false when boxes are not vertically aligned', () => { + expect(isSideBySideOnLine([0, 0, 5, 2], [5, 1, 10, 3])).toBeFalsy(); + }); + it('should return false when two boxes are apart from each other', () => { + expect(isSideBySideOnLine([0, 0, 5, 2], [7, 0, 10, 2])).toBeFalsy(); + }); +}); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/findLargestIndex.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/findLargestIndex.test.ts new file mode 100644 index 000000000..3c4abf352 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/findLargestIndex.test.ts @@ -0,0 +1,46 @@ +import { findLargestIndex } from '../findLargestIndex'; + +describe('findLargestIndex', () => { + it('should find correct index', () => { + expect(findLargestIndex(0, 100, index => (index <= 49 ? index : null))).toEqual({ + index: 49, + value: 49 + }); + expect(findLargestIndex(0, 100, index => (index <= 50 ? index : null))).toEqual({ + index: 50, + value: 50 + }); + expect(findLargestIndex(0, 100, index => (index <= 51 ? index : null))).toEqual({ + index: 51, + value: 51 + }); + }); + + it('should find correct index at the edge of the range', () => { + expect(findLargestIndex(0, 100, index => (index === 0 ? index : null))).toEqual({ + index: 0, + value: 0 + }); + expect(findLargestIndex(0, 100, index => (index <= 150 ? index : null))).toEqual({ + index: 99, + value: 99 + }); + }); + + it('should find correct index in a range of 1 width', () => { + expect(findLargestIndex(0, 1, _ => true)).toEqual({ + index: 0, + value: true + }); + }); + + it('should return null for empty ranges', () => { + expect(findLargestIndex(0, 0, _ => true)).toBeNull(); + }); + + it('should return null when no match in the range', () => { + expect(findLargestIndex(0, 100, _ => null)).toBeNull(); + expect(findLargestIndex(0, 100, index => (index <= -50 ? index : null))).toBeNull(); + expect(findLargestIndex(0, 1, _ => null)).toBeNull(); + }); +}); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts new file mode 100644 index 000000000..78c663ced --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts @@ -0,0 +1,135 @@ +import { TextSpan } from '../../../types'; +import { + spanCompare, + spanContains, + spanFromSubSpan, + spanGetSubSpan, + spanGetText, + spanIncludesIndex, + spanIntersection, + spanIntersects, + spanLen +} from '../textSpanUtils'; + +describe('spanGetText', () => { + it('should return valid span text', () => { + expect(spanGetText('0123456789', [3, 5])).toBe('34'); + expect(spanGetText('0123456789', [0, 10])).toBe('0123456789'); + }); + it('should return null for null text', () => { + expect(spanGetText(null, [3, 5])).toBe(null); + }); + it('should return empty text for empty or negative span', () => { + expect(spanGetText('0123456789', [0, 0])).toBe(''); + expect(spanGetText('0123456789', [5, 3])).toBe(''); + }); + it('should return text span for negative or large indices', () => { + expect(spanGetText('0123456789', [-10, 20])).toBe('0123456789'); + }); +}); + +describe('spanLen', () => { + it('should return span length', () => { + expect(spanLen([5, 10])).toBe(5); + expect(spanLen([10, 10])).toBe(0); + }); + it('should return zero for negative spans', () => { + expect(spanLen([10, 5])).toBe(0); + }); +}); + +describe('spanIntersects', () => { + it('should properly distinguish span intersection', () => { + expect(spanIntersects([10, 19], [20, 30])).toBeFalsy(); + expect(spanIntersects([10, 20], [20, 30])).toBeFalsy(); + expect(spanIntersects([10, 21], [20, 30])).toBeTruthy(); + expect(spanIntersects([29, 40], [20, 30])).toBeTruthy(); + expect(spanIntersects([30, 41], [20, 30])).toBeFalsy(); + expect(spanIntersects([31, 40], [20, 30])).toBeFalsy(); + + expect(spanIntersects([25, 26], [20, 30])).toBeTruthy(); + + expect(spanIntersects([20, 30], [10, 19])).toBeFalsy(); + expect(spanIntersects([20, 30], [10, 20])).toBeFalsy(); + expect(spanIntersects([20, 30], [10, 21])).toBeTruthy(); + expect(spanIntersects([20, 30], [29, 40])).toBeTruthy(); + expect(spanIntersects([20, 30], [30, 41])).toBeFalsy(); + expect(spanIntersects([20, 30], [31, 40])).toBeFalsy(); + }); +}); + +describe('spanIncludesIndex', () => { + it('should return true for indices inside a span', () => { + expect(spanIncludesIndex([10, 20], 10)).toBeTruthy(); + expect(spanIncludesIndex([10, 20], 15)).toBeTruthy(); + expect(spanIncludesIndex([10, 20], 19)).toBeTruthy(); + }); + it('should return false for indices outside a span', () => { + expect(spanIncludesIndex([10, 20], 9)).toBeFalsy(); + expect(spanIncludesIndex([10, 20], 20)).toBeFalsy(); + expect(spanIncludesIndex([10, 20], 21)).toBeFalsy(); + }); +}); + +describe('spanContains', () => { + it('should return true when a span contains other span', () => { + expect(spanContains([10, 20], [15, 18])).toBeTruthy(); + expect(spanContains([10, 20], [10, 18])).toBeTruthy(); + expect(spanContains([10, 20], [15, 20])).toBeTruthy(); + }); + it("should return true when a span doesn't contain other span", () => { + expect(spanContains([10, 20], [9, 10])).toBeFalsy(); + expect(spanContains([10, 20], [9, 18])).toBeFalsy(); + expect(spanContains([10, 20], [15, 21])).toBeFalsy(); + expect(spanContains([10, 20], [21, 30])).toBeFalsy(); + }); +}); + +describe('spanIntersection', () => { + it('should return span intersection', () => { + expect(spanIntersection([10, 20], [15, 18])).toEqual([15, 18]); + expect(spanIntersection([10, 20], [10, 18])).toEqual([10, 18]); + expect(spanIntersection([10, 20], [15, 25])).toEqual([15, 20]); + }); + it('should return a span when the span is contained in another span', () => { + const a = [10, 20] as TextSpan; + expect(spanIntersection(a, [0, 30])).toBe(a); + expect(spanIntersection(a, [10, 21])).toBe(a); + expect(spanIntersection([0, 30], a)).toBe(a); + expect(spanIntersection([10, 20], a)).toBe(a); + }); +}); + +describe('spanFromSubSpan', () => { + it('should return a span that represents a sub-span (span in span) in a base span', () => { + expect(spanFromSubSpan([10, 20], [0, 5])).toEqual([10, 15]); + expect(spanFromSubSpan([10, 20], [5, 10])).toEqual([15, 20]); + expect(spanFromSubSpan([10, 20], [5, 20])).toEqual([15, 20]); + }); +}); + +describe('spanGetSubSpan', () => { + it('should return a span on a base span', () => { + expect(spanGetSubSpan([10, 20], [10, 15])).toEqual([0, 5]); + expect(spanGetSubSpan([10, 20], [15, 20])).toEqual([5, 10]); + }); + it('should return an empty span when given spans has no intersection', () => { + expect(spanLen(spanGetSubSpan([10, 20], [0, 5]))).toBe(0); + expect(spanLen(spanGetSubSpan([10, 20], [20, 25]))).toBe(0); + }); +}); + +describe('spanCompare', () => { + it('should return zero for same spans', () => { + expect(spanCompare([0, 0], [0, 0])).toBe(0); + expect(spanCompare([10, 20], [10, 20])).toBe(0); + }); + it('should return negative for spans before another', () => { + expect(spanCompare([10, 20], [11, 20]) < 0).toBeTruthy(); + expect(spanCompare([10, 20], [10, 21]) < 0).toBeTruthy(); + }); + it('should return positive for spans after another', () => { + expect(spanCompare([10, 20], [9, 20]) > 0).toBeTruthy(); + expect(spanCompare([10, 20], [10, 19]) > 0).toBeTruthy(); + }); +}); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts new file mode 100644 index 000000000..2a3de4d31 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts @@ -0,0 +1,76 @@ +import { Bbox, TextSpan } from '../../types'; +import { spanIntersection, spanLen } from './textSpanUtils'; + +export const LEFT = 0; +export const TOP = 1; +export const RIGHT = 2; +export const BOTTOM = 3; + +/** + * Check whether two bbox intersect + * + * Same to `intersects` in DocumentPreview/utils/box.ts, + * but for type `Bbox`, which doesn't have page property + * @param boxA one bbox + * @param boxB another bbox + * @returns true iff boxA and boxB are overwrapped + */ +export function bboxIntersects(boxA: Bbox, boxB: Bbox) { + const [leftA, topA, rightA, bottomA] = boxA; + const [leftB, topB, rightB, bottomB] = boxB; + return !(leftB >= rightA || rightB <= leftA || topB >= bottomA || bottomB <= topA); +} + +/** + * Get bbox for a text span assuming each character takes horizontal spaces evenly + * @param bbox bbox occupied with a text + * @param origLength length of the text + * @returns bbox for the text + */ +export function bboxGetSpanByRatio(bbox: Bbox, origLength: number, span: TextSpan) { + const theSpan = spanIntersection([0, origLength], span); + if (origLength === 0 || spanLen(theSpan) <= 0) { + return [bbox[0], bbox[1], bbox[0], bbox[3]] as Bbox; + } + + const [spanStart, spanEnd] = span; + const [left, top, right, bottom] = bbox; + const width = right - left; + const resultLeft = left + (width / origLength) * spanStart; + const resultRight = left + (width / origLength) * spanEnd; + + return [resultLeft, top, resultRight, bottom] as Bbox; +} + +/** + * Check whether two bboxes seems to be side-by-side on a same line. + * @param boxA + * @param boxB + * @returns + */ +export function isSideBySideOnLine(boxA: Bbox, boxB: Bbox) { + if (bboxIntersects(boxA, boxB)) { + return false; + } + + const [leftA, topA, rightA, bottomA] = boxA; + const [leftB, topB, rightB, bottomB] = boxB; + const heightA = bottomA - topA; + const heightB = bottomB - topB; + + // compare height ratio + const OVERWRAP_RATIO = 0.8; + if (!(heightA * OVERWRAP_RATIO < heightB || heightB * OVERWRAP_RATIO < heightA)) { + return false; + } + + const avgHeight = (heightA + heightB) / 2; + const overWrapHeight = Math.max(0, Math.min(bottomA, bottomB) - Math.max(topA, topB)); + if (overWrapHeight < avgHeight * OVERWRAP_RATIO) { + return false; + } + + // see if boxes can be neighborhoods + const verticalGap = Math.max(0, leftB - rightA, leftA - rightB); + return verticalGap < avgHeight; +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts new file mode 100644 index 000000000..655093698 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts @@ -0,0 +1,53 @@ +import { TextMappings } from 'components/DocumentPreview/types'; +import { getTextMappings } from 'components/DocumentPreview/utils/documentData'; +import { QueryResult } from 'ibm-watson/discovery/v2'; +import { processDoc, ProcessedDoc } from 'utils/document'; +import { Location } from 'utils/document/processDoc'; +import { DocumentFields, TextSpan } from '../../types'; + +export function getDocFieldValue( + document: DocumentFields, + field: string, + index?: number, + span?: Location | TextSpan +) { + let fieldText: string | undefined; + + const documentFieldArray = document[field]; + if (!Array.isArray(documentFieldArray) && !index) { + fieldText = documentFieldArray; + } else { + fieldText = documentFieldArray?.[index ?? 0]; + } + if (!fieldText || !span) { + return fieldText; + } + + if (Array.isArray(span)) { + return fieldText.substring(span[0], span[1]); + } else { + return fieldText.substring(span.begin, span.end); + } +} + +export type ExtractedDocumentInfo = { + processedDoc: ProcessedDoc; + textMappings?: TextMappings; +}; + +export async function extractDocumentInfo(document: QueryResult) { + const docHtml = document.html; + const textMappings = getTextMappings(document) ?? undefined; + + // HtmlView.tsx + const processedDoc = await processDoc( + { ...document, docHtml }, + { sections: true, bbox: true, bboxInnerText: true } + ); + + if (!processedDoc.bboxes) { + throw Error('Unexpected result from processDoc'); + } + + return { processedDoc, textMappings }; +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/findLargestIndex.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/findLargestIndex.ts new file mode 100644 index 000000000..34a3f1a03 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/findLargestIndex.ts @@ -0,0 +1,34 @@ +/** + * Find the largest index that satisfies the matchFn and the value of matchFn then + * @param begin begin index of the range. inclusive + * @param end end index of the rage. exclusive + * @param matchFn + */ +export function findLargestIndex( + begin: number, + end: number, + matchFn: (index: number) => V | null, + splitMid?: boolean +): { index: number; value: V } | null { + if (end - begin < 1) return null; + + const midIndex = splitMid ? begin + Math.floor((end - begin) / 2) : end - 1; + const value = matchFn(midIndex); + if (!(value == null)) { + if (end - (midIndex + 1) > 0) { + const r = findLargestIndex(midIndex + 1, end, matchFn, true); + if (r) return r; + else return { index: midIndex, value }; + } else { + return { index: midIndex, value }; + } + } else { + if (midIndex - begin > 0) { + const r = findLargestIndex(begin, midIndex, matchFn, true); + if (r) return r; + else return null; + } else { + return null; + } + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts new file mode 100644 index 000000000..a511faa30 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts @@ -0,0 +1,3 @@ +export function nonEmpty(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts new file mode 100644 index 000000000..b0ae85939 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts @@ -0,0 +1,58 @@ +import { TextSpan } from '../../types'; + +export const START = 0; +export const END = 1; + +export function spanGetText(text: T, span: TextSpan) { + if (!text) return text; + if (spanLen(span) === 0) return ''; + return text.substring(span[START], span[END]); +} + +export function spanLen(span: TextSpan) { + return Math.max(0, span[END] - span[START]); +} + +export function spanIntersects([beginA, endA]: TextSpan, [beginB, endB]: TextSpan): boolean { + return beginA < endB && endA > beginB; +} + +export function spanIncludesIndex([begin, end]: TextSpan, index: number) { + return begin <= index && index < end; +} + +export function spanContains(span: TextSpan, other: TextSpan) { + return span[START] <= other[START] && other[END] <= span[END]; +} + +export function spanIntersection(a: TextSpan, b: TextSpan): TextSpan { + if (spanContains(a, b)) return b; + if (spanContains(b, a)) return a; + const start = Math.max(a[START], b[START]); + const end = Math.min(a[END], b[END]); + return [start, start <= end ? end : start]; +} + +export function spanUnion(a: TextSpan, b: TextSpan): TextSpan { + if (spanContains(a, b) || spanLen(b) === 0) return a; + if (spanContains(b, a) || spanLen(a) === 0) return b; + const start = Math.min(a[START], b[START]); + const end = Math.max(a[END], b[END]); + return [start, start <= end ? end : start]; +} + +export function spanOffset([start, end]: TextSpan, offset: number): TextSpan { + return [start + offset, end + offset]; +} + +export function spanFromSubSpan(base: TextSpan, subSpan: TextSpan) { + return spanIntersection(base, spanOffset(subSpan, base[START])); +} + +export function spanGetSubSpan(base: TextSpan, span: TextSpan) { + return spanOffset(spanIntersection(base, span), -base[START]); +} + +export function spanCompare([startA, endA]: TextSpan, [startB, endB]: TextSpan) { + return startA === startB ? endA - endB : startA - startB; +} From 66c7c56a59a517b88ddbf60539ca8e57553e6709 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Fri, 15 Oct 2021 15:49:38 +0900 Subject: [PATCH 12/51] feat: add option for bbox text to processDoc --- .../document/__tests__/processDoc.spec.tsx | 29 +++++++++++++++ .../src/utils/document/processDoc.ts | 36 +++++++++++++++---- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/discovery-react-components/src/utils/document/__tests__/processDoc.spec.tsx b/packages/discovery-react-components/src/utils/document/__tests__/processDoc.spec.tsx index ad894bfb8..15c424b87 100644 --- a/packages/discovery-react-components/src/utils/document/__tests__/processDoc.spec.tsx +++ b/packages/discovery-react-components/src/utils/document/__tests__/processDoc.spec.tsx @@ -219,3 +219,32 @@ describe('processDoc', () => { expect(doc.tables![2].bboxes[0]).toEqual(bboxData); }); }); + +describe('processDoc', () => { + let doc: ProcessedDoc; + + beforeAll(async () => { + // parse doc for use in tests + doc = await processDoc(contractData.results[0], { bbox: true, bboxInnerText: true }); + }); + + it('successfully picks up bboxes', () => { + expect(doc.bboxes).toHaveLength(1584); + }); + + it('successfully picks up bbox text source', () => { + expect(doc.bboxes).toHaveLength(1584); + + // + // On 22 December 2008 ART EFFECTS LIMITED and Customer entered into an Information Technology Procurement Framework Agreement ("the + // + expect(doc.bboxes[0].innerTextSource).toEqual( + 'On 22 December 2008 ART EFFECTS LIMITED and Customer entered into an Information Technology Procurement Framework Agreement ("the ' + ); + expect(doc.bboxes[0].innerTextLocation).toEqual({ begin: 2530, end: 2660 }); + + // <Enter Amendment Text> + expect(doc.bboxes[1490].innerTextSource).toEqual('<Enter Amendment Text> '); + expect(doc.bboxes[1490].innerTextLocation).toEqual({ begin: 442990, end: 443016 }); + }); +}); diff --git a/packages/discovery-react-components/src/utils/document/processDoc.ts b/packages/discovery-react-components/src/utils/document/processDoc.ts index a39b42879..aca49147d 100644 --- a/packages/discovery-react-components/src/utils/document/processDoc.ts +++ b/packages/discovery-react-components/src/utils/document/processDoc.ts @@ -28,6 +28,7 @@ interface Options { sections?: boolean; tables?: boolean; bbox?: boolean; + bboxInnerText?: boolean; itemMap?: boolean; } @@ -66,6 +67,8 @@ export interface ProcessedBbox { page: number; className: string; location: Location; + innerTextSource?: string; + innerTextLocation?: Location; } export interface Table { @@ -130,7 +133,7 @@ export async function processDoc( const parser = new SaxParser(); // setup initial parsing handling - setupDocParser(parser, doc); + setupDocParser(parser, doc, options); const htmlContent = Array.isArray(html) ? html[0] : html; @@ -145,7 +148,7 @@ export async function processDoc( return doc; } -function setupDocParser(parser: SaxParser, doc: ProcessedDoc): void { +function setupDocParser(parser: SaxParser, doc: ProcessedDoc, options: Options): void { parser.pushState({ onopentag: (_: Parser, tagName: string): void => { /* eslint-disable-next-line default-case */ @@ -155,7 +158,7 @@ function setupDocParser(parser: SaxParser, doc: ProcessedDoc): void { break; } case 'body': { - setupBodyParser(parser, doc); + setupBodyParser(parser, doc, options); break; } } @@ -189,11 +192,11 @@ function setupStyleParser(parser: SaxParser, doc: ProcessedDoc): void { }); } -function setupBodyParser(parser: SaxParser, doc: ProcessedDoc): void { +function setupBodyParser(parser: SaxParser, doc: ProcessedDoc, options: Options): void { parser.pushState({ onopentag: (p: Parser, tagName: string, attributes: Attributes): void => { if (SECTION_NAMES.includes(tagName)) { - setupSectionParser(parser, doc, tagName, attributes, p.startIndex, p); + setupSectionParser(parser, doc, tagName, attributes, p.startIndex, p, options); } } }); @@ -205,7 +208,8 @@ function setupSectionParser( sectionTagName: string, sectionTagAttrs: Attributes, sectionStartIndex: number, - sectionParser: Parser + sectionParser: Parser, + options: Options ): void { let lastClassName = ''; let currentTable: Table | null = null; @@ -283,6 +287,13 @@ function setupSectionParser( if (doc.bboxes) { doc.bboxes.push(currentBbox); } + if (options.bboxInnerText) { + currentBbox.innerTextSource = ''; + currentBbox.innerTextLocation = { + begin: p.endIndex != null ? p.endIndex + 1 : -1, + end: -1 + }; + } if (currentTable && doc.tables) { currentTable.bboxes.push(currentBbox); } @@ -309,6 +320,10 @@ function setupSectionParser( ); } + if (currentBbox && options.bboxInnerText) { + currentBbox.innerTextSource += text; + } + sectionHtml.push(text); }, @@ -335,6 +350,15 @@ function setupSectionParser( if (doc.bboxes && tagName === BBOX_TAG && currentBbox) { currentBbox.location.end = getChildEndFromCloseTag(p); + + if (options.bboxInnerText && currentBbox.innerTextLocation) { + currentBbox.innerTextLocation.end = getChildEndFromCloseTag(p); + if (currentBbox.innerTextLocation.end < 0 && currentBbox.innerTextSource != null) { + currentBbox.innerTextLocation.begin = + currentBbox.innerTextLocation.end - currentBbox.innerTextSource.length; + } + } + currentBbox = null; } From 47855f400fc3211e487d630ab720d32e35121b7b Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 10 Nov 2021 15:55:10 +0900 Subject: [PATCH 13/51] feat: add text layer classes --- .../utils/textLayout/BaseTextLayout.ts | 75 +++++++++++++++ .../utils/textLayout/HtmlBboxTextLayout.ts | 56 +++++++++++ .../textLayout/PdfTextContentTextLayout.ts | 94 +++++++++++++++++++ .../textLayout/TextMappingsTextLayout.ts | 67 +++++++++++++ .../utils/textLayout/dom.ts | 67 +++++++++++++ .../utils/textLayout/index.ts | 3 + .../utils/textLayout/types.ts | 67 +++++++++++++ 7 files changed, 429 insertions(+) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/index.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts new file mode 100644 index 000000000..77e043328 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts @@ -0,0 +1,75 @@ +import { Bbox, TextSpan } from '../../types'; +import { TextLayout, TextLayoutCell, TextLayoutCellBase } from './types'; +import { spanGetText, spanIntersection, spanOffset, START } from '../common/textSpanUtils'; +import { bboxGetSpanByRatio } from '../common/bboxUtils'; + +/** + * Base implementation of text layout cell + */ +export class BaseTextLayoutCell> + implements TextLayoutCell +{ + readonly parent: Layout; + readonly id: number; + readonly pageNum: number; + readonly bbox: Bbox; + readonly text: string; + + constructor({ + parent, + id, + pageNum, + bbox, + text + }: { + parent: Layout; + id: number; + pageNum: number; + bbox: Bbox; + text: string; + }) { + this.parent = parent; + this.id = id; + this.pageNum = pageNum; + this.bbox = bbox; + this.text = text; + } + + getPartial(span: TextSpan): TextLayoutCellBase { + return new PartialTextLayoutCell(this, span); + } + getNormalized(): { cell: TextLayoutCell; span?: TextSpan } { + return { cell: this }; + } + getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { + if (options?.useRatio) { + return bboxGetSpanByRatio(this.bbox, this.text.length, span); + } + return null; + } +} + +/** + * Text span on a base text layout cell + */ +export class PartialTextLayoutCell implements TextLayoutCellBase { + readonly base: TextLayoutCell; + readonly span: TextSpan; + + constructor(base: TextLayoutCell, span: TextSpan) { + this.base = base; + this.span = spanIntersection([0, base.text.length], span); + } + + get text() { + return spanGetText(this.base.text, this.span); + } + + getPartial(span: TextSpan): TextLayoutCellBase { + const newSpan = spanIntersection(this.span, spanOffset(span, this.span[START])); + return new PartialTextLayoutCell(this.base, newSpan); + } + getNormalized() { + return { cell: this.base, span: this.span }; + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts new file mode 100644 index 000000000..851ab6991 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts @@ -0,0 +1,56 @@ +import { decodeHTML } from 'entities'; +import { ProcessedBbox } from 'utils/document'; +import { Bbox, TextSpan } from '../../types'; +import { BaseTextLayoutCell } from './BaseTextLayout'; +import { HtmlBboxInfo, TextLayout } from './types'; + +export class HtmlBboxTextLayout implements TextLayout { + private readonly bboxInfo: HtmlBboxInfo; + readonly cells: HtmlBboxTextLayoutCell[]; + + constructor(bboxInfo: HtmlBboxInfo, pageNum: number) { + this.bboxInfo = bboxInfo; + this.cells = + bboxInfo.bboxes + ?.filter(bbox => bbox.page === pageNum) + .map((bbox, index) => { + return new HtmlBboxTextLayoutCell(this, index, bbox); + }) ?? []; + } + + cellAt(id: number) { + return this.cells[id]; + } + + installStyle() { + if (this.bboxInfo.styles) { + // TODO: install style to DOM if not yet. For getBboxForTextSpan in cell + } + } +} + +class HtmlBboxTextLayoutCell extends BaseTextLayoutCell { + private readonly processedBbox: ProcessedBbox; + + constructor(parent: HtmlBboxTextLayout, index: number, processedBbox: ProcessedBbox) { + const id = index; + const pageNum = processedBbox.page; + const bbox: Bbox = [ + processedBbox.left, + processedBbox.top, + processedBbox.right, + processedBbox.bottom + ]; + const text = decodeHTML(processedBbox.innerTextSource ?? ''); + super({ parent, id, pageNum, bbox, text }); + + this.processedBbox = processedBbox; // keep this for later improvement + } + + getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { + if (this.processedBbox != null) { + // TODO: calculate bbox for text span using text on browser + } + return super.getBboxForTextSpan(span, options); + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts new file mode 100644 index 000000000..9a5c09e71 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -0,0 +1,94 @@ +import { PDFPageViewport, PDFPageViewportOptions, TextContentItem } from 'pdfjs-dist'; +import { Bbox, TextSpan } from '../../types'; +import { bboxIntersects } from '../common/bboxUtils'; +import { BaseTextLayoutCell } from './BaseTextLayout'; +import { getAdjustedCellByOffsetByDom } from './dom'; +import { HtmlBboxInfo, PdfTextContentInfo, TextLayout } from './types'; + +export class PdfTextContentTextLayout implements TextLayout { + private readonly textContentInfo: PdfTextContentInfo; + readonly cells: PdfTextContentTextLayoutCell[]; + private spans: HTMLElement[] | undefined; + + constructor(textContentInfo: PdfTextContentInfo, pageNum: number, htmlBboxInfo?: HtmlBboxInfo) { + this.textContentInfo = textContentInfo; + + const textContentItems = textContentInfo.textContent.items; + + this.cells = textContentItems + .map((item, index) => { + return new PdfTextContentTextLayoutCell(this, index, item, pageNum); + }) + .filter(cell => { + if (htmlBboxInfo?.bboxes?.length) { + return htmlBboxInfo.bboxes.some(bbox => { + return bboxIntersects(cell.bbox, [bbox.left, bbox.top, bbox.right, bbox.bottom]); + }); + } + return true; + }); + } + + get viewport() { + return this.textContentInfo.viewport; + } + + cellAt(id: number) { + return this.cells[id]; + } + + setSpans(spans: HTMLElement[] | undefined) { + this.spans = spans; + } + spanAt(id: number) { + return this.spans?.[id]; + } +} + +class PdfTextContentTextLayoutCell extends BaseTextLayoutCell { + // private readonly textItem: TextContentItem; + + constructor( + parent: PdfTextContentTextLayout, + index: number, + textItem: TextContentItem, + pageNum: number + ) { + const id = index; + const bbox = PdfTextContentTextLayoutCell.getBbox(textItem, parent.viewport); + const text = textItem.str; + super({ parent, id, pageNum, bbox, text }); + + // this.textItem = textItem; + } + + getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { + const spanElement = this.parent.spanAt(this.id); + if (spanElement && spanElement.parentNode) { + const scale = this.parent.viewport.scale; + const bbox = getAdjustedCellByOffsetByDom(this, span, spanElement, scale); + if (bbox) { + return bbox; + } + } + return super.getBboxForTextSpan(span, options); + } + + static getBbox(textItem: TextContentItem, viewport: PDFPageViewport): Bbox { + const { transform } = textItem; + + const patchedViewport = viewport as PDFPageViewportOptions & PDFPageViewport; + const defaultSideways = patchedViewport.rotation % 180 !== 0; + + // not sure this is true... + const [fontHeightPx, , offsetX, offsetY, x, y] = transform; + + const [xMin, yMin, , yMax] = patchedViewport.viewBox; + const top = defaultSideways ? x + offsetX + yMin : yMax - (y + offsetY); + const left = defaultSideways ? y - xMin : x - xMin; + const bottom = top + fontHeightPx; + const adjustHeight = fontHeightPx * 0.2; + + return [left, top + adjustHeight, left + textItem.width, bottom + adjustHeight]; + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts new file mode 100644 index 000000000..f3f51bdb6 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts @@ -0,0 +1,67 @@ +import { Cell, CellField } from 'components/DocumentPreview/types'; +import { DocumentFields, DocumentFieldHighlight, TextSpan } from '../../types'; +import { getDocFieldValue } from '../common/documentUtils'; +import { TextBoxMappingResult } from '../textBoxMapping/types'; +import { + spanGetSubSpan, + spanContains, + spanIntersection, + spanIntersects +} from '../common/textSpanUtils'; +import { BaseTextLayoutCell } from './BaseTextLayout'; +import { TextLayout, TextMappingInfo } from './types'; + +export class TextMappingsTextLayout implements TextLayout { + readonly cells: TextMappingsTextLayoutCell[]; + + constructor(textMappingInfo: TextMappingInfo, pageNum: number) { + const { textMappings, document } = textMappingInfo; + + this.cells = textMappings.text_mappings + .filter(cell => cell.page.page_number === pageNum) + .map((cell, index) => { + return new TextMappingsTextLayoutCell(this, index, document, cell); + }); + } + + cellAt(id: number) { + return this.cells[id]; + } + + getHighlight(highlight: DocumentFieldHighlight): TextBoxMappingResult { + const highlightSpan: TextSpan = [highlight.location.begin, highlight.location.end]; + const highlightCells = this.cells + .filter(cell => { + const { cellField } = cell; + return ( + cellField.name === highlight.field && + cellField.index === highlight.fieldIndex && + spanIntersects(cellField.span, highlightSpan) + ); + }) + .map(cell => { + const { cellField } = cell; + const currentSpan = spanIntersection(cellField.span, highlightSpan); + if (spanContains(highlightSpan, cellField.span)) { + return { cell, sourceSpan: currentSpan }; + } + const subSpan = spanGetSubSpan(cellField.span, currentSpan); + return { cell: cell.getPartial(subSpan), sourceSpan: currentSpan }; + }); + return highlightCells; + } +} + +class TextMappingsTextLayoutCell extends BaseTextLayoutCell { + readonly cellField: CellField; + + constructor(parent: TextMappingsTextLayout, index: number, document: DocumentFields, cell: Cell) { + const id = index; + const pageNum = cell.page.page_number; + const bbox = cell.page.bbox; + const text = + getDocFieldValue(document, cell.field.name, cell.field.index, cell.field.span) ?? ''; + super({ parent, id, pageNum, bbox, text }); + this.cellField = cell.field; + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts new file mode 100644 index 000000000..d2cfae28d --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts @@ -0,0 +1,67 @@ +import { getTextNodeAndOffset, uniqRects } from 'utils/document/documentUtils'; +import { Bbox, TextSpan } from '../../types'; +import { BOTTOM, LEFT, RIGHT, TOP } from '../common/bboxUtils'; +import { END, START } from '../common/textSpanUtils'; +import { TextLayoutCell } from './types'; + +const debugOut = require('debug')?.('pdf:textLayout:dom'); +function debug(...args: any) { + debugOut?.apply(null, args); +} + +export function getAdjustedCellByOffsetByDom( + cell: TextLayoutCell, + textSpan: TextSpan, + spanElement: HTMLElement, + scale: number +): Bbox | null { + if (!(spanElement.firstChild instanceof Text) || !(spanElement.lastChild instanceof Text)) { + debug('unexpected. span dont have text node'); + return null; + } + + const beginOffset = textSpan[START]; + const endOffset = Math.min(cell.text.length, textSpan[END]); + + let left = cell.bbox[LEFT]; + let right = cell.bbox[RIGHT]; + const top = cell.bbox[TOP]; + const bottom = cell.bbox[BOTTOM]; + + // convert offset + function getAdjustedOffset(orgOffset: number) { + return orgOffset; + } + try { + const { textNode: beginTextNode, textOffset: beginTextOffset } = + beginOffset > 0 + ? getTextNodeAndOffset(spanElement, getAdjustedOffset(beginOffset)) + : { textNode: spanElement.firstChild, textOffset: 0 }; + const { textNode: endTextNode, textOffset: endTextOffset } = + endOffset > 0 + ? getTextNodeAndOffset(spanElement, getAdjustedOffset(endOffset)) + : { textNode: spanElement.lastChild, textOffset: spanElement.lastChild.length }; + + debug('finding text node for: ', cell.text); + debug(' textContent: ', beginTextNode.textContent); + debug(' beginOffset: ', beginTextOffset); + debug(' textContent: ', endTextNode.textContent); + debug(' endOffset: ', endTextOffset); + + const range = document.createRange(); + range.setStart(beginTextNode, Math.min(beginTextOffset, beginTextNode.length)); + range.setEnd(endTextNode, Math.min(endTextOffset, endTextNode.length)); + + // create highlight rect(s) inside of a field + const parentRect = spanElement.parentElement?.getBoundingClientRect(); + Array.prototype.forEach.call(uniqRects(range.getClientRects() as DOMRectList), rect => { + left = (rect.left - parentRect!.left) / scale; + right = left + rect.width / scale; + }); + + return [left, top, right, bottom]; + } catch (e) { + debug('Caught exception on calculating bbox from DOM: ', e); + } + return null; +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/index.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/index.ts new file mode 100644 index 000000000..cfef239cf --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/index.ts @@ -0,0 +1,3 @@ +export { HtmlBboxTextLayout } from './HtmlBboxTextLayout'; +export { PdfTextContentTextLayout } from './PdfTextContentTextLayout'; +export { TextMappingsTextLayout } from './TextMappingsTextLayout'; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts new file mode 100644 index 000000000..7ed59012d --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts @@ -0,0 +1,67 @@ +import { TextMappings } from 'components/DocumentPreview/types'; +import { PDFPageViewport, TextContent } from 'pdfjs-dist'; +import { ProcessedDoc } from 'utils/document'; +import { Bbox, DocumentFields, TextSpan } from '../../types'; + +/** + * Text layout information + */ +export interface TextLayout { + /** cells, paris of bbox and text, of this text layout */ + readonly cells: CellType[]; + /** get cell by ID */ + cellAt(id: CellType['id']): CellType; +} + +/** + * Text layout cell. A text and its bbox. + */ +export interface TextLayoutCell extends TextLayoutCellBase { + readonly parent: TextLayout; + /** ID to identify this cell in */ + readonly id: IDType; + /** text of this cell */ + readonly text: string; + readonly pageNum: number; + readonly bbox: Bbox; + + /** + * get bbox for the given text span. + * @returns null when it's not available + */ + getBboxForTextSpan(span: TextSpan, options?: { useRatio?: boolean }): Bbox | null; +} + +/** + * Generic text layout cell. Bbox may not be directly available. + * Mainly for sub-string of a text layout cell. + */ +export interface TextLayoutCellBase { + /** text of this cell */ + readonly text: string; + /** get sub-span of this text layout */ + getPartial(span: TextSpan): TextLayoutCellBase; + /** get normalized form, the base text layout cell and a span on it */ + getNormalized(): { cell: TextLayoutCell; span?: TextSpan }; +} + +/** + * Information to create HtmlBboxTextLayout + */ +export type HtmlBboxInfo = Pick; + +/** + * Information to create PdfTextContentTextLayout + */ +export type PdfTextContentInfo = { + textContent: TextContent; + viewport: PDFPageViewport; +}; + +/** + * Information to create TextMappingsTextLayout + */ +export type TextMappingInfo = { + document: DocumentFields; + textMappings: TextMappings; +}; From 66b3e43399bd7261b4e3fe3a77229dbd08c7ce88 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 10 Nov 2021 16:06:25 +0900 Subject: [PATCH 14/51] feat: add highlighting logic and README --- .../PdfViewerHighlight/utils/Highlighter.ts | 158 ++++++++++++++++++ .../PdfViewerHighlight/utils/README.md | 37 ++++ .../utils/textBoxMapping/CellProvider.ts | 96 +++++++++++ .../MappingSourceTextProvider.ts | 61 +++++++ .../MappingTargetCellProvider.ts | 57 +++++++ .../utils/textBoxMapping/TextBoxMapping.ts | 87 ++++++++++ .../utils/textBoxMapping/TextProvider.ts | 92 ++++++++++ .../__tests__/TextProvider.test.ts | 56 +++++++ .../utils/textBoxMapping/getTextBoxMapping.ts | 133 +++++++++++++++ .../utils/textBoxMapping/index.ts | 1 + .../utils/textBoxMapping/types.ts | 16 ++ 11 files changed, 794 insertions(+) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/__tests__/TextProvider.test.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/index.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts new file mode 100644 index 000000000..8216cd532 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts @@ -0,0 +1,158 @@ +import { TextMappings } from 'components/DocumentPreview/types'; +import flatMap from 'lodash/flatMap'; +import { PDFPageViewport, TextContent } from 'pdfjs-dist'; +import { + DocumentFields, + DocumentFieldHighlight, + HighlightShape, + HighlightShapeBox +} from '../types'; +import { getTextBoxMappings } from './textBoxMapping'; +import { TextBoxMapping, TextBoxMappingResult } from './textBoxMapping/types'; +import { HtmlBboxTextLayout, PdfTextContentTextLayout, TextMappingsTextLayout } from './textLayout'; +import { HtmlBboxInfo } from './textLayout/types'; +import { spanOffset, START } from './common/textSpanUtils'; +import { nonEmpty } from './common/nonEmpty'; + +const debugOut = require('debug')?.('pdf:Highlighter'); +function debug(...args: any) { + debugOut?.apply(null, args); +} + +export class Highlighter { + readonly pageNum: number; + private readonly textMappingsLayout: TextMappingsTextLayout; + private pdfTextContentLayout: PdfTextContentTextLayout | null = null; + private textToHtmlBboxMappings: TextBoxMapping | null = null; + private textToPdfTextItemMappings: TextBoxMapping | null = null; + + constructor({ + document, + textMappings, + pageNum, + htmlBboxInfo, + pdfTextContentInfo + }: { + document: DocumentFields; + textMappings: TextMappings; + pageNum: number; + htmlBboxInfo?: HtmlBboxInfo; + pdfTextContentInfo?: { + textContent: TextContent; + viewport: PDFPageViewport; + spans?: HTMLElement[]; + }; + }) { + this.pageNum = pageNum; + this.textMappingsLayout = new TextMappingsTextLayout({ document, textMappings }, pageNum); + if (htmlBboxInfo) { + this.setProcessedDoc(htmlBboxInfo); + } + if (pdfTextContentInfo) { + this.setTextContentItems( + pdfTextContentInfo.textContent, + pdfTextContentInfo.viewport, + pdfTextContentInfo.spans, + htmlBboxInfo + ); + } + } + + setProcessedDoc(htmlBoxInfo: HtmlBboxInfo) { + const htmlLayout = new HtmlBboxTextLayout(htmlBoxInfo, this.pageNum); + this.textToHtmlBboxMappings = getTextBoxMappings(this.textMappingsLayout, htmlLayout); + } + + setTextContentItems( + textContent: TextContent, + viewport: PDFPageViewport, + spans?: HTMLElement[], + htmlBoxInfo?: HtmlBboxInfo + ) { + this.pdfTextContentLayout = new PdfTextContentTextLayout( + { textContent, viewport }, + this.pageNum, + htmlBoxInfo + ); + this.textToPdfTextItemMappings = getTextBoxMappings( + this.textMappingsLayout, + this.pdfTextContentLayout + ); + this.setTextContentDivs(spans); + } + + setTextContentDivs(spans?: HTMLElement[]) { + this.pdfTextContentLayout?.setSpans(spans); + } + + getHighlightTextMappingResult(highlight: DocumentFieldHighlight): TextBoxMappingResult { + let items = this.textMappingsLayout.getHighlight(highlight); + + const doMapping = (items: TextBoxMappingResult, textBoxMapping: TextBoxMapping, parent: any) => + flatMap(items, item => { + if (item.cell) { + const { cell: baseCell } = item.cell.getNormalized(); + if (baseCell.parent === parent) { + const newItems = textBoxMapping.apply(item.cell); + return newItems.map(({ cell, sourceSpan }) => { + return { + cell, + sourceSpan: spanOffset(sourceSpan, item.sourceSpan[START]) + }; + }); + } + return item; + } + return []; + }); + + const { textToPdfTextItemMappings, textToHtmlBboxMappings } = this; + if (textToPdfTextItemMappings) { + items = doMapping(items, textToPdfTextItemMappings, this.textMappingsLayout); + } + if (textToHtmlBboxMappings) { + items = doMapping(items, textToHtmlBboxMappings, this.textMappingsLayout); + } + return items; + } + + getHighlight( + highlight: T + ): HighlightShape & Omit { + debug('getHighlight: %o', highlight); + const { field, fieldIndex, location, className, ...rest } = highlight; + const items = this.getHighlightTextMappingResult({ field, fieldIndex, location }); + debug('getHighlight - items: %o', items); + + const boxShapes: HighlightShapeBox[] = items + .map((item, index) => { + const { cell: baseCell, span: baseSpan } = item.cell?.getNormalized() || {}; + if (baseCell) { + let bbox = baseCell.bbox; + if (baseSpan) { + bbox = + baseCell.getBboxForTextSpan(baseSpan) || + baseCell.getBboxForTextSpan(baseSpan, { useRatio: true }) || + baseCell.bbox; + } + debug('getHighlight - cell(%i): %o', item.cell); + debug(' box: %o', bbox); + return { + bbox, + isStart: index === 0, + isEnd: index === items.length - 1 + }; + } else { + debug('getHighlight - cell(%i) missing. source span: %o', item.sourceSpan); + } + // drop something!! + return null; + }) + .filter(nonEmpty); + return { + boxes: boxShapes, + className, + ...rest + }; + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md new file mode 100644 index 000000000..94a34ae74 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md @@ -0,0 +1,37 @@ +## How highlighting works + +### TextLayout + +`TextLayout` shows that what text is placed where in a page. `TextLayout` has multiple `TextLayoutCells`, which shows a particular text is rendered in a particular boundary box. + +So, `metadata.text_mappings` is a kind of `TextLayout` because it bounds text to a boundary box. `bbox`es stored in `html` field can be `TextLayout`s. Also, text content items from PDF (i.e. PDF programmatic text) can be `TextLayout`s. + +Each type of text layout has each granularity, text length and the size of boundary box in a `TextLayoutCell` are different. For example, a cell from `text_mappings` typically has longer text (sometimes it's a paragraph) and large boundary box. A cell from PDF text content item has shorter text (say it's word or short phrase) and small boundary box. + +For highlighting, smaller boundary boxes allow more accurate highlight location. + +### Find smaller text layout cell using `TextBoxMappings` + +So, we build mappings from larger cells to smaller cells. More detail, map a span on a text of a large cell to a span on a text of a smaller cell. + +We typically starts with cells from `text_mapping` because we can find a cell and s span on it from a span on a field. Then we can use the mappings to find smaller cells, which are typically from PDF text content items. + +However, calculation of the mapping is not straightforward. Cells can be over-wrapped, order of smaller cells may not same to the text in a larger cells. So, `getTextBoxMappings` and it helpers `TextNormalizer`, `TextProvider`, `CellProvider` are for calculating the best mapping even with the situation. + +### Text layout cell to boundary box + +`TextLayout` shows what text is placed where in a page. `TextLayout` has multiple `TextLayoutCells`, which shows a particular text is rendered in a particular boundary box. + +So, `metadata.text_mappings` is a kind of `TextLayout` because it bounds text to a boundary box. `bbox`es stored in `html` field can be `TextLayout`s. Also, text content items from PDF (i.e. PDF programmatic text) can be `TextLayout`s. + +Each type of text layout has each granularity, text length and the size of boundary box in a `TextLayoutCell` are different. For example, a cell from `text_mappings` typically has longer text (sometimes it's a paragraph) and large boundary box. A cell from PDF text content item has shorter text (say it's word or short phrase) and small boundary box. + +For highlighting, smaller boundary boxes allow more accurate highlight location. + +Even with a small cell, text to highlight may be a span on a cell text. In the case, we have to calculate boundary box for the text. By default, cells approximate the boundary box by assigning width evenly to every characters in the cell text. + +Some `TextLayoutCalls` has capability of calculating boundary box for a sub-span of its text. For example, cells for PDF text items `PdfTextContentTextLayoutCell` can calculate boundary boxes for given text spans. It internally uses DOM and DOM's `getBoundingClientRect` to get the result. + +### Highlighter + +`Highlighter` manages available information about a document and a page, and calculate boundary boxes for given spans on fields. diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts new file mode 100644 index 000000000..331277f79 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts @@ -0,0 +1,96 @@ +import { isSideBySideOnLine } from '../common/bboxUtils'; +import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; + +export class CellProvider { + private readonly skippedCells: TextLayoutCellBase[] = []; + private cells: TextLayoutCellBase[]; // make sure to handle this as immutable array + private cursor: number = 0; + + constructor(cells: TextLayoutCellBase[]) { + this.cells = [...cells]; + } + + hasNext() { + while (this.cursor < this.cells.length) { + const cell = this.cells[this.cursor]; + if (cell.text.trim().length !== 0) { + break; + } + this.skip(); + } + return this.cursor < this.cells.length; + } + + /** get cells on a line */ + private getNextCells = (() => { + let lastCells: TextLayoutCellBase[] | null = null; + let lastCursor: number | null = null; + let lastResult: TextLayoutCellBase[] | null = null; + + return () => { + if (lastResult && lastCells === this.cells && lastCursor === this.cursor) { + return lastResult; + } + + const result: TextLayoutCellBase[] = []; + let lastCell: TextLayoutCell | null = null; + for (let i = this.cursor; i < this.cells.length; i += 1) { + const currentBox = this.cells[i]; + // maybe we need to break this loop by big box change + const { cell: baseCurrentCell } = currentBox.getNormalized(); + if (lastCell && !isSideBySideOnLine(lastCell.bbox, baseCurrentCell.bbox)) { + break; + } + result.push(currentBox); + lastCell = baseCurrentCell; + } + lastCells = this.cells; + lastCursor = this.cursor; + lastResult = result; + + return result; + }; + })(); + + /** get text from cells on a line */ + getNextText() { + const nextCells = this.getNextCells(); + const texts = nextCells.map(cell => cell.text); + return { texts, nextCellIndex: this.cursor }; + } + + /** consume first n chars */ + consume(length: number): TextLayoutCellBase[] { + const result: TextLayoutCellBase[] = []; + + let lengthToConsume = length; + while (lengthToConsume > 0) { + const current = this.cells[this.cursor]; + const bboxTextLength = current.text.length; + + if (lengthToConsume < bboxTextLength) { + // in this case, split bbox and consume matched part + // add prefix to the result + const consumed = current.getPartial([0, lengthToConsume]); + result.push(consumed); + + const remaining = current.getPartial([lengthToConsume, bboxTextLength]); + const newCells = [...this.cells]; + newCells[this.cursor] = remaining; + this.cells = newCells; + break; + } + + result.push(current); + lengthToConsume -= bboxTextLength; + this.cursor += 1; + } + return result; + } + + /** skip the current cell */ + skip() { + this.skippedCells.push(this.cells[this.cursor]); + this.cursor += 1; + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts new file mode 100644 index 000000000..9c1b12384 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts @@ -0,0 +1,61 @@ +import { TextSpan } from '../../types'; +import { TextProvider } from './TextProvider'; +import { TextNormalizer } from '../common/TextNormalizer'; +import minBy from 'lodash/minBy'; +import { spanGetText, spanLen, START } from '../common/textSpanUtils'; +import { TextLayoutCell } from '../textLayout/types'; + +const debugOut = require('debug')?.('pdf:mapping:MappingSourceTextProvider'); +function debug(...args: any) { + debugOut?.apply(null, args); +} + +export class MappingSourceTextProvider { + private readonly cell: TextLayoutCell; + private readonly normalizer: TextNormalizer; + private readonly provider: TextProvider; + + constructor(cell: TextLayoutCell) { + this.cell = cell; + this.normalizer = new TextNormalizer(cell.text); + this.provider = new TextProvider(this.normalizer.normalizedText); + } + + getMatch(text: string) { + const normalizedText = this.normalizer.normalize(text); + debug('getMatch "%s", normalized "%s"', text, normalizedText); + const normalizedMatches = this.provider.getMatches(normalizedText); + debug('normalized matches: %o', normalizedMatches); + + // find best + const normalizedResult = minBy(normalizedMatches, m => m.minHistoryDistance); + if (!normalizedResult) { + debug('getMatch result: null'); + return null; + } + + const rawMatchedSpan = this.normalizer.toRaw(normalizedResult.span); + const rawSkipTextSpan = this.normalizer.toRaw([ + normalizedResult.span[START] - normalizedResult.skipText.length, + normalizedResult.span[START] + ]); + const r = { + span: rawMatchedSpan, + skipText: spanGetText(this.cell.text, rawSkipTextSpan), + score: spanLen(rawMatchedSpan) - normalizedResult.minHistoryDistance, + approxLenAfterEnd: normalizedResult.textAfterEnd.length + }; + debug('getMatch result: %o', r); + return r; + } + + consume(span: TextSpan) { + const normalizedSpan = this.normalizer.toNormalized(span); + this.provider.consume(normalizedSpan); + debug('text span consumed %o', span); + } + + isBlank(text: string) { + return this.normalizer.isBlank(text); + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts new file mode 100644 index 000000000..72990c7e2 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts @@ -0,0 +1,57 @@ +import { TextLayoutCellBase } from '../textLayout/types'; +import { TextNormalizer } from '../common/TextNormalizer'; +import { CellProvider } from './CellProvider'; +import { END } from '../common/textSpanUtils'; + +export class MappingTargetBoxProvider { + private readonly cellProvider: CellProvider; + private current: { + nextCellIndex: number; + normalizer: TextNormalizer; + leadingSpaces: number; + } | null = null; + + constructor(cells: TextLayoutCellBase[]) { + this.cellProvider = new CellProvider(cells); + } + + hasNext() { + while (this.cellProvider.hasNext()) { + const { texts, nextCellIndex } = this.cellProvider.getNextText(); + const text = texts.join(''); + const leadingSpaces = text.match(/^\s*/)?.[0].length ?? 0; + const trimmedText = text.substring(leadingSpaces); + if (trimmedText.length > 0) { + const normalizer = new TextNormalizer(trimmedText); + this.current = { + nextCellIndex, + normalizer, + leadingSpaces + }; + return true; + } + this.cellProvider.skip(); // skip blank only + } + this.current = null; + return false; + } + + getNextInfo() { + return { + text: this.current!.normalizer.normalizedText, + index: this.current!.nextCellIndex + }; + } + + consume(length: number) { + const rawSpan = this.current!.normalizer.toRaw([0, length]); + const rawLength = this.current!.leadingSpaces + rawSpan[END]; + this.current = null; + return this.cellProvider.consume(rawLength); + } + + skip() { + this.current = null; + this.cellProvider.skip(); + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts new file mode 100644 index 000000000..15580c849 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts @@ -0,0 +1,87 @@ +import { TextSpan } from '../../types'; +import { TextBoxMapping, TextBoxMappingEntry, TextBoxMappingResult } from './types'; +import { Dictionary } from 'lodash'; +import groupBy from 'lodash/groupBy'; +import { + spanCompare, + spanFromSubSpan, + spanGetSubSpan, + spanIntersection, + spanIntersects +} from '../common/textSpanUtils'; +import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; +import { TextNormalizer } from '../common/TextNormalizer'; + +const debugOut = require('debug')?.('pdf:mapping:TextBoxMappingImpl'); +function debug(...args: any) { + debugOut?.apply(null, args); +} + +export class TextBoxMappingImpl implements TextBoxMapping { + private readonly mappingEntryMap: Dictionary; + + constructor(mappingEntries: TextBoxMappingEntry[]) { + this.mappingEntryMap = groupBy(mappingEntries, m => m.text.cell.id); + + // sort by span offset + Object.values(this.mappingEntryMap).forEach(value => { + value.sort((a, b) => spanCompare(a.text.span, b.text.span)); + }); + debug('TextBoxMapping created'); + debug(this); + } + + getEntries(sourceCell: TextLayoutCell, spanInSourceCell: TextSpan) { + return (this.mappingEntryMap[sourceCell.id] || []).filter(m => + spanIntersects(m.text.span, spanInSourceCell) + ); + } + + apply(source: TextLayoutCellBase, aSpan?: TextSpan): TextBoxMappingResult { + const span: TextSpan = aSpan || [0, source.text.length]; + + const { cell: sourceCell, span: sourceSpan } = source.getNormalized(); + const spanInSourceCell = sourceSpan ? spanFromSubSpan(sourceSpan, span) : span; + + debug('applying TextBoxMapping'); + debug(source, span); + const entries = this.getEntries(sourceCell, spanInSourceCell); + const result = entries.map(m => { + if (!m.box) { + return { cell: null, sourceSpan: m.text.span }; + } else { + let boxSpan; + if (hasSameText(m.text.cell, m.text.span, source, spanInSourceCell)) { + boxSpan = spanGetSubSpan(m.text.span, spanInSourceCell); + } else { + const n1 = new TextNormalizer(m.text.cell.text); + const normalizedBoxSpan = spanGetSubSpan( + n1.toNormalized(m.text.span), + n1.toNormalized(spanInSourceCell) + ); + const n2 = new TextNormalizer(m.box.cell.text); + boxSpan = n2.toRaw(normalizedBoxSpan); + } + + return { + cell: m.box.cell.getPartial(boxSpan), + sourceSpan: spanIntersection(m.text.span, spanInSourceCell) + }; + } + }); + debug('applying TextBoxMapping - result'); + debug(result); + return result; + } +} + +function hasSameText( + textCell: TextLayoutCellBase, + textSpan: TextSpan, + sourceCell: TextLayoutCellBase, + sourceSpan: TextSpan +) { + const left = textCell.text.substring(...textSpan); + const right = sourceCell.text.substring(...sourceSpan); + return left === right; +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts new file mode 100644 index 000000000..08f8ff4e9 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts @@ -0,0 +1,92 @@ +import { TextSpan } from '../../types'; +import { + END, + START, + spanIntersects, + spanIncludesIndex, + spanGetText, + spanIntersection +} from '../common/textSpanUtils'; +import { findLargestIndex } from '../common/findLargestIndex'; + +const MAX_HISTORY = 3; + +export type TextMatch = { + span: TextSpan; + skipText: string; + minHistoryDistance: number; + textAfterEnd: string; +}; + +export class TextProvider { + private readonly fieldText: string; + private remainingSpans: TextSpan[]; + private history: number[] = [0]; // Keep MAX_HISTORY last recently consumed + + constructor(fieldText: string) { + this.fieldText = fieldText; + this.remainingSpans = [[0, fieldText.length]]; + } + + getMatches(text: string, minLength = 1, maxLength = text.length): TextMatch[] { + const match = findLargestIndex(minLength, maxLength + 1, index => { + const lengthToMatch = index; + const textToMatch = text.substring(0, lengthToMatch); + + const result: TextMatch[] = []; + for (const aSpan of this.remainingSpans) { + const [spanBegin, spanEnd] = aSpan; + const spanText = this.fieldText.slice(spanBegin, spanEnd); + + const foundIndex = spanText.indexOf(textToMatch); + if (foundIndex >= 0) { + const foundSpanBegin = spanBegin + foundIndex; + const foundSpanEnd = foundSpanBegin + textToMatch.length; + const historyDistances = this.history.map(i => { + const v = foundSpanBegin - i; + return v >= 0 ? v : Number.MAX_SAFE_INTEGER; + }); + result.push({ + span: [foundSpanBegin, foundSpanEnd], + skipText: spanText.substring(0, foundIndex), + minHistoryDistance: Math.min(...historyDistances, this.fieldText.length), + textAfterEnd: this.remainingSpans + .map(span => { + const validSpan = spanIntersection([foundSpanEnd, this.fieldText.length], span); + return spanGetText(this.fieldText, validSpan); + }) + .join('') + }); + } + } + return result.length > 0 ? result : null; + }); + + return match ? match.value : []; + } + + consume(span: TextSpan) { + const remaining: TextSpan[] = []; + this.remainingSpans.forEach(remainingSpan => { + if (spanIntersects(span, remainingSpan)) { + if (remainingSpan[START] < span[START]) { + remaining.push([remainingSpan[START], span[START]]); + } + if (span[END] < remainingSpan[END]) { + remaining.push([span[END], remainingSpan[END]]); + } + } else { + remaining.push(remainingSpan); + } + }); + this.remainingSpans = remaining; + + // update history + const validSpans = [span[END], ...this.history].filter(index => { + if (spanIncludesIndex(span, index)) return false; + if (!this.remainingSpans.some(s => spanIncludesIndex(s, index))) return false; + return true; + }); + this.history = validSpans.slice(0, MAX_HISTORY); + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/__tests__/TextProvider.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/__tests__/TextProvider.test.ts new file mode 100644 index 000000000..928b4cabd --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/__tests__/TextProvider.test.ts @@ -0,0 +1,56 @@ +import { TextProvider } from '../TextProvider'; + +describe('TextProvider', () => { + it('should find correct span for a text', () => { + const fieldText = 'This is a sample sample text content.'; + const provider = new TextProvider(fieldText); + + const r = provider.getMatches('sample')[0]; + expect(r?.skipText).toBe('This is a '); + expect(r?.span).toEqual([10, 16]); + expect(r?.minHistoryDistance).toBe(10); + expect(r?.textAfterEnd).toBe(' sample text content.'); + }); + + it('should find correct spans for a text after consuming a span', () => { + const fieldText = 'This is a sample sample text content.'; + const matcher = new TextProvider(fieldText); + + // match and consumer a word + let match = matcher.getMatches('sample'); + let r = match[0]; + matcher.consume(r?.span); + + // find span in former of remaining spans + match = matcher.getMatches(' is'); + expect(match).toHaveLength(1); + r = match[0]; + expect(r?.skipText).toBe('This'); + expect(r?.span).toEqual([4, 7]); + expect(r?.minHistoryDistance).toBe(4); + expect(r?.textAfterEnd).toBe(' a sample text content.'); + + // find span in latter of remaining spans + match = matcher.getMatches('sample'); + expect(match).toHaveLength(1); + r = match[0]; + expect(r?.skipText).toBe(' '); + expect(r?.span).toEqual([17, 23]); + expect(r?.minHistoryDistance).toBe(1); + expect(r?.textAfterEnd).toBe(' text content.'); + + // find spans in both of remaining spans + match = matcher.getMatches('s'); + expect(match).toHaveLength(2); + r = match[0]; + expect(r?.skipText).toBe('Thi'); + expect(r?.span).toEqual([3, 4]); + expect(r?.minHistoryDistance).toBe(3); + expect(r?.textAfterEnd).toBe(' is a sample text content.'); + r = match[1]; + expect(r?.skipText).toBe(' '); + expect(r?.span).toEqual([17, 18]); + expect(r?.minHistoryDistance).toBe(1); + expect(r?.textAfterEnd).toBe('ample text content.'); + }); +}); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts new file mode 100644 index 000000000..dc618c6b7 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -0,0 +1,133 @@ +import minBy from 'lodash/minBy'; +import { TextSpan } from '../../types'; +import { bboxIntersects } from '../common/bboxUtils'; +import { nonEmpty } from '../common/nonEmpty'; +import { spanLen, spanUnion } from '../common/textSpanUtils'; +import { TextLayout, TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; +import { MappingSourceTextProvider } from './MappingSourceTextProvider'; +import { MappingTargetBoxProvider } from './MappingTargetCellProvider'; +import { TextBoxMappingImpl } from './TextBoxMapping'; +import { TextBoxMappingEntry } from './types'; + +const debugOut = require('debug')?.('pdf:mapping:getTextBoxMapping'); +function debug(...args: any) { + debugOut?.apply(null, args); +} + +function findMatchInSources( + sources: { + cell: TextLayoutCell; + provider: MappingSourceTextProvider; + }[], + textToMatch: string +) { + // find matches + const matches = sources.map(source => { + const match = source.provider.getMatch(textToMatch); + return { + cell: source.cell, + provider: source.provider, + match + }; + }); + + // calc cost for each match + let skipTextLen = 0; + const matchesWithCost = matches.map(aMatch => { + const { match: providerMatch } = aMatch; + const cost = !providerMatch + ? Number.MAX_SAFE_INTEGER + : skipTextLen + providerMatch.skipText.length - spanLen(providerMatch.span); + + skipTextLen += providerMatch?.approxLenAfterEnd ?? 0; + + return { ...aMatch, cost }; + }); + + // find best match + const bestMatch = minBy(matchesWithCost, match => match.cost); + return bestMatch; +} + +export function getTextBoxMappings< + SourceCell extends TextLayoutCell, + TargetCell extends TextLayoutCell +>(source: TextLayout, target: TextLayout) { + const sourceProviders = source.cells.map(cell => new MappingSourceTextProvider(cell)); + const targetProvider = new MappingTargetBoxProvider(target.cells); + + const targetIndexToSources = target.cells.map(targetCell => { + return source.cells + .map((sourceCell, index) => { + if (!bboxIntersects(sourceCell.bbox, targetCell.bbox)) { + return null; + } + return { cell: sourceCell, provider: sourceProviders[index] }; + }) + .filter(nonEmpty); + }); + + const mappingEntries: TextBoxMappingEntry[] = []; + + debug('getTextBoxMapping'); + while (targetProvider.hasNext()) { + // find matches + const { index: targetCellIndex, text: targetText } = targetProvider.getNextInfo(); + debug('> find match at index %d, text: %s', targetCellIndex, targetText); + const matchInSource = findMatchInSources(targetIndexToSources[targetCellIndex], targetText); + debug('> source cell(s) matched: %o', matchInSource); + + // skip when no match found... + if (!matchInSource?.match || spanLen(matchInSource.match.span) === 0) { + targetProvider.skip(); + continue; + } + + const matchedSourceSpan = matchInSource.match.span; + const matchedSourceProvider = matchInSource.provider; + const matchedLength = spanLen(matchedSourceSpan); + + const matchedTargetCells = targetProvider.consume(matchedLength); + debug('> target cells for matched length: %d', matchedLength); + debug(matchedTargetCells); + + let consumedSourceSpan: TextSpan = [0, 0]; + matchedTargetCells.forEach(mTargetCell => { + const trimmedCell = trimCell(mTargetCell); + if (trimmedCell.text.length > 0) { + const matchToTargetCell = matchedSourceProvider.getMatch(trimmedCell.text); + debug('>> target cell %o (%o) to source %o', mTargetCell, trimmedCell, matchToTargetCell); + if (matchToTargetCell) { + // consume source text which is just mapped to the target + matchedSourceProvider.consume(matchToTargetCell.span); + consumedSourceSpan = spanUnion(consumedSourceSpan, matchToTargetCell.span); + mappingEntries.push({ + text: { cell: matchInSource.cell, span: matchToTargetCell.span }, + box: { cell: trimmedCell } + }); + debug('>> added mapping entry %o', mappingEntries[mappingEntries.length - 1]); + } + } + }); + // consume entire the range that is matched to sources + if (spanLen(consumedSourceSpan) > 0) { + matchedSourceProvider.consume(consumedSourceSpan); + debug('> span consumed in source: ', consumedSourceSpan); + } + } + + return new TextBoxMappingImpl(mappingEntries); +} + +function trimCell(cell: TextLayoutCellBase) { + const text = cell.text; + const nLeadingSpaces = text.match(/^\s*/)![0].length; + const nTrailingSpaces = text.match(/\s*$/)![0].length; + if (nLeadingSpaces === 0 && nTrailingSpaces === 0) { + return cell; + } + if (text.length > nLeadingSpaces + nTrailingSpaces) { + return cell.getPartial([nLeadingSpaces, text.length - nTrailingSpaces]); + } + return cell.getPartial([0, 0]); +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/index.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/index.ts new file mode 100644 index 000000000..8e16507ac --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/index.ts @@ -0,0 +1 @@ +export { getTextBoxMappings } from './getTextBoxMapping'; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts new file mode 100644 index 000000000..132694a7d --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts @@ -0,0 +1,16 @@ +import { TextSpan } from '../../types'; +import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; + +export type TextBoxMappingResult = { + cell: TextLayoutCellBase | null; + sourceSpan: TextSpan; +}[]; + +export interface TextBoxMapping { + apply(source: TextLayoutCellBase, span?: TextSpan): TextBoxMappingResult; +} + +export interface TextBoxMappingEntry { + text: { cell: TextLayoutCell; span: TextSpan }; + box: { cell: TextLayoutCellBase } | null; +} From e43318a6f1afc9c9b044ce43ac886b500b055652 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 10 Nov 2021 16:43:20 +0900 Subject: [PATCH 15/51] feat: add PDF highlight component --- .../PdfViewerHighlight/PdfViewerHighlight.tsx | 109 +++++++++++ .../PdfViewerWithHighlight.stories.scss | 25 +++ .../PdfViewerWithHighlight.stories.tsx | 182 ++++++++++++++++++ .../PdfViewerWithHighlight.tsx | 105 ++++++++++ .../_document-preview-pdf-viewer.scss | 13 ++ 5 files changed, 434 insertions(+) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx new file mode 100644 index 000000000..7991ddfb3 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx @@ -0,0 +1,109 @@ +import React, { FC, useMemo, useEffect } from 'react'; +import cx from 'classnames'; +import { DocumentFieldHighlight } from './types'; +import { QueryResult } from 'ibm-watson/discovery/v2'; +import { PdfTextLayerInfo } from '../PdfViewer/PdfViewerTextLayer'; +import { Highlighter } from './utils/Highlighter'; +import { ExtractedDocumentInfo } from './utils/common/documentUtils'; +import { settings } from 'carbon-components'; + +interface Props { + className?: string; + highlightClassName?: string; + + document: QueryResult; + documentInfo: ExtractedDocumentInfo | null; + pageNum: number; + highlights: DocumentFieldHighlight[]; + pdfTextLayerInfo?: PdfTextLayerInfo; + + scale?: number; + + useHtmlBbox?: boolean; + usePdfTextItem?: boolean; +} + +const PdfViewerHighlight: FC = ({ + className, + highlightClassName, + document, + documentInfo, + pageNum, + highlights, + pdfTextLayerInfo, + scale = 1.0, + useHtmlBbox = true, + usePdfTextItem = true +}) => { + const { + viewport: pdfViewport, + textContent: pdfTextContent, + textDivs: pdfTextDivs + } = pdfTextLayerInfo || {}; + const highlighter = useMemo(() => { + if (documentInfo && documentInfo.textMappings) { + return new Highlighter({ + document, + textMappings: documentInfo.textMappings, + pageNum, + htmlBboxInfo: useHtmlBbox + ? { + bboxes: documentInfo.processedDoc.bboxes, + styles: documentInfo.processedDoc.styles + } + : undefined, + pdfTextContentInfo: + usePdfTextItem && pdfTextContent && pdfViewport + ? { textContent: pdfTextContent, viewport: pdfViewport } + : undefined + }); + } + return null; + }, [document, documentInfo, pageNum, pdfTextContent, pdfViewport, useHtmlBbox, usePdfTextItem]); + + useEffect(() => { + if (highlighter) { + highlighter.setTextContentDivs(pdfTextDivs); + } + }, [highlighter, pdfTextDivs]); + + const highlightBoxes = useMemo(() => { + return highlights.map(highlight => { + return highlighter?.getHighlight(highlight); + }); + }, [highlighter, highlights]); + + return ( +
+ {highlightBoxes.map((hl, hlIndex) => { + return ( + + {hl?.boxes.map((item, index) => { + const padding = 0; + const [left, top, right, bottom] = item.bbox; + return ( +
+ ); + })} + + ); + })} +
+ ); +}; + +export default PdfViewerHighlight; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss new file mode 100644 index 000000000..6de187694 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss @@ -0,0 +1,25 @@ +.withTextSelection { + display: flex; + height: 800px; + + .rightPane { + flex: 1 1 auto; + width: 20%; + overflow-y: scroll; + + p { + margin-bottom: 0.5rem; + } + } + .text { + overflow-wrap: break-word; + white-space: pre-wrap; + font-size: 10pt; + font-family: 'Courier New', Courier, monospace; + } + + .highlight { + opacity: 0.4; + background: rgba(255, 64, 128, 1); + } +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx new file mode 100644 index 000000000..67bdd3ffa --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx @@ -0,0 +1,182 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { storiesOf } from '@storybook/react'; +import { withKnobs, radios, number } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import PdfViewerWithHighlight from './PdfViewerWithHighlight'; +import { flatten } from 'lodash'; +import { DocumentFieldHighlight } from './types'; + +import { document as doc } from 'components/DocumentPreview/__fixtures__/Art Effects.pdf'; +import document from 'components/DocumentPreview/__fixtures__/Art Effects Koya Creative Base TSA 2008.pdf.json'; + +import './PdfViewerWithHighlight.stories.scss'; + +const pageKnob = { + label: 'Page', + options: { + range: true, + min: 1, + max: 8, + step: 1 + }, + defaultValue: 1 +}; + +const zoomKnob = { + label: 'Zoom', + options: { + 'Zoom out (50%)': '0.5', + 'Default (100%)': '1', + 'Zoom in (150%)': '1.5' + }, + defaultValue: '1' +}; + +const EMPTY: never[] = []; + +const WithTextSelection: typeof PdfViewerWithHighlight = props => { + const [selectedField, setSelectedField] = useState('text|||0'); + const { document } = props; + + const handleOnChangeField = useCallback((e: React.ChangeEvent) => { + setSelectedField(e.target.value); + }, []); + const [selectedFieldName, selectedFieldIndex] = useMemo(() => { + const [n, i] = selectedField?.split('|||') || []; + return [n, Number(i)]; + }, [selectedField]); + const fieldOptions = useMemo(() => { + const fields = Object.keys(document).filter(field => { + return !field.match(/^(document_id|extracted_|enriched_)/) && document[field]?.length > 0; + }); + return flatten( + fields.map(field => { + return document[field] + .map((content: any, index: number) => { + if (typeof content === 'string') { + return { + value: `${field}|||${index}`, + label: `${field}[${index}]` + }; + } + return null; + }) + .filter((x: any) => !!x); + }) + ); + }, [document]); + + // text selection & highlights + const [highlights, setHighlights] = useState([]); + + const fieldTextNodeRef = useRef(null); + const getFieldTextSelection = () => { + const selection = window.getSelection(); + if (!fieldTextNodeRef.current) { + return null; + } + if (!selection || selection.rangeCount < 1 || selection.isCollapsed) { + return null; + } + + const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; + const anchorParentNode = anchorNode?.parentNode as HTMLElement; + const focusParentNode = focusNode?.parentNode as HTMLElement; + if ( + anchorParentNode !== fieldTextNodeRef.current || + focusParentNode !== fieldTextNodeRef.current + ) { + return null; + } + + const text = selection.toString(); + return { text, begin: anchorOffset, end: focusOffset }; + }; + const handleOnMouseUp = (_: MouseEvent) => { + const textSelection = getFieldTextSelection(); + if (!textSelection) { + return; + } + + const { begin, end } = textSelection; + const fieldText = document[selectedFieldName][selectedFieldIndex]; + + const highlight: DocumentFieldHighlight = { + field: selectedFieldName, + fieldIndex: selectedFieldIndex, + location: { begin: Math.min(begin, end), end: Math.max(begin, end) }, + text: fieldText?.substring(begin, end) + } as DocumentFieldHighlight; + setHighlights([highlight]); + }; + + return ( +
+ +
+
+ +
+

+ {/* eslint-disable-next-line jsx-a11y/no-onchange*/} + +

+
Select text to highlight
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +

+ {selectedField && + document[selectedFieldName][selectedFieldIndex] + .replace(/ /g, '\u00a0') // NBSP + .replaceAll('\n', '\\n')} +

+
+
+ ); +}; + +storiesOf('DocumentPreview/components/PdfViewerWithHighlight', module) + .addDecorator(withKnobs) + .add('default', () => { + const page = number(pageKnob.label, pageKnob.defaultValue, pageKnob.options); + const zoom = radios(zoomKnob.label, zoomKnob.options, zoomKnob.defaultValue); + const scale = parseFloat(zoom); + const setLoadingAction = action('setLoading'); + + return ( + + ); + }) + .add('with text selection', () => { + const page = number(pageKnob.label, pageKnob.defaultValue, pageKnob.options); + const zoom = radios(zoomKnob.label, zoomKnob.options, zoomKnob.defaultValue); + const scale = parseFloat(zoom); + const setLoadingAction = action('setLoading'); + + return ( + + ); + }); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx new file mode 100644 index 000000000..a1f6fef63 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -0,0 +1,105 @@ +import React, { FC, useState, useEffect } from 'react'; +import { PDFSource } from 'pdfjs-dist'; +import { QueryResult } from 'ibm-watson/discovery/v2'; +import { DocumentFieldHighlight } from './types'; +import PdfViewer from '../PdfViewer/PdfViewer'; +import PdfViewerHighlight from './PdfViewerHighlight'; +import { extractDocumentInfo, ExtractedDocumentInfo } from './utils/common/documentUtils'; + +interface Props { + className?: string; + highlightClassName?: string; + + /** + * PDF file data as base64-encoded string + */ + file: string; + + /** + * Page number, starting at 1 + */ + page: number; + + /** + * Zoom factor, where `1` is equal to 100% + */ + scale: number; + + /** + * Options passed to PdfJsLib.getDocument + */ + pdfLoadOptions?: PDFSource; + + /** + * Callback invoked with page count, once `file` has been parsed + */ + setPageCount?: (count: number) => void; + /** + * Check if document is loading + */ + setLoading?: (loading: boolean) => void; + /** + * Callback which is invoked with whether to enable/disable toolbar controls + */ + setHideToolbarControls?: (disabled: boolean) => void; + + /** + * A document + */ + document: QueryResult; + + /** + * Highlight + */ + highlights: DocumentFieldHighlight[]; + + /** + * Consider bboxes in HTML field to highlight (internal) + */ + useHtmlBbox?: boolean; +} + +const PdfViewerWithHighlight: FC = ({ + highlightClassName, + document, + highlights, + useHtmlBbox, + ...rest +}) => { + const { page, scale } = rest; + const [textLayerInfo, setTextLayerInfo] = useState(); + + const [documentInfo, setDocumentInfo] = useState(null); + useEffect(() => { + let cancelled = false; + const extractDocInfo = async () => { + const info = await extractDocumentInfo(document); + if (!cancelled) { + setDocumentInfo(info); + } + }; + extractDocInfo(); + return () => { + cancelled = true; + }; + }, [document]); + + const highlightReady = !!documentInfo && !!textLayerInfo; + return ( + + + + ); +}; + +export default PdfViewerWithHighlight; diff --git a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss index dce987167..fb66bb360 100644 --- a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss +++ b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss @@ -12,3 +12,16 @@ .#{$prefix}--document-preview-pdf-viewer--text { transform-origin: left top 0px; } + +.#{$prefix}--document-preview-pdf-viewer-highlight { + position: absolute; + transform-origin: left top 0px; + top: 0; + left: 0; +} + +.#{$prefix}--document-preview-pdf-viewer-highlight--item { + position: absolute; + opacity: 0.5; + background: rgba(0, 0, 255, 1); +} From 1d46e110822fa91da9f87758cb6684519b6d16e2 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 10 Nov 2021 20:59:44 +0900 Subject: [PATCH 16/51] fix: fix readme --- .../PdfViewerHighlight/utils/README.md | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md index 94a34ae74..30a215684 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md @@ -2,33 +2,27 @@ ### TextLayout -`TextLayout` shows that what text is placed where in a page. `TextLayout` has multiple `TextLayoutCells`, which shows a particular text is rendered in a particular boundary box. +`TextLayout` shows that what text is placed where in a page. `TextLayout` has multiple `TextLayoutCells`. Each cell shows a particular text is rendered in a particular boundary box. -So, `metadata.text_mappings` is a kind of `TextLayout` because it bounds text to a boundary box. `bbox`es stored in `html` field can be `TextLayout`s. Also, text content items from PDF (i.e. PDF programmatic text) can be `TextLayout`s. +So, `metadata.text_mappings` is a kind of `TextLayout` because it bounds text to a boundary box. `bbox`es stored in `html` field can also be a `TextLayout`. Text content items from PDF (i.e. PDF programmatic text) as well. -Each type of text layout has each granularity, text length and the size of boundary box in a `TextLayoutCell` are different. For example, a cell from `text_mappings` typically has longer text (sometimes it's a paragraph) and large boundary box. A cell from PDF text content item has shorter text (say it's word or short phrase) and small boundary box. +Depending on its source, each type of text layout has each granularity, i.e. text length and the size of boundary box in a `TextLayoutCell` are different. For example, a cell from `text_mappings` typically has longer text (sometimes it's a paragraph) and large boundary box. A cell from PDF text content item has shorter text (say it's word or short phrase) and small boundary box. -For highlighting, smaller boundary boxes allow more accurate highlight location. +For highlighting, smaller boundary boxes produces more accurate highlight boundary box. ### Find smaller text layout cell using `TextBoxMappings` -So, we build mappings from larger cells to smaller cells. More detail, map a span on a text of a large cell to a span on a text of a smaller cell. +So, we build mappings from larger cells to smaller cells. More detail, mapping from a span on a text in a large cell to a span on a text in a smaller cell. -We typically starts with cells from `text_mapping` because we can find a cell and s span on it from a span on a field. Then we can use the mappings to find smaller cells, which are typically from PDF text content items. +To find highlight boundary box, we typically starts with cells from `text_mapping` because we can find a cell and s span on it from a span on a field. Then, use the mappings to find smaller cells, which are typically from PDF text content items. -However, calculation of the mapping is not straightforward. Cells can be over-wrapped, order of smaller cells may not same to the text in a larger cells. So, `getTextBoxMappings` and it helpers `TextNormalizer`, `TextProvider`, `CellProvider` are for calculating the best mapping even with the situation. +However, calculation of the mapping is not straightforward. Cells can be over-wrapped, order of smaller cells may not same to the text in a larger cells. `getTextBoxMappings` and it helpers `TextNormalizer`, `TextProvider`, `CellProvider` are used to calculate a good mapping even with the situation. ### Text layout cell to boundary box -`TextLayout` shows what text is placed where in a page. `TextLayout` has multiple `TextLayoutCells`, which shows a particular text is rendered in a particular boundary box. +Now, we have small cells for highlighting. -So, `metadata.text_mappings` is a kind of `TextLayout` because it bounds text to a boundary box. `bbox`es stored in `html` field can be `TextLayout`s. Also, text content items from PDF (i.e. PDF programmatic text) can be `TextLayout`s. - -Each type of text layout has each granularity, text length and the size of boundary box in a `TextLayoutCell` are different. For example, a cell from `text_mappings` typically has longer text (sometimes it's a paragraph) and large boundary box. A cell from PDF text content item has shorter text (say it's word or short phrase) and small boundary box. - -For highlighting, smaller boundary boxes allow more accurate highlight location. - -Even with a small cell, text to highlight may be a span on a cell text. In the case, we have to calculate boundary box for the text. By default, cells approximate the boundary box by assigning width evenly to every characters in the cell text. +Even with a small cell, text to highlight may be a span on a cell text. In the case, we have to calculate boundary box for the span. By default, cells approximate the boundary box by assigning width evenly to every characters in the cell text. Some `TextLayoutCalls` has capability of calculating boundary box for a sub-span of its text. For example, cells for PDF text items `PdfTextContentTextLayoutCell` can calculate boundary boxes for given text spans. It internally uses DOM and DOM's `getBoundingClientRect` to get the result. From 9b33674849553dd8082cf65511cc5c4d1737edb1 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 10 Nov 2021 21:54:35 +0900 Subject: [PATCH 17/51] fix: revise readme --- .../components/PdfViewerHighlight/utils/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md index 30a215684..4f4ad2966 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md @@ -18,6 +18,20 @@ To find highlight boundary box, we typically starts with cells from `text_mappin However, calculation of the mapping is not straightforward. Cells can be over-wrapped, order of smaller cells may not same to the text in a larger cells. `getTextBoxMappings` and it helpers `TextNormalizer`, `TextProvider`, `CellProvider` are used to calculate a good mapping even with the situation. +#### How to build mappings + +`CellProvider` denotes fine-grained text layout. It provides small text layout cells with the text. `MappingTargetBoxProvider` wraps `CellProvider` mainly for normalizing text. Normalization is important because the text in original PDF can be refined in field text. For example, two consequence spaces are normalized to one, and quotation marks can be normalized. + +`TextProvider` provides text from course-grained text layout cells. User can consume spans on the text (i.e. mark the text span used) and the class manages text which is yet to be consumed. The class can find `match` to a given text in the remaining text and returns score of match. `MappingSourceTextProvider` wraps `TextProvider` for text normalization. + +With these classes, `getTextBoxMappings` builds mappings as follow: + +1. Load text from `CellProvider`. It may spans on multiple text layout cells +2. Find match in `TextProvider`, and then consume the matched text +3. For each text layout cells in the matched text, + 1. associate the text layout cell and a span on the matched text + 2. mark the text layout cell consumed + ### Text layout cell to boundary box Now, we have small cells for highlighting. From 73c2ff87295094f50bc0393e74b24b4114c27198 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 17 Nov 2021 15:45:37 +0900 Subject: [PATCH 18/51] fix: fix readme --- .../components/PdfViewerHighlight/utils/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md index 4f4ad2966..daec4a7a5 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/README.md @@ -4,7 +4,7 @@ `TextLayout` shows that what text is placed where in a page. `TextLayout` has multiple `TextLayoutCells`. Each cell shows a particular text is rendered in a particular boundary box. -So, `metadata.text_mappings` is a kind of `TextLayout` because it bounds text to a boundary box. `bbox`es stored in `html` field can also be a `TextLayout`. Text content items from PDF (i.e. PDF programmatic text) as well. +So, `metadata.text_mappings` is a kind of `TextLayout` because it bounds text to boundary boxes. `bbox`es stored in `html` field can also be a `TextLayout`. Text objects in a PDF page (`TextContentItem`s in `pdfjs-dist` npm package) can also be a `TextLayout`. Depending on its source, each type of text layout has each granularity, i.e. text length and the size of boundary box in a `TextLayoutCell` are different. For example, a cell from `text_mappings` typically has longer text (sometimes it's a paragraph) and large boundary box. A cell from PDF text content item has shorter text (say it's word or short phrase) and small boundary box. @@ -14,19 +14,19 @@ For highlighting, smaller boundary boxes produces more accurate highlight bounda So, we build mappings from larger cells to smaller cells. More detail, mapping from a span on a text in a large cell to a span on a text in a smaller cell. -To find highlight boundary box, we typically starts with cells from `text_mapping` because we can find a cell and s span on it from a span on a field. Then, use the mappings to find smaller cells, which are typically from PDF text content items. +To find highlight boundary box, we typically starts with cells from `text_mapping` because we can find a cell and a span on it from a span on a field. Then, use the mappings to find smaller cells, which are typically from PDF text content items. -However, calculation of the mapping is not straightforward. Cells can be over-wrapped, order of smaller cells may not same to the text in a larger cells. `getTextBoxMappings` and it helpers `TextNormalizer`, `TextProvider`, `CellProvider` are used to calculate a good mapping even with the situation. +However, calculation of the mapping is not straightforward. A smaller cell can be overlapped with two or more larger cells. The order of smaller cells may not be the same as the text in a larger cells. They make hard to find a smaller cell from a span on a text in a larger cell. `getTextBoxMappings` and it helpers `TextNormalizer`, `TextProvider`, `CellProvider` are used to calculate a good mapping even with the situation. #### How to build mappings -`CellProvider` denotes fine-grained text layout. It provides small text layout cells with the text. `MappingTargetBoxProvider` wraps `CellProvider` mainly for normalizing text. Normalization is important because the text in original PDF can be refined in field text. For example, two consequence spaces are normalized to one, and quotation marks can be normalized. +`CellProvider` denotes fine-grained text layout. It provides small text layout cells with the text. `MappingTargetBoxProvider` wraps `CellProvider` mainly for normalizing text. Normalization is important because the text in original PDF can be refined in field text. For example, two consecutive spaces are normalized to one, and quotation marks can be normalized. `TextProvider` provides text from course-grained text layout cells. User can consume spans on the text (i.e. mark the text span used) and the class manages text which is yet to be consumed. The class can find `match` to a given text in the remaining text and returns score of match. `MappingSourceTextProvider` wraps `TextProvider` for text normalization. With these classes, `getTextBoxMappings` builds mappings as follow: -1. Load text from `CellProvider`. It may spans on multiple text layout cells +1. Load text from `CellProvider`. It may span on multiple text layout cells 2. Find match in `TextProvider`, and then consume the matched text 3. For each text layout cells in the matched text, 1. associate the text layout cell and a span on the matched text From 050db2d91fd70707a66da42b61b22401bc7cc21a Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 17 Nov 2021 18:22:12 +0900 Subject: [PATCH 19/51] refactor: extract logic of iterating range rects --- .../utils/textLayout/dom.ts | 26 +++++-------- .../src/utils/document/documentUtils.ts | 37 ++++++++++++++----- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts index d2cfae28d..498879b72 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts @@ -1,4 +1,4 @@ -import { getTextNodeAndOffset, uniqRects } from 'utils/document/documentUtils'; +import { forEachRectInRange, getTextNodeAndOffset } from 'utils/document/documentUtils'; import { Bbox, TextSpan } from '../../types'; import { BOTTOM, LEFT, RIGHT, TOP } from '../common/bboxUtils'; import { END, START } from '../common/textSpanUtils'; @@ -23,23 +23,14 @@ export function getAdjustedCellByOffsetByDom( const beginOffset = textSpan[START]; const endOffset = Math.min(cell.text.length, textSpan[END]); - let left = cell.bbox[LEFT]; - let right = cell.bbox[RIGHT]; - const top = cell.bbox[TOP]; - const bottom = cell.bbox[BOTTOM]; - - // convert offset - function getAdjustedOffset(orgOffset: number) { - return orgOffset; - } try { const { textNode: beginTextNode, textOffset: beginTextOffset } = beginOffset > 0 - ? getTextNodeAndOffset(spanElement, getAdjustedOffset(beginOffset)) + ? getTextNodeAndOffset(spanElement, beginOffset) : { textNode: spanElement.firstChild, textOffset: 0 }; const { textNode: endTextNode, textOffset: endTextOffset } = endOffset > 0 - ? getTextNodeAndOffset(spanElement, getAdjustedOffset(endOffset)) + ? getTextNodeAndOffset(spanElement, endOffset) : { textNode: spanElement.lastChild, textOffset: spanElement.lastChild.length }; debug('finding text node for: ', cell.text); @@ -48,13 +39,14 @@ export function getAdjustedCellByOffsetByDom( debug(' textContent: ', endTextNode.textContent); debug(' endOffset: ', endTextOffset); - const range = document.createRange(); - range.setStart(beginTextNode, Math.min(beginTextOffset, beginTextNode.length)); - range.setEnd(endTextNode, Math.min(endTextOffset, endTextNode.length)); - // create highlight rect(s) inside of a field + let left = cell.bbox[LEFT]; + let right = cell.bbox[RIGHT]; + const top = cell.bbox[TOP]; + const bottom = cell.bbox[BOTTOM]; + const parentRect = spanElement.parentElement?.getBoundingClientRect(); - Array.prototype.forEach.call(uniqRects(range.getClientRects() as DOMRectList), rect => { + forEachRectInRange(beginTextNode, beginTextOffset, endTextNode, endTextOffset, rect => { left = (rect.left - parentRect!.left) / scale; right = left + rect.width / scale; }); diff --git a/packages/discovery-react-components/src/utils/document/documentUtils.ts b/packages/discovery-react-components/src/utils/document/documentUtils.ts index d82fa9b35..ebadd1cd0 100644 --- a/packages/discovery-react-components/src/utils/document/documentUtils.ts +++ b/packages/discovery-react-components/src/utils/document/documentUtils.ts @@ -144,11 +144,6 @@ export function createFieldRects({ endTextNode, endOffset }: CreateFieldRectsProps): void { - // create a Range for each field - const range = document.createRange(); - range.setStart(beginTextNode, Math.min(beginOffset, beginTextNode.length)); - range.setEnd(endTextNode, Math.min(endOffset, endTextNode.length)); - // create a field container const fieldNode = document.createElement('div'); fieldNode.className = 'field'; @@ -158,21 +153,45 @@ export function createFieldRects({ fragment.appendChild(fieldNode); // create highlight rect(s) inside of a field - Array.prototype.forEach.call(uniqRects(range.getClientRects() as DOMRectList), rect => { + forEachRectInRange(beginTextNode, beginOffset, endTextNode, endOffset, rect => { const div = document.createElement('div'); div.className = 'field--rect'; div.setAttribute('data-testid', 'field-rect'); div.setAttribute( 'style', `top: ${rect.top - parentRect.top}px; - left: ${rect.left - parentRect.left}px; - width: ${rect.width}px; - height: ${rect.height}px;` + left: ${rect.left - parentRect.left}px; + width: ${rect.width}px; + height: ${rect.height}px;` ); fieldNode.appendChild(div); }); } +/** + * Iterate over all the DOMRects for a range + * @param beginTextNode + * @param beginOffset + * @param endTextNode + * @param endOffset + * @param callback a callback invoked with each DOMRect in a range + */ +export function forEachRectInRange( + beginTextNode: Text, + beginOffset: number, + endTextNode: Text, + endOffset: number, + callback: (rect: DOMRect) => any +) { + // create a Range + const range = document.createRange(); + range.setStart(beginTextNode, Math.min(beginOffset, beginTextNode.length)); + range.setEnd(endTextNode, Math.min(endOffset, endTextNode.length)); + + // visit rects in the range + Array.prototype.forEach.call(uniqRects(range.getClientRects() as DOMRectList), callback); +} + // Some browsers (Chrome, Safari) return duplicate rects export function uniqRects(rects: DOMRectList): Partial { return uniqWith( From f62c4196f72c751f72498b29978cdce2d1598777 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 17 Nov 2021 22:01:24 +0900 Subject: [PATCH 20/51] fix: apply review comments - add return type - add document to methods/classes --- .../components/PdfViewer/PdfViewer.tsx | 1 + .../PdfViewerHighlight/PdfViewerHighlight.tsx | 117 +++++++++++++----- .../PdfViewerWithHighlight.tsx | 57 +++------ .../PdfViewerHighlight/{utils => }/README.md | 0 .../PdfViewerHighlight/utils/Highlighter.ts | 109 ++++++++++------ .../utils/common/TextNormalizer.ts | 92 +++++++++++--- .../utils/common/__tests__/bboxUtils.test.ts | 8 +- .../utils/common/bboxUtils.ts | 29 ++--- .../utils/common/documentUtils.ts | 17 ++- .../utils/common/nonEmpty.ts | 6 + .../utils/common/textSpanUtils.ts | 63 ++++++++-- .../utils/textBoxMapping/CellProvider.ts | 73 ++++++----- .../MappingSourceTextProvider.ts | 15 ++- .../MappingTargetCellProvider.ts | 17 ++- .../utils/textBoxMapping/TextBoxMapping.ts | 19 ++- .../utils/textBoxMapping/TextProvider.ts | 15 +++ .../utils/textBoxMapping/getTextBoxMapping.ts | 26 +++- .../utils/textBoxMapping/types.ts | 12 ++ .../utils/textLayout/BaseTextLayout.ts | 9 ++ .../utils/textLayout/HtmlBboxTextLayout.ts | 15 ++- .../textLayout/PdfTextContentTextLayout.ts | 35 ++++-- .../textLayout/TextMappingsTextLayout.ts | 12 ++ .../utils/textLayout/dom.ts | 8 ++ .../utils/textLayout/types.ts | 2 +- .../components/DocumentPreview/utils/box.ts | 2 +- 25 files changed, 538 insertions(+), 221 deletions(-) rename packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/{utils => }/README.md (100%) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index 4a0de76ef..3c90b0196 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -199,4 +199,5 @@ function getCanvasInfo(viewport: any): CanvasInfo { return { width, height, canvasWidth, canvasHeight, canvasScale }; } +export type PdfViewerProps = Props; export default PdfViewer; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx index 7991ddfb3..44e626c33 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx @@ -6,28 +6,71 @@ import { PdfTextLayerInfo } from '../PdfViewer/PdfViewerTextLayer'; import { Highlighter } from './utils/Highlighter'; import { ExtractedDocumentInfo } from './utils/common/documentUtils'; import { settings } from 'carbon-components'; +import { TextMappings } from 'components/DocumentPreview/types'; +import { ProcessedDoc } from 'utils/document'; interface Props { + /** + * Class name to style highlight layer + */ className?: string; + + /** + * Class name to style each highlight + */ highlightClassName?: string; + /** + * Document data returned by query + */ document: QueryResult; - documentInfo: ExtractedDocumentInfo | null; + + /** + * Parsed document information + */ + parsedDocument: ExtractedDocumentInfo | null; + + /** + * Current page, starting at index 1 + */ pageNum: number; + + /** + * Highlight spans on fields in document + */ highlights: DocumentFieldHighlight[]; - pdfTextLayerInfo?: PdfTextLayerInfo; + /** + * PDF text content information in a page from parsed PDF + */ + pdfTextLayerInfo: PdfTextLayerInfo | null; + + /** + * Zoom factor, where `1` is equal to 100% + */ scale?: number; + /** + * Flag to whether or not to use bbox information from html field in the document. + * True by default. This is for testing and debugging purpose. + */ useHtmlBbox?: boolean; + + /** + * Flag to whether to use PDF text items for finding bbox for highlighting. + * True by default. This is for testing and debugging purpose. + */ usePdfTextItem?: boolean; } +/** + * Text highlight layer for PdfViewer + */ const PdfViewerHighlight: FC = ({ className, highlightClassName, document, - documentInfo, + parsedDocument, pageNum, highlights, pdfTextLayerInfo, @@ -35,37 +78,20 @@ const PdfViewerHighlight: FC = ({ useHtmlBbox = true, usePdfTextItem = true }) => { - const { - viewport: pdfViewport, - textContent: pdfTextContent, - textDivs: pdfTextDivs - } = pdfTextLayerInfo || {}; - const highlighter = useMemo(() => { - if (documentInfo && documentInfo.textMappings) { - return new Highlighter({ - document, - textMappings: documentInfo.textMappings, - pageNum, - htmlBboxInfo: useHtmlBbox - ? { - bboxes: documentInfo.processedDoc.bboxes, - styles: documentInfo.processedDoc.styles - } - : undefined, - pdfTextContentInfo: - usePdfTextItem && pdfTextContent && pdfViewport - ? { textContent: pdfTextContent, viewport: pdfViewport } - : undefined - }); - } - return null; - }, [document, documentInfo, pageNum, pdfTextContent, pdfViewport, useHtmlBbox, usePdfTextItem]); + const highlighter = useHighlighter({ + document, + textMappings: parsedDocument?.textMappings, + processedDoc: useHtmlBbox ? parsedDocument?.processedDoc : undefined, + pdfTextLayerInfo: (usePdfTextItem && pdfTextLayerInfo) || undefined, + pageNum + }); + const { textDivs } = pdfTextLayerInfo || {}; useEffect(() => { if (highlighter) { - highlighter.setTextContentDivs(pdfTextDivs); + highlighter.setTextContentDivs(textDivs); } - }, [highlighter, pdfTextDivs]); + }, [highlighter, textDivs]); const highlightBoxes = useMemo(() => { return highlights.map(highlight => { @@ -106,4 +132,35 @@ const PdfViewerHighlight: FC = ({ ); }; +const useHighlighter = ({ + document, + textMappings, + processedDoc, + pdfTextLayerInfo, + pageNum +}: { + document: QueryResult; + textMappings?: TextMappings; + processedDoc?: ProcessedDoc; + pdfTextLayerInfo?: PdfTextLayerInfo; + pageNum: number; +}) => { + return useMemo(() => { + if (textMappings) { + return new Highlighter({ + document, + textMappings, + pageNum, + htmlBboxInfo: processedDoc && { + bboxes: processedDoc.bboxes, + styles: processedDoc.styles + }, + pdfTextContentInfo: + pdfTextLayerInfo?.textContent && pdfTextLayerInfo?.viewport ? pdfTextLayerInfo : undefined + }); + } + return null; + }, [document, pageNum, pdfTextLayerInfo, processedDoc, textMappings]); +}; + export default PdfViewerHighlight; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx index a1f6fef63..6036ff996 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -1,64 +1,37 @@ import React, { FC, useState, useEffect } from 'react'; -import { PDFSource } from 'pdfjs-dist'; -import { QueryResult } from 'ibm-watson/discovery/v2'; import { DocumentFieldHighlight } from './types'; -import PdfViewer from '../PdfViewer/PdfViewer'; +import PdfViewer, { PdfViewerProps } from '../PdfViewer/PdfViewer'; import PdfViewerHighlight from './PdfViewerHighlight'; import { extractDocumentInfo, ExtractedDocumentInfo } from './utils/common/documentUtils'; +import { QueryResult } from 'ibm-watson/discovery/v2'; +import { PdfTextLayerInfo } from '../PdfViewer/PdfViewerTextLayer'; -interface Props { - className?: string; - highlightClassName?: string; - - /** - * PDF file data as base64-encoded string - */ - file: string; - - /** - * Page number, starting at 1 - */ - page: number; - - /** - * Zoom factor, where `1` is equal to 100% - */ - scale: number; - - /** - * Options passed to PdfJsLib.getDocument - */ - pdfLoadOptions?: PDFSource; - +interface Props extends PdfViewerProps { /** - * Callback invoked with page count, once `file` has been parsed + * Class name to style each highlight */ - setPageCount?: (count: number) => void; - /** - * Check if document is loading - */ - setLoading?: (loading: boolean) => void; - /** - * Callback which is invoked with whether to enable/disable toolbar controls - */ - setHideToolbarControls?: (disabled: boolean) => void; + highlightClassName?: string; /** - * A document + * Document data returned by query */ document: QueryResult; /** - * Highlight + * Highlight spans on fields in document */ highlights: DocumentFieldHighlight[]; /** - * Consider bboxes in HTML field to highlight (internal) + * Consider bboxes in HTML field to highlight. + * True by default. This is for testing purpose. */ useHtmlBbox?: boolean; } +/** + * PDF viewer component with text highlighting capability + */ const PdfViewerWithHighlight: FC = ({ highlightClassName, document, @@ -67,7 +40,7 @@ const PdfViewerWithHighlight: FC = ({ ...rest }) => { const { page, scale } = rest; - const [textLayerInfo, setTextLayerInfo] = useState(); + const [textLayerInfo, setTextLayerInfo] = useState(null); const [documentInfo, setDocumentInfo] = useState(null); useEffect(() => { @@ -90,7 +63,7 @@ const PdfViewerWithHighlight: FC = ({ - flatMap(items, item => { - if (item.cell) { - const { cell: baseCell } = item.cell.getNormalized(); - if (baseCell.parent === parent) { - const newItems = textBoxMapping.apply(item.cell); - return newItems.map(({ cell, sourceSpan }) => { - return { - cell, - sourceSpan: spanOffset(sourceSpan, item.sourceSpan[START]) - }; - }); - } - return item; - } - return []; - }); - - const { textToPdfTextItemMappings, textToHtmlBboxMappings } = this; - if (textToPdfTextItemMappings) { - items = doMapping(items, textToPdfTextItemMappings, this.textMappingsLayout); - } - if (textToHtmlBboxMappings) { - items = doMapping(items, textToHtmlBboxMappings, this.textMappingsLayout); - } - return items; + /** + * Update text content HTML elements + * @param textContentDivs HTML elements where text content items are rendered + */ + setTextContentDivs(textContentDivs?: HTMLElement[]) { + this.pdfTextContentLayout?.setDivs(textContentDivs); } + /** + * Get highlight shape from a span on a field + * @param highlight a span on a document field to highlight + * @returns highlight shape + */ getHighlight( highlight: T ): HighlightShape & Omit { @@ -142,10 +131,8 @@ export class Highlighter { isStart: index === 0, isEnd: index === items.length - 1 }; - } else { - debug('getHighlight - cell(%i) missing. source span: %o', item.sourceSpan); } - // drop something!! + debug('getHighlight - cell(%i) is not mapped. source span: %o', item.sourceSpan); return null; }) .filter(nonEmpty); @@ -155,4 +142,44 @@ export class Highlighter { ...rest }; } + + /** + * Get text layout cells from a span on a field + * @param highlight a span on a document field to highlight + * @returns TextLayoutCells representing the given highlight + */ + private getHighlightTextMappingResult(highlight: DocumentFieldHighlight): TextBoxMappingResult { + let items = this.textMappingsLayout.getHighlight(highlight); + + const doMapping = ( + items: TextBoxMappingResult, + textBoxMapping: TextBoxMapping, + parent: TextLayout + ) => + flatMap(items, item => { + if (item.cell) { + const { cell: baseCell } = item.cell.getNormalized(); + if (baseCell.parent === parent) { + const newItems = textBoxMapping.apply(item.cell); + return newItems.map(({ cell, sourceSpan }) => { + return { + cell, + sourceSpan: spanOffset(sourceSpan, item.sourceSpan[START]) + }; + }); + } + return item; + } + return []; + }); + + const { textToPdfTextItemMappings, textToHtmlBboxMappings } = this; + if (textToPdfTextItemMappings) { + items = doMapping(items, textToPdfTextItemMappings, this.textMappingsLayout); + } + if (textToHtmlBboxMappings) { + items = doMapping(items, textToHtmlBboxMappings, this.textMappingsLayout); + } + return items; + } } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts index 8eab9cf07..edfc7967f 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts @@ -3,13 +3,24 @@ import { END, spanLen, START } from './textSpanUtils'; type SpanMapping = { rawSpan: TextSpan; normalizedSpan: TextSpan }; -const SPACES = { +type CharNormalizer = { + /** + * Get normalized character of the original string + */ + normal: (original: string) => string; + /** + * String representation regex that matches to characters to be normalized + */ + regexString: string; +}; + +const SPACES: CharNormalizer = { normal: () => ' ', regexString: '\\s+' }; -const DOUBLE_QUOTE = { - normal: () => '"', +const DOUBLE_QUOTE: CharNormalizer = { + normal: (_: string) => '"', regexString: `[${[ '«', // U+00AB '»', // U+00BB @@ -27,7 +38,7 @@ const DOUBLE_QUOTE = { ].join('')}]` }; -const QUOTE = { +const QUOTE: CharNormalizer = { normal: () => "'", regexString: `[${[ '‹', // U+2039 @@ -44,7 +55,10 @@ const QUOTE = { ].join('')}]` }; -const SURROGATE_PAIR = { +// handle a character that is encoded as a surrogate pair +// in Javascript string (i.e. UTF-16), whose length is 2 +// as a single character +const SURROGATE_PAIR: CharNormalizer = { normal: (_: string) => '_', regexString: '[\uD800-\uDBFF][\uDC00-\uDFFF]' }; @@ -52,13 +66,13 @@ const SURROGATE_PAIR = { // remove "Combining Diacritical Marks" from the string // NOTE: we may have to do this after conversion again // str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") -const DIACRITICAL_MARK = { +const DIACRITICAL_MARK: CharNormalizer = { normal: () => '', regexString: '[\u0300-\u036f]' }; const DIACRITICAL_MARK_REGEX = new RegExp(DIACRITICAL_MARK.regexString, 'g'); -function normalizeDiacriticalMarks(text: string, keepLength = false) { +function normalizeDiacriticalMarks(text: string, keepLength = false): string { const r = text .normalize('NFD') .replace(DIACRITICAL_MARK_REGEX, DIACRITICAL_MARK.normal) @@ -86,11 +100,20 @@ const NORMALIZATIONS_REGEX = new RegExp( ); /** - * Normalize text + * Normalize the following in text: + * - two or more consecutive spaces to a single space + * - variants of single quote to `'` + * - variants of double quote to `"` + * - surrogate pairs to a single character `_` + * - remove diacritical marks (accent) from characters + * + * This is used for preprocessing to compare texts to ignore minor + * text differences. + * * @param text text to normalize * @returns normalized text @see TextNormalizer */ -export function normalizeText(text: string) { +function normalizeText(text: string): string { const r = NORMALIZATIONS.reduce((text, n) => { return text.replace(n.regex, m => n.normal(m)); }, text); @@ -101,10 +124,11 @@ export function normalizeText(text: string) { * Text normalizer with mapping between spans on original and normalized text * * Normalize the following in a text: - * - two or more consequent spaces - * - single or double quote - * - surrogate pairs - * - diacritical marks (accent) + * - two or more consecutive spaces to a single space + * - variants of single quote to `'` + * - variants of double quote to `"` + * - surrogate pairs to a single character `_` + * - remove diacritical marks (accent) from characters */ export class TextNormalizer { readonly rawText: string; @@ -161,6 +185,7 @@ export class TextNormalizer { } match = re.exec(this.rawText); } + if (cur < this.rawText.length) { const newText = this.rawText.substring(cur); const rawSpan: TextSpan = [cur, cur + newText.length]; @@ -171,10 +196,16 @@ export class TextNormalizer { normalizationMappings.push({ rawSpan, normalizedSpan }); addNormalizedText(newText); } + this.normalizedText = normalizedText; this.normalizationMappings = optimizeSpanMappings(normalizationMappings); } + /** + * Convert a span on original text to a span on normalized text + * @param rawSpan span on original text + * @returns span on normalized text + */ toNormalized(rawSpan: TextSpan): TextSpan { const [rawBegin, rawEnd] = rawSpan; @@ -193,6 +224,11 @@ export class TextNormalizer { return [normalizedIndex(rawBegin), normalizedIndex(rawEnd)]; } + /** + * Convert a span on normalized text to a span on normalized text + * @param normalizedSpan span on normalized text + * @returns span on original text + */ toRaw(normalizedSpan: TextSpan): TextSpan { const [normalizedBegin, normalizedEnd] = normalizedSpan; @@ -213,12 +249,22 @@ export class TextNormalizer { return [rawIndex(normalizedBegin), rawIndex(normalizedEnd)]; } - normalize(text: string) { + /** + * Normalize a text. @see TextNormalizer for the details of the normalization + * @param text text to be normalized + * @returns normalized text + */ + normalize(text: string): string { return normalizeText(text); } - isBlank(text: string) { - return text.length === 0 || text.trim().length === 0 || text.match(/^\s*$/); + /** + * Check whether a given text is blank or not + * @param text text to be tested + * @returns `true` when the text only contains spaces + */ + isBlank(text: string): boolean { + return text.length === 0 || text.trim().length === 0 || !!text.match(/^\s*$/); } } @@ -241,7 +287,19 @@ function mapCharIndexOnSpans( ); } -function optimizeSpanMappings(mappings: SpanMapping[]) { +/** + * Optimize the mappings between spans on original text and spans on normalized text + * by merging consecutive identical mappings + * + * Example: given mapping: + * (original: [0,10] -> normalized: [0,10]) + * (original: [10,20] -> normalized: [10,20]) + * (original: [20,25] -> normalized: [20,21]) + * The mapping above is optimized to: + * (original: [0,20] -> normalized: [0,20]) + * (original: [20,25] -> normalized: [20,21]) + */ +function optimizeSpanMappings(mappings: SpanMapping[]): SpanMapping[] { const sameLength = (mapping: SpanMapping) => spanLen(mapping.normalizedSpan) === spanLen(mapping.rawSpan); const isShifted = (a: SpanMapping, b: SpanMapping) => diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts index f67fc87fd..419f0836b 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts @@ -1,4 +1,4 @@ -import { bboxGetSpanByRatio, bboxIntersects, isSideBySideOnLine } from '../bboxUtils'; +import { bboxGetSpanByRatio, bboxIntersects, isNextToEachOther } from '../bboxUtils'; describe('bboxIntersects', () => { it('should return true when boxes intersect', () => { @@ -30,12 +30,12 @@ describe('bboxGetSpanByRatio', () => { describe('isSideBySideOnLine', () => { it('should return true for side-by-side boxes', () => { - expect(isSideBySideOnLine([0, 0, 5, 2], [5, 0, 10, 2])).toBeTruthy(); + expect(isNextToEachOther([0, 0, 5, 2], [5, 0, 10, 2])).toBeTruthy(); }); it('should return false when boxes are not vertically aligned', () => { - expect(isSideBySideOnLine([0, 0, 5, 2], [5, 1, 10, 3])).toBeFalsy(); + expect(isNextToEachOther([0, 0, 5, 2], [5, 1, 10, 3])).toBeFalsy(); }); it('should return false when two boxes are apart from each other', () => { - expect(isSideBySideOnLine([0, 0, 5, 2], [7, 0, 10, 2])).toBeFalsy(); + expect(isNextToEachOther([0, 0, 5, 2], [7, 0, 10, 2])).toBeFalsy(); }); }); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts index 2a3de4d31..a918f64f0 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts @@ -1,3 +1,4 @@ +import { intersects } from 'components/DocumentPreview/utils/box'; import { Bbox, TextSpan } from '../../types'; import { spanIntersection, spanLen } from './textSpanUtils'; @@ -13,12 +14,10 @@ export const BOTTOM = 3; * but for type `Bbox`, which doesn't have page property * @param boxA one bbox * @param boxB another bbox - * @returns true iff boxA and boxB are overwrapped + * @returns true iff boxA and boxB are overlapped */ -export function bboxIntersects(boxA: Bbox, boxB: Bbox) { - const [leftA, topA, rightA, bottomA] = boxA; - const [leftB, topB, rightB, bottomB] = boxB; - return !(leftB >= rightA || rightB <= leftA || topB >= bottomA || bottomB <= topA); +export function bboxIntersects(boxA: Bbox, boxB: Bbox): boolean { + return intersects(boxA, boxB); } /** @@ -27,7 +26,7 @@ export function bboxIntersects(boxA: Bbox, boxB: Bbox) { * @param origLength length of the text * @returns bbox for the text */ -export function bboxGetSpanByRatio(bbox: Bbox, origLength: number, span: TextSpan) { +export function bboxGetSpanByRatio(bbox: Bbox, origLength: number, span: TextSpan): Bbox { const theSpan = spanIntersection([0, origLength], span); if (origLength === 0 || spanLen(theSpan) <= 0) { return [bbox[0], bbox[1], bbox[0], bbox[3]] as Bbox; @@ -39,16 +38,14 @@ export function bboxGetSpanByRatio(bbox: Bbox, origLength: number, span: TextSpa const resultLeft = left + (width / origLength) * spanStart; const resultRight = left + (width / origLength) * spanEnd; - return [resultLeft, top, resultRight, bottom] as Bbox; + return [resultLeft, top, resultRight, bottom]; } /** - * Check whether two bboxes seems to be side-by-side on a same line. - * @param boxA - * @param boxB - * @returns + * Check whether the two bboxes are next to each other in a row. + * This is used to get a text of a line from a list of small text cells. */ -export function isSideBySideOnLine(boxA: Bbox, boxB: Bbox) { +export function isNextToEachOther(boxA: Bbox, boxB: Bbox): boolean { if (bboxIntersects(boxA, boxB)) { return false; } @@ -59,14 +56,14 @@ export function isSideBySideOnLine(boxA: Bbox, boxB: Bbox) { const heightB = bottomB - topB; // compare height ratio - const OVERWRAP_RATIO = 0.8; - if (!(heightA * OVERWRAP_RATIO < heightB || heightB * OVERWRAP_RATIO < heightA)) { + const OVERLAP_RATIO = 0.8; + if (!(heightA * OVERLAP_RATIO < heightB || heightB * OVERLAP_RATIO < heightA)) { return false; } const avgHeight = (heightA + heightB) / 2; - const overWrapHeight = Math.max(0, Math.min(bottomA, bottomB) - Math.max(topA, topB)); - if (overWrapHeight < avgHeight * OVERWRAP_RATIO) { + const overlapHeight = Math.max(0, Math.min(bottomA, bottomB) - Math.max(topA, topB)); + if (overlapHeight < avgHeight * OVERLAP_RATIO) { return false; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts index 655093698..576fb6c49 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts @@ -5,12 +5,21 @@ import { processDoc, ProcessedDoc } from 'utils/document'; import { Location } from 'utils/document/processDoc'; import { DocumentFields, TextSpan } from '../../types'; +/** + * Get value of the specified field from a search result document + * + * @param document search result document + * @param field field name + * @param index field index. 0 by default + * @param span (optional) span on the field value to return. Returns entire the field value by default + * @returns text + */ export function getDocFieldValue( document: DocumentFields, field: string, index?: number, span?: Location | TextSpan -) { +): string | undefined { let fieldText: string | undefined; const documentFieldArray = document[field]; @@ -35,11 +44,13 @@ export type ExtractedDocumentInfo = { textMappings?: TextMappings; }; -export async function extractDocumentInfo(document: QueryResult) { +/** + * Extract bboxes and text_mappings from a search result document + */ +export async function extractDocumentInfo(document: QueryResult): Promise { const docHtml = document.html; const textMappings = getTextMappings(document) ?? undefined; - // HtmlView.tsx const processedDoc = await processDoc( { ...document, docHtml }, { sections: true, bbox: true, bboxInnerText: true } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts index a511faa30..be6fb569f 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts @@ -1,3 +1,9 @@ +/** + * A filter to drop any non-null values from a list. + * Use with `Array.filter` method to get a list of non-null type. + * + * `const list: number[] = [1, null, 2].filter(nonEmpty); // [1,2]` + */ export function nonEmpty(value: T | null | undefined): value is T { return value !== null && value !== undefined; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts index b0ae85939..58b388e01 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts @@ -3,28 +3,52 @@ import { TextSpan } from '../../types'; export const START = 0; export const END = 1; -export function spanGetText(text: T, span: TextSpan) { +/** + * Get text for a given span + */ +export function spanGetText( + text: T, + span: TextSpan +): string | T { if (!text) return text; if (spanLen(span) === 0) return ''; return text.substring(span[START], span[END]); } -export function spanLen(span: TextSpan) { +/** + * Get span length + */ +export function spanLen(span: TextSpan): number { return Math.max(0, span[END] - span[START]); } +/** + * Check whether two spans has intersection or not + */ export function spanIntersects([beginA, endA]: TextSpan, [beginB, endB]: TextSpan): boolean { + // TODO: integrate with spansIntersect in documentUtils.ts return beginA < endB && endA > beginB; } -export function spanIncludesIndex([begin, end]: TextSpan, index: number) { +/** + * Check whether a span includes an given character index or not + */ +export function spanIncludesIndex([begin, end]: TextSpan, index: number): boolean { return begin <= index && index < end; } -export function spanContains(span: TextSpan, other: TextSpan) { +/** + * Check whether a span contains another span + * (i.e. for all index in `other` span, the index is in `span` span) + */ +export function spanContains(span: TextSpan, other: TextSpan): boolean { return span[START] <= other[START] && other[END] <= span[END]; } +/** + * Get the largest span that is contained by both of given spans + * @returns intersection of two spans when the two spans intersects. Zero-length span otherwise. + */ export function spanIntersection(a: TextSpan, b: TextSpan): TextSpan { if (spanContains(a, b)) return b; if (spanContains(b, a)) return a; @@ -33,7 +57,10 @@ export function spanIntersection(a: TextSpan, b: TextSpan): TextSpan { return [start, start <= end ? end : start]; } -export function spanUnion(a: TextSpan, b: TextSpan): TextSpan { +/** + * Get the smallest span that contains both of given spans + */ +export function spanMerge(a: TextSpan, b: TextSpan): TextSpan { if (spanContains(a, b) || spanLen(b) === 0) return a; if (spanContains(b, a) || spanLen(a) === 0) return b; const start = Math.min(a[START], b[START]); @@ -41,18 +68,38 @@ export function spanUnion(a: TextSpan, b: TextSpan): TextSpan { return [start, start <= end ? end : start]; } +/** + * Offset spans by given offset + */ export function spanOffset([start, end]: TextSpan, offset: number): TextSpan { return [start + offset, end + offset]; } -export function spanFromSubSpan(base: TextSpan, subSpan: TextSpan) { +/** + * Get a span from a `subSpan` on a given `base` span + * + * For example, `spanFromSubSpan([10, 20], [1, 2]) // [11, 12]` + */ +export function spanFromSubSpan(base: TextSpan, subSpan: TextSpan): TextSpan { return spanIntersection(base, spanOffset(subSpan, base[START])); } -export function spanGetSubSpan(base: TextSpan, span: TextSpan) { +/** + * Get a span within a given `base` span for a `span` + * + * For example, `spanGetSubSpan([10, 20], [11, 12]) // [1, 2]` + */ +export function spanGetSubSpan(base: TextSpan, span: TextSpan): TextSpan { return spanOffset(spanIntersection(base, span), -base[START]); } -export function spanCompare([startA, endA]: TextSpan, [startB, endB]: TextSpan) { +/** + * Compare method for spans + * + * @param spanA a span to compare + * @param spanB another span to compare + * @returns a positive number when spanA is after spanB, a negative number when spanA is before spanB, zero when spanA equals to spanB + */ +export function spanCompare([startA, endA]: TextSpan, [startB, endB]: TextSpan): number { return startA === startB ? endA - endB : startA - startB; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts index 331277f79..73dd4ffe8 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts @@ -1,16 +1,16 @@ -import { isSideBySideOnLine } from '../common/bboxUtils'; +import { isNextToEachOther } from '../common/bboxUtils'; import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; export class CellProvider { private readonly skippedCells: TextLayoutCellBase[] = []; - private cells: TextLayoutCellBase[]; // make sure to handle this as immutable array + private cells: readonly TextLayoutCellBase[]; private cursor: number = 0; constructor(cells: TextLayoutCellBase[]) { - this.cells = [...cells]; + this.cells = Object.freeze([...cells]); } - hasNext() { + hasNext(): boolean { while (this.cursor < this.cells.length) { const cell = this.cells[this.cursor]; if (cell.text.trim().length !== 0) { @@ -22,44 +22,55 @@ export class CellProvider { } /** get cells on a line */ - private getNextCells = (() => { - let lastCells: TextLayoutCellBase[] | null = null; - let lastCursor: number | null = null; - let lastResult: TextLayoutCellBase[] | null = null; + private getNextCells(): TextLayoutCellBase[] { + const { + cells: lastCells, + cursor: lastCursor, + result: lastResult + } = this.getNextCellsCache || {}; - return () => { - if (lastResult && lastCells === this.cells && lastCursor === this.cursor) { - return lastResult; - } + if (lastResult && lastCells === this.cells && lastCursor === this.cursor) { + return lastResult; + } - const result: TextLayoutCellBase[] = []; - let lastCell: TextLayoutCell | null = null; - for (let i = this.cursor; i < this.cells.length; i += 1) { - const currentBox = this.cells[i]; - // maybe we need to break this loop by big box change - const { cell: baseCurrentCell } = currentBox.getNormalized(); - if (lastCell && !isSideBySideOnLine(lastCell.bbox, baseCurrentCell.bbox)) { - break; - } - result.push(currentBox); - lastCell = baseCurrentCell; + const result: TextLayoutCellBase[] = []; + let lastCell: TextLayoutCell | null = null; + for (let i = this.cursor; i < this.cells.length; i += 1) { + const currentBox = this.cells[i]; + // maybe we need to break this loop by big box change + const { cell: baseCurrentCell } = currentBox.getNormalized(); + if (lastCell && !isNextToEachOther(lastCell.bbox, baseCurrentCell.bbox)) { + break; } - lastCells = this.cells; - lastCursor = this.cursor; - lastResult = result; + result.push(currentBox); + lastCell = baseCurrentCell; + } - return result; + this.getNextCellsCache = { + cells: this.cells, + cursor: this.cursor, + result }; - })(); + return result; + } + + private getNextCellsCache: { + cells: readonly TextLayoutCellBase[]; + cursor: number; + result: TextLayoutCellBase[]; + } | null = null; /** get text from cells on a line */ - getNextText() { + getNextText(): { texts: string[]; nextCellIndex: number } { const nextCells = this.getNextCells(); const texts = nextCells.map(cell => cell.text); return { texts, nextCellIndex: this.cursor }; } - /** consume first n chars */ + /** + * consume (mark as used) first n chars from the cursor + * @return text layout cells on the consumed text + */ consume(length: number): TextLayoutCellBase[] { const result: TextLayoutCellBase[] = []; @@ -77,7 +88,7 @@ export class CellProvider { const remaining = current.getPartial([lengthToConsume, bboxTextLength]); const newCells = [...this.cells]; newCells[this.cursor] = remaining; - this.cells = newCells; + this.cells = Object.freeze(newCells); break; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts index 9c1b12384..22dadc34a 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts @@ -10,6 +10,10 @@ function debug(...args: any) { debugOut?.apply(null, args); } +/** + * TextProvider with normalization + * @see TextProvider + */ export class MappingSourceTextProvider { private readonly cell: TextLayoutCell; private readonly normalizer: TextNormalizer; @@ -21,6 +25,9 @@ export class MappingSourceTextProvider { this.provider = new TextProvider(this.normalizer.normalizedText); } + /** + * Find the best span where the give text matches to the rest of the text + */ getMatch(text: string) { const normalizedText = this.normalizer.normalize(text); debug('getMatch "%s", normalized "%s"', text, normalizedText); @@ -49,13 +56,19 @@ export class MappingSourceTextProvider { return r; } + /** + * Mark the given `span` as used + */ consume(span: TextSpan) { const normalizedSpan = this.normalizer.toNormalized(span); this.provider.consume(normalizedSpan); debug('text span consumed %o', span); } - isBlank(text: string) { + /** + * Check whether a given text is blank or not + */ + isBlank(text: string): boolean { return this.normalizer.isBlank(text); } } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts index 72990c7e2..04bad0ac4 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts @@ -3,6 +3,10 @@ import { TextNormalizer } from '../common/TextNormalizer'; import { CellProvider } from './CellProvider'; import { END } from '../common/textSpanUtils'; +/** + * Cell provider with normalization + * @see CellProvider + */ export class MappingTargetBoxProvider { private readonly cellProvider: CellProvider; private current: { @@ -15,7 +19,8 @@ export class MappingTargetBoxProvider { this.cellProvider = new CellProvider(cells); } - hasNext() { + /** check whether this provider has another item to visit or not */ + hasNext(): boolean { while (this.cellProvider.hasNext()) { const { texts, nextCellIndex } = this.cellProvider.getNextText(); const text = texts.join(''); @@ -36,20 +41,26 @@ export class MappingTargetBoxProvider { return false; } - getNextInfo() { + /** get the next value */ + getNextInfo(): { text: string; index: number } { return { text: this.current!.normalizer.normalizedText, index: this.current!.nextCellIndex }; } - consume(length: number) { + /** + * consume (mark as used) first n chars from the cursor + * @return text layout cells on the consumed text + */ + consume(length: number): TextLayoutCellBase[] { const rawSpan = this.current!.normalizer.toRaw([0, length]); const rawLength = this.current!.leadingSpaces + rawSpan[END]; this.current = null; return this.cellProvider.consume(rawLength); } + /** mark the current cell skipped (when no match found in source) */ skip() { this.current = null; this.cellProvider.skip(); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts index 15580c849..95c6cded0 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts @@ -17,6 +17,9 @@ function debug(...args: any) { debugOut?.apply(null, args); } +/** + * Text box mapping + */ export class TextBoxMappingImpl implements TextBoxMapping { private readonly mappingEntryMap: Dictionary; @@ -31,12 +34,17 @@ export class TextBoxMappingImpl implements TextBoxMapping { debug(this); } - getEntries(sourceCell: TextLayoutCell, spanInSourceCell: TextSpan) { + /** get text mapping entries for a given span `spanInSourceCell` on a given `sourceCell` */ + private getEntries( + sourceCell: TextLayoutCell, + spanOnSourceCell: TextSpan + ): TextBoxMappingEntry[] { return (this.mappingEntryMap[sourceCell.id] || []).filter(m => - spanIntersects(m.text.span, spanInSourceCell) + spanIntersects(m.text.span, spanOnSourceCell) ); } + /** @inheritdoc */ apply(source: TextLayoutCellBase, aSpan?: TextSpan): TextBoxMappingResult { const span: TextSpan = aSpan || [0, source.text.length]; @@ -51,7 +59,7 @@ export class TextBoxMappingImpl implements TextBoxMapping { return { cell: null, sourceSpan: m.text.span }; } else { let boxSpan; - if (hasSameText(m.text.cell, m.text.span, source, spanInSourceCell)) { + if (equalsSpanText(m.text.cell, m.text.span, source, spanInSourceCell)) { boxSpan = spanGetSubSpan(m.text.span, spanInSourceCell); } else { const n1 = new TextNormalizer(m.text.cell.text); @@ -75,7 +83,10 @@ export class TextBoxMappingImpl implements TextBoxMapping { } } -function hasSameText( +/** + * Check if text on spans on cells are the same or not + */ +function equalsSpanText( textCell: TextLayoutCellBase, textSpan: TextSpan, sourceCell: TextLayoutCellBase, diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts index 08f8ff4e9..2a5825240 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts @@ -12,12 +12,21 @@ import { findLargestIndex } from '../common/findLargestIndex'; const MAX_HISTORY = 3; export type TextMatch = { + /** matched text span */ span: TextSpan; + /** text before the matched text. i.e. text that will be skipped by using this match */ skipText: string; + /** distance from the nearest cursors */ minHistoryDistance: number; + /** text after the matched text */ textAfterEnd: string; }; +/** + * Manage text in a source (larger) cell. + * - Find text (in a target cell) from the _unused_ text + * - Once a span is mapped to a target (smaller) cell, mark the the correspondent span _used_ + */ export class TextProvider { private readonly fieldText: string; private remainingSpans: TextSpan[]; @@ -28,6 +37,9 @@ export class TextProvider { this.remainingSpans = [[0, fieldText.length]]; } + /** + * Get how the given `text` matches to the currently available text + */ getMatches(text: string, minLength = 1, maxLength = text.length): TextMatch[] { const match = findLargestIndex(minLength, maxLength + 1, index => { const lengthToMatch = index; @@ -65,6 +77,9 @@ export class TextProvider { return match ? match.value : []; } + /** + * Mark the `span` as used + */ consume(span: TextSpan) { const remaining: TextSpan[] = []; this.remainingSpans.forEach(remainingSpan => { diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts index dc618c6b7..f664757b3 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -2,18 +2,24 @@ import minBy from 'lodash/minBy'; import { TextSpan } from '../../types'; import { bboxIntersects } from '../common/bboxUtils'; import { nonEmpty } from '../common/nonEmpty'; -import { spanLen, spanUnion } from '../common/textSpanUtils'; +import { spanLen, spanMerge } from '../common/textSpanUtils'; import { TextLayout, TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; import { MappingSourceTextProvider } from './MappingSourceTextProvider'; import { MappingTargetBoxProvider } from './MappingTargetCellProvider'; import { TextBoxMappingImpl } from './TextBoxMapping'; -import { TextBoxMappingEntry } from './types'; +import { TextBoxMapping, TextBoxMappingEntry } from './types'; const debugOut = require('debug')?.('pdf:mapping:getTextBoxMapping'); function debug(...args: any) { debugOut?.apply(null, args); } +/** + * Find the best source (larger text layout cell) where text `textToMatch` is in + * @param sources source (larger) text layout cells overlapping the current target cell + * @param textToMatch text form target cell(s) + * @returns the best source where the `textToMatch` is matched and the text location in the source + */ function findMatchInSources( sources: { cell: TextLayoutCell; @@ -49,10 +55,16 @@ function findMatchInSources( return bestMatch; } +/** + * Calculate text box mapping from `source` text layout to `target` text layout + * @param source text layout with larger cells + * @param target text layout with smaller cells + * @returns a text box mapping instance + */ export function getTextBoxMappings< SourceCell extends TextLayoutCell, TargetCell extends TextLayoutCell ->(source: TextLayout, target: TextLayout) { +>(source: TextLayout, target: TextLayout): TextBoxMapping { const sourceProviders = source.cells.map(cell => new MappingSourceTextProvider(cell)); const targetProvider = new MappingTargetBoxProvider(target.cells); @@ -100,7 +112,7 @@ export function getTextBoxMappings< if (matchToTargetCell) { // consume source text which is just mapped to the target matchedSourceProvider.consume(matchToTargetCell.span); - consumedSourceSpan = spanUnion(consumedSourceSpan, matchToTargetCell.span); + consumedSourceSpan = spanMerge(consumedSourceSpan, matchToTargetCell.span); mappingEntries.push({ text: { cell: matchInSource.cell, span: matchToTargetCell.span }, box: { cell: trimmedCell } @@ -119,6 +131,10 @@ export function getTextBoxMappings< return new TextBoxMappingImpl(mappingEntries); } +/** + * Get a text layout cell that represents a trimmed text of a given `cell` + * @returns a new cell for the trimmed text. Zero-length cell when the text of the given `cell` is blank + */ function trimCell(cell: TextLayoutCellBase) { const text = cell.text; const nLeadingSpaces = text.match(/^\s*/)![0].length; @@ -129,5 +145,5 @@ function trimCell(cell: TextLayoutCellBase) { if (text.length > nLeadingSpaces + nTrailingSpaces) { return cell.getPartial([nLeadingSpaces, text.length - nTrailingSpaces]); } - return cell.getPartial([0, 0]); + return cell.getPartial([0, 0]); // return zero-length cell } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts index 132694a7d..2c7e0d666 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts @@ -6,10 +6,22 @@ export type TextBoxMappingResult = { sourceSpan: TextSpan; }[]; +/** + * Interface for text box mapping + */ export interface TextBoxMapping { + /** + * Get spans on target (smaller) cells for a given span on a source (larger) cell + * @param source source text layout cell + * @param span span on the source cell + */ apply(source: TextLayoutCellBase, span?: TextSpan): TextBoxMappingResult; } +/** + * Interface for text box mapping entries. + * Internal. Used only in text box mapping implementation + */ export interface TextBoxMappingEntry { text: { cell: TextLayoutCell; span: TextSpan }; box: { cell: TextLayoutCellBase } | null; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts index 77e043328..7e8f7f9e5 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts @@ -35,12 +35,17 @@ export class BaseTextLayoutCell> this.text = text; } + /** @inheritdoc */ getPartial(span: TextSpan): TextLayoutCellBase { return new PartialTextLayoutCell(this, span); } + + /** @inheritdoc */ getNormalized(): { cell: TextLayoutCell; span?: TextSpan } { return { cell: this }; } + + /** @inheritdoc */ getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { if (options?.useRatio) { return bboxGetSpanByRatio(this.bbox, this.text.length, span); @@ -61,14 +66,18 @@ export class PartialTextLayoutCell implements TextLayoutCellBase { this.span = spanIntersection([0, base.text.length], span); } + /* @inheritdoc */ get text() { return spanGetText(this.base.text, this.span); } + /** @inheritdoc */ getPartial(span: TextSpan): TextLayoutCellBase { const newSpan = spanIntersection(this.span, spanOffset(span, this.span[START])); return new PartialTextLayoutCell(this.base, newSpan); } + + /** @inheritdoc */ getNormalized() { return { cell: this.base, span: this.span }; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts index 851ab6991..359120466 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts @@ -4,6 +4,9 @@ import { Bbox, TextSpan } from '../../types'; import { BaseTextLayoutCell } from './BaseTextLayout'; import { HtmlBboxInfo, TextLayout } from './types'; +/** + * Text layout based on bboxes in HTML field + */ export class HtmlBboxTextLayout implements TextLayout { private readonly bboxInfo: HtmlBboxInfo; readonly cells: HtmlBboxTextLayoutCell[]; @@ -18,17 +21,24 @@ export class HtmlBboxTextLayout implements TextLayout { }) ?? []; } + /** @inheritdoc */ cellAt(id: number) { return this.cells[id]; } + /** + * Install style to DOM if not yet. The style will be used to calculate bbox in `getBboxForTextSpan` + */ installStyle() { if (this.bboxInfo.styles) { - // TODO: install style to DOM if not yet. For getBboxForTextSpan in cell + // TODO: implement this } } } +/** + * Text layout cell based on bboxes in HTML field + */ class HtmlBboxTextLayoutCell extends BaseTextLayoutCell { private readonly processedBbox: ProcessedBbox; @@ -47,9 +57,10 @@ class HtmlBboxTextLayoutCell extends BaseTextLayoutCell { this.processedBbox = processedBbox; // keep this for later improvement } + /** @inheritdoc */ getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { if (this.processedBbox != null) { - // TODO: calculate bbox for text span using text on browser + // TODO: implement this. calculate bbox for text span using text on browser } return super.getBboxForTextSpan(span, options); } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts index 9a5c09e71..401dd7578 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -5,10 +5,13 @@ import { BaseTextLayoutCell } from './BaseTextLayout'; import { getAdjustedCellByOffsetByDom } from './dom'; import { HtmlBboxInfo, PdfTextContentInfo, TextLayout } from './types'; +/** + * Text layout based on PDF text objects + */ export class PdfTextContentTextLayout implements TextLayout { private readonly textContentInfo: PdfTextContentInfo; readonly cells: PdfTextContentTextLayoutCell[]; - private spans: HTMLElement[] | undefined; + private divs: HTMLElement[] | undefined; constructor(textContentInfo: PdfTextContentInfo, pageNum: number, htmlBboxInfo?: HtmlBboxInfo) { this.textContentInfo = textContentInfo; @@ -29,25 +32,31 @@ export class PdfTextContentTextLayout implements TextLayout { - // private readonly textItem: TextContentItem; - constructor( parent: PdfTextContentTextLayout, index: number, @@ -58,12 +67,11 @@ class PdfTextContentTextLayoutCell extends BaseTextLayoutCell { readonly cells: TextMappingsTextLayoutCell[]; @@ -24,10 +27,16 @@ export class TextMappingsTextLayout implements TextLayout { readonly cellField: CellField; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts index 498879b72..23dfe7d4a 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts @@ -9,6 +9,14 @@ function debug(...args: any) { debugOut?.apply(null, args); } +/** + * Get a bbox for a span on a text layout cell using DOM element rendered on browser + * @param cell text layout cell + * @param textSpan span on the text layout cell + * @param spanElement an DOM element where the text layout cell is rendered + * @param scale the current scale factor + * @returns bbox for the span on the cell + */ export function getAdjustedCellByOffsetByDom( cell: TextLayoutCell, textSpan: TextSpan, diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts index 7ed59012d..061910b6a 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts @@ -6,7 +6,7 @@ import { Bbox, DocumentFields, TextSpan } from '../../types'; /** * Text layout information */ -export interface TextLayout { +export interface TextLayout { /** cells, paris of bbox and text, of this text layout */ readonly cells: CellType[]; /** get cell by ID */ diff --git a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts index 99d01bdf7..986bd8b5f 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts @@ -7,7 +7,7 @@ import { ProcessedBbox } from '../../../utils/document/processDoc'; * @param boxB second bbox * @returns bool */ -function intersects(boxA: number[], boxB: number[]): boolean { +export function intersects(boxA: number[], boxB: number[]): boolean { const [leftA, topA, rightA, bottomA, pageA] = boxA; const [leftB, topB, rightB, bottomB, pageB] = boxB; return !(leftB > rightA || rightB < leftA || topB > bottomA || bottomB < topA || pageA !== pageB); From 3e06fdae114179bd74402b3f6a7c2d7f151cda17 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 17 Nov 2021 22:45:17 +0900 Subject: [PATCH 21/51] fix: remove unnecessary commets --- .../utils/textLayout/PdfTextContentTextLayout.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts index 401dd7578..dce5d916d 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -91,9 +91,7 @@ class PdfTextContentTextLayoutCell extends BaseTextLayoutCell Date: Wed, 17 Nov 2021 22:46:11 +0900 Subject: [PATCH 22/51] fix: highlighting on header and footer --- .../utils/textBoxMapping/getTextBoxMapping.ts | 7 +++- .../textLayout/PdfTextContentTextLayout.ts | 32 ++++++++++--------- .../utils/textLayout/types.ts | 3 ++ 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts index f664757b3..d692bf114 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -69,7 +69,7 @@ export function getTextBoxMappings< const targetProvider = new MappingTargetBoxProvider(target.cells); const targetIndexToSources = target.cells.map(targetCell => { - return source.cells + const cells = source.cells .map((sourceCell, index) => { if (!bboxIntersects(sourceCell.bbox, targetCell.bbox)) { return null; @@ -77,6 +77,11 @@ export function getTextBoxMappings< return { cell: sourceCell, provider: sourceProviders[index] }; }) .filter(nonEmpty); + + if (cells.some(({ cell }) => cell.isInHtmlBbox)) { + return cells.filter(({ cell }) => cell.isInHtmlBbox); + } + return cells; }); const mappingEntries: TextBoxMappingEntry[] = []; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts index dce5d916d..b6a97afec 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -18,18 +18,16 @@ export class PdfTextContentTextLayout implements TextLayout { - return new PdfTextContentTextLayoutCell(this, index, item, pageNum); - }) - .filter(cell => { - if (htmlBboxInfo?.bboxes?.length) { - return htmlBboxInfo.bboxes.some(bbox => { - return bboxIntersects(cell.bbox, [bbox.left, bbox.top, bbox.right, bbox.bottom]); - }); - } - return true; - }); + this.cells = textContentItems.map((item, index) => { + const cellBbox = PdfTextContentTextLayoutCell.getBbox(item, this.viewport); + let isInHtmlBbox = false; + if (htmlBboxInfo?.bboxes?.length) { + isInHtmlBbox = htmlBboxInfo.bboxes.some(bbox => { + return bboxIntersects(cellBbox, [bbox.left, bbox.top, bbox.right, bbox.bottom]); + }); + } + return new PdfTextContentTextLayoutCell(this, index, item, pageNum, cellBbox, isInHtmlBbox); + }); } /** get viewport of the current page */ @@ -57,14 +55,18 @@ export class PdfTextContentTextLayout implements TextLayout { + /** @inheritdoc */ + readonly isInHtmlBbox?: boolean; + constructor( parent: PdfTextContentTextLayout, index: number, textItem: TextContentItem, - pageNum: number + pageNum: number, + bbox: Bbox, + isInHtmlBbox?: boolean ) { const id = index; - const bbox = PdfTextContentTextLayoutCell.getBbox(textItem, parent.viewport); const text = textItem.str; super({ parent, id, pageNum, bbox, text }); } @@ -85,7 +87,7 @@ class PdfTextContentTextLayoutCell extends BaseTextLayoutCell extends TextLayoutCellBase { * @returns null when it's not available */ getBboxForTextSpan(span: TextSpan, options?: { useRatio?: boolean }): Bbox | null; + + /** a special property for PDF text content item cell. True when this cell overlaps HTML cell */ + readonly isInHtmlBbox?: boolean; } /** From 7a6ef58d82e6a58c5691b9a611a4185f497dfb13 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 18 Nov 2021 12:34:17 +0900 Subject: [PATCH 23/51] fix: fix boxUtil test failure --- .../PdfViewerHighlight/utils/common/bboxUtils.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts index a918f64f0..8884a2d97 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts @@ -1,4 +1,3 @@ -import { intersects } from 'components/DocumentPreview/utils/box'; import { Bbox, TextSpan } from '../../types'; import { spanIntersection, spanLen } from './textSpanUtils'; @@ -10,14 +9,18 @@ export const BOTTOM = 3; /** * Check whether two bbox intersect * - * Same to `intersects` in DocumentPreview/utils/box.ts, - * but for type `Bbox`, which doesn't have page property + * Similar to `intersects` in DocumentPreview/utils/box.ts, differences are: + * - this is for type `Bbox`, which doesn't have page property + * - the `right` and `bottom` values are exclusive. So + * `bboxIntersects([0,1,0,1], [1,2,0,1])` returns `false` * @param boxA one bbox * @param boxB another bbox * @returns true iff boxA and boxB are overlapped */ export function bboxIntersects(boxA: Bbox, boxB: Bbox): boolean { - return intersects(boxA, boxB); + const [leftA, topA, rightA, bottomA] = boxA; + const [leftB, topB, rightB, bottomB] = boxB; + return !(leftB >= rightA || rightB <= leftA || topB >= bottomA || bottomB <= topA); } /** From 3d02caf212b1a22c2dc108125d6ca026e0edc407 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 18 Nov 2021 15:21:50 +0900 Subject: [PATCH 24/51] refactor: use one bbox intersection logic --- .../PdfViewerHighlight/utils/common/bboxUtils.ts | 10 ++-------- .../src/components/DocumentPreview/utils/box.ts | 8 +++++++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts index 8884a2d97..f7e911f8e 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts @@ -1,3 +1,4 @@ +import { intersects } from 'components/DocumentPreview/utils/box'; import { Bbox, TextSpan } from '../../types'; import { spanIntersection, spanLen } from './textSpanUtils'; @@ -8,19 +9,12 @@ export const BOTTOM = 3; /** * Check whether two bbox intersect - * - * Similar to `intersects` in DocumentPreview/utils/box.ts, differences are: - * - this is for type `Bbox`, which doesn't have page property - * - the `right` and `bottom` values are exclusive. So - * `bboxIntersects([0,1,0,1], [1,2,0,1])` returns `false` * @param boxA one bbox * @param boxB another bbox * @returns true iff boxA and boxB are overlapped */ export function bboxIntersects(boxA: Bbox, boxB: Bbox): boolean { - const [leftA, topA, rightA, bottomA] = boxA; - const [leftB, topB, rightB, bottomB] = boxB; - return !(leftB >= rightA || rightB <= leftA || topB >= bottomA || bottomB <= topA); + return intersects(boxA, boxB); } /** diff --git a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts index 986bd8b5f..e5dd2643d 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts @@ -10,7 +10,13 @@ import { ProcessedBbox } from '../../../utils/document/processDoc'; export function intersects(boxA: number[], boxB: number[]): boolean { const [leftA, topA, rightA, bottomA, pageA] = boxA; const [leftB, topB, rightB, bottomB, pageB] = boxB; - return !(leftB > rightA || rightB < leftA || topB > bottomA || bottomB < topA || pageA !== pageB); + return !( + leftB >= rightA || + rightB <= leftA || + topB >= bottomA || + bottomB <= topA || + pageA !== pageB + ); } /** From 3b810afc7e4a12d0373b1c8193a25bd65e4b7c69 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 18 Nov 2021 21:08:09 +0900 Subject: [PATCH 25/51] feat: add Japanese PDF sample --- .../__fixtures__/DiscoComponent-ja.pdf.ts | 2 + .../DiscoComponents-ja_document.json | 55 +++++++++++++++++++ .../PdfViewerWithHighlight.stories.tsx | 36 ++++++++++-- 3 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponent-ja.pdf.ts create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponents-ja_document.json diff --git a/packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponent-ja.pdf.ts b/packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponent-ja.pdf.ts new file mode 100644 index 000000000..9d62e2f10 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponent-ja.pdf.ts @@ -0,0 +1,2 @@ +export const document = + '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MDA2XDAwMDX/XDAxMVwwMDAgXDAwMFFcMDAwdVwwMDBhXDAwMHJcMDAwdFwwMDB6XDAwMCBcMDAwUFwwMDBEXDAwMEZcMDAwQ1wwMDBvXDAwMG5cMDAwdFwwMDBlXDAwMHhcMDAwdCkKL0NyZWF0b3IgKFdvcmQpIC9DcmVhdGlvbkRhdGUgKEQ6MjAyMTExMTgwNzIxMzBaMDAnMDAnKSAvTW9kRGF0ZSAoRDoyMDIxMTExODA3MjEzMFowMCcwMCcpCj4+CmVuZG9iagp4cmVmCjAgNTMKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAyNjY2IDAwMDAwIG4gCjAwMDAwMDU4MDUgMDAwMDAgbiAKMDAwMDAwMDAyMiAwMDAwMCBuIAowMDAwMDAyNzc2IDAwMDAwIG4gCjAwMDAwMDU3NjkgMDAwMDAgbiAKMDAwMDAwMDAwMCAwMDAwMCBuIAowMDAwMDA1OTQ0IDAwMDAwIG4gCjAwMDAwMDAwMDAgMDAwMDAgbiAKMDAwMDAxMjM1MSAwMDAwMCBuIAowMDAwMDAwMDAwIDAwMDAwIG4gCjAwMDAwMTg3NTggMDAwMDAgbiAKMDAwMDAwMDAwMCAwMDAwMCBuIAowMDAwMDIyMjIwIDAwMDAwIG4gCjAwMDAwMDAwMDAgMDAwMDAgbiAKMDAwMDAzMTY0NiAwMDAwMCBuIAowMDAwMDAwMDAwIDAwMDAwIG4gCjAwMDAwNDM4MDggMDAwMDAgbiAKMDAwMDAwMDAwMCAwMDAwMCBuIAowMDAwMDU0MzI5IDAwMDAwIG4gCjAwMDAwNjI3OTIgMDAwMDAgbiAKMDAwMDAwMDAwMCAwMDAwMCBuIAowMDAwMDY2MjQ4IDAwMDAwIG4gCjAwMDAwMDMwMDIgMDAwMDAgbiAKMDAwMDAwMzA1NiAwMDAwMCBuIAowMDAwMDA1ODk0IDAwMDAwIG4gCjAwMDAwMDY1NTUgMDAwMDAgbiAKMDAwMDAwNjE2NiAwMDAwMCBuIAowMDAwMDA2Nzk2IDAwMDAwIG4gCjAwMDAwMTI5NjIgMDAwMDAgbiAKMDAwMDAxMjU3MyAwMDAwMCBuIAowMDAwMDEzMjAzIDAwMDAwIG4gCjAwMDAwMTkzODIgMDAwMDAgbiAKMDAwMDAxODk4NyAwMDAwMCBuIAowMDAwMDE5NjE5IDAwMDAwIG4gCjAwMDAwMjMzNTQgMDAwMDAgbiAKMDAwMDAyMjY1MSAwMDAwMCBuIAowMDAwMDIzNTkxIDAwMDAwIG4gCjAwMDAwMzIzNDYgMDAwMDAgbiAKMDAwMDAzMTkwMCAwMDAwMCBuIAowMDAwMDMyNTgyIDAwMDAwIG4gCjAwMDAwNDUwMzUgMDAwMDAgbiAKMDAwMDA0NDI4MiAwMDAwMCBuIAowMDAwMDQ1MjcyIDAwMDAwIG4gCjAwMDAwNTQ5MTcgMDAwMDAgbiAKMDAwMDA1NDUzOSAwMDAwMCBuIAowMDAwMDU1MTUzIDAwMDAwIG4gCjAwMDAwNjI5NjggMDAwMDAgbiAKMDAwMDA2MzIwNCAwMDAwMCBuIAowMDAwMDY2NzA3IDAwMDAwIG4gCjAwMDAwNjY0MTEgMDAwMDAgbiAKMDAwMDA2Njk1NSAwMDAwMCBuIAowMDAwMDY3MzM3IDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNTMgL1Jvb3QgMjUgMCBSIC9JbmZvIDUyIDAgUiAvSUQgWyA8MjljNGM0Zjk5MWM1NzEzYzg1NWQxMWRkMTlkYTQ0NDE+CjwyOWM0YzRmOTkxYzU3MTNjODU1ZDExZGQxOWRhNDQ0MT4gXSA+PgpzdGFydHhyZWYKNjc3MDYKJSVFT0YK'; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponents-ja_document.json b/packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponents-ja_document.json new file mode 100644 index 000000000..4789d596d --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/__fixtures__/DiscoComponents-ja_document.json @@ -0,0 +1,55 @@ +{ + "document_id": "feab8705259090b89fbcbb15942cb10d", + "result_metadata": { + "collection_id": "b6cdf1cd-902c-8ea3-0000-017d32224d8f" + }, + "enriched_text": [ + { + "entities": [ + { + "model_name": "natural_language_understanding", + "mentions": [ + { + "confidence": 0.9950965, + "location": { + "end": 2, + "begin": 0 + }, + "text": "最初" + } + ], + "text": "最初", + "type": "Ordinal" + } + ] + } + ], + "metadata": { + "parent_document_id": "feab8705259090b89fbcbb15942cb10d", + "customer_id": "IBMid-270001M55T" + }, + "extracted_metadata": { + "sha1": "4FF2B41ED7A77975ABB21D9E4025DF31335E6451", + "numPages": "1", + "filename": "DiscoComponents-ja-updated.pdf", + "file_type": "pdf", + "text_mappings": "{\"text_mappings\":[{\"page\":{\"page_number\":1,\"bbox\":[54.51987838745117,87.82411193847656,400.4930725097656,194.260009765625]},\"field\":{\"name\":\"title\",\"index\":0,\"span\":[0,20]}},{\"page\":{\"page_number\":1,\"bbox\":[54.51987838745117,411.83612060546875,262.9510192871094,425.62003993988037]},\"field\":{\"name\":\"subtitle\",\"index\":0,\"span\":[0,19]}},{\"page\":{\"page_number\":1,\"bbox\":[268.46466064453125,416.1183776855469,325.5726318359375,425.375319480896]},\"field\":{\"name\":\"subtitle\",\"index\":1,\"span\":[0,3]}},{\"page\":{\"page_number\":1,\"bbox\":[54.51987838745117,644.3582763671875,313.07745361328125,653.6152181625366]},\"field\":{\"name\":\"subtitle\",\"index\":2,\"span\":[0,15]}},{\"page\":{\"page_number\":1,\"bbox\":[54.51987838745117,456.12786865234375,95.6172866821289,463.06002855300903]},\"field\":{\"name\":\"text\",\"index\":0,\"span\":[0,4]}},{\"page\":{\"page_number\":1,\"bbox\":[100.0745620727539,452.9471435546875,257.0570983886719,463.06002855300903]},\"field\":{\"name\":\"text\",\"index\":0,\"span\":[4,27]}},{\"page\":{\"page_number\":1,\"bbox\":[261.5120849609375,452.9471435546875,408.1592712402344,463.0600233078003]},\"field\":{\"name\":\"text\",\"index\":0,\"span\":[27,49]}},{\"page\":{\"page_number\":1,\"bbox\":[412.5315856933594,456.12786865234375,464.3571472167969,463.06002855300903]},\"field\":{\"name\":\"text\",\"index\":0,\"span\":[49,54]}},{\"page\":{\"page_number\":1,\"bbox\":[54.51987838745117,452.9471435546875,534.0211791992188,596.2600049972534]},\"field\":{\"name\":\"text\",\"index\":0,\"span\":[54,234]}},{\"page\":{\"page_number\":1,\"bbox\":[54.519996643066406,679.4979858398438,535.1033325195312,723.2200269699097]},\"field\":{\"name\":\"text\",\"index\":0,\"span\":[234,353]}}],\"pages\":[{\"page_number\":0,\"height\":842.0,\"width\":595.0,\"origin\":\"TopLeft\"}]}", + "title": "Discovery Component README Japanese", + "publicationdate": "2021-11-18" + }, + "subtitle": ["Discovery Component", "の使用", "サンプルアプリケーションの実行"], + "html": "Discovery Component README Japanese

Discovery Components

Discovery Component

の使用

最初に

IBM Watson Discovery の

Improve and Customize

ページで

Document retrieval プロジェクトをカスタマイズする必要があります。たとえばファセットや検索 バーや検索結果を設定できます。その後 Discovery component を使ったアプリケ ーションを作成します。アプリケーションは指定したプロジェクトの設定をロードしま す。 必要なソフトウェア: git, nvm, yarn または npm

サンプルアプリケーションの実行

• サンプルアプリケーションはこのライブラリーが提供するコアコンポーネントのカタログです。実際のデ ータを使ってコンポーネントがどのように動くかを簡単に見ることができます。コードを変更して、カスタ マイズする方法を確認することもできます。

", + "text": [ + "最初に IBM Watson Discovery の Improve and Customize ページで Document retrieval プロジェクトをカスタマイズする必要があります。たとえばファセットや検索 バーや検索結果を設定できます。その後 Discovery component を使ったアプリケ ーションを作成します。アプリケーションは指定したプロジェクトの設定をロードしま す。 必要なソフトウェア: git, nvm, yarn または npm • サンプルアプリケーションはこのライブラリーが提供するコアコンポーネントのカタログです。実際のデ ータを使ってコンポーネントがどのように動くかを簡単に見ることができます。コードを変更して、カスタ マイズする方法を確認することもできます。" + ], + "title": "Discovery Components", + "document_passages": [ + { + "passage_text": "Discovery Components", + "start_offset": 0, + "end_offset": 20, + "field": "title" + } + ], + "table_results_references": [] +} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx index 67bdd3ffa..fe2dde415 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx @@ -5,11 +5,18 @@ import { action } from '@storybook/addon-actions'; import PdfViewerWithHighlight from './PdfViewerWithHighlight'; import { flatten } from 'lodash'; import { DocumentFieldHighlight } from './types'; +import './PdfViewerWithHighlight.stories.scss'; import { document as doc } from 'components/DocumentPreview/__fixtures__/Art Effects.pdf'; import document from 'components/DocumentPreview/__fixtures__/Art Effects Koya Creative Base TSA 2008.pdf.json'; -import './PdfViewerWithHighlight.stories.scss'; +import { document as docJa } from 'components/DocumentPreview/__fixtures__/DiscoComponent-ja.pdf'; +import documentJa from 'components/DocumentPreview/__fixtures__/DiscoComponents-ja_document.json'; + +import PDFJS from 'pdfjs-dist'; +import { getDocFieldValue } from './utils/common/documentUtils'; +(PDFJS as any).cMapUrl = './node_modules/pdfjs-dist/cmaps/'; +(PDFJS as any).cMapPacked = true; const pageKnob = { label: 'Page', @@ -49,9 +56,11 @@ const WithTextSelection: typeof PdfViewerWithHighlight = props => { const fields = Object.keys(document).filter(field => { return !field.match(/^(document_id|extracted_|enriched_)/) && document[field]?.length > 0; }); + return flatten( fields.map(field => { - return document[field] + const documentFields = Array.isArray(document[field]) ? document[field] : [document[field]]; + return documentFields .map((content: any, index: number) => { if (typeof content === 'string') { return { @@ -99,7 +108,7 @@ const WithTextSelection: typeof PdfViewerWithHighlight = props => { } const { begin, end } = textSelection; - const fieldText = document[selectedFieldName][selectedFieldIndex]; + const fieldText = getDocFieldValue(document, selectedFieldName, selectedFieldIndex); const highlight: DocumentFieldHighlight = { field: selectedFieldName, @@ -135,9 +144,9 @@ const WithTextSelection: typeof PdfViewerWithHighlight = props => { {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}

{selectedField && - document[selectedFieldName][selectedFieldIndex] + getDocFieldValue(document, selectedFieldName, selectedFieldIndex)! .replace(/ /g, '\u00a0') // NBSP - .replaceAll('\n', '\\n')} + .replace(/\n/g, '\\n')}

@@ -179,4 +188,21 @@ storiesOf('DocumentPreview/components/PdfViewerWithHighlight', module) highlights={EMPTY} /> ); + }) + .add('with PDF in Japanese', () => { + const page = number(pageKnob.label, pageKnob.defaultValue, pageKnob.options); + const zoom = radios(zoomKnob.label, zoomKnob.options, zoomKnob.defaultValue); + const scale = parseFloat(zoom); + const setLoadingAction = action('setLoading'); + + return ( + + ); }); From 488b160d2fdb7573cc22fe8fa3df04daac50c4cf Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Fri, 19 Nov 2021 13:57:36 +0900 Subject: [PATCH 26/51] refactor: move cell trim method --- .../utils/textBoxMapping/getTextBoxMapping.ts | 27 ++------------ .../utils/textLayout/BaseTextLayout.ts | 27 ++++++++++++++ .../textLayout/PdfTextContentTextLayout.ts | 35 ++++++++++--------- .../utils/textLayout/types.ts | 2 ++ 4 files changed, 50 insertions(+), 41 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts index d692bf114..0f280ff1e 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -3,7 +3,7 @@ import { TextSpan } from '../../types'; import { bboxIntersects } from '../common/bboxUtils'; import { nonEmpty } from '../common/nonEmpty'; import { spanLen, spanMerge } from '../common/textSpanUtils'; -import { TextLayout, TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; +import { TextLayout, TextLayoutCell } from '../textLayout/types'; import { MappingSourceTextProvider } from './MappingSourceTextProvider'; import { MappingTargetBoxProvider } from './MappingTargetCellProvider'; import { TextBoxMappingImpl } from './TextBoxMapping'; @@ -30,11 +30,7 @@ function findMatchInSources( // find matches const matches = sources.map(source => { const match = source.provider.getMatch(textToMatch); - return { - cell: source.cell, - provider: source.provider, - match - }; + return { ...source, match }; }); // calc cost for each match @@ -110,7 +106,7 @@ export function getTextBoxMappings< let consumedSourceSpan: TextSpan = [0, 0]; matchedTargetCells.forEach(mTargetCell => { - const trimmedCell = trimCell(mTargetCell); + const trimmedCell = mTargetCell.trim(); if (trimmedCell.text.length > 0) { const matchToTargetCell = matchedSourceProvider.getMatch(trimmedCell.text); debug('>> target cell %o (%o) to source %o', mTargetCell, trimmedCell, matchToTargetCell); @@ -135,20 +131,3 @@ export function getTextBoxMappings< return new TextBoxMappingImpl(mappingEntries); } - -/** - * Get a text layout cell that represents a trimmed text of a given `cell` - * @returns a new cell for the trimmed text. Zero-length cell when the text of the given `cell` is blank - */ -function trimCell(cell: TextLayoutCellBase) { - const text = cell.text; - const nLeadingSpaces = text.match(/^\s*/)![0].length; - const nTrailingSpaces = text.match(/\s*$/)![0].length; - if (nLeadingSpaces === 0 && nTrailingSpaces === 0) { - return cell; - } - if (text.length > nLeadingSpaces + nTrailingSpaces) { - return cell.getPartial([nLeadingSpaces, text.length - nTrailingSpaces]); - } - return cell.getPartial([0, 0]); // return zero-length cell -} diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts index 7e8f7f9e5..099934ac2 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts @@ -52,6 +52,11 @@ export class BaseTextLayoutCell> } return null; } + + /** @inheritdoc */ + trim(): TextLayoutCellBase { + return trimCell(this); + } } /** @@ -81,4 +86,26 @@ export class PartialTextLayoutCell implements TextLayoutCellBase { getNormalized() { return { cell: this.base, span: this.span }; } + + /** @inheritdoc */ + trim(): TextLayoutCellBase { + return trimCell(this); + } +} + +/** + * Get a text layout cell that represents a trimmed text of a given `cell` + * @returns a new cell for the trimmed text. Zero-length cell when the text of the given `cell` is blank + */ +function trimCell(cell: TextLayoutCellBase) { + const text = cell.text; + const nLeadingSpaces = text.match(/^\s*/)![0].length; + const nTrailingSpaces = text.match(/\s*$/)![0].length; + if (nLeadingSpaces === 0 && nTrailingSpaces === 0) { + return cell; + } + if (text.length > nLeadingSpaces + nTrailingSpaces) { + return cell.getPartial([nLeadingSpaces, text.length - nTrailingSpaces]); + } + return cell.getPartial([0, 0]); // return zero-length cell } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts index b6a97afec..0b5e53007 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -19,7 +19,7 @@ export class PdfTextContentTextLayout implements TextLayout { - const cellBbox = PdfTextContentTextLayoutCell.getBbox(item, this.viewport); + const cellBbox = getBbox(item, this.viewport); let isInHtmlBbox = false; if (htmlBboxInfo?.bboxes?.length) { isInHtmlBbox = htmlBboxInfo.bboxes.some(bbox => { @@ -64,11 +64,12 @@ class PdfTextContentTextLayoutCell extends BaseTextLayoutCell Date: Wed, 24 Nov 2021 13:20:31 +0900 Subject: [PATCH 27/51] fix: pdfjs typings version --- package.json | 1 + .../discovery-react-components/package.json | 3 +-- yarn.lock | 24 ++++--------------- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 3be7ea346..a53e97d36 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/lodash": "^4.14.141", "@types/mustache": "^0.8.32", "@types/node": "^12.7.3", + "@types/pdfjs-dist": "2.7.5", "@types/react": "^16.9.2", "@types/react-dom": "^16.9.0", "@types/react-resize-detector": "^4.2.0", diff --git a/packages/discovery-react-components/package.json b/packages/discovery-react-components/package.json index 49e64485a..5c8961b2f 100644 --- a/packages/discovery-react-components/package.json +++ b/packages/discovery-react-components/package.json @@ -18,7 +18,7 @@ "eslint": "yarn run g:eslint --quiet '{src,.storybook}/**/*.{js,jsx,ts,tsx}'", "lint": "yarn run circular && yarn run eslint", "start": "rollup -c -w", - "storybook": "start-storybook --ci --port=9002", + "storybook": "../../node_modules/.bin/start-storybook --ci --port=9002", "storybook:build": "build-storybook", "storybook:build:release": "cross-env STORYBOOK_BUILD_MODE=production build-storybook -o ../../docs/storybook", "analyze": "yarn run g:analyze 'dist/index.js'", @@ -43,7 +43,6 @@ "react-virtualized": "9.21.1" }, "devDependencies": { - "@types/pdfjs-dist": "^2.10.378", "cross-env": "^7.0.3", "css-loader": "^3.4.2", "madge": "^5.0.1", diff --git a/yarn.lock b/yarn.lock index 513c03d4e..b91ed1353 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2271,7 +2271,6 @@ __metadata: version: 0.0.0-use.local resolution: "@ibm-watson/discovery-react-components@workspace:packages/discovery-react-components" dependencies: - "@types/pdfjs-dist": ^2.10.378 classnames: ^2.2.6 cross-env: ^7.0.3 css-loader: ^3.4.2 @@ -4939,12 +4938,10 @@ __metadata: languageName: node linkType: hard -"@types/pdfjs-dist@npm:^2.10.378": - version: 2.10.378 - resolution: "@types/pdfjs-dist@npm:2.10.378" - dependencies: - pdfjs-dist: "*" - checksum: 36dd6010f7d23a995efdf11ea4ecb56f371f8bfb3e83a5c311666726e13238597ed1519701d0e2e6fb297270d01ad6aece9582b036fd4cb3aa301e61ea364978 +"@types/pdfjs-dist@npm:2.7.5": + version: 2.7.5 + resolution: "@types/pdfjs-dist@npm:2.7.5" + checksum: a81d499327520f46cf25c683a1f56cfcf07e12d9ad0ef4d560e320a7072f7dd7e7f2a2b26966655120cf7aa084c90f5c732ba65fc835a6271d4ab9858b9fc2b4 languageName: node linkType: hard @@ -19528,18 +19525,6 @@ __metadata: languageName: node linkType: hard -"pdfjs-dist@npm:*": - version: 2.11.338 - resolution: "pdfjs-dist@npm:2.11.338" - peerDependencies: - worker-loader: ^3.0.8 - peerDependenciesMeta: - worker-loader: - optional: true - checksum: 1b946a3eeb3312a79e12b4e0aa066bb2b98487b9ee329666edc840a194602595cf84de9a3f6dbb023b808699a6ebb0cd06e751314fc4c0ffa56f7be12855d296 - languageName: node - linkType: hard - "pdfjs-dist@npm:^2.2.228": version: 2.2.228 resolution: "pdfjs-dist@npm:2.2.228" @@ -22919,6 +22904,7 @@ __metadata: "@types/lodash": ^4.14.141 "@types/mustache": ^0.8.32 "@types/node": ^12.7.3 + "@types/pdfjs-dist": 2.7.5 "@types/react": ^16.9.2 "@types/react-dom": ^16.9.0 "@types/react-resize-detector": ^4.2.0 From 5811f4f022b0ff640dbc1f1ad9fdf80ac79942e9 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Wed, 24 Nov 2021 13:21:03 +0900 Subject: [PATCH 28/51] fix: adapt to latest PdfViewer --- .../PdfViewerHighlight/PdfViewerHighlight.tsx | 18 +++++++++--------- .../PdfViewerWithHighlight.tsx | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx index 44e626c33..636e25ef4 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx @@ -2,7 +2,7 @@ import React, { FC, useMemo, useEffect } from 'react'; import cx from 'classnames'; import { DocumentFieldHighlight } from './types'; import { QueryResult } from 'ibm-watson/discovery/v2'; -import { PdfTextLayerInfo } from '../PdfViewer/PdfViewerTextLayer'; +import { PdfRenderedText } from '../PdfViewer/PdfViewerTextLayer'; import { Highlighter } from './utils/Highlighter'; import { ExtractedDocumentInfo } from './utils/common/documentUtils'; import { settings } from 'carbon-components'; @@ -43,7 +43,7 @@ interface Props { /** * PDF text content information in a page from parsed PDF */ - pdfTextLayerInfo: PdfTextLayerInfo | null; + pdfRenderedText: PdfRenderedText | null; /** * Zoom factor, where `1` is equal to 100% @@ -73,7 +73,7 @@ const PdfViewerHighlight: FC = ({ parsedDocument, pageNum, highlights, - pdfTextLayerInfo, + pdfRenderedText, scale = 1.0, useHtmlBbox = true, usePdfTextItem = true @@ -82,11 +82,11 @@ const PdfViewerHighlight: FC = ({ document, textMappings: parsedDocument?.textMappings, processedDoc: useHtmlBbox ? parsedDocument?.processedDoc : undefined, - pdfTextLayerInfo: (usePdfTextItem && pdfTextLayerInfo) || undefined, + pdfRenderedText: (usePdfTextItem && pdfRenderedText) || undefined, pageNum }); - const { textDivs } = pdfTextLayerInfo || {}; + const { textDivs } = pdfRenderedText || {}; useEffect(() => { if (highlighter) { highlighter.setTextContentDivs(textDivs); @@ -136,13 +136,13 @@ const useHighlighter = ({ document, textMappings, processedDoc, - pdfTextLayerInfo, + pdfRenderedText, pageNum }: { document: QueryResult; textMappings?: TextMappings; processedDoc?: ProcessedDoc; - pdfTextLayerInfo?: PdfTextLayerInfo; + pdfRenderedText?: PdfRenderedText; pageNum: number; }) => { return useMemo(() => { @@ -156,11 +156,11 @@ const useHighlighter = ({ styles: processedDoc.styles }, pdfTextContentInfo: - pdfTextLayerInfo?.textContent && pdfTextLayerInfo?.viewport ? pdfTextLayerInfo : undefined + pdfRenderedText?.textContent && pdfRenderedText?.viewport ? pdfRenderedText : undefined }); } return null; - }, [document, pageNum, pdfTextLayerInfo, processedDoc, textMappings]); + }, [document, pageNum, pdfRenderedText, processedDoc, textMappings]); }; export default PdfViewerHighlight; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx index 6036ff996..cd32c1598 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -4,7 +4,7 @@ import PdfViewer, { PdfViewerProps } from '../PdfViewer/PdfViewer'; import PdfViewerHighlight from './PdfViewerHighlight'; import { extractDocumentInfo, ExtractedDocumentInfo } from './utils/common/documentUtils'; import { QueryResult } from 'ibm-watson/discovery/v2'; -import { PdfTextLayerInfo } from '../PdfViewer/PdfViewerTextLayer'; +import { PdfRenderedText } from '../PdfViewer/PdfViewerTextLayer'; interface Props extends PdfViewerProps { /** @@ -40,7 +40,7 @@ const PdfViewerWithHighlight: FC = ({ ...rest }) => { const { page, scale } = rest; - const [textLayerInfo, setTextLayerInfo] = useState(null); + const [renderedText, setRenderedText] = useState(null); const [documentInfo, setDocumentInfo] = useState(null); useEffect(() => { @@ -57,14 +57,14 @@ const PdfViewerWithHighlight: FC = ({ }; }, [document]); - const highlightReady = !!documentInfo && !!textLayerInfo; + const highlightReady = !!documentInfo && !!renderedText; return ( - + Date: Thu, 2 Dec 2021 20:43:19 +0900 Subject: [PATCH 29/51] fix: use postcss to manupulate pdfjs-web css --- .../scripts/generate-pdfjs_web_mixin.js | 41 +++++++++++++++++++ .../discovery-styles/scripts/update-styles.sh | 24 ++++------- .../document-preview/_pdfjs_web_mixins.scss | 18 +++++--- 3 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js diff --git a/packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js b/packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js new file mode 100644 index 000000000..68265ccfc --- /dev/null +++ b/packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js @@ -0,0 +1,41 @@ +/** + * Generate mixin SCSS for pdfjs web.css .textLayer styles + * + * Usage: node + */ +const postcss = require('postcss'); +const fs = require('fs'); + +const originalPdfjsWebCss = process.argv[2]; +const mixinPdfjsWebScss = process.argv[3]; + +// load teh original style +const cssText = fs.readFileSync(originalPdfjsWebCss, { encoding: 'utf-8' }); +const cssRoot = postcss.parse(cssText); + +// remove rules not related to .textLayer +cssRoot.walkRules(rule => { + if (rule.selector.includes('.textLayer')) { + return; + } + rule.remove(); +}); + +// keep copyright comment +cssRoot.walkComments(comment => { + if (comment.text.includes('Copyright')) { + return; + } + comment.remove(); +}); + +// write mixin scss +const generatedCss = ` +/* DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED FROM \`update-styles.sh\`. */ +@mixin pdfjsTextLayer { + // CSS from ~pdfjs-dist/web/pdf_viewer.css for scoped style + ${cssRoot.toString()} +} +`; + +fs.writeFileSync(mixinPdfjsWebScss, generatedCss, { encoding: 'utf-8' }); diff --git a/packages/discovery-styles/scripts/update-styles.sh b/packages/discovery-styles/scripts/update-styles.sh index 49ab2c595..423191ce1 100755 --- a/packages/discovery-styles/scripts/update-styles.sh +++ b/packages/discovery-styles/scripts/update-styles.sh @@ -1,20 +1,10 @@ #!/bin/sh +BASEDIR=$(dirname "$0")/.. +PDFJS_WEB_CSS=$BASEDIR/../../node_modules/pdfjs-dist/web/pdf_viewer.css +PDFJS_SCSS=$BASEDIR/scss/components/document-preview/_pdfjs_web_mixins.scss -PDFJS_WEB_CSS=../../node_modules/pdfjs-dist/web/pdf_viewer.css -PDFJS_SCSS=scss/components/document-preview/_pdfjs_web_mixins.scss +# generate PDFJS_SCSS +node $BASEDIR/scripts/generate-pdfjs_web_mixin.js "$PDFJS_WEB_CSS" "$PDFJS_SCSS" -function replace_quote() { - file=$1 - key=$2 - tmp=$file.tmp - - sed -e "/BEGIN-QUOTE $key/q" $file > $tmp - cat >> $tmp - sed -ne "/END-QUOTE $key/,\$p" $file >> $tmp - cp $tmp $file; - rm $tmp; -} - -cat $PDFJS_WEB_CSS | awk '/^\/\*/,/\*\//' | replace_quote $PDFJS_SCSS "COMMENT" -cat $PDFJS_WEB_CSS | awk '/^\.textLayer/,/}/' | replace_quote $PDFJS_SCSS "TEXT-LAYER" -../../node_modules/.bin/prettier --write $PDFJS_SCSS +# perttier +../../node_modules/.bin/prettier --write "$PDFJS_SCSS" diff --git a/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss index 37e94e1c8..22f31964b 100644 --- a/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss +++ b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss @@ -1,7 +1,6 @@ +/* DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED FROM `update-styles.sh`. */ @mixin pdfjsTextLayer { // CSS from ~pdfjs-dist/web/pdf_viewer.css for scoped style - - // BEGIN-QUOTE COMMENT /* Copyright 2014 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,9 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - // END-QUOTE COMMENT - // BEGIN-QUOTE TEXT-LAYER .textLayer { position: absolute; left: 0; @@ -29,6 +26,7 @@ opacity: 0.2; line-height: 1; } + .textLayer > span { color: transparent; position: absolute; @@ -37,6 +35,7 @@ -webkit-transform-origin: 0% 0%; transform-origin: 0% 0%; } + .textLayer .highlight { margin: -1px; padding: 1px; @@ -44,24 +43,31 @@ background-color: rgb(180, 0, 170); border-radius: 4px; } + .textLayer .highlight.begin { border-radius: 4px 0px 0px 4px; } + .textLayer .highlight.end { border-radius: 0px 4px 4px 0px; } + .textLayer .highlight.middle { border-radius: 0px; } + .textLayer .highlight.selected { background-color: rgb(0, 100, 0); } + .textLayer ::-moz-selection { background: rgb(0, 0, 255); } + .textLayer ::selection { background: rgb(0, 0, 255); } + .textLayer .endOfContent { display: block; position: absolute; @@ -76,8 +82,8 @@ -ms-user-select: none; user-select: none; } + .textLayer .endOfContent.active { top: 0px; } - // END-QUOTE TEXT-LAYER -} // end mixin +} From 158b8a23aab35d607d40498d85da65f1ae60cb32 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 2 Dec 2021 20:49:45 +0900 Subject: [PATCH 30/51] fix: add comment to the style update script --- packages/discovery-styles/scripts/update-styles.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/discovery-styles/scripts/update-styles.sh b/packages/discovery-styles/scripts/update-styles.sh index 423191ce1..7c0e47dbb 100755 --- a/packages/discovery-styles/scripts/update-styles.sh +++ b/packages/discovery-styles/scripts/update-styles.sh @@ -1,9 +1,18 @@ #!/bin/sh +# +# This script updates PDF text layer CSS from the `pdfjs-dist` npm package +# +# When you upgrade the `pdfjs-dist` package, you have to run this script +# to include the style changes. +# +# Usage: $ scripts/update-styles.sh +# - You must run `yarn` to install `pdfjs-dist` package before running +# BASEDIR=$(dirname "$0")/.. + +# pdfjs textLayer styles PDFJS_WEB_CSS=$BASEDIR/../../node_modules/pdfjs-dist/web/pdf_viewer.css PDFJS_SCSS=$BASEDIR/scss/components/document-preview/_pdfjs_web_mixins.scss - -# generate PDFJS_SCSS node $BASEDIR/scripts/generate-pdfjs_web_mixin.js "$PDFJS_WEB_CSS" "$PDFJS_SCSS" # perttier From 0705d2ea2000e2b956df2affe57bedacd1d9eca0 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 2 Dec 2021 20:58:11 +0900 Subject: [PATCH 31/51] refactor: move useAsyncFunctionCall to utils --- .../src/components/DocumentPreview/DocumentPreview.tsx | 1 + .../DocumentPreview/components/PdfViewer/PdfViewer.tsx | 2 +- .../components/PdfViewer/PdfViewerTextLayer.tsx | 4 ++-- .../PdfViewer => utils}/useAsyncFunctionCall.ts | 8 ++++---- 4 files changed, 8 insertions(+), 7 deletions(-) rename packages/discovery-react-components/src/{components/DocumentPreview/components/PdfViewer => utils}/useAsyncFunctionCall.ts (88%) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx b/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx index b06e33612..13813ab7b 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx @@ -154,6 +154,7 @@ function PreviewDocument({ const ErrorBoundDocumentPreview: any = withErrorBoundary(DocumentPreview); ErrorBoundDocumentPreview.PreviewToolbar = PreviewToolbar; ErrorBoundDocumentPreview.PreviewDocument = PreviewDocument; +ErrorBoundDocumentPreview.PdfViewerHighlight = PdfViewerHighlight; export default ErrorBoundDocumentPreview; export { ErrorBoundDocumentPreview as DocumentPreview }; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index 4a0de76ef..dc34badf7 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -9,7 +9,7 @@ import PdfjsLib, { import PdfjsWorkerAsText from 'pdfjs-dist/build/pdf.worker.min.js'; import { settings } from 'carbon-components'; import PdfViewerTextLayer, { PdfRenderedText } from './PdfViewerTextLayer'; -import useAsyncFunctionCall from './useAsyncFunctionCall'; +import useAsyncFunctionCall from 'utils/useAsyncFunctionCall'; setupPdfjs(); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx index e6f536076..21277ee2e 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx @@ -3,7 +3,7 @@ import cx from 'classnames'; import { PDFPageProxy, PDFPageViewport, TextContent, TextContentItem } from 'pdfjs-dist'; import { EventBus } from 'pdfjs-dist/lib/web/ui_utils'; import { TextLayerBuilder } from 'pdfjs-dist/lib/web/text_layer_builder'; -import useAsyncFunctionCall from './useAsyncFunctionCall'; +import useAsyncFunctionCall from 'utils/useAsyncFunctionCall'; interface Props { className?: string; @@ -127,7 +127,7 @@ async function _renderTextLayer( textLayerDiv.innerHTML = ''; const deferredRenderEndPromise = new Promise(resolve => { const listener = () => { - resolve(undefined); + resolve(); builder?.eventBus.off('textlayerrendered', listener); }; builder?.eventBus.on('textlayerrendered', listener); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/useAsyncFunctionCall.ts b/packages/discovery-react-components/src/utils/useAsyncFunctionCall.ts similarity index 88% rename from packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/useAsyncFunctionCall.ts rename to packages/discovery-react-components/src/utils/useAsyncFunctionCall.ts index 6f3d5f929..c5077d4d1 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/useAsyncFunctionCall.ts +++ b/packages/discovery-react-components/src/utils/useAsyncFunctionCall.ts @@ -16,25 +16,25 @@ function useAsyncFunctionCall, ReturnType = AsyncFun const [result, setResult] = useState(); useEffect(() => { - let state: 'pending' | 'fulfilled' | 'rejected' = 'pending'; + let resolved = false; const abortController = new AbortController(); asyncFunction(abortController.signal) .then((promiseResult: ReturnType) => { - state = 'fulfilled'; + resolved = false; if (!abortController.signal.aborted && promiseResult !== undefined) { setResult(promiseResult); } }) .catch(err => { - state = 'rejected'; + resolved = false; if (!abortController.signal.aborted) { throw err; } }); return (): void => { - if (state === 'pending') { + if (!resolved) { abortController.abort(); } }; From 9e9cad5058d02d827f27971ef3d80d80072790fa Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 2 Dec 2021 20:59:06 +0900 Subject: [PATCH 32/51] fix: name of package script --- packages/discovery-styles/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/discovery-styles/package.json b/packages/discovery-styles/package.json index 7c6b9e05f..ceeb66d32 100644 --- a/packages/discovery-styles/package.json +++ b/packages/discovery-styles/package.json @@ -7,7 +7,7 @@ "repository": "https://github.com/watson-developer-cloud/discovery-components", "main": "scss/index.scss", "scripts": { - "prebuild": "scripts/update-styles.sh", + "update-style": "scripts/update-styles.sh", "build": "node-sass --importer=../../node_modules/node-sass-tilde-importer --source-map=true scss/index.scss css/index.css", "prepublish": "yarn run build", "start": "yarn run build -- --watch", From 052336a81b92576ce11101ffc70766fdce6bda64 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 2 Dec 2021 21:44:14 +0900 Subject: [PATCH 33/51] fix: apply CI comment --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 8f1c45781..6da07fa05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2271,13 +2271,13 @@ __metadata: version: 0.0.0-use.local resolution: "@ibm-watson/discovery-react-components@workspace:packages/discovery-react-components" dependencies: - "@types/pdfjs-dist": ^2.10.378 "@storybook/addon-actions": ^5.3.21 "@storybook/addon-docs": ^5.3.21 "@storybook/addon-knobs": ^5.3.21 "@storybook/core": ^5.3.21 "@storybook/react": ^5.3.21 "@storybook/source-loader": ^5.3.21 + "@types/pdfjs-dist": ^2.10.378 classnames: ^2.2.6 cross-env: ^7.0.3 css-loader: ^3.4.2 From e081c92ee35042ab074f196f5e0218247c179ce9 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 2 Dec 2021 22:42:53 +0900 Subject: [PATCH 34/51] fix: fix broken logic --- .../src/utils/useAsyncFunctionCall.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/discovery-react-components/src/utils/useAsyncFunctionCall.ts b/packages/discovery-react-components/src/utils/useAsyncFunctionCall.ts index c5077d4d1..fe97a789e 100644 --- a/packages/discovery-react-components/src/utils/useAsyncFunctionCall.ts +++ b/packages/discovery-react-components/src/utils/useAsyncFunctionCall.ts @@ -21,13 +21,13 @@ function useAsyncFunctionCall, ReturnType = AsyncFun asyncFunction(abortController.signal) .then((promiseResult: ReturnType) => { - resolved = false; + resolved = true; if (!abortController.signal.aborted && promiseResult !== undefined) { setResult(promiseResult); } }) .catch(err => { - resolved = false; + resolved = true; if (!abortController.signal.aborted) { throw err; } From 68d895dbaa4952e502318ff2a56e969ba5195d44 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Thu, 2 Dec 2021 22:49:59 +0900 Subject: [PATCH 35/51] fix: remove unused code --- .../src/components/DocumentPreview/DocumentPreview.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx b/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx index 13813ab7b..b06e33612 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx @@ -154,7 +154,6 @@ function PreviewDocument({ const ErrorBoundDocumentPreview: any = withErrorBoundary(DocumentPreview); ErrorBoundDocumentPreview.PreviewToolbar = PreviewToolbar; ErrorBoundDocumentPreview.PreviewDocument = PreviewDocument; -ErrorBoundDocumentPreview.PdfViewerHighlight = PdfViewerHighlight; export default ErrorBoundDocumentPreview; export { ErrorBoundDocumentPreview as DocumentPreview }; From 8c82fae7f89a722606f042998db2097a37daed96 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Fri, 3 Dec 2021 13:57:50 +0900 Subject: [PATCH 36/51] fix: apply review comments around pdfjs css --- packages/discovery-styles/package.json | 8 ++++++-- .../scripts/generate-pdfjs_web_mixin.js | 4 ++-- .../{update-styles.sh => update-styles-from-pdfjs.sh} | 6 +++--- .../components/document-preview/_pdfjs_web_mixins.scss | 2 +- yarn.lock | 10 ++++++++++ 5 files changed, 22 insertions(+), 8 deletions(-) rename packages/discovery-styles/scripts/{update-styles.sh => update-styles-from-pdfjs.sh} (77%) diff --git a/packages/discovery-styles/package.json b/packages/discovery-styles/package.json index ceeb66d32..e8c84e651 100644 --- a/packages/discovery-styles/package.json +++ b/packages/discovery-styles/package.json @@ -7,11 +7,11 @@ "repository": "https://github.com/watson-developer-cloud/discovery-components", "main": "scss/index.scss", "scripts": { - "update-style": "scripts/update-styles.sh", "build": "node-sass --importer=../../node_modules/node-sass-tilde-importer --source-map=true scss/index.scss css/index.css", "prepublish": "yarn run build", "start": "yarn run build -- --watch", - "analyze": "yarn run g:analyze css/index.css" + "analyze": "yarn run g:analyze css/index.css", + "update-styles-from-pdfjs": "scripts/update-styles-from-pdfjs.sh" }, "files": [ "css/**/*", @@ -25,5 +25,9 @@ }, "publishConfig": { "access": "public" + }, + "devDependencies": { + "@types/prettier": "^2", + "prettier": "^2.4.1" } } diff --git a/packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js b/packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js index 68265ccfc..ef99bdf52 100644 --- a/packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js +++ b/packages/discovery-styles/scripts/generate-pdfjs_web_mixin.js @@ -9,7 +9,7 @@ const fs = require('fs'); const originalPdfjsWebCss = process.argv[2]; const mixinPdfjsWebScss = process.argv[3]; -// load teh original style +// load the original style const cssText = fs.readFileSync(originalPdfjsWebCss, { encoding: 'utf-8' }); const cssRoot = postcss.parse(cssText); @@ -31,7 +31,7 @@ cssRoot.walkComments(comment => { // write mixin scss const generatedCss = ` -/* DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED FROM \`update-styles.sh\`. */ +/* DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED FROM \`update-styles-from-pdfjs.sh\`. */ @mixin pdfjsTextLayer { // CSS from ~pdfjs-dist/web/pdf_viewer.css for scoped style ${cssRoot.toString()} diff --git a/packages/discovery-styles/scripts/update-styles.sh b/packages/discovery-styles/scripts/update-styles-from-pdfjs.sh similarity index 77% rename from packages/discovery-styles/scripts/update-styles.sh rename to packages/discovery-styles/scripts/update-styles-from-pdfjs.sh index 7c0e47dbb..d4cf33c89 100755 --- a/packages/discovery-styles/scripts/update-styles.sh +++ b/packages/discovery-styles/scripts/update-styles-from-pdfjs.sh @@ -13,7 +13,7 @@ BASEDIR=$(dirname "$0")/.. # pdfjs textLayer styles PDFJS_WEB_CSS=$BASEDIR/../../node_modules/pdfjs-dist/web/pdf_viewer.css PDFJS_SCSS=$BASEDIR/scss/components/document-preview/_pdfjs_web_mixins.scss -node $BASEDIR/scripts/generate-pdfjs_web_mixin.js "$PDFJS_WEB_CSS" "$PDFJS_SCSS" +yarn node $BASEDIR/scripts/generate-pdfjs_web_mixin.js "$PDFJS_WEB_CSS" "$PDFJS_SCSS" -# perttier -../../node_modules/.bin/prettier --write "$PDFJS_SCSS" +# prettier +yarn run prettier --write "$PDFJS_SCSS" diff --git a/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss index 22f31964b..f8d96b2f4 100644 --- a/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss +++ b/packages/discovery-styles/scss/components/document-preview/_pdfjs_web_mixins.scss @@ -1,4 +1,4 @@ -/* DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED FROM `update-styles.sh`. */ +/* DO NOT EDIT. THIS FILE IS AUTOMATICALLY GENERATED FROM `update-styles-from-pdfjs.sh`. */ @mixin pdfjsTextLayer { // CSS from ~pdfjs-dist/web/pdf_viewer.css for scoped style /* Copyright 2014 Mozilla Foundation diff --git a/yarn.lock b/yarn.lock index 6da07fa05..f901dc1f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2308,6 +2308,9 @@ __metadata: "@ibm-watson/discovery-styles@^1.5.0-beta.2, @ibm-watson/discovery-styles@workspace:packages/discovery-styles": version: 0.0.0-use.local resolution: "@ibm-watson/discovery-styles@workspace:packages/discovery-styles" + dependencies: + "@types/prettier": ^2 + prettier: ^2.4.1 peerDependencies: carbon-components: ">= 10.6.0 < 11" languageName: unknown @@ -4954,6 +4957,13 @@ __metadata: languageName: node linkType: hard +"@types/prettier@npm:^2": + version: 2.4.2 + resolution: "@types/prettier@npm:2.4.2" + checksum: 76e230b2d11028af11fe12e09b2d5b10b03738e9abf819ae6ebb0f78cac13d39f860755ce05ac3855b608222518d956628f5d00322dc206cc6d1f2d8d1519f1e + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.3 resolution: "@types/prop-types@npm:15.7.3" From 2c28bd939b35ff4dd334115f425e2ee3b9bcf49f Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Fri, 3 Dec 2021 14:19:06 +0900 Subject: [PATCH 37/51] fix: pdfjs typings version --- packages/discovery-react-components/package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/discovery-react-components/package.json b/packages/discovery-react-components/package.json index 59e406ec5..1fde74fe7 100644 --- a/packages/discovery-react-components/package.json +++ b/packages/discovery-react-components/package.json @@ -43,13 +43,13 @@ "react-virtualized": "9.21.1" }, "devDependencies": { - "@types/pdfjs-dist": "^2.10.378", "@storybook/addon-actions": "^5.3.21", "@storybook/addon-docs": "^5.3.21", "@storybook/addon-knobs": "^5.3.21", "@storybook/core": "^5.3.21", "@storybook/react": "^5.3.21", "@storybook/source-loader": "^5.3.21", + "@types/pdfjs-dist": "^2.1.7", "cross-env": "^7.0.3", "css-loader": "^3.4.2", "madge": "^5.0.1", diff --git a/yarn.lock b/yarn.lock index f901dc1f1..5fb35b26a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2277,7 +2277,7 @@ __metadata: "@storybook/core": ^5.3.21 "@storybook/react": ^5.3.21 "@storybook/source-loader": ^5.3.21 - "@types/pdfjs-dist": ^2.10.378 + "@types/pdfjs-dist": ^2.1.7 classnames: ^2.2.6 cross-env: ^7.0.3 css-loader: ^3.4.2 @@ -4948,12 +4948,12 @@ __metadata: languageName: node linkType: hard -"@types/pdfjs-dist@npm:^2.10.378": - version: 2.10.378 - resolution: "@types/pdfjs-dist@npm:2.10.378" +"@types/pdfjs-dist@npm:^2.1.7": + version: 2.10.377 + resolution: "@types/pdfjs-dist@npm:2.10.377" dependencies: pdfjs-dist: "*" - checksum: 36dd6010f7d23a995efdf11ea4ecb56f371f8bfb3e83a5c311666726e13238597ed1519701d0e2e6fb297270d01ad6aece9582b036fd4cb3aa301e61ea364978 + checksum: c4623b60e334dfcc50bc584d35977f13edd95646e139df8ae3cb52338e919dbdaf443454055368eb535cf3d1cc3f2f62463c3d883ab85c60ea9acf98e794aba1 languageName: node linkType: hard From 032842c30c71c263a72f37c62dfcdaa43b5da982 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Fri, 3 Dec 2021 14:57:28 +0900 Subject: [PATCH 38/51] fix: apply review comments --- .../discovery-react-components/package.json | 2 +- .../PdfViewer/PdfViewer.stories.tsx | 4 +- .../components/PdfViewer/PdfViewer.tsx | 44 +++++++------------ .../PdfViewer/PdfViewerTextLayer.tsx | 10 ++--- .../components/PdfViewer/types.ts | 11 +++++ yarn.lock | 24 +++------- 6 files changed, 36 insertions(+), 59 deletions(-) create mode 100644 packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/types.ts diff --git a/packages/discovery-react-components/package.json b/packages/discovery-react-components/package.json index 1fde74fe7..0f5baa81a 100644 --- a/packages/discovery-react-components/package.json +++ b/packages/discovery-react-components/package.json @@ -49,7 +49,7 @@ "@storybook/core": "^5.3.21", "@storybook/react": "^5.3.21", "@storybook/source-loader": "^5.3.21", - "@types/pdfjs-dist": "^2.1.7", + "@types/pdfjs-dist": "2.1.7", "cross-env": "^7.0.3", "css-loader": "^3.4.2", "madge": "^5.0.1", diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx index 11a3d0f08..9284c5c16 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.stories.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { withKnobs, radios, number, boolean } from '@storybook/addon-knobs'; +import { withKnobs, radios, number } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; import PdfViewer from './PdfViewer'; import { document as doc } from 'components/DocumentPreview/__fixtures__/Art Effects.pdf'; @@ -33,7 +33,6 @@ storiesOf('DocumentPreview/components/PdfViewer', module) const zoom = radios(zoomKnob.label, zoomKnob.options, zoomKnob.defaultValue); const scale = parseFloat(zoom); - const showTextLayer = boolean('Show text layer', false); const setLoadingAction = action('setLoading'); const setRenderedTextAction = action('setRenderedText'); @@ -43,7 +42,6 @@ storiesOf('DocumentPreview/components/PdfViewer', module) file={atob(doc)} page={page} scale={scale} - showTextLayer={showTextLayer} setLoading={setLoadingAction} setRenderedText={setRenderedTextAction} /> diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx index dc34badf7..ea37114a6 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewer.tsx @@ -4,16 +4,18 @@ import PdfjsLib, { PDFDocumentProxy, PDFPageProxy, PDFPageViewport, + PDFPromise, PDFRenderTask } from 'pdfjs-dist'; import PdfjsWorkerAsText from 'pdfjs-dist/build/pdf.worker.min.js'; import { settings } from 'carbon-components'; -import PdfViewerTextLayer, { PdfRenderedText } from './PdfViewerTextLayer'; import useAsyncFunctionCall from 'utils/useAsyncFunctionCall'; +import PdfViewerTextLayer, { PdfRenderedText } from './PdfViewerTextLayer'; +import { PdfDisplayProps } from './types'; setupPdfjs(); -interface Props { +type Props = PdfDisplayProps & { className?: string; /** @@ -22,22 +24,7 @@ interface Props { file: string; /** - * Page number, starting at 1 - */ - page: number; - - /** - * Zoom factor, where `1` is equal to 100% - */ - scale: number; - - /** - * Render text layer - */ - showTextLayer?: boolean; - - /** - * Text layer class name. Only applicable when showTextLayer is true + * Text layer class name */ textLayerClassName?: string; @@ -57,14 +44,13 @@ interface Props { * Callback for text layer info */ setRenderedText?: (info: PdfRenderedText | null) => any; -} +}; const PdfViewer: FC = ({ className, file, page, scale, - showTextLayer, textLayerClassName, setPageCount, setLoading, @@ -128,14 +114,12 @@ const PdfViewer: FC = ({ width={canvasInfo?.canvasWidth} height={canvasInfo?.canvasHeight} /> - {showTextLayer && ( - - )} + {children}
); @@ -146,7 +130,7 @@ PdfViewer.defaultProps = { scale: 1 }; -function _loadPdf(data: string): Promise { +function _loadPdf(data: string): PDFPromise { return PdfjsLib.getDocument({ data }).promise; } @@ -175,6 +159,7 @@ function setupPdfjs(): void { if (typeof Worker !== 'undefined') { const blob = new Blob([PdfjsWorkerAsText], { type: 'text/javascript' }); const pdfjsWorker = new Worker(URL.createObjectURL(blob)) as any; + // @ts-expect-error Upgrading pdfjs-dist and its typings would resolve the issue PdfjsLib.GlobalWorkerOptions.workerPort = pdfjsWorker; } else { PdfjsLib.GlobalWorkerOptions.workerSrc = PdfjsWorkerAsText; @@ -199,4 +184,5 @@ function getCanvasInfo(viewport: any): CanvasInfo { return { width, height, canvasWidth, canvasHeight, canvasScale }; } +export type PdfViewerProps = Props; export default PdfViewer; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx index 21277ee2e..5c76fb515 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/PdfViewerTextLayer.tsx @@ -4,8 +4,9 @@ import { PDFPageProxy, PDFPageViewport, TextContent, TextContentItem } from 'pdf import { EventBus } from 'pdfjs-dist/lib/web/ui_utils'; import { TextLayerBuilder } from 'pdfjs-dist/lib/web/text_layer_builder'; import useAsyncFunctionCall from 'utils/useAsyncFunctionCall'; +import { PdfDisplayProps } from './types'; -interface Props { +type Props = Pick & { className?: string; /** @@ -13,16 +14,11 @@ interface Props { */ loadedPage: PDFPageProxy | null | undefined; - /** - * Zoom factor, where `1` is equal to 100% - */ - scale: number; - /** * Callback for text layer info */ setRenderedText?: (info: PdfRenderedText | null) => any; -} +}; export type PdfRenderedText = { /** diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/types.ts new file mode 100644 index 000000000..063a65818 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewer/types.ts @@ -0,0 +1,11 @@ +export type PdfDisplayProps = { + /** + * Page number, starting at 1 + */ + page: number; + + /** + * Zoom factor, where `1` is equal to 100% + */ + scale: number; +}; diff --git a/yarn.lock b/yarn.lock index 5fb35b26a..6ebd9e514 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2277,7 +2277,7 @@ __metadata: "@storybook/core": ^5.3.21 "@storybook/react": ^5.3.21 "@storybook/source-loader": ^5.3.21 - "@types/pdfjs-dist": ^2.1.7 + "@types/pdfjs-dist": 2.1.7 classnames: ^2.2.6 cross-env: ^7.0.3 css-loader: ^3.4.2 @@ -4948,12 +4948,10 @@ __metadata: languageName: node linkType: hard -"@types/pdfjs-dist@npm:^2.1.7": - version: 2.10.377 - resolution: "@types/pdfjs-dist@npm:2.10.377" - dependencies: - pdfjs-dist: "*" - checksum: c4623b60e334dfcc50bc584d35977f13edd95646e139df8ae3cb52338e919dbdaf443454055368eb535cf3d1cc3f2f62463c3d883ab85c60ea9acf98e794aba1 +"@types/pdfjs-dist@npm:2.1.7": + version: 2.1.7 + resolution: "@types/pdfjs-dist@npm:2.1.7" + checksum: 14ca335658a85ab5cab908f3ef3ec104cb62487acc2465bb74b6d0430cd720dca30a8804440e152967b1dd7fb384c65ef05da1702a1ec87b905c0f5c73bbe653 languageName: node linkType: hard @@ -19544,18 +19542,6 @@ __metadata: languageName: node linkType: hard -"pdfjs-dist@npm:*": - version: 2.11.338 - resolution: "pdfjs-dist@npm:2.11.338" - peerDependencies: - worker-loader: ^3.0.8 - peerDependenciesMeta: - worker-loader: - optional: true - checksum: 1b946a3eeb3312a79e12b4e0aa066bb2b98487b9ee329666edc840a194602595cf84de9a3f6dbb023b808699a6ebb0cd06e751314fc4c0ffa56f7be12855d296 - languageName: node - linkType: hard - "pdfjs-dist@npm:^2.2.228": version: 2.2.228 resolution: "pdfjs-dist@npm:2.2.228" From 3eedcf8d05f9db99c061d1fe3c39c3843c9a2d88 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 18:54:13 +0900 Subject: [PATCH 39/51] refactor: refactor common utils --- .../components/PdfViewerHighlight/types.ts | 9 ++++++--- .../utils/common/textSpanUtils.ts | 17 +++++++++-------- .../src/components/DocumentPreview/types.ts | 6 ++++-- .../src/utils/document/documentUtils.ts | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts index 5496895c1..151540c5f 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts @@ -1,9 +1,12 @@ -import { Bbox as DocumentPreviewBbox } from '../../../DocumentPreview/types'; +import { + Bbox as DocPreviewBbox, + TextSpan as DocPreviewTextSpan +} from '../../../DocumentPreview/types'; import { Location } from 'utils/document/processDoc'; // (re-)export useful types -export type Bbox = DocumentPreviewBbox; -export type TextSpan = [number, number]; +export type Bbox = DocPreviewBbox; +export type TextSpan = DocPreviewTextSpan; /** * A document. Same to QueryResult, but this more focuses on document fields diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts index 58b388e01..fe173649b 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts @@ -1,8 +1,17 @@ +import { spansIntersect } from 'utils/document/documentUtils'; import { TextSpan } from '../../types'; export const START = 0; export const END = 1; +/** + * Check whether two spans has intersection or not + * TextSpan version of spansIntersect in utils/document/documentUtil.ts + */ +export function spanIntersects([beginA, endA]: TextSpan, [beginB, endB]: TextSpan): boolean { + return spansIntersect({ begin: beginA, end: endA }, { begin: beginB, end: endB }); +} + /** * Get text for a given span */ @@ -22,14 +31,6 @@ export function spanLen(span: TextSpan): number { return Math.max(0, span[END] - span[START]); } -/** - * Check whether two spans has intersection or not - */ -export function spanIntersects([beginA, endA]: TextSpan, [beginB, endB]: TextSpan): boolean { - // TODO: integrate with spansIntersect in documentUtils.ts - return beginA < endB && endA > beginB; -} - /** * Check whether a span includes an given character index or not */ diff --git a/packages/discovery-react-components/src/components/DocumentPreview/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/types.ts index e72598074..70bdf5ffe 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/types.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/types.ts @@ -6,6 +6,9 @@ export interface TextMappings { // [ left, top, right, bottom ] export type Bbox = [number, number, number, number]; +/** [ start (inclusive), end (exclusive) ] */ +export type TextSpan = [number, number]; + export type Origin = 'TopLeft' | 'BottomLeft'; export interface Page { @@ -32,8 +35,7 @@ export interface CellPage { export interface CellField { name: string; index: number; - // [ START, END ] - span: [number, number]; + span: TextSpan; } export interface StyledCell extends CellPage { diff --git a/packages/discovery-react-components/src/utils/document/documentUtils.ts b/packages/discovery-react-components/src/utils/document/documentUtils.ts index ebadd1cd0..6ea03da37 100644 --- a/packages/discovery-react-components/src/utils/document/documentUtils.ts +++ b/packages/discovery-react-components/src/utils/document/documentUtils.ts @@ -217,5 +217,5 @@ export function spansIntersect( { begin: beginA, end: endA }: Span, { begin: beginB, end: endB }: Span ): boolean { - return beginA <= endB && endA > beginB; + return beginA < endB && endA > beginB; } From c13942b792eb899d645de81bf1f2b382c7e24cd1 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 18:55:23 +0900 Subject: [PATCH 40/51] refactor: refacto getTextBoxMapping --- .../utils/textBoxMapping/TextBoxMapping.ts | 22 +- .../utils/textBoxMapping/getTextBoxMapping.ts | 268 +++++++++++------- 2 files changed, 190 insertions(+), 100 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts index 95c6cded0..a326f6c30 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts @@ -12,7 +12,7 @@ import { import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; import { TextNormalizer } from '../common/TextNormalizer'; -const debugOut = require('debug')?.('pdf:mapping:TextBoxMappingImpl'); +const debugOut = require('debug')?.('pdf:mapping:TextBoxMapping'); function debug(...args: any) { debugOut?.apply(null, args); } @@ -20,7 +20,7 @@ function debug(...args: any) { /** * Text box mapping */ -export class TextBoxMappingImpl implements TextBoxMapping { +class TextBoxMappingImpl implements TextBoxMapping { private readonly mappingEntryMap: Dictionary; constructor(mappingEntries: TextBoxMappingEntry[]) { @@ -83,6 +83,24 @@ export class TextBoxMappingImpl implements TextBoxMapping { } } +/** + * Text mapping builder + */ +export class TextBoxMappingBuilder { + mappingEntries: TextBoxMappingEntry[] = []; + + /** add new mapping data */ + addMapping(text: TextBoxMappingEntry['text'], box: TextBoxMappingEntry['box']) { + this.mappingEntries.push({ text, box }); + debug('>> added a new mapping entry (%o) => (cell: %o)', text, text, box?.cell); + } + + /** get TextBoxMapping */ + toTextBoxMapping() { + return new TextBoxMappingImpl(this.mappingEntries); + } +} + /** * Check if text on spans on cells are the same or not */ diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts index 0f280ff1e..fb0174ebc 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -3,54 +3,17 @@ import { TextSpan } from '../../types'; import { bboxIntersects } from '../common/bboxUtils'; import { nonEmpty } from '../common/nonEmpty'; import { spanLen, spanMerge } from '../common/textSpanUtils'; -import { TextLayout, TextLayoutCell } from '../textLayout/types'; +import { TextLayout, TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; import { MappingSourceTextProvider } from './MappingSourceTextProvider'; import { MappingTargetBoxProvider } from './MappingTargetCellProvider'; -import { TextBoxMappingImpl } from './TextBoxMapping'; -import { TextBoxMapping, TextBoxMappingEntry } from './types'; +import { TextBoxMappingBuilder } from './TextBoxMapping'; +import { TextBoxMapping } from './types'; const debugOut = require('debug')?.('pdf:mapping:getTextBoxMapping'); function debug(...args: any) { debugOut?.apply(null, args); } -/** - * Find the best source (larger text layout cell) where text `textToMatch` is in - * @param sources source (larger) text layout cells overlapping the current target cell - * @param textToMatch text form target cell(s) - * @returns the best source where the `textToMatch` is matched and the text location in the source - */ -function findMatchInSources( - sources: { - cell: TextLayoutCell; - provider: MappingSourceTextProvider; - }[], - textToMatch: string -) { - // find matches - const matches = sources.map(source => { - const match = source.provider.getMatch(textToMatch); - return { ...source, match }; - }); - - // calc cost for each match - let skipTextLen = 0; - const matchesWithCost = matches.map(aMatch => { - const { match: providerMatch } = aMatch; - const cost = !providerMatch - ? Number.MAX_SAFE_INTEGER - : skipTextLen + providerMatch.skipText.length - spanLen(providerMatch.span); - - skipTextLen += providerMatch?.approxLenAfterEnd ?? 0; - - return { ...aMatch, cost }; - }); - - // find best match - const bestMatch = minBy(matchesWithCost, match => match.cost); - return bestMatch; -} - /** * Calculate text box mapping from `source` text layout to `target` text layout * @param source text layout with larger cells @@ -60,74 +23,183 @@ function findMatchInSources( export function getTextBoxMappings< SourceCell extends TextLayoutCell, TargetCell extends TextLayoutCell ->(source: TextLayout, target: TextLayout): TextBoxMapping { - const sourceProviders = source.cells.map(cell => new MappingSourceTextProvider(cell)); - const targetProvider = new MappingTargetBoxProvider(target.cells); - - const targetIndexToSources = target.cells.map(targetCell => { - const cells = source.cells - .map((sourceCell, index) => { - if (!bboxIntersects(sourceCell.bbox, targetCell.bbox)) { - return null; +>(sourceLayout: TextLayout, targetLayout: TextLayout): TextBoxMapping { + debug('getTextBoxMapping: enter'); + + const target = new Target(targetLayout); + const source = new Source(sourceLayout, targetLayout); + const builder = new TextBoxMappingBuilder(); + + target.processText((targetCellId, targetText, markTargetAsMapped) => { + const matchInSource = source.findMatch(targetCellId, targetText); + if (matchInSource) { + const mappedTargetCells = markTargetAsMapped(matchInSource.matchLength); + + let mappedSourceFullSpan: TextSpan = [0, 0]; + mappedTargetCells.forEach(targetCell => { + const mappedSourceSpan = matchInSource.markSourceAsMapped(targetCell.text); + if (mappedSourceSpan) { + builder.addMapping( + { cell: matchInSource.cell, span: mappedSourceSpan }, + { cell: targetCell } + ); + mappedSourceFullSpan = spanMerge(mappedSourceFullSpan, mappedSourceSpan); } - return { cell: sourceCell, provider: sourceProviders[index] }; - }) - .filter(nonEmpty); - - if (cells.some(({ cell }) => cell.isInHtmlBbox)) { - return cells.filter(({ cell }) => cell.isInHtmlBbox); + }); + if (spanLen(mappedSourceFullSpan) > 0) { + matchInSource.markSourceMappedBySpan(mappedSourceFullSpan); + } } - return cells; }); - const mappingEntries: TextBoxMappingEntry[] = []; + return builder.toTextBoxMapping(); +} - debug('getTextBoxMapping'); - while (targetProvider.hasNext()) { - // find matches - const { index: targetCellIndex, text: targetText } = targetProvider.getNextInfo(); - debug('> find match at index %d, text: %s', targetCellIndex, targetText); - const matchInSource = findMatchInSources(targetIndexToSources[targetCellIndex], targetText); - debug('> source cell(s) matched: %o', matchInSource); - - // skip when no match found... - if (!matchInSource?.match || spanLen(matchInSource.match.span) === 0) { - targetProvider.skip(); - continue; - } +/** + * Utility class for manipulating target text layout in getTextBoxMapping + */ +class Target { + targetProvider: MappingTargetBoxProvider; - const matchedSourceSpan = matchInSource.match.span; - const matchedSourceProvider = matchInSource.provider; - const matchedLength = spanLen(matchedSourceSpan); - - const matchedTargetCells = targetProvider.consume(matchedLength); - debug('> target cells for matched length: %d', matchedLength); - debug(matchedTargetCells); - - let consumedSourceSpan: TextSpan = [0, 0]; - matchedTargetCells.forEach(mTargetCell => { - const trimmedCell = mTargetCell.trim(); - if (trimmedCell.text.length > 0) { - const matchToTargetCell = matchedSourceProvider.getMatch(trimmedCell.text); - debug('>> target cell %o (%o) to source %o', mTargetCell, trimmedCell, matchToTargetCell); - if (matchToTargetCell) { - // consume source text which is just mapped to the target - matchedSourceProvider.consume(matchToTargetCell.span); - consumedSourceSpan = spanMerge(consumedSourceSpan, matchToTargetCell.span); - mappingEntries.push({ - text: { cell: matchInSource.cell, span: matchToTargetCell.span }, - box: { cell: trimmedCell } - }); - debug('>> added mapping entry %o', mappingEntries[mappingEntries.length - 1]); + constructor(targetLayout: TextLayout) { + this.targetProvider = new MappingTargetBoxProvider(targetLayout.cells); + } + + /** + * Try to map text fragments (`cellId` and `text` passed to `textMapper`) + * in target using a given `textMapper` + */ + processText( + textMapper: ( + cellId: number, + text: string, + markTargetMapped: (length: number) => TextLayoutCellBase[] + ) => void + ) { + while (this.targetProvider.hasNext()) { + const { index: cellId, text: nextText } = this.targetProvider.getNextInfo(); + debug('> find match at index %d, text: %s', cellId, nextText); + + let isMapped = false; + const markAsMapped = (matchedLength: number) => { + if (matchedLength > 0) { + isMapped = true; + const matchedTargetCells = this.targetProvider.consume(matchedLength); + debug('> raw target cells for matched length: %d', matchedLength); + debug(matchedTargetCells); + + return matchedTargetCells.map(cell => cell.trim()).filter(cell => cell.text.length > 0); } + return []; + }; + + textMapper(cellId, nextText, markAsMapped); + if (!isMapped) { + this.targetProvider.skip(); } + } + } +} + +/** + * Utility class for manipulating source text layout and its source text in getTextBoxMapping + */ +class Source { + sourceProviders: MappingSourceTextProvider[]; + targetIndexToSources: { + cell: SourceCell; + provider: MappingSourceTextProvider; + }[][]; + + constructor(sourceLayout: TextLayout, targetLayout: TextLayout) { + this.sourceProviders = sourceLayout.cells.map(cell => new MappingSourceTextProvider(cell)); + this.targetIndexToSources = targetLayout.cells.map(targetCell => { + const cells = sourceLayout.cells + .map((sourceCell, index) => { + if (!bboxIntersects(sourceCell.bbox, targetCell.bbox)) { + return null; + } + return { cell: sourceCell, provider: this.sourceProviders[index] }; + }) + .filter(nonEmpty); + + if (cells.some(({ cell }) => cell.isInHtmlBbox)) { + return cells.filter(({ cell }) => cell.isInHtmlBbox); + } + return cells; }); - // consume entire the range that is matched to sources - if (spanLen(consumedSourceSpan) > 0) { - matchedSourceProvider.consume(consumedSourceSpan); - debug('> span consumed in source: ', consumedSourceSpan); + } + + /** + * Find the best (i.e. longest length `text`) match in source which intersects + * with the target cell of given `targetCellId` + * @param targetCellId + * @param text + * @return matched source information and functions to mark the matched span as mapped + */ + findMatch(targetCellId: TargetCell['id'], text: string) { + const candidateSources = this.targetIndexToSources[targetCellId]; + const bestMatch = Source.findBestMatch(candidateSources, text); + debug('> source cell(s) matched: %o', bestMatch); + + if (!bestMatch?.match || spanLen(bestMatch.match.span) === 0) { + return null; } + + const matchedCell = bestMatch.cell; + const matchedSourceSpan = bestMatch.match.span; + const matchedSourceProvider = bestMatch.provider; + + return { + cell: matchedCell, + matchLength: spanLen(matchedSourceSpan), + markSourceAsMapped: (text: string) => { + const mappedSource = matchedSourceProvider.getMatch(text); + debug('>> target cell %o to source %o', text, mappedSource); + return mappedSource?.span; + }, + markSourceMappedBySpan: (span: TextSpan) => { + if (spanLen(span) > 0) { + matchedSourceProvider.consume(span); + } + } + }; } - return new TextBoxMappingImpl(mappingEntries); + /** + * Find the best source (larger text layout cell) where text `textToMatch` is in + * @param sources source (larger) text layout cells overlapping the current target cell + * @param textToMatch text form target cell(s) + * @returns the best source where the `textToMatch` is matched and the text location in the source + */ + private static findBestMatch( + sources: { + cell: TextLayoutCell; + provider: MappingSourceTextProvider; + }[], + textToMatch: string + ) { + // find matches + const matches = sources.map(source => { + const match = source.provider.getMatch(textToMatch); + return { ...source, match }; + }); + + // calc cost for each match + let skipTextLen = 0; + const matchesWithCost = matches.map(aMatch => { + const { match: providerMatch } = aMatch; + const cost = !providerMatch + ? Number.MAX_SAFE_INTEGER + : skipTextLen + providerMatch.skipText.length - spanLen(providerMatch.span); + + skipTextLen += providerMatch?.approxLenAfterEnd ?? 0; + + return { ...aMatch, cost }; + }); + + // find best match + const bestMatch = minBy(matchesWithCost, match => match.cost); + return bestMatch; + } } From a9dd38ec2b91b2f04f0e7c17a67f4cdd112916ca Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 19:04:48 +0900 Subject: [PATCH 41/51] fix: fix yarn error --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6ebd9e514..b047d1520 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2267,7 +2267,7 @@ __metadata: languageName: node linkType: hard -"@ibm-watson/discovery-react-components@^1.5.0-beta.2, @ibm-watson/discovery-react-components@workspace:packages/discovery-react-components": +"@ibm-watson/discovery-react-components@^1.5.0-beta.3, @ibm-watson/discovery-react-components@workspace:packages/discovery-react-components": version: 0.0.0-use.local resolution: "@ibm-watson/discovery-react-components@workspace:packages/discovery-react-components" dependencies: @@ -10260,7 +10260,7 @@ __metadata: resolution: "discovery-search-app@workspace:examples/discovery-search-app" dependencies: "@carbon/icons": ^10.5.0 - "@ibm-watson/discovery-react-components": ^1.5.0-beta.2 + "@ibm-watson/discovery-react-components": ^1.5.0-beta.3 "@ibm-watson/discovery-styles": ^1.5.0-beta.2 body-parser: ^1.19.0 carbon-components: ^10.6.0 From f6fbcd28bc2a403c635711d679582fc94b1b0fbe Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 20:30:48 +0900 Subject: [PATCH 42/51] fix: fix test error --- .../PdfViewerHighlight/utils/common/textSpanUtils.ts | 7 +++++-- .../src/utils/document/documentUtils.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts index fe173649b..e40acefce 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts @@ -1,4 +1,3 @@ -import { spansIntersect } from 'utils/document/documentUtils'; import { TextSpan } from '../../types'; export const START = 0; @@ -9,7 +8,11 @@ export const END = 1; * TextSpan version of spansIntersect in utils/document/documentUtil.ts */ export function spanIntersects([beginA, endA]: TextSpan, [beginB, endB]: TextSpan): boolean { - return spansIntersect({ begin: beginA, end: endA }, { begin: beginB, end: endB }); + // TODO: integrate with spansIntersect in documentUtils.ts + // currently, the function returns true to spansIntersect([1,2], [0,1]) + // which is expected to be false here. And fixing it results in test error + // We need further investigate if we can fix the spansIntersect. + return beginA < endB && endA > beginB; } /** diff --git a/packages/discovery-react-components/src/utils/document/documentUtils.ts b/packages/discovery-react-components/src/utils/document/documentUtils.ts index 6ea03da37..ebadd1cd0 100644 --- a/packages/discovery-react-components/src/utils/document/documentUtils.ts +++ b/packages/discovery-react-components/src/utils/document/documentUtils.ts @@ -217,5 +217,5 @@ export function spansIntersect( { begin: beginA, end: endA }: Span, { begin: beginB, end: endB }: Span ): boolean { - return beginA < endB && endA > beginB; + return beginA <= endB && endA > beginB; } From dc09472f13c440607ec3d6862be5b566e183ff0d Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 21:08:30 +0900 Subject: [PATCH 43/51] refactor: move utility methods --- .../PdfViewerHighlight/utils/Highlighter.ts | 2 +- .../utils/common/TextNormalizer.ts | 2 +- .../utils/common/__tests__/bboxUtils.test.ts | 19 +---------------- .../utils/common/bboxUtils.ts | 21 +++---------------- .../MappingSourceTextProvider.ts | 6 +++--- .../MappingTargetCellProvider.ts | 2 +- .../utils/textBoxMapping/TextBoxMapping.ts | 6 +++--- .../utils/textBoxMapping/TextProvider.ts | 2 +- .../utils/textBoxMapping/getTextBoxMapping.ts | 6 +++--- .../utils/textLayout/BaseTextLayout.ts | 4 ++-- .../textLayout/PdfTextContentTextLayout.ts | 4 ++-- .../textLayout/TextMappingsTextLayout.ts | 10 ++++----- .../utils/textLayout/dom.ts | 4 ++-- .../utils/__tests__/box.test.ts | 19 ++++++++++++++++- .../__tests__/textSpan.test.ts} | 4 ++-- .../components/DocumentPreview/utils/box.ts | 9 ++++++-- .../textSpanUtils.ts => utils/textSpan.ts} | 2 +- 17 files changed, 56 insertions(+), 66 deletions(-) rename packages/discovery-react-components/src/components/DocumentPreview/{components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts => utils/__tests__/textSpan.test.ts} (98%) rename packages/discovery-react-components/src/components/DocumentPreview/{components/PdfViewerHighlight/utils/common/textSpanUtils.ts => utils/textSpan.ts} (98%) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts index f6b0ed4f7..7afa139bf 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts @@ -7,11 +7,11 @@ import { HighlightShape, HighlightShapeBox } from '../types'; +import { spanOffset, START } from '../../../utils/textSpan'; import { getTextBoxMappings } from './textBoxMapping'; import { TextBoxMapping, TextBoxMappingResult } from './textBoxMapping/types'; import { HtmlBboxTextLayout, PdfTextContentTextLayout, TextMappingsTextLayout } from './textLayout'; import { HtmlBboxInfo, TextLayout, TextLayoutCell } from './textLayout/types'; -import { spanOffset, START } from './common/textSpanUtils'; import { nonEmpty } from './common/nonEmpty'; const debugOut = require('debug')?.('pdf:Highlighter'); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts index edfc7967f..bb4b0f67c 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts @@ -1,5 +1,5 @@ import { TextSpan } from '../../types'; -import { END, spanLen, START } from './textSpanUtils'; +import { END, spanLen, START } from '../../../../utils/textSpan'; type SpanMapping = { rawSpan: TextSpan; normalizedSpan: TextSpan }; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts index 419f0836b..153a2e1b3 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts @@ -1,21 +1,4 @@ -import { bboxGetSpanByRatio, bboxIntersects, isNextToEachOther } from '../bboxUtils'; - -describe('bboxIntersects', () => { - it('should return true when boxes intersect', () => { - expect(bboxIntersects([10, 10, 20, 20], [15, 15, 25, 25])).toBeTruthy(); - }); - - it("should return false when boxes don't intersect", () => { - expect(bboxIntersects([10, 10, 20, 20], [15, 25, 25, 35])).toBeFalsy(); - }); - - it('should return false when one box is on another', () => { - expect(bboxIntersects([10, 10, 20, 20], [20, 10, 30, 20])).toBeFalsy(); - expect(bboxIntersects([10, 10, 20, 20], [0, 10, 10, 20])).toBeFalsy(); - expect(bboxIntersects([10, 10, 20, 20], [10, 20, 20, 30])).toBeFalsy(); - expect(bboxIntersects([10, 10, 20, 20], [10, 0, 20, 10])).toBeFalsy(); - }); -}); +import { bboxGetSpanByRatio, isNextToEachOther } from '../bboxUtils'; describe('bboxGetSpanByRatio', () => { it('should return proper bbox for spans on text', () => { diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts index f7e911f8e..533f0918c 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts @@ -1,21 +1,6 @@ -import { intersects } from 'components/DocumentPreview/utils/box'; import { Bbox, TextSpan } from '../../types'; -import { spanIntersection, spanLen } from './textSpanUtils'; - -export const LEFT = 0; -export const TOP = 1; -export const RIGHT = 2; -export const BOTTOM = 3; - -/** - * Check whether two bbox intersect - * @param boxA one bbox - * @param boxB another bbox - * @returns true iff boxA and boxB are overlapped - */ -export function bboxIntersects(boxA: Bbox, boxB: Bbox): boolean { - return intersects(boxA, boxB); -} +import { bboxesIntersect } from '../../../../utils/box'; +import { spanIntersection, spanLen } from '../../../../utils/textSpan'; /** * Get bbox for a text span assuming each character takes horizontal spaces evenly @@ -43,7 +28,7 @@ export function bboxGetSpanByRatio(bbox: Bbox, origLength: number, span: TextSpa * This is used to get a text of a line from a list of small text cells. */ export function isNextToEachOther(boxA: Bbox, boxB: Bbox): boolean { - if (bboxIntersects(boxA, boxB)) { + if (bboxesIntersect(boxA, boxB)) { return false; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts index 22dadc34a..337fe72d9 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts @@ -1,9 +1,9 @@ +import minBy from 'lodash/minBy'; +import { spanGetText, spanLen, START } from '../../../../utils/textSpan'; import { TextSpan } from '../../types'; -import { TextProvider } from './TextProvider'; import { TextNormalizer } from '../common/TextNormalizer'; -import minBy from 'lodash/minBy'; -import { spanGetText, spanLen, START } from '../common/textSpanUtils'; import { TextLayoutCell } from '../textLayout/types'; +import { TextProvider } from './TextProvider'; const debugOut = require('debug')?.('pdf:mapping:MappingSourceTextProvider'); function debug(...args: any) { diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts index 04bad0ac4..fe4434991 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts @@ -1,7 +1,7 @@ +import { END } from '../../../../utils/textSpan'; import { TextLayoutCellBase } from '../textLayout/types'; import { TextNormalizer } from '../common/TextNormalizer'; import { CellProvider } from './CellProvider'; -import { END } from '../common/textSpanUtils'; /** * Cell provider with normalization diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts index a326f6c30..68e0c33f6 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts @@ -1,16 +1,16 @@ -import { TextSpan } from '../../types'; -import { TextBoxMapping, TextBoxMappingEntry, TextBoxMappingResult } from './types'; import { Dictionary } from 'lodash'; import groupBy from 'lodash/groupBy'; +import { TextSpan } from '../../types'; import { spanCompare, spanFromSubSpan, spanGetSubSpan, spanIntersection, spanIntersects -} from '../common/textSpanUtils'; +} from '../../../../utils/textSpan'; import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; import { TextNormalizer } from '../common/TextNormalizer'; +import { TextBoxMapping, TextBoxMappingEntry, TextBoxMappingResult } from './types'; const debugOut = require('debug')?.('pdf:mapping:TextBoxMapping'); function debug(...args: any) { diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts index 2a5825240..9528b7669 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts @@ -6,7 +6,7 @@ import { spanIncludesIndex, spanGetText, spanIntersection -} from '../common/textSpanUtils'; +} from '../../../../utils/textSpan'; import { findLargestIndex } from '../common/findLargestIndex'; const MAX_HISTORY = 3; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts index fb0174ebc..1ca5d1329 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -1,8 +1,8 @@ import minBy from 'lodash/minBy'; import { TextSpan } from '../../types'; -import { bboxIntersects } from '../common/bboxUtils'; import { nonEmpty } from '../common/nonEmpty'; -import { spanLen, spanMerge } from '../common/textSpanUtils'; +import { bboxesIntersect } from '../../../../utils/box'; +import { spanLen, spanMerge } from '../../../../utils/textSpan'; import { TextLayout, TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; import { MappingSourceTextProvider } from './MappingSourceTextProvider'; import { MappingTargetBoxProvider } from './MappingTargetCellProvider'; @@ -116,7 +116,7 @@ class Source { const cells = sourceLayout.cells .map((sourceCell, index) => { - if (!bboxIntersects(sourceCell.bbox, targetCell.bbox)) { + if (!bboxesIntersect(sourceCell.bbox, targetCell.bbox)) { return null; } return { cell: sourceCell, provider: this.sourceProviders[index] }; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts index 099934ac2..7ad9ac173 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts @@ -1,7 +1,7 @@ +import { spanGetText, spanIntersection, spanOffset, START } from '../../../../utils/textSpan'; import { Bbox, TextSpan } from '../../types'; -import { TextLayout, TextLayoutCell, TextLayoutCellBase } from './types'; -import { spanGetText, spanIntersection, spanOffset, START } from '../common/textSpanUtils'; import { bboxGetSpanByRatio } from '../common/bboxUtils'; +import { TextLayout, TextLayoutCell, TextLayoutCellBase } from './types'; /** * Base implementation of text layout cell diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts index 0b5e53007..3399b6d33 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -1,6 +1,6 @@ +import { bboxesIntersect } from 'components/DocumentPreview/utils/box'; import { PDFPageViewport, PDFPageViewportOptions, TextContentItem } from 'pdfjs-dist'; import { Bbox, TextSpan } from '../../types'; -import { bboxIntersects } from '../common/bboxUtils'; import { BaseTextLayoutCell } from './BaseTextLayout'; import { getAdjustedCellByOffsetByDom } from './dom'; import { HtmlBboxInfo, PdfTextContentInfo, TextLayout } from './types'; @@ -23,7 +23,7 @@ export class PdfTextContentTextLayout implements TextLayout { - return bboxIntersects(cellBbox, [bbox.left, bbox.top, bbox.right, bbox.bottom]); + return bboxesIntersect(cellBbox, [bbox.left, bbox.top, bbox.right, bbox.bottom]); }); } return new PdfTextContentTextLayoutCell(this, index, item, pageNum, cellBbox, isInHtmlBbox); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts index 53c7e7c81..dddb92c58 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts @@ -1,13 +1,13 @@ -import { Cell, CellField } from 'components/DocumentPreview/types'; -import { DocumentFields, DocumentFieldHighlight, TextSpan } from '../../types'; -import { getDocFieldValue } from '../common/documentUtils'; -import { TextBoxMappingResult } from '../textBoxMapping/types'; +import { Cell, CellField } from '../../../../types'; import { spanGetSubSpan, spanContains, spanIntersection, spanIntersects -} from '../common/textSpanUtils'; +} from '../../../../utils/textSpan'; +import { DocumentFields, DocumentFieldHighlight, TextSpan } from '../../types'; +import { getDocFieldValue } from '../common/documentUtils'; +import { TextBoxMappingResult } from '../textBoxMapping/types'; import { BaseTextLayoutCell } from './BaseTextLayout'; import { TextLayout, TextMappingInfo } from './types'; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts index 23dfe7d4a..0d461433e 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts @@ -1,7 +1,7 @@ import { forEachRectInRange, getTextNodeAndOffset } from 'utils/document/documentUtils'; import { Bbox, TextSpan } from '../../types'; -import { BOTTOM, LEFT, RIGHT, TOP } from '../common/bboxUtils'; -import { END, START } from '../common/textSpanUtils'; +import { BOTTOM, LEFT, RIGHT, TOP } from '../../../../utils/box'; +import { END, START } from '../../../../utils/textSpan'; import { TextLayoutCell } from './types'; const debugOut = require('debug')?.('pdf:textLayout:dom'); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/box.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/box.test.ts index 4c3bfeb78..e76b5f828 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/box.test.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/box.test.ts @@ -1,4 +1,4 @@ -import { findMatchingBbox } from '../box'; +import { findMatchingBbox, bboxesIntersect } from '../box'; import { CellPage } from '../../types'; const originalDocBbox = [ { @@ -329,4 +329,21 @@ describe('box', () => { ]; expect(findMatchingBbox(originalDocBbox[1] as CellPage, processedDocBbox)).toEqual(result); }); + + describe('bboxesIntersect', () => { + it('should return true when boxes intersect', () => { + expect(bboxesIntersect([10, 10, 20, 20], [15, 15, 25, 25])).toBeTruthy(); + }); + + it("should return false when boxes don't intersect", () => { + expect(bboxesIntersect([10, 10, 20, 20], [15, 25, 25, 35])).toBeFalsy(); + }); + + it('should return false when one box is on another', () => { + expect(bboxesIntersect([10, 10, 20, 20], [20, 10, 30, 20])).toBeFalsy(); + expect(bboxesIntersect([10, 10, 20, 20], [0, 10, 10, 20])).toBeFalsy(); + expect(bboxesIntersect([10, 10, 20, 20], [10, 20, 20, 30])).toBeFalsy(); + expect(bboxesIntersect([10, 10, 20, 20], [10, 0, 20, 10])).toBeFalsy(); + }); + }); }); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/textSpan.test.ts similarity index 98% rename from packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts rename to packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/textSpan.test.ts index 78c663ced..a1f7d18ad 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/textSpanUtils.test.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/textSpan.test.ts @@ -1,4 +1,3 @@ -import { TextSpan } from '../../../types'; import { spanCompare, spanContains, @@ -9,7 +8,8 @@ import { spanIntersection, spanIntersects, spanLen -} from '../textSpanUtils'; +} from '../textSpan'; +import { TextSpan } from '../../types'; describe('spanGetText', () => { it('should return valid span text', () => { diff --git a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts index e5dd2643d..448eadf82 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts @@ -1,13 +1,18 @@ import { CellPage } from '../types'; import { ProcessedBbox } from '../../../utils/document/processDoc'; +export const LEFT = 0; +export const TOP = 1; +export const RIGHT = 2; +export const BOTTOM = 3; + /** * Check whether two bbox intersect * @param boxA first bbox * @param boxB second bbox * @returns bool */ -export function intersects(boxA: number[], boxB: number[]): boolean { +export function bboxesIntersect(boxA: number[], boxB: number[]): boolean { const [leftA, topA, rightA, bottomA, pageA] = boxA; const [leftB, topB, rightB, bottomB, pageB] = boxB; return !( @@ -28,7 +33,7 @@ export const findMatchingBbox = (docBox: CellPage, htmlBox: ProcessedBbox[]) => return htmlBox.filter(pBbox => { const { left, top, right, bottom, page } = pBbox; const [left2, top2, right2, bottom2] = docBox.bbox; - return intersects( + return bboxesIntersect( [left2, top2, right2, bottom2, docBox.page_number], [left, top, right, bottom, page] ); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/textSpan.ts similarity index 98% rename from packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts rename to packages/discovery-react-components/src/components/DocumentPreview/utils/textSpan.ts index e40acefce..2b0f543bf 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/textSpanUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/textSpan.ts @@ -1,4 +1,4 @@ -import { TextSpan } from '../../types'; +import { TextSpan } from '../types'; export const START = 0; export const END = 1; From 0967b0511e94c57e3ba7cf5b1ab7d9261b1f1e3e Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 21:29:27 +0900 Subject: [PATCH 44/51] fix: apply review comments --- .../PdfViewerHighlight/PdfViewerHighlight.tsx | 31 +++++++----------- .../PdfViewerWithHighlight.tsx | 32 +++++++------------ .../components/PdfViewerHighlight/types.ts | 5 +-- .../utils/common/TextNormalizer.ts | 6 ++-- 4 files changed, 26 insertions(+), 48 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx index 636e25ef4..e0e7c886a 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx @@ -1,15 +1,16 @@ import React, { FC, useMemo, useEffect } from 'react'; import cx from 'classnames'; -import { DocumentFieldHighlight } from './types'; +import { settings } from 'carbon-components'; import { QueryResult } from 'ibm-watson/discovery/v2'; +import { ProcessedDoc } from 'utils/document'; +import { TextMappings } from '../../types'; +import { PdfDisplayProps } from '../PdfViewer/types'; import { PdfRenderedText } from '../PdfViewer/PdfViewerTextLayer'; -import { Highlighter } from './utils/Highlighter'; +import { DocumentFieldHighlight } from './types'; import { ExtractedDocumentInfo } from './utils/common/documentUtils'; -import { settings } from 'carbon-components'; -import { TextMappings } from 'components/DocumentPreview/types'; -import { ProcessedDoc } from 'utils/document'; +import { Highlighter } from './utils/Highlighter'; -interface Props { +type Props = PdfDisplayProps & { /** * Class name to style highlight layer */ @@ -30,11 +31,6 @@ interface Props { */ parsedDocument: ExtractedDocumentInfo | null; - /** - * Current page, starting at index 1 - */ - pageNum: number; - /** * Highlight spans on fields in document */ @@ -45,11 +41,6 @@ interface Props { */ pdfRenderedText: PdfRenderedText | null; - /** - * Zoom factor, where `1` is equal to 100% - */ - scale?: number; - /** * Flag to whether or not to use bbox information from html field in the document. * True by default. This is for testing and debugging purpose. @@ -61,7 +52,7 @@ interface Props { * True by default. This is for testing and debugging purpose. */ usePdfTextItem?: boolean; -} +}; /** * Text highlight layer for PdfViewer @@ -71,10 +62,10 @@ const PdfViewerHighlight: FC = ({ highlightClassName, document, parsedDocument, - pageNum, + page, highlights, pdfRenderedText, - scale = 1.0, + scale, useHtmlBbox = true, usePdfTextItem = true }) => { @@ -83,7 +74,7 @@ const PdfViewerHighlight: FC = ({ textMappings: parsedDocument?.textMappings, processedDoc: useHtmlBbox ? parsedDocument?.processedDoc : undefined, pdfRenderedText: (usePdfTextItem && pdfRenderedText) || undefined, - pageNum + pageNum: page }); const { textDivs } = pdfRenderedText || {}; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx index cd32c1598..5960059d4 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -1,10 +1,11 @@ -import React, { FC, useState, useEffect } from 'react'; -import { DocumentFieldHighlight } from './types'; -import PdfViewer, { PdfViewerProps } from '../PdfViewer/PdfViewer'; -import PdfViewerHighlight from './PdfViewerHighlight'; -import { extractDocumentInfo, ExtractedDocumentInfo } from './utils/common/documentUtils'; +import React, { FC, useState, useCallback } from 'react'; import { QueryResult } from 'ibm-watson/discovery/v2'; +import useAsyncFunctionCall from 'utils/useAsyncFunctionCall'; +import PdfViewer, { PdfViewerProps } from '../PdfViewer/PdfViewer'; import { PdfRenderedText } from '../PdfViewer/PdfViewerTextLayer'; +import { DocumentFieldHighlight } from './types'; +import PdfViewerHighlight from './PdfViewerHighlight'; +import { extractDocumentInfo } from './utils/common/documentUtils'; interface Props extends PdfViewerProps { /** @@ -42,30 +43,19 @@ const PdfViewerWithHighlight: FC = ({ const { page, scale } = rest; const [renderedText, setRenderedText] = useState(null); - const [documentInfo, setDocumentInfo] = useState(null); - useEffect(() => { - let cancelled = false; - const extractDocInfo = async () => { - const info = await extractDocumentInfo(document); - if (!cancelled) { - setDocumentInfo(info); - } - }; - extractDocInfo(); - return () => { - cancelled = true; - }; - }, [document]); + const documentInfo = useAsyncFunctionCall( + useCallback(async () => await extractDocumentInfo(document), [document]) + ); const highlightReady = !!documentInfo && !!renderedText; return ( - + ' ', + normal: (_: string) => ' ', regexString: '\\s+' }; @@ -39,7 +39,7 @@ const DOUBLE_QUOTE: CharNormalizer = { }; const QUOTE: CharNormalizer = { - normal: () => "'", + normal: (_: string) => "'", regexString: `[${[ '‹', // U+2039 '›', // U+203A @@ -67,7 +67,7 @@ const SURROGATE_PAIR: CharNormalizer = { // NOTE: we may have to do this after conversion again // str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") const DIACRITICAL_MARK: CharNormalizer = { - normal: () => '', + normal: (_: string) => '', regexString: '[\u0300-\u036f]' }; const DIACRITICAL_MARK_REGEX = new RegExp(DIACRITICAL_MARK.regexString, 'g'); From bb448d5b37cbbc70a2354bd32813368c696718f9 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 22:00:15 +0900 Subject: [PATCH 45/51] fix: remove unnecessary change --- packages/discovery-react-components/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/discovery-react-components/package.json b/packages/discovery-react-components/package.json index f4c8bd4e6..6bd4f8468 100644 --- a/packages/discovery-react-components/package.json +++ b/packages/discovery-react-components/package.json @@ -18,7 +18,7 @@ "eslint": "yarn run g:eslint --quiet '{src,.storybook}/**/*.{js,jsx,ts,tsx}'", "lint": "yarn run circular && yarn run eslint", "start": "rollup -c -w", - "storybook": "../../node_modules/.bin/start-storybook --ci --port=9002", + "storybook": "start-storybook --ci --port=9002", "storybook:build": "build-storybook", "storybook:build:release": "cross-env STORYBOOK_BUILD_MODE=production build-storybook -o ../../docs/storybook", "analyze": "yarn run g:analyze 'dist/index.js'", From d651ea81863f7f09b044296c4cef9a477ab19c7e Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Mon, 6 Dec 2021 22:00:27 +0900 Subject: [PATCH 46/51] chore: add comment --- .../components/PdfViewerHighlight/utils/Highlighter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts index 7afa139bf..b977bbda0 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts @@ -19,6 +19,9 @@ function debug(...args: any) { debugOut?.apply(null, args); } +/** + * Highlighter - calculate highlight bbox from spans on text fields + */ export class Highlighter { readonly pageNum: number; private readonly textMappingsLayout: TextMappingsTextLayout; From e1fe864bed333975123024e82165ba73f158955b Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Tue, 7 Dec 2021 15:36:26 +0900 Subject: [PATCH 47/51] chore: upadte yarn lock file --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index b047d1520..59fa173ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2267,7 +2267,7 @@ __metadata: languageName: node linkType: hard -"@ibm-watson/discovery-react-components@^1.5.0-beta.3, @ibm-watson/discovery-react-components@workspace:packages/discovery-react-components": +"@ibm-watson/discovery-react-components@^1.5.0-beta.4, @ibm-watson/discovery-react-components@workspace:packages/discovery-react-components": version: 0.0.0-use.local resolution: "@ibm-watson/discovery-react-components@workspace:packages/discovery-react-components" dependencies: @@ -2305,7 +2305,7 @@ __metadata: languageName: unknown linkType: soft -"@ibm-watson/discovery-styles@^1.5.0-beta.2, @ibm-watson/discovery-styles@workspace:packages/discovery-styles": +"@ibm-watson/discovery-styles@^1.5.0-beta.4, @ibm-watson/discovery-styles@workspace:packages/discovery-styles": version: 0.0.0-use.local resolution: "@ibm-watson/discovery-styles@workspace:packages/discovery-styles" dependencies: @@ -10260,8 +10260,8 @@ __metadata: resolution: "discovery-search-app@workspace:examples/discovery-search-app" dependencies: "@carbon/icons": ^10.5.0 - "@ibm-watson/discovery-react-components": ^1.5.0-beta.3 - "@ibm-watson/discovery-styles": ^1.5.0-beta.2 + "@ibm-watson/discovery-react-components": ^1.5.0-beta.4 + "@ibm-watson/discovery-styles": ^1.5.0-beta.4 body-parser: ^1.19.0 carbon-components: ^10.6.0 carbon-components-react: ^7.7.0 From 0911538d0cf89a11a81b40795ca0c863465091de Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Tue, 7 Dec 2021 19:04:08 +0900 Subject: [PATCH 48/51] fix: apply review comments --- .../PdfViewerHighlight/PdfViewerHighlight.tsx | 12 +++--- .../PdfViewerWithHighlight.stories.scss | 13 +++--- .../PdfViewerWithHighlight.tsx | 8 ++-- .../PdfViewerHighlight/utils/Highlighter.ts | 2 +- .../utils/common/bboxUtils.ts | 8 +++- .../utils/textBoxMapping/CellProvider.ts | 12 ++++-- .../MappingTargetCellProvider.ts | 12 ++++-- .../utils/textBoxMapping/TextBoxMapping.ts | 18 +++++--- .../utils/textBoxMapping/TextProvider.ts | 19 ++++++-- .../utils/textBoxMapping/getTextBoxMapping.ts | 2 +- .../utils/textLayout/BaseTextLayout.ts | 28 +++++++++--- .../utils/textLayout/HtmlBboxTextLayout.ts | 8 +++- .../textLayout/PdfTextContentTextLayout.ts | 24 ++++++++--- .../textLayout/TextMappingsTextLayout.ts | 4 +- .../utils/textLayout/dom.ts | 6 +-- .../utils/textLayout/types.ts | 43 +++++++++++++++---- .../src/components/DocumentPreview/types.ts | 2 +- .../components/DocumentPreview/utils/box.ts | 5 --- .../utils/common => utils}/nonEmpty.ts | 0 .../_document-preview-pdf-viewer.scss | 6 ++- 20 files changed, 160 insertions(+), 72 deletions(-) rename packages/discovery-react-components/src/{components/DocumentPreview/components/PdfViewerHighlight/utils/common => utils}/nonEmpty.ts (100%) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx index e0e7c886a..5ab03b7ac 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx @@ -45,13 +45,13 @@ type Props = PdfDisplayProps & { * Flag to whether or not to use bbox information from html field in the document. * True by default. This is for testing and debugging purpose. */ - useHtmlBbox?: boolean; + _useHtmlBbox?: boolean; /** * Flag to whether to use PDF text items for finding bbox for highlighting. * True by default. This is for testing and debugging purpose. */ - usePdfTextItem?: boolean; + _usePdfTextItem?: boolean; }; /** @@ -66,14 +66,14 @@ const PdfViewerHighlight: FC = ({ highlights, pdfRenderedText, scale, - useHtmlBbox = true, - usePdfTextItem = true + _useHtmlBbox = true, + _usePdfTextItem = true }) => { const highlighter = useHighlighter({ document, textMappings: parsedDocument?.textMappings, - processedDoc: useHtmlBbox ? parsedDocument?.processedDoc : undefined, - pdfRenderedText: (usePdfTextItem && pdfRenderedText) || undefined, + processedDoc: _useHtmlBbox ? parsedDocument?.processedDoc : undefined, + pdfRenderedText: (_usePdfTextItem && pdfRenderedText) || undefined, pageNum: page }); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss index 6de187694..5703cca25 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss @@ -1,10 +1,13 @@ +// Carbon highlight color for white theme +// https://www.carbondesignsystem.com/guidelines/color/usage/ +$highlight: #d0e2ff; + .withTextSelection { display: flex; - height: 800px; .rightPane { - flex: 1 1 auto; - width: 20%; + flex: 1 1 30%; + height: 100vh; overflow-y: scroll; p { @@ -19,7 +22,7 @@ } .highlight { - opacity: 0.4; - background: rgba(255, 64, 128, 1); + opacity: 0.3; + background: darken($highlight, 30%); } } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx index 5960059d4..1a2955b1a 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -27,7 +27,7 @@ interface Props extends PdfViewerProps { * Consider bboxes in HTML field to highlight. * True by default. This is for testing purpose. */ - useHtmlBbox?: boolean; + _useHtmlBbox?: boolean; } /** @@ -37,7 +37,7 @@ const PdfViewerWithHighlight: FC = ({ highlightClassName, document, highlights, - useHtmlBbox, + _useHtmlBbox, ...rest }) => { const { page, scale } = rest; @@ -58,8 +58,8 @@ const PdfViewerWithHighlight: FC = ({ page={page} highlights={highlights} scale={scale} - useHtmlBbox={useHtmlBbox} - usePdfTextItem={true} + _useHtmlBbox={_useHtmlBbox} + _usePdfTextItem={true} /> ); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts index b977bbda0..0dc14f75b 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts @@ -1,6 +1,7 @@ import { TextMappings } from 'components/DocumentPreview/types'; import flatMap from 'lodash/flatMap'; import { PDFPageViewport, TextContent } from 'pdfjs-dist'; +import { nonEmpty } from 'utils/nonEmpty'; import { DocumentFields, DocumentFieldHighlight, @@ -12,7 +13,6 @@ import { getTextBoxMappings } from './textBoxMapping'; import { TextBoxMapping, TextBoxMappingResult } from './textBoxMapping/types'; import { HtmlBboxTextLayout, PdfTextContentTextLayout, TextMappingsTextLayout } from './textLayout'; import { HtmlBboxInfo, TextLayout, TextLayoutCell } from './textLayout/types'; -import { nonEmpty } from './common/nonEmpty'; const debugOut = require('debug')?.('pdf:Highlighter'); function debug(...args: any) { diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts index 533f0918c..cf37e0f34 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts @@ -28,6 +28,13 @@ export function bboxGetSpanByRatio(bbox: Bbox, origLength: number, span: TextSpa * This is used to get a text of a line from a list of small text cells. */ export function isNextToEachOther(boxA: Bbox, boxB: Bbox): boolean { + // + // The ratio of height used to check whether two bboxes are on the same line or not. + // With the value 0.8, when more than 80% of range of height of each bbox overlaps + // one of another, they are considered on the same line. + // + const OVERLAP_RATIO = 0.8; + if (bboxesIntersect(boxA, boxB)) { return false; } @@ -38,7 +45,6 @@ export function isNextToEachOther(boxA: Bbox, boxB: Bbox): boolean { const heightB = bottomB - topB; // compare height ratio - const OVERLAP_RATIO = 0.8; if (!(heightA * OVERLAP_RATIO < heightB || heightB * OVERLAP_RATIO < heightA)) { return false; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts index 73dd4ffe8..dd8e2f932 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts @@ -21,7 +21,9 @@ export class CellProvider { return this.cursor < this.cells.length; } - /** get cells on a line */ + /** + * get cells on a line + */ private getNextCells(): TextLayoutCellBase[] { const { cells: lastCells, @@ -60,7 +62,9 @@ export class CellProvider { result: TextLayoutCellBase[]; } | null = null; - /** get text from cells on a line */ + /** + * get text from cells on a line + */ getNextText(): { texts: string[]; nextCellIndex: number } { const nextCells = this.getNextCells(); const texts = nextCells.map(cell => cell.text); @@ -99,7 +103,9 @@ export class CellProvider { return result; } - /** skip the current cell */ + /** + * skip the current cell + */ skip() { this.skippedCells.push(this.cells[this.cursor]); this.cursor += 1; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts index fe4434991..ff6984f6e 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts @@ -19,7 +19,9 @@ export class MappingTargetBoxProvider { this.cellProvider = new CellProvider(cells); } - /** check whether this provider has another item to visit or not */ + /** + * check whether this provider has another item to visit or not + */ hasNext(): boolean { while (this.cellProvider.hasNext()) { const { texts, nextCellIndex } = this.cellProvider.getNextText(); @@ -41,7 +43,9 @@ export class MappingTargetBoxProvider { return false; } - /** get the next value */ + /** + * get the next value + */ getNextInfo(): { text: string; index: number } { return { text: this.current!.normalizer.normalizedText, @@ -60,7 +64,9 @@ export class MappingTargetBoxProvider { return this.cellProvider.consume(rawLength); } - /** mark the current cell skipped (when no match found in source) */ + /** + * mark the current cell skipped (when no match found in source) + */ skip() { this.current = null; this.cellProvider.skip(); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts index 68e0c33f6..00aed9e40 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts @@ -18,7 +18,8 @@ function debug(...args: any) { } /** - * Text box mapping + * Text box mapping. Mapping between cells (i.e. text box) in a TextLayout + * to ones in another TextLayout. */ class TextBoxMappingImpl implements TextBoxMapping { private readonly mappingEntryMap: Dictionary; @@ -34,7 +35,9 @@ class TextBoxMappingImpl implements TextBoxMapping { debug(this); } - /** get text mapping entries for a given span `spanInSourceCell` on a given `sourceCell` */ + /** + * get text mapping entries for a given span `spanInSourceCell` on a given `sourceCell` + */ private getEntries( sourceCell: TextLayoutCell, spanOnSourceCell: TextSpan @@ -44,7 +47,9 @@ class TextBoxMappingImpl implements TextBoxMapping { ); } - /** @inheritdoc */ + /** + * @inheritdoc + */ apply(source: TextLayoutCellBase, aSpan?: TextSpan): TextBoxMappingResult { const span: TextSpan = aSpan || [0, source.text.length]; @@ -84,18 +89,19 @@ class TextBoxMappingImpl implements TextBoxMapping { } /** - * Text mapping builder + * Builder for the TextMapping */ export class TextBoxMappingBuilder { mappingEntries: TextBoxMappingEntry[] = []; - /** add new mapping data */ + /** + * add new mapping data + */ addMapping(text: TextBoxMappingEntry['text'], box: TextBoxMappingEntry['box']) { this.mappingEntries.push({ text, box }); debug('>> added a new mapping entry (%o) => (cell: %o)', text, text, box?.cell); } - /** get TextBoxMapping */ toTextBoxMapping() { return new TextBoxMappingImpl(this.mappingEntries); } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts index 9528b7669..700be90ce 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts @@ -12,13 +12,24 @@ import { findLargestIndex } from '../common/findLargestIndex'; const MAX_HISTORY = 3; export type TextMatch = { - /** matched text span */ + /** + * matched text span + */ span: TextSpan; - /** text before the matched text. i.e. text that will be skipped by using this match */ + + /** + * text before the matched text. i.e. text that will be skipped by using this match + */ skipText: string; - /** distance from the nearest cursors */ + + /** + * distance from the nearest cursors + */ minHistoryDistance: number; - /** text after the matched text */ + + /** + * text after the matched text + */ textAfterEnd: string; }; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts index 1ca5d1329..44f0f0b1d 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -1,6 +1,6 @@ import minBy from 'lodash/minBy'; +import { nonEmpty } from 'utils/nonEmpty'; import { TextSpan } from '../../types'; -import { nonEmpty } from '../common/nonEmpty'; import { bboxesIntersect } from '../../../../utils/box'; import { spanLen, spanMerge } from '../../../../utils/textSpan'; import { TextLayout, TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts index 7ad9ac173..8732f4571 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts @@ -35,17 +35,23 @@ export class BaseTextLayoutCell> this.text = text; } - /** @inheritdoc */ + /** + * @inheritdoc + */ getPartial(span: TextSpan): TextLayoutCellBase { return new PartialTextLayoutCell(this, span); } - /** @inheritdoc */ + /** + * @inheritdoc + */ getNormalized(): { cell: TextLayoutCell; span?: TextSpan } { return { cell: this }; } - /** @inheritdoc */ + /** + * @inheritdoc + */ getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { if (options?.useRatio) { return bboxGetSpanByRatio(this.bbox, this.text.length, span); @@ -53,7 +59,9 @@ export class BaseTextLayoutCell> return null; } - /** @inheritdoc */ + /** + * @inheritdoc + */ trim(): TextLayoutCellBase { return trimCell(this); } @@ -76,18 +84,24 @@ export class PartialTextLayoutCell implements TextLayoutCellBase { return spanGetText(this.base.text, this.span); } - /** @inheritdoc */ + /** + * @inheritdoc + */ getPartial(span: TextSpan): TextLayoutCellBase { const newSpan = spanIntersection(this.span, spanOffset(span, this.span[START])); return new PartialTextLayoutCell(this.base, newSpan); } - /** @inheritdoc */ + /** + * @inheritdoc + */ getNormalized() { return { cell: this.base, span: this.span }; } - /** @inheritdoc */ + /** + * @inheritdoc + */ trim(): TextLayoutCellBase { return trimCell(this); } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts index 359120466..db60ce738 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts @@ -21,7 +21,9 @@ export class HtmlBboxTextLayout implements TextLayout { }) ?? []; } - /** @inheritdoc */ + /** + * @inheritdoc + */ cellAt(id: number) { return this.cells[id]; } @@ -57,7 +59,9 @@ class HtmlBboxTextLayoutCell extends BaseTextLayoutCell { this.processedBbox = processedBbox; // keep this for later improvement } - /** @inheritdoc */ + /** + * @inheritdoc + */ getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { if (this.processedBbox != null) { // TODO: implement this. calculate bbox for text span using text on browser diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts index 3399b6d33..a57dd011c 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -30,22 +30,30 @@ export class PdfTextContentTextLayout implements TextLayout { - /** @inheritdoc */ + /** + * @inheritdoc + */ readonly isInHtmlBbox?: boolean; constructor( @@ -72,7 +82,9 @@ class PdfTextContentTextLayoutCell extends BaseTextLayoutCell { diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts index a568253df..3dc3e9776 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts @@ -7,9 +7,14 @@ import { Bbox, DocumentFields, TextSpan } from '../../types'; * Text layout information */ export interface TextLayout { - /** cells, paris of bbox and text, of this text layout */ + /** + * cells, paris of bbox and text, of this text layout + */ readonly cells: CellType[]; - /** get cell by ID */ + + /** + * get cell by ID + */ cellAt(id: CellType['id']): CellType; } @@ -18,10 +23,17 @@ export interface TextLayout { */ export interface TextLayoutCell extends TextLayoutCellBase { readonly parent: TextLayout; - /** ID to identify this cell in */ + + /** + * ID to identify this cell in + */ readonly id: IDType; - /** text of this cell */ + + /** + * text of this cell + */ readonly text: string; + readonly pageNum: number; readonly bbox: Bbox; @@ -31,7 +43,9 @@ export interface TextLayoutCell extends TextLayoutCellBase { */ getBboxForTextSpan(span: TextSpan, options?: { useRatio?: boolean }): Bbox | null; - /** a special property for PDF text content item cell. True when this cell overlaps HTML cell */ + /** + * a special property for PDF text content item cell. True when this cell overlaps HTML cell + */ readonly isInHtmlBbox?: boolean; } @@ -40,13 +54,24 @@ export interface TextLayoutCell extends TextLayoutCellBase { * Mainly for sub-string of a text layout cell. */ export interface TextLayoutCellBase { - /** text of this cell */ + /** + * text of this cell + */ readonly text: string; - /** get sub-span of this text layout */ + + /** + * get sub-span of this text layout + */ getPartial(span: TextSpan): TextLayoutCellBase; - /** get normalized form, the base text layout cell and a span on it */ + + /** + * get normalized form, the base text layout cell and a span on it + */ getNormalized(): { cell: TextLayoutCell; span?: TextSpan }; - /** get cell for the trimmed text */ + + /** + * get cell for the trimmed text + */ trim(): TextLayoutCellBase; } diff --git a/packages/discovery-react-components/src/components/DocumentPreview/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/types.ts index 70bdf5ffe..686203d42 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/types.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/types.ts @@ -6,7 +6,7 @@ export interface TextMappings { // [ left, top, right, bottom ] export type Bbox = [number, number, number, number]; -/** [ start (inclusive), end (exclusive) ] */ +// [ start (inclusive), end (exclusive) ] export type TextSpan = [number, number]; export type Origin = 'TopLeft' | 'BottomLeft'; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts index 448eadf82..33005c151 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts @@ -1,11 +1,6 @@ import { CellPage } from '../types'; import { ProcessedBbox } from '../../../utils/document/processDoc'; -export const LEFT = 0; -export const TOP = 1; -export const RIGHT = 2; -export const BOTTOM = 3; - /** * Check whether two bbox intersect * @param boxA first bbox diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts b/packages/discovery-react-components/src/utils/nonEmpty.ts similarity index 100% rename from packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/nonEmpty.ts rename to packages/discovery-react-components/src/utils/nonEmpty.ts diff --git a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss index fb66bb360..28a1d3544 100644 --- a/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss +++ b/packages/discovery-styles/scss/components/document-preview/_document-preview-pdf-viewer.scss @@ -1,4 +1,6 @@ @import './pdfjs_web_mixins'; +@import '../../vars'; +@import './mixins'; .#{$prefix}--document-preview-pdf-viewer { position: relative; @@ -22,6 +24,6 @@ .#{$prefix}--document-preview-pdf-viewer-highlight--item { position: absolute; - opacity: 0.5; - background: rgba(0, 0, 255, 1); + opacity: 0.3; + background: darken($highlight, 30%); } From 457f2ec67d55a5a817be4b3f7448d1e8637eb2a0 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Tue, 7 Dec 2021 19:42:18 +0900 Subject: [PATCH 49/51] refactor: extract common props --- .../PdfViewerHighlight/PdfViewerHighlight.tsx | 58 +++++-------------- .../PdfViewerWithHighlight.tsx | 29 ++-------- .../components/PdfViewerHighlight/types.ts | 30 ++++++++++ 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx index 5ab03b7ac..326b33afa 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx @@ -6,53 +6,27 @@ import { ProcessedDoc } from 'utils/document'; import { TextMappings } from '../../types'; import { PdfDisplayProps } from '../PdfViewer/types'; import { PdfRenderedText } from '../PdfViewer/PdfViewerTextLayer'; -import { DocumentFieldHighlight } from './types'; import { ExtractedDocumentInfo } from './utils/common/documentUtils'; import { Highlighter } from './utils/Highlighter'; +import { HighlightProps } from './types'; -type Props = PdfDisplayProps & { - /** - * Class name to style highlight layer - */ - className?: string; +type Props = PdfDisplayProps & + HighlightProps & { + /** + * Class name to style highlight layer + */ + className?: string; - /** - * Class name to style each highlight - */ - highlightClassName?: string; + /** + * Parsed document information + */ + parsedDocument: ExtractedDocumentInfo | null; - /** - * Document data returned by query - */ - document: QueryResult; - - /** - * Parsed document information - */ - parsedDocument: ExtractedDocumentInfo | null; - - /** - * Highlight spans on fields in document - */ - highlights: DocumentFieldHighlight[]; - - /** - * PDF text content information in a page from parsed PDF - */ - pdfRenderedText: PdfRenderedText | null; - - /** - * Flag to whether or not to use bbox information from html field in the document. - * True by default. This is for testing and debugging purpose. - */ - _useHtmlBbox?: boolean; - - /** - * Flag to whether to use PDF text items for finding bbox for highlighting. - * True by default. This is for testing and debugging purpose. - */ - _usePdfTextItem?: boolean; -}; + /** + * PDF text content information in a page from parsed PDF + */ + pdfRenderedText: PdfRenderedText | null; + }; /** * Text highlight layer for PdfViewer diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx index 1a2955b1a..b0c4a3661 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -1,34 +1,12 @@ import React, { FC, useState, useCallback } from 'react'; -import { QueryResult } from 'ibm-watson/discovery/v2'; import useAsyncFunctionCall from 'utils/useAsyncFunctionCall'; import PdfViewer, { PdfViewerProps } from '../PdfViewer/PdfViewer'; import { PdfRenderedText } from '../PdfViewer/PdfViewerTextLayer'; -import { DocumentFieldHighlight } from './types'; import PdfViewerHighlight from './PdfViewerHighlight'; import { extractDocumentInfo } from './utils/common/documentUtils'; +import { HighlightProps } from './types'; -interface Props extends PdfViewerProps { - /** - * Class name to style each highlight - */ - highlightClassName?: string; - - /** - * Document data returned by query - */ - document: QueryResult; - - /** - * Highlight spans on fields in document - */ - highlights: DocumentFieldHighlight[]; - - /** - * Consider bboxes in HTML field to highlight. - * True by default. This is for testing purpose. - */ - _useHtmlBbox?: boolean; -} +type Props = PdfViewerProps & HighlightProps; /** * PDF viewer component with text highlighting capability @@ -38,6 +16,7 @@ const PdfViewerWithHighlight: FC = ({ document, highlights, _useHtmlBbox, + _usePdfTextItem, ...rest }) => { const { page, scale } = rest; @@ -59,7 +38,7 @@ const PdfViewerWithHighlight: FC = ({ highlights={highlights} scale={scale} _useHtmlBbox={_useHtmlBbox} - _usePdfTextItem={true} + _usePdfTextItem={_usePdfTextItem} /> ); diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts index 962b2b9eb..a6936c80b 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts @@ -1,5 +1,6 @@ import { Bbox as DocPreviewBbox, TextSpan as DocPreviewTextSpan } from '../../types'; import { Location } from 'utils/document/processDoc'; +import { QueryResult } from 'ibm-watson/discovery/v2'; // (re-)export useful types export type Bbox = DocPreviewBbox; @@ -37,3 +38,32 @@ export interface HighlightShapeBox { isStart?: boolean; isEnd?: boolean; } + +export interface HighlightProps { + /** + * Class name to style each highlight + */ + highlightClassName?: string; + + /** + * Document data returned by query + */ + document: QueryResult; + + /** + * Highlight spans on fields in document + */ + highlights: DocumentFieldHighlight[]; + + /** + * Consider bboxes in HTML field to highlight. + * True by default. This is for testing purpose. + */ + _useHtmlBbox?: boolean; + + /** + * Flag to whether to use PDF text items for finding bbox for highlighting. + * True by default. This is for testing and debugging purpose. + */ + _usePdfTextItem?: boolean; +} From 89e4f753d42e3e77feee2be264c5a31d22540750 Mon Sep 17 00:00:00 2001 From: Susumu Fukuda Date: Tue, 7 Dec 2021 20:01:10 +0900 Subject: [PATCH 50/51] feat: export PdfViewerWithHighlight via DocPreview --- .../src/components/DocumentPreview/DocumentPreview.tsx | 2 ++ .../components/PdfViewerHighlight/PdfViewerWithHighlight.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx b/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx index b06e33612..342cce130 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/DocumentPreview.tsx @@ -8,6 +8,7 @@ import SimpleDocument from './components/SimpleDocument/SimpleDocument'; import withErrorBoundary, { WithErrorBoundaryProps } from 'utils/hoc/withErrorBoundary'; import { defaultMessages, Messages } from './messages'; import HtmlView from './components/HtmlView/HtmlView'; +import PdfViewerWithHighlight from './components/PdfViewerHighlight/PdfViewerWithHighlight'; import { isCsvFile, isJsonFile } from './utils/documentData'; const { ZOOM_IN, ZOOM_OUT } = PreviewToolbar; @@ -154,6 +155,7 @@ function PreviewDocument({ const ErrorBoundDocumentPreview: any = withErrorBoundary(DocumentPreview); ErrorBoundDocumentPreview.PreviewToolbar = PreviewToolbar; ErrorBoundDocumentPreview.PreviewDocument = PreviewDocument; +ErrorBoundDocumentPreview.PdfViewerWithHighlight = PdfViewerWithHighlight; export default ErrorBoundDocumentPreview; export { ErrorBoundDocumentPreview as DocumentPreview }; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx index b0c4a3661..e706cb321 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -32,7 +32,7 @@ const PdfViewerWithHighlight: FC = ({ Date: Wed, 8 Dec 2021 21:55:57 +0900 Subject: [PATCH 51/51] fix: fix app build --- examples/discovery-search-app/package.json | 1 + yarn.lock | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/discovery-search-app/package.json b/examples/discovery-search-app/package.json index 283978d2a..0e67e055f 100644 --- a/examples/discovery-search-app/package.json +++ b/examples/discovery-search-app/package.json @@ -28,6 +28,7 @@ "carbon-components": "^10.6.0", "carbon-components-react": "^7.7.0", "classnames": "^2.2.6", + "core-js": "^2.6.12", "cors": "^2.8.5", "dotenv": "^8.1.0", "express": "^4.17.1", diff --git a/yarn.lock b/yarn.lock index 59fa173ae..cf9abdb5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9102,7 +9102,7 @@ __metadata: languageName: node linkType: hard -"core-js@npm:^2.4.0": +"core-js@npm:^2.4.0, core-js@npm:^2.6.12": version: 2.6.12 resolution: "core-js@npm:2.6.12" checksum: 44fa9934a85f8c78d61e0c8b7b22436330471ffe59ec5076fe7f324d6e8cf7f824b14b1c81ca73608b13bdb0fef035bd820989bf059767ad6fa13123bb8bd016 @@ -10266,6 +10266,7 @@ __metadata: carbon-components: ^10.6.0 carbon-components-react: ^7.7.0 classnames: ^2.2.6 + core-js: ^2.6.12 cors: ^2.8.5 cross-env: ^7.0.3 dotenv: ^8.1.0