-
Notifications
You must be signed in to change notification settings - Fork 220
Fix/linebreaks conversion quotes #7511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
cd3c94d
379a9b7
47f73d9
b9aeb5a
1e2f4a2
6c63734
e8b750c
635a18c
d42bef5
b954e6d
aeebeba
1e3dac0
03f24ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -71,6 +71,10 @@ function quotePositionCacheKey(quote: string, pos?: number) { | |||||||||||||||||||||||
| return `${quote}:${pos}`; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function normalizePDFText(str: string): string { | ||||||||||||||||||||||||
| return str.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' '); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Return the text layer element of the PDF page containing `node`. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
|
|
@@ -422,64 +426,61 @@ async function anchorQuote( | |||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Search pages for the best match, ignoring whitespace differences. | ||||||||||||||||||||||||
| const strippedPrefix = | ||||||||||||||||||||||||
| // Normalize quote and context consistently with selector creation. | ||||||||||||||||||||||||
| const normalizedPrefix = | ||||||||||||||||||||||||
| quoteSelector.prefix !== undefined | ||||||||||||||||||||||||
| ? stripSpaces(quoteSelector.prefix) | ||||||||||||||||||||||||
| ? normalizePDFText(quoteSelector.prefix) | ||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||
| const strippedSuffix = | ||||||||||||||||||||||||
| const normalizedSuffix = | ||||||||||||||||||||||||
| quoteSelector.suffix !== undefined | ||||||||||||||||||||||||
| ? stripSpaces(quoteSelector.suffix) | ||||||||||||||||||||||||
| ? normalizePDFText(quoteSelector.suffix) | ||||||||||||||||||||||||
| : undefined; | ||||||||||||||||||||||||
| const strippedQuote = stripSpaces(quoteSelector.exact); | ||||||||||||||||||||||||
| const normalizedQuote = normalizePDFText(quoteSelector.exact); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| let bestMatch; | ||||||||||||||||||||||||
| for (const page of pageIndexes) { | ||||||||||||||||||||||||
| const text = await getPageTextContent(page); | ||||||||||||||||||||||||
| const strippedText = stripSpaces(text); | ||||||||||||||||||||||||
| const normalizedText = normalizePDFText(text); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Determine expected offset of quote in current page based on position hint. | ||||||||||||||||||||||||
| let strippedHint; | ||||||||||||||||||||||||
| let normalizedHint; | ||||||||||||||||||||||||
| if (expectedPageIndex !== undefined && expectedOffsetInPage !== undefined) { | ||||||||||||||||||||||||
| if (page < expectedPageIndex) { | ||||||||||||||||||||||||
| strippedHint = strippedText.length; // Prefer matches closer to end of page. | ||||||||||||||||||||||||
| normalizedHint = normalizedText.length; // Prefer matches closer to end of page. | ||||||||||||||||||||||||
| } else if (page === expectedPageIndex) { | ||||||||||||||||||||||||
| // Translate expected offset in whitespace-inclusive version of page | ||||||||||||||||||||||||
| // text into offset in whitespace-stripped version of page text. | ||||||||||||||||||||||||
| [strippedHint] = translateOffsets( | ||||||||||||||||||||||||
| // Translate expected offset in original text into offset in normalized text. | ||||||||||||||||||||||||
| [normalizedHint] = translateOffsets( | ||||||||||||||||||||||||
| text, | ||||||||||||||||||||||||
| strippedText, | ||||||||||||||||||||||||
| normalizedText, | ||||||||||||||||||||||||
| expectedOffsetInPage, | ||||||||||||||||||||||||
| expectedOffsetInPage, | ||||||||||||||||||||||||
| isNotSpace, | ||||||||||||||||||||||||
| // We don't need to normalize here since both input strings are | ||||||||||||||||||||||||
| // derived from the same input. | ||||||||||||||||||||||||
| { normalize: false }, | ||||||||||||||||||||||||
| char => char !== ' ', | ||||||||||||||||||||||||
| { normalize: true }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
Comment on lines
+451
to
459
|
||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||
| strippedHint = 0; // Prefer matches closer to start of page. | ||||||||||||||||||||||||
| normalizedHint = 0; // Prefer matches closer to start of page. | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const match = matchQuote(strippedText, strippedQuote, { | ||||||||||||||||||||||||
| prefix: strippedPrefix, | ||||||||||||||||||||||||
| suffix: strippedSuffix, | ||||||||||||||||||||||||
| hint: strippedHint, | ||||||||||||||||||||||||
| const match = matchQuote(normalizedText, normalizedQuote, { | ||||||||||||||||||||||||
| prefix: normalizedPrefix, | ||||||||||||||||||||||||
| suffix: normalizedSuffix, | ||||||||||||||||||||||||
| hint: normalizedHint, | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!match) { | ||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!bestMatch || match.score > bestMatch.match.score) { | ||||||||||||||||||||||||
| // Translate match offset from whitespace-stripped version of page text | ||||||||||||||||||||||||
| // back to original text. | ||||||||||||||||||||||||
| // Translate match offset from normalized text back to original text. | ||||||||||||||||||||||||
| const [start, end] = translateOffsets( | ||||||||||||||||||||||||
| strippedText, | ||||||||||||||||||||||||
| normalizedText, | ||||||||||||||||||||||||
| text, | ||||||||||||||||||||||||
| match.start, | ||||||||||||||||||||||||
| match.end, | ||||||||||||||||||||||||
| isNotSpace, | ||||||||||||||||||||||||
| char => char !== ' ', | ||||||||||||||||||||||||
| { normalize: true }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
Comment on lines
477
to
484
|
||||||||||||||||||||||||
| bestMatch = { | ||||||||||||||||||||||||
| page, | ||||||||||||||||||||||||
|
|
@@ -500,21 +501,22 @@ async function anchorQuote( | |||||||||||||||||||||||
| // helps to avoid incorrectly stopping the search early if the quote is | ||||||||||||||||||||||||
| // a word or phrase that is common in the document. | ||||||||||||||||||||||||
| const exactQuoteMatch = | ||||||||||||||||||||||||
| strippedText.slice(match.start, match.end) === strippedQuote; | ||||||||||||||||||||||||
| normalizedText.slice(match.start, match.end) === normalizedQuote; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const exactPrefixMatch = | ||||||||||||||||||||||||
| strippedPrefix !== undefined && | ||||||||||||||||||||||||
| strippedText.slice( | ||||||||||||||||||||||||
| Math.max(0, match.start - strippedPrefix.length), | ||||||||||||||||||||||||
| normalizedPrefix !== undefined && | ||||||||||||||||||||||||
| normalizedText.slice( | ||||||||||||||||||||||||
| Math.max(0, match.start - normalizedPrefix.length), | ||||||||||||||||||||||||
| match.start, | ||||||||||||||||||||||||
| ) === strippedPrefix; | ||||||||||||||||||||||||
| ) === normalizedPrefix; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const exactSuffixMatch = | ||||||||||||||||||||||||
| strippedSuffix !== undefined && | ||||||||||||||||||||||||
| strippedText.slice(match.end, strippedSuffix.length) === strippedSuffix; | ||||||||||||||||||||||||
| normalizedSuffix !== undefined && | ||||||||||||||||||||||||
| normalizedText.slice(match.end, match.end + normalizedSuffix.length) === | ||||||||||||||||||||||||
| normalizedSuffix; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const hasContext = | ||||||||||||||||||||||||
| strippedPrefix !== undefined || strippedSuffix !== undefined; | ||||||||||||||||||||||||
| normalizedPrefix !== undefined || normalizedSuffix !== undefined; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||
| exactQuoteMatch && | ||||||||||||||||||||||||
|
|
@@ -795,7 +797,20 @@ export async function describe(range: Range): Promise<Selector[]> { | |||||||||||||||||||||||
| end: pageOffset + endPos.offset, | ||||||||||||||||||||||||
| } as TextPositionSelector; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const quote = TextQuoteAnchor.fromRange(pageView.div, textRange).toSelector(); | ||||||||||||||||||||||||
| const rawQuote = TextQuoteAnchor.fromRange( | ||||||||||||||||||||||||
| pageView.div, | ||||||||||||||||||||||||
| textRange, | ||||||||||||||||||||||||
| ).toSelector(); | ||||||||||||||||||||||||
| const quote: TextQuoteSelector = { | ||||||||||||||||||||||||
| ...rawQuote, | ||||||||||||||||||||||||
| exact: normalizePDFText(rawQuote.exact).trim(), | ||||||||||||||||||||||||
| prefix: rawQuote.prefix | ||||||||||||||||||||||||
| ? normalizePDFText(rawQuote.prefix).trim() | ||||||||||||||||||||||||
| : rawQuote.prefix, | ||||||||||||||||||||||||
| suffix: rawQuote.suffix | ||||||||||||||||||||||||
| ? normalizePDFText(rawQuote.suffix).trim() | ||||||||||||||||||||||||
| : rawQuote.suffix, | ||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||
|
Comment on lines
+804
to
+813
|
||||||||||||||||||||||||
| const quote: TextQuoteSelector = { | |
| ...rawQuote, | |
| exact: normalizePDFText(rawQuote.exact).trim(), | |
| prefix: rawQuote.prefix | |
| ? normalizePDFText(rawQuote.prefix).trim() | |
| : rawQuote.prefix, | |
| suffix: rawQuote.suffix | |
| ? normalizePDFText(rawQuote.suffix).trim() | |
| : rawQuote.suffix, | |
| }; | |
| const quote: TextQuoteSelector = rawQuote; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| const BLOCK_TAGS = new Set([ | ||
| 'ADDRESS', | ||
| 'ARTICLE', | ||
| 'ASIDE', | ||
| 'BLOCKQUOTE', | ||
| 'DIV', | ||
| 'DL', | ||
| 'FIELDSET', | ||
| 'FIGCAPTION', | ||
| 'FIGURE', | ||
| 'FOOTER', | ||
| 'FORM', | ||
| 'H1', | ||
| 'H2', | ||
| 'H3', | ||
| 'H4', | ||
| 'H5', | ||
| 'H6', | ||
| 'HEADER', | ||
| 'HR', | ||
| 'LI', | ||
| 'MAIN', | ||
| 'NAV', | ||
| 'OL', | ||
| 'P', | ||
| 'PRE', | ||
|
Elimpizza marked this conversation as resolved.
Outdated
|
||
| 'SECTION', | ||
| 'TABLE', | ||
| 'TD', | ||
| 'TH', | ||
| 'TR', | ||
| 'UL', | ||
| ]); | ||
|
|
||
| const SPACE = ' '; | ||
|
|
||
| /** Collapse any run of whitespace (including newlines) to a single space. */ | ||
| export function collapseWhitespace(text: string): string { | ||
| return text.replace(/\s+/g, SPACE); | ||
| } | ||
|
|
||
| function isBlock(node: Node): boolean { | ||
| return node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.nodeName); | ||
| } | ||
|
|
||
| type RenderedText = { | ||
| text: string; | ||
| rawToNorm: number[]; | ||
| normToRaw: number[]; | ||
| }; | ||
|
|
||
| function appendNormalizedChar( | ||
| ch: string, | ||
| rawIndex: number, | ||
| state: { | ||
| output: string; | ||
| rawToNorm: number[]; | ||
| normToRaw: number[]; | ||
| }, | ||
| ) { | ||
| const isWs = /\s/.test(ch); | ||
| if (isWs) { | ||
| if (state.output.length === 0 || state.output.endsWith(SPACE)) { | ||
| return; | ||
| } | ||
| ch = SPACE; | ||
| } | ||
|
|
||
| state.output += ch; | ||
| state.normToRaw.push(rawIndex); | ||
| if (state.rawToNorm[rawIndex] === undefined) { | ||
| state.rawToNorm[rawIndex] = state.normToRaw.length - 1; | ||
| } | ||
|
Comment on lines
+68
to
+72
|
||
| } | ||
|
|
||
| function buildRenderedText(root: Element): RenderedText { | ||
| const rawText = root.textContent ?? ''; | ||
| const state = { | ||
| output: '', | ||
| rawToNorm: Array(rawText.length + 1).fill(undefined) as number[], | ||
| normToRaw: [] as number[], | ||
| }; | ||
|
Comment on lines
+75
to
+84
|
||
|
|
||
| let rawPos = 0; | ||
|
|
||
| const appendSpace = () => { | ||
| appendNormalizedChar(SPACE, rawPos, state); | ||
| }; | ||
|
|
||
| const walk = (node: Node) => { | ||
| if (node.nodeType === Node.TEXT_NODE) { | ||
| const text = node.textContent ?? ''; | ||
| for (let i = 0; i < text.length; i++) { | ||
| appendNormalizedChar(text[i], rawPos, state); | ||
| rawPos += 1; | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if (node.nodeType === Node.ELEMENT_NODE) { | ||
| const el = node as Element; | ||
|
|
||
| if (el.tagName === 'BR') { | ||
| appendSpace(); | ||
| return; | ||
| } | ||
|
|
||
| const block = isBlock(el); | ||
| if (block) { | ||
| appendSpace(); | ||
| } | ||
|
|
||
| for (const child of Array.from(node.childNodes)) { | ||
| walk(child); | ||
| } | ||
|
|
||
| if (block) { | ||
| appendSpace(); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| walk(root); | ||
|
|
||
| // Map end-of-string raw offset. | ||
| if (state.rawToNorm[rawPos] === undefined) { | ||
| state.rawToNorm[rawPos] = state.normToRaw.length; | ||
| } | ||
| state.normToRaw.push(rawPos); | ||
|
|
||
| const text = state.output; // Already normalized/collapsed | ||
| return { text, rawToNorm: state.rawToNorm, normToRaw: state.normToRaw }; | ||
| } | ||
|
|
||
| function translateRawToNorm(rawToNorm: number[], rawOffset: number): number { | ||
| if (rawOffset < 0) { | ||
| return 0; | ||
| } | ||
| const clamped = Math.min(rawOffset, rawToNorm.length - 1); | ||
| for (let i = clamped; i >= 0; i--) { | ||
| const val = rawToNorm[i]; | ||
| if (val !== undefined) { | ||
| return val; | ||
| } | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| function translateNormToRaw(normToRaw: number[], normOffset: number): number { | ||
| if (normOffset <= 0) { | ||
| return 0; | ||
| } | ||
| const clamped = Math.min(normOffset, normToRaw.length - 1); | ||
| return normToRaw[clamped]; | ||
| } | ||
|
|
||
| export function renderedTextFromRange(range: Range): string { | ||
| const container = range.cloneContents(); | ||
| const div = document.createElement('div'); | ||
| div.appendChild(container); | ||
| const { text } = buildRenderedText(div); | ||
| return text; | ||
| } | ||
|
|
||
| export function renderedTextWithOffsets(root: Element): { | ||
| text: string; | ||
| rawToNorm: number[]; | ||
| normToRaw: number[]; | ||
| toNorm: (rawOffset: number) => number; | ||
| toRaw: (normOffset: number) => number; | ||
| } { | ||
| const rendered = buildRenderedText(root); | ||
| return { | ||
| ...rendered, | ||
| toNorm: rawOffset => translateRawToNorm(rendered.rawToNorm, rawOffset), | ||
| toRaw: normOffset => translateNormToRaw(rendered.normToRaw, normOffset), | ||
| }; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.