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/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/__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/PdfViewerHighlight.tsx b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx new file mode 100644 index 000000000..326b33afa --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerHighlight.tsx @@ -0,0 +1,131 @@ +import React, { FC, useMemo, useEffect } from 'react'; +import cx from 'classnames'; +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 { ExtractedDocumentInfo } from './utils/common/documentUtils'; +import { Highlighter } from './utils/Highlighter'; +import { HighlightProps } from './types'; + +type Props = PdfDisplayProps & + HighlightProps & { + /** + * Class name to style highlight layer + */ + className?: string; + + /** + * Parsed document information + */ + parsedDocument: ExtractedDocumentInfo | null; + + /** + * PDF text content information in a page from parsed PDF + */ + pdfRenderedText: PdfRenderedText | null; + }; + +/** + * Text highlight layer for PdfViewer + */ +const PdfViewerHighlight: FC = ({ + className, + highlightClassName, + document, + parsedDocument, + page, + highlights, + pdfRenderedText, + scale, + _useHtmlBbox = true, + _usePdfTextItem = true +}) => { + const highlighter = useHighlighter({ + document, + textMappings: parsedDocument?.textMappings, + processedDoc: _useHtmlBbox ? parsedDocument?.processedDoc : undefined, + pdfRenderedText: (_usePdfTextItem && pdfRenderedText) || undefined, + pageNum: page + }); + + const { textDivs } = pdfRenderedText || {}; + useEffect(() => { + if (highlighter) { + highlighter.setTextContentDivs(textDivs); + } + }, [highlighter, textDivs]); + + 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 ( +
+ ); + })} + + ); + })} +
+ ); +}; + +const useHighlighter = ({ + document, + textMappings, + processedDoc, + pdfRenderedText, + pageNum +}: { + document: QueryResult; + textMappings?: TextMappings; + processedDoc?: ProcessedDoc; + pdfRenderedText?: PdfRenderedText; + pageNum: number; +}) => { + return useMemo(() => { + if (textMappings) { + return new Highlighter({ + document, + textMappings, + pageNum, + htmlBboxInfo: processedDoc && { + bboxes: processedDoc.bboxes, + styles: processedDoc.styles + }, + pdfTextContentInfo: + pdfRenderedText?.textContent && pdfRenderedText?.viewport ? pdfRenderedText : undefined + }); + } + return null; + }, [document, pageNum, pdfRenderedText, processedDoc, textMappings]); +}; + +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..5703cca25 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.scss @@ -0,0 +1,28 @@ +// Carbon highlight color for white theme +// https://www.carbondesignsystem.com/guidelines/color/usage/ +$highlight: #d0e2ff; + +.withTextSelection { + display: flex; + + .rightPane { + flex: 1 1 30%; + height: 100vh; + 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.3; + background: darken($highlight, 30%); + } +} 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..fe2dde415 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.stories.tsx @@ -0,0 +1,208 @@ +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 './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 { 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', + 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 => { + const documentFields = Array.isArray(document[field]) ? document[field] : [document[field]]; + return documentFields + .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 = getDocFieldValue(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 && + getDocFieldValue(document, selectedFieldName, selectedFieldIndex)! + .replace(/ /g, '\u00a0') // NBSP + .replace(/\n/g, '\\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 ( + + ); + }) + .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 ( + + ); + }); 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..e706cb321 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/PdfViewerWithHighlight.tsx @@ -0,0 +1,47 @@ +import React, { FC, useState, useCallback } from 'react'; +import useAsyncFunctionCall from 'utils/useAsyncFunctionCall'; +import PdfViewer, { PdfViewerProps } from '../PdfViewer/PdfViewer'; +import { PdfRenderedText } from '../PdfViewer/PdfViewerTextLayer'; +import PdfViewerHighlight from './PdfViewerHighlight'; +import { extractDocumentInfo } from './utils/common/documentUtils'; +import { HighlightProps } from './types'; + +type Props = PdfViewerProps & HighlightProps; + +/** + * PDF viewer component with text highlighting capability + */ +const PdfViewerWithHighlight: FC = ({ + highlightClassName, + document, + highlights, + _useHtmlBbox, + _usePdfTextItem, + ...rest +}) => { + const { page, scale } = rest; + const [renderedText, setRenderedText] = useState(null); + + const documentInfo = useAsyncFunctionCall( + useCallback(async () => await extractDocumentInfo(document), [document]) + ); + + const highlightReady = !!documentInfo && !!renderedText; + return ( + + + + ); +}; + +export default PdfViewerWithHighlight; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/README.md b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/README.md new file mode 100644 index 000000000..daec4a7a5 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/README.md @@ -0,0 +1,45 @@ +## How highlighting works + +### TextLayout + +`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 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. + +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, 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 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. 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 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 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 + 2. mark the text layout cell consumed + +### Text layout cell to boundary box + +Now, we have small cells for highlighting. + +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. + +### 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/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts new file mode 100644 index 000000000..a6936c80b --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/types.ts @@ -0,0 +1,69 @@ +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; +export type TextSpan = DocPreviewTextSpan; + +/** + * 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; +} + +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; +} 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..0dc14f75b --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/Highlighter.ts @@ -0,0 +1,188 @@ +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, + 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'; + +const debugOut = require('debug')?.('pdf:Highlighter'); +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; + 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.setHtmlBboxInfo(htmlBboxInfo); + } + if (pdfTextContentInfo) { + this.setTextContentItems( + pdfTextContentInfo.textContent, + pdfTextContentInfo.viewport, + pdfTextContentInfo.spans, + htmlBboxInfo + ); + } + } + + /** + * Update highlight with bboxes in HTML field in document + * @param htmlBoxInfo processed document info including bboxes + */ + setHtmlBboxInfo(htmlBoxInfo: HtmlBboxInfo) { + const htmlLayout = new HtmlBboxTextLayout(htmlBoxInfo, this.pageNum); + this.textToHtmlBboxMappings = getTextBoxMappings(this.textMappingsLayout, htmlLayout); + } + + /** + * Update highlighter with PDF text content + * @param textContent PDF text content of the current page + * @param viewport viewport of the currently rendered PDF page + * @param textContentDivs HTML elements where text content items are rendered + * @param htmlBoxInfo processed document info including bboxes + */ + setTextContentItems( + textContent: TextContent, + viewport: PDFPageViewport, + textContentDivs?: HTMLElement[], + htmlBoxInfo?: HtmlBboxInfo + ) { + this.pdfTextContentLayout = new PdfTextContentTextLayout( + { textContent, viewport }, + this.pageNum, + htmlBoxInfo + ); + this.textToPdfTextItemMappings = getTextBoxMappings( + this.textMappingsLayout, + this.pdfTextContentLayout + ); + this.setTextContentDivs(textContentDivs); + } + + /** + * 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 { + 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 + }; + } + debug('getHighlight - cell(%i) is not mapped. source span: %o', item.sourceSpan); + return null; + }) + .filter(nonEmpty); + return { + boxes: boxShapes, + className, + ...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 new file mode 100644 index 000000000..3fe8387b8 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/TextNormalizer.ts @@ -0,0 +1,324 @@ +import { TextSpan } from '../../types'; +import { END, spanLen, START } from '../../../../utils/textSpan'; + +type SpanMapping = { rawSpan: TextSpan; normalizedSpan: TextSpan }; + +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: (_: string) => ' ', + regexString: '\\s+' +}; + +const DOUBLE_QUOTE: CharNormalizer = { + normal: (_: string) => '"', + 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: CharNormalizer = { + normal: (_: string) => "'", + regexString: `[${[ + '‹', // U+2039 + '›', // U+203A + '’', // U+2019 + '❮', // U+276E + '❯', // U+276F + '‘', // U+2018 + '‚', // U+201A + '‛', // U+201B + '❛', // U+275B + '❜', // U+275C + '❟' // U+275F + ].join('')}]` +}; + +// 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]' +}; + +// 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: CharNormalizer = { + normal: (_: string) => '', + regexString: '[\u0300-\u036f]' +}; +const DIACRITICAL_MARK_REGEX = new RegExp(DIACRITICAL_MARK.regexString, 'g'); + +function normalizeDiacriticalMarks(text: string, keepLength = false): string { + 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 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 + */ +function normalizeText(text: string): 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 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; + 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); + } + + /** + * 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; + + 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)]; + } + + /** + * 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; + + 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 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); + } + + /** + * 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*$/); + } +} + +/** + * 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))) + ); +} + +/** + * 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) => + 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..153a2e1b3 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/__tests__/bboxUtils.test.ts @@ -0,0 +1,24 @@ +import { bboxGetSpanByRatio, isNextToEachOther } from '../bboxUtils'; + +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(isNextToEachOther([0, 0, 5, 2], [5, 0, 10, 2])).toBeTruthy(); + }); + it('should return false when boxes are not vertically aligned', () => { + expect(isNextToEachOther([0, 0, 5, 2], [5, 1, 10, 3])).toBeFalsy(); + }); + it('should return false when two boxes are apart from each other', () => { + 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/__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/bboxUtils.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts new file mode 100644 index 000000000..cf37e0f34 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/bboxUtils.ts @@ -0,0 +1,61 @@ +import { Bbox, TextSpan } from '../../types'; +import { bboxesIntersect } from '../../../../utils/box'; +import { spanIntersection, spanLen } from '../../../../utils/textSpan'; + +/** + * 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): Bbox { + 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]; +} + +/** + * 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 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; + } + + const [leftA, topA, rightA, bottomA] = boxA; + const [leftB, topB, rightB, bottomB] = boxB; + const heightA = bottomA - topA; + const heightB = bottomB - topB; + + // compare height ratio + if (!(heightA * OVERLAP_RATIO < heightB || heightB * OVERLAP_RATIO < heightA)) { + return false; + } + + const avgHeight = (heightA + heightB) / 2; + const overlapHeight = Math.max(0, Math.min(bottomA, bottomB) - Math.max(topA, topB)); + if (overlapHeight < avgHeight * OVERLAP_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..576fb6c49 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/common/documentUtils.ts @@ -0,0 +1,64 @@ +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'; + +/** + * 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]; + 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; +}; + +/** + * 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; + + 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/textBoxMapping/CellProvider.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts new file mode 100644 index 000000000..dd8e2f932 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/CellProvider.ts @@ -0,0 +1,113 @@ +import { isNextToEachOther } from '../common/bboxUtils'; +import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; + +export class CellProvider { + private readonly skippedCells: TextLayoutCellBase[] = []; + private cells: readonly TextLayoutCellBase[]; + private cursor: number = 0; + + constructor(cells: TextLayoutCellBase[]) { + this.cells = Object.freeze([...cells]); + } + + hasNext(): boolean { + 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(): TextLayoutCellBase[] { + const { + cells: lastCells, + cursor: lastCursor, + result: lastResult + } = this.getNextCellsCache || {}; + + 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 && !isNextToEachOther(lastCell.bbox, baseCurrentCell.bbox)) { + break; + } + result.push(currentBox); + lastCell = baseCurrentCell; + } + + 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(): { texts: string[]; nextCellIndex: number } { + const nextCells = this.getNextCells(); + const texts = nextCells.map(cell => cell.text); + return { texts, nextCellIndex: this.cursor }; + } + + /** + * 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[] = []; + + 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 = Object.freeze(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..337fe72d9 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingSourceTextProvider.ts @@ -0,0 +1,74 @@ +import minBy from 'lodash/minBy'; +import { spanGetText, spanLen, START } from '../../../../utils/textSpan'; +import { TextSpan } from '../../types'; +import { TextNormalizer } from '../common/TextNormalizer'; +import { TextLayoutCell } from '../textLayout/types'; +import { TextProvider } from './TextProvider'; + +const debugOut = require('debug')?.('pdf:mapping:MappingSourceTextProvider'); +function debug(...args: any) { + debugOut?.apply(null, args); +} + +/** + * TextProvider with normalization + * @see TextProvider + */ +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); + } + + /** + * 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); + 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; + } + + /** + * 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); + } + + /** + * 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 new file mode 100644 index 000000000..ff6984f6e --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/MappingTargetCellProvider.ts @@ -0,0 +1,74 @@ +import { END } from '../../../../utils/textSpan'; +import { TextLayoutCellBase } from '../textLayout/types'; +import { TextNormalizer } from '../common/TextNormalizer'; +import { CellProvider } from './CellProvider'; + +/** + * Cell provider with normalization + * @see CellProvider + */ +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); + } + + /** + * 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(''); + 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; + } + + /** + * get the next value + */ + getNextInfo(): { text: string; index: number } { + return { + text: this.current!.normalizer.normalizedText, + index: this.current!.nextCellIndex + }; + } + + /** + * 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 new file mode 100644 index 000000000..00aed9e40 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextBoxMapping.ts @@ -0,0 +1,122 @@ +import { Dictionary } from 'lodash'; +import groupBy from 'lodash/groupBy'; +import { TextSpan } from '../../types'; +import { + spanCompare, + spanFromSubSpan, + spanGetSubSpan, + spanIntersection, + spanIntersects +} 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) { + debugOut?.apply(null, args); +} + +/** + * 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; + + 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); + } + + /** + * 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, spanOnSourceCell) + ); + } + + /** + * @inheritdoc + */ + 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 (equalsSpanText(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; + } +} + +/** + * Builder for the TextMapping + */ +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); + } + + toTextBoxMapping() { + return new TextBoxMappingImpl(this.mappingEntries); + } +} + +/** + * Check if text on spans on cells are the same or not + */ +function equalsSpanText( + 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..700be90ce --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/TextProvider.ts @@ -0,0 +1,118 @@ +import { TextSpan } from '../../types'; +import { + END, + START, + spanIntersects, + spanIncludesIndex, + spanGetText, + spanIntersection +} from '../../../../utils/textSpan'; +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[]; + private history: number[] = [0]; // Keep MAX_HISTORY last recently consumed + + constructor(fieldText: string) { + this.fieldText = fieldText; + 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; + 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 : []; + } + + /** + * Mark the `span` as used + */ + 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..44f0f0b1d --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/getTextBoxMapping.ts @@ -0,0 +1,205 @@ +import minBy from 'lodash/minBy'; +import { nonEmpty } from 'utils/nonEmpty'; +import { TextSpan } from '../../types'; +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'; +import { TextBoxMappingBuilder } from './TextBoxMapping'; +import { TextBoxMapping } from './types'; + +const debugOut = require('debug')?.('pdf:mapping:getTextBoxMapping'); +function debug(...args: any) { + debugOut?.apply(null, args); +} + +/** + * 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 +>(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); + } + }); + if (spanLen(mappedSourceFullSpan) > 0) { + matchInSource.markSourceMappedBySpan(mappedSourceFullSpan); + } + } + }); + + return builder.toTextBoxMapping(); +} + +/** + * Utility class for manipulating target text layout in getTextBoxMapping + */ +class Target { + targetProvider: MappingTargetBoxProvider; + + 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 (!bboxesIntersect(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; + }); + } + + /** + * 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); + } + } + }; + } + + /** + * 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; + } +} 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..2c7e0d666 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textBoxMapping/types.ts @@ -0,0 +1,28 @@ +import { TextSpan } from '../../types'; +import { TextLayoutCell, TextLayoutCellBase } from '../textLayout/types'; + +export type TextBoxMappingResult = { + cell: TextLayoutCellBase | null; + 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 new file mode 100644 index 000000000..8732f4571 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/BaseTextLayout.ts @@ -0,0 +1,125 @@ +import { spanGetText, spanIntersection, spanOffset, START } from '../../../../utils/textSpan'; +import { Bbox, TextSpan } from '../../types'; +import { bboxGetSpanByRatio } from '../common/bboxUtils'; +import { TextLayout, TextLayoutCell, TextLayoutCellBase } from './types'; + +/** + * 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; + } + + /** + * @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); + } + return null; + } + + /** + * @inheritdoc + */ + trim(): TextLayoutCellBase { + return trimCell(this); + } +} + +/** + * 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); + } + + /* @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 }; + } + + /** + * @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/HtmlBboxTextLayout.ts b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts new file mode 100644 index 000000000..db60ce738 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/HtmlBboxTextLayout.ts @@ -0,0 +1,71 @@ +import { decodeHTML } from 'entities'; +import { ProcessedBbox } from 'utils/document'; +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[]; + + 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); + }) ?? []; + } + + /** + * @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: implement this + } + } +} + +/** + * Text layout cell based on bboxes in HTML field + */ +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 + } + + /** + * @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 + } + 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..a57dd011c --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/PdfTextContentTextLayout.ts @@ -0,0 +1,118 @@ +import { bboxesIntersect } from 'components/DocumentPreview/utils/box'; +import { PDFPageViewport, PDFPageViewportOptions, TextContentItem } from 'pdfjs-dist'; +import { Bbox, TextSpan } from '../../types'; +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 divs: HTMLElement[] | undefined; + + constructor(textContentInfo: PdfTextContentInfo, pageNum: number, htmlBboxInfo?: HtmlBboxInfo) { + this.textContentInfo = textContentInfo; + + const textContentItems = textContentInfo.textContent.items; + + this.cells = textContentItems.map((item, index) => { + const cellBbox = getBbox(item, this.viewport); + let isInHtmlBbox = false; + if (htmlBboxInfo?.bboxes?.length) { + isInHtmlBbox = htmlBboxInfo.bboxes.some(bbox => { + return bboxesIntersect(cellBbox, [bbox.left, bbox.top, bbox.right, bbox.bottom]); + }); + } + return new PdfTextContentTextLayoutCell(this, index, item, pageNum, cellBbox, isInHtmlBbox); + }); + } + + /** + * get viewport of the current page + */ + get viewport(): PDFPageViewport { + return this.textContentInfo.viewport; + } + + /** + * @inheritdoc + */ + cellAt(id: number) { + return this.cells[id]; + } + + /** + * set PDF text content item divs + */ + setDivs(divs: HTMLElement[] | undefined) { + this.divs = divs; + } + + /** + * get HTML element for a given cell id + */ + divAt(id: number): HTMLElement | undefined { + return this.divs?.[id]; + } +} + +/** + * Text layout cell based on PDF text objects + */ +class PdfTextContentTextLayoutCell extends BaseTextLayoutCell { + /** + * @inheritdoc + */ + readonly isInHtmlBbox?: boolean; + + constructor( + parent: PdfTextContentTextLayout, + index: number, + textItem: TextContentItem, + pageNum: number, + bbox: Bbox, + isInHtmlBbox: boolean + ) { + const id = index; + const text = textItem.str; + super({ parent, id, pageNum, bbox, text }); + this.isInHtmlBbox = isInHtmlBbox; + } + + /** + * @inheritdoc + */ + getBboxForTextSpan(span: TextSpan, options: { useRatio?: boolean }): Bbox | null { + const spanElement = this.parent.divAt(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); + } +} + +/** + * Get bbox from a PDF text content item + */ +function getBbox(textItem: TextContentItem, viewport: PDFPageViewport): Bbox { + const { transform } = textItem; + + const patchedViewport = viewport as PDFPageViewportOptions & PDFPageViewport; + const defaultSideways = patchedViewport.rotation % 180 !== 0; + + 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..e3486f453 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/TextMappingsTextLayout.ts @@ -0,0 +1,81 @@ +import { Cell, CellField } from '../../../../types'; +import { + spanGetSubSpan, + spanContains, + spanIntersection, + spanIntersects +} 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'; + +/** + * Text layout based on text mappings + */ +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); + }); + } + + /** + * @inheritdoc + */ + cellAt(id: number) { + return this.cells[id]; + } + + /** + * Get highlighted text layout cells from a span on a field in a search result document + * @param highlight field and span to highlight + * @returns a text cell based on + */ + 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; + } +} + +/** + * Text layout cell based on a text mapping cell + */ +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..73edcfe48 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/dom.ts @@ -0,0 +1,63 @@ +import { forEachRectInRange, getTextNodeAndOffset } from 'utils/document/documentUtils'; +import { Bbox, TextSpan } from '../../types'; +import { END, START } from '../../../../utils/textSpan'; +import { TextLayoutCell } from './types'; + +const debugOut = require('debug')?.('pdf:textLayout:dom'); +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, + 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]); + + try { + const { textNode: beginTextNode, textOffset: beginTextOffset } = + beginOffset > 0 + ? getTextNodeAndOffset(spanElement, beginOffset) + : { textNode: spanElement.firstChild, textOffset: 0 }; + const { textNode: endTextNode, textOffset: endTextOffset } = + endOffset > 0 + ? getTextNodeAndOffset(spanElement, 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); + + // create highlight rect(s) inside of a field + let [left, top, right, bottom] = cell.bbox; + + const parentRect = spanElement.parentElement?.getBoundingClientRect(); + forEachRectInRange(beginTextNode, beginTextOffset, endTextNode, endTextOffset, 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..3dc3e9776 --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/components/PdfViewerHighlight/utils/textLayout/types.ts @@ -0,0 +1,97 @@ +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; + + /** + * a special property for PDF text content item cell. True when this cell overlaps HTML cell + */ + readonly isInHtmlBbox?: boolean; +} + +/** + * 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 }; + + /** + * get cell for the trimmed text + */ + trim(): TextLayoutCellBase; +} + +/** + * 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; +}; diff --git a/packages/discovery-react-components/src/components/DocumentPreview/types.ts b/packages/discovery-react-components/src/components/DocumentPreview/types.ts index e72598074..686203d42 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/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/utils/__tests__/textSpan.test.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/textSpan.test.ts new file mode 100644 index 000000000..a1f7d18ad --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/__tests__/textSpan.test.ts @@ -0,0 +1,135 @@ +import { + spanCompare, + spanContains, + spanFromSubSpan, + spanGetSubSpan, + spanGetText, + spanIncludesIndex, + spanIntersection, + spanIntersects, + spanLen +} from '../textSpan'; +import { TextSpan } from '../../types'; + +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/utils/box.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts index 99d01bdf7..33005c151 100644 --- a/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/box.ts @@ -7,10 +7,16 @@ import { ProcessedBbox } from '../../../utils/document/processDoc'; * @param boxB second bbox * @returns bool */ -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 !(leftB > rightA || rightB < leftA || topB > bottomA || bottomB < topA || pageA !== pageB); + return !( + leftB >= rightA || + rightB <= leftA || + topB >= bottomA || + bottomB <= topA || + pageA !== pageB + ); } /** @@ -22,7 +28,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/utils/textSpan.ts b/packages/discovery-react-components/src/components/DocumentPreview/utils/textSpan.ts new file mode 100644 index 000000000..2b0f543bf --- /dev/null +++ b/packages/discovery-react-components/src/components/DocumentPreview/utils/textSpan.ts @@ -0,0 +1,109 @@ +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 { + // 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; +} + +/** + * 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]); +} + +/** + * Get span length + */ +export function spanLen(span: TextSpan): number { + return Math.max(0, span[END] - span[START]); +} + +/** + * 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; +} + +/** + * 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; + const start = Math.max(a[START], b[START]); + const end = Math.min(a[END], b[END]); + return [start, start <= end ? end : start]; +} + +/** + * 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]); + const end = Math.max(a[END], b[END]); + 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]; +} + +/** + * 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])); +} + +/** + * 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]); +} + +/** + * 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/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/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( 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; } diff --git a/packages/discovery-react-components/src/utils/nonEmpty.ts b/packages/discovery-react-components/src/utils/nonEmpty.ts new file mode 100644 index 000000000..be6fb569f --- /dev/null +++ b/packages/discovery-react-components/src/utils/nonEmpty.ts @@ -0,0 +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-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..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; @@ -12,3 +14,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.3; + background: darken($highlight, 30%); +} diff --git a/yarn.lock b/yarn.lock index b047d1520..cf9abdb5f 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: @@ -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 @@ -10260,12 +10260,13 @@ __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 classnames: ^2.2.6 + core-js: ^2.6.12 cors: ^2.8.5 cross-env: ^7.0.3 dotenv: ^8.1.0