mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
Feat: add word and sentence counts for selected text
This commit is contained in:
@@ -1057,6 +1057,10 @@ module.exports = {
|
||||
__dirname,
|
||||
'../modules/reference-picker/frontend/components/reference-picker-controller.tsx'
|
||||
),
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/selected-word-count/frontend/components/selected-word-count-controller.tsx'
|
||||
),
|
||||
],
|
||||
mainEditorLayoutPanels: [],
|
||||
pythonRunner: [],
|
||||
|
||||
@@ -1974,6 +1974,7 @@
|
||||
"select_tax_id_type": "",
|
||||
"select_user": "",
|
||||
"select_user_role": "",
|
||||
"selected_text_word_count": "",
|
||||
"selection_deleted": "",
|
||||
"send": "",
|
||||
"send_confirmation_code": "",
|
||||
@@ -1982,6 +1983,7 @@
|
||||
"send_request": "",
|
||||
"sending": "",
|
||||
"sentence_completion": "",
|
||||
"sentences": "",
|
||||
"server_error": "",
|
||||
"server_pro_license_entitlement_line_1": "",
|
||||
"server_pro_license_entitlement_line_2": "",
|
||||
|
||||
@@ -68,7 +68,17 @@ export const ToolbarMenuBar = () => {
|
||||
type: 'command',
|
||||
label: t('word_count_lower'),
|
||||
disabled: !wordCountEnabled,
|
||||
handler: () => {
|
||||
handler: ({ location }) => {
|
||||
if (location === 'menu-bar') {
|
||||
const event = new Event('word-count:open-requested', {
|
||||
cancelable: true,
|
||||
})
|
||||
|
||||
if (!window.dispatchEvent(event)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setShowWordCountModal(true)
|
||||
},
|
||||
id: 'word_count',
|
||||
|
||||
@@ -1501,11 +1501,13 @@
|
||||
"select_users": "Benutzer auswählen",
|
||||
"selected": "Ausgewählt",
|
||||
"selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern",
|
||||
"selected_text_word_count": "Wortanzahl im ausgewählten Text",
|
||||
"send": "Absenden",
|
||||
"send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden",
|
||||
"send_confirmation_code": "Bestätigungscode senden",
|
||||
"send_test_email": "Test-Mail senden",
|
||||
"sending": "Wird gesendet",
|
||||
"sentences": "Sätze",
|
||||
"server_error": "Serverfehler",
|
||||
"services": "Services",
|
||||
"session_created_at": "Session erzeugt um",
|
||||
|
||||
@@ -2570,6 +2570,7 @@
|
||||
"selected": "Selected",
|
||||
"selected_by_overleaf_staff": "Selected by Overleaf staff",
|
||||
"selected_plural": "selected",
|
||||
"selected_text_word_count": "Selected text word count",
|
||||
"selection_deleted": "Selection deleted",
|
||||
"send": "Send",
|
||||
"send_notification_emails_to_users": "Send notification emails to the current and the new owners",
|
||||
@@ -2581,6 +2582,7 @@
|
||||
"sending": "Sending",
|
||||
"sent": "Sent",
|
||||
"sentence_completion": "Sentence completion",
|
||||
"sentences": "Sentences",
|
||||
"server_error": "Server Error",
|
||||
"server_pro_license_entitlement_line_1": "<0>__appName__</0> Server Pro license",
|
||||
"server_pro_license_entitlement_line_2": "You currently have <0>__count__ active users</0>. If you need to increase your license entitlement, please <1>contact Overleaf</1>.",
|
||||
|
||||
@@ -655,7 +655,9 @@
|
||||
"select_projects": "Выбрать проекты",
|
||||
"select_user": "Выбрать пользователя",
|
||||
"select_users": "Выбрать пользователей",
|
||||
"selected_text_word_count": "Количество слов в выбранном тексте",
|
||||
"send_notification_emails_to_users": "Отправить уведовление текущему и новому владельцам",
|
||||
"sentences": "Предложения",
|
||||
"server_error": "Ошибка сервера",
|
||||
"services": "Сервисы",
|
||||
"session_created_at": "Сессия создана",
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Text } from '@codemirror/state'
|
||||
import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context'
|
||||
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { createSegmenters } from '@/features/word-count-modal/utils/segmenters'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { WORD_COUNT_OPEN_REQUESTED_EVENT } from '../selected-word-count-events'
|
||||
import {
|
||||
countWordsInSelection,
|
||||
type SelectedWordCountResult,
|
||||
} from '../utils/count-words-in-selection'
|
||||
import { createSentenceSegmenter } from '../utils/sentence-segmenter'
|
||||
import SelectedWordCountModal from './selected-word-count-modal'
|
||||
|
||||
type SelectedWordCountRequest = {
|
||||
doc: Text
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
export default function SelectedWordCountController() {
|
||||
const { spellCheckLanguage } = useProjectSettingsContext()
|
||||
const { view } = useEditorViewContext()
|
||||
const { showVisual } = useEditorPropertiesContext()
|
||||
const { openEntity, selectedEntityCount } = useFileTreeOpenContext()
|
||||
const { view: layoutView } = useLayoutContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [request, setRequest] = useState<SelectedWordCountRequest | null>(null)
|
||||
const [data, setData] = useState<SelectedWordCountResult | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const segmenters = useMemo(() => {
|
||||
return createSegmenters(spellCheckLanguage?.replace(/_/, '-'))
|
||||
}, [spellCheckLanguage])
|
||||
|
||||
const sentenceSegmenter = useMemo(() => {
|
||||
return createSentenceSegmenter(spellCheckLanguage?.replace(/_/, '-'))
|
||||
}, [spellCheckLanguage])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
setRequest(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenRequested = (event: Event) => {
|
||||
if (
|
||||
!view ||
|
||||
showVisual ||
|
||||
layoutView !== 'editor' ||
|
||||
selectedEntityCount !== 1 ||
|
||||
openEntity?.type !== 'doc'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const { doc, selection } = view.state
|
||||
const { from, to } = selection.main
|
||||
|
||||
if (from >= to) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setRequest({ doc, from, to })
|
||||
setData(null)
|
||||
setError(false)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
WORD_COUNT_OPEN_REQUESTED_EVENT,
|
||||
handleOpenRequested
|
||||
)
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
WORD_COUNT_OPEN_REQUESTED_EVENT,
|
||||
handleOpenRequested
|
||||
)
|
||||
}
|
||||
}, [layoutView, openEntity, selectedEntityCount, showVisual, view])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !request) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const nextData = countWordsInSelection(
|
||||
request.doc.toString(),
|
||||
{ from: request.from, to: request.to },
|
||||
segmenters,
|
||||
sentenceSegmenter
|
||||
)
|
||||
setData(nextData)
|
||||
} catch (error) {
|
||||
debugConsole.error(error)
|
||||
setError(true)
|
||||
}
|
||||
}, [open, request, segmenters, sentenceSegmenter])
|
||||
|
||||
return (
|
||||
<SelectedWordCountModal
|
||||
show={open}
|
||||
onClose={onClose}
|
||||
data={data}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Col, Container, Row } from 'react-bootstrap'
|
||||
import { WordCountError } from '@/features/word-count-modal/components/word-count-error'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import type { SelectedWordCountResult } from '../utils/count-words-in-selection'
|
||||
|
||||
const numberFormat = new Intl.NumberFormat()
|
||||
|
||||
type SelectedWordCountModalProps = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
data: SelectedWordCountResult | null
|
||||
error: boolean
|
||||
}
|
||||
|
||||
export default function SelectedWordCountModal({
|
||||
show,
|
||||
onClose,
|
||||
data,
|
||||
error,
|
||||
}: SelectedWordCountModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const rows = data
|
||||
? [
|
||||
{
|
||||
key: 'total',
|
||||
label: t('total_words'),
|
||||
value: data.totalWords,
|
||||
testId: 'selected-word-count-total',
|
||||
},
|
||||
{
|
||||
key: 'sentences',
|
||||
label: t('sentences'),
|
||||
value: data.sentences,
|
||||
testId: 'selected-word-count-sentences',
|
||||
},
|
||||
{
|
||||
key: 'headers',
|
||||
label: t('headers'),
|
||||
value: data.headers,
|
||||
testId: 'selected-word-count-headers',
|
||||
},
|
||||
{
|
||||
key: 'math-inline',
|
||||
label: t('math_inline'),
|
||||
value: data.mathInline,
|
||||
testId: 'selected-word-count-math-inline',
|
||||
},
|
||||
{
|
||||
key: 'math-display',
|
||||
label: t('math_display'),
|
||||
value: data.mathDisplay,
|
||||
testId: 'selected-word-count-math-display',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<OLModal
|
||||
animation
|
||||
show={show}
|
||||
onHide={onClose}
|
||||
initialFocus={false}
|
||||
id="selected-word-count-modal"
|
||||
data-testid="selected-word-count-modal"
|
||||
>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('selected_text_word_count')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
{data === null && !error && <LoadingSpinner />}
|
||||
{error && <WordCountError />}
|
||||
{data !== null && (
|
||||
<Container fluid>
|
||||
{rows.map(({ key, label, value, testId }) => (
|
||||
<Row key={key}>
|
||||
<Col xs={4}>
|
||||
<div className="float-end">{label}:</div>
|
||||
</Col>
|
||||
<Col xs={6} data-testid={testId}>
|
||||
{numberFormat.format(value)}
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Container>
|
||||
)}
|
||||
</OLModalBody>
|
||||
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={onClose}>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const WORD_COUNT_OPEN_REQUESTED_EVENT = 'word-count:open-requested'
|
||||
@@ -0,0 +1,759 @@
|
||||
import { NodeType, type SyntaxNodeRef } from '@lezer/common'
|
||||
import { LaTeXLanguage } from '@/features/source-editor/languages/latex/latex-language'
|
||||
import { findPreambleExtent } from '@/features/word-count-modal/utils/find-preamble-extent'
|
||||
import type { Segmenters } from '@/features/word-count-modal/utils/segmenters'
|
||||
import type { SentenceSegmenter } from './sentence-segmenter'
|
||||
|
||||
type SourceRange = {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
type TextNode = SourceRange & {
|
||||
text: string
|
||||
}
|
||||
|
||||
type SimpleMacroDefinition = {
|
||||
from: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export type SelectedWordCountResult = {
|
||||
totalWords: number
|
||||
sentences: number
|
||||
headers: number
|
||||
mathInline: number
|
||||
mathDisplay: number
|
||||
}
|
||||
|
||||
const mergeRanges = (ranges: SourceRange[]): SourceRange[] => {
|
||||
const merged: SourceRange[] = []
|
||||
const sorted = [...ranges].sort(
|
||||
(left, right) => left.from - right.from || left.to - right.to
|
||||
)
|
||||
|
||||
for (const span of sorted) {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous && span.from <= previous.to) {
|
||||
previous.to = Math.max(previous.to, span.to)
|
||||
} else {
|
||||
merged.push({ ...span })
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const replacementsMap = new Map<string, string>([
|
||||
['aa', 'å'],
|
||||
['AA', 'Å'],
|
||||
['ae', 'æ'],
|
||||
['AE', 'Æ'],
|
||||
['oe', 'œ'],
|
||||
['OE', 'Œ'],
|
||||
['o', 'ø'],
|
||||
['O', 'Ø'],
|
||||
['ss', 'ß'],
|
||||
['SS', 'SS'],
|
||||
['l', 'ł'],
|
||||
['L', 'Ł'],
|
||||
['dh', 'ð'],
|
||||
['DH', 'Ð'],
|
||||
['dj', 'đ'],
|
||||
['DJ', 'Ð'],
|
||||
['th', 'þ'],
|
||||
['TH', 'Þ'],
|
||||
['ng', 'ŋ'],
|
||||
['NG', 'Ŋ'],
|
||||
['i', 'ı'],
|
||||
['j', 'ȷ'],
|
||||
['&', '&'],
|
||||
['$', '$'],
|
||||
['%', '%'],
|
||||
['#', '#'],
|
||||
['_', '_'],
|
||||
['{', '{'],
|
||||
['}', '}'],
|
||||
['H', 'ő'],
|
||||
['b', 'o'],
|
||||
['c', 'ç'],
|
||||
['d', 'o'],
|
||||
['k', 'ą'],
|
||||
['r', 'å'],
|
||||
['t', 'o͡o'],
|
||||
['u', 'ŏ'],
|
||||
['v', 'š'],
|
||||
["'", ''],
|
||||
['^', ''],
|
||||
['"', ''],
|
||||
['=', ''],
|
||||
['.', ''],
|
||||
['`', ''],
|
||||
['~', ''],
|
||||
['TeX', 'TeX'],
|
||||
['LaTeX', 'LaTeX'],
|
||||
['BibTeX', 'BibTeX'],
|
||||
['textbackslash', '\\'],
|
||||
])
|
||||
|
||||
const hiddenContentCommands = new Set(['phantom', 'hphantom', 'vphantom'])
|
||||
|
||||
const nonRenderingCommands = new Set([
|
||||
'addtocounter',
|
||||
'addtolength',
|
||||
'bibliography',
|
||||
'bibliographystyle',
|
||||
'cline',
|
||||
'color',
|
||||
'hspace',
|
||||
'includegraphics',
|
||||
'includesvg',
|
||||
'label',
|
||||
'pagestyle',
|
||||
'rule',
|
||||
'setcounter',
|
||||
'setlength',
|
||||
'thispagestyle',
|
||||
'vspace',
|
||||
])
|
||||
|
||||
const transparentTextCommands = new Set([
|
||||
'MakeLowercase',
|
||||
'MakeUppercase',
|
||||
'fbox',
|
||||
'lowercase',
|
||||
'mbox',
|
||||
'uppercase',
|
||||
])
|
||||
|
||||
const boundaryTextCommands = new Set([
|
||||
'IEEEauthorblockA',
|
||||
'IEEEauthorblockN',
|
||||
'centerline',
|
||||
])
|
||||
|
||||
const nonRenderingEnvironments = new Set([
|
||||
'comment',
|
||||
'filecontents',
|
||||
'filecontents*',
|
||||
])
|
||||
|
||||
const verbatimEnvironments = new Set([
|
||||
'Verbatim',
|
||||
'boxedverbatim',
|
||||
'codeexample',
|
||||
'lstlisting',
|
||||
'minted',
|
||||
'tcblisting',
|
||||
'verbatim',
|
||||
])
|
||||
|
||||
const plainMathTextCommands = new Set(['mbox', 'mathrm', 'textnormal'])
|
||||
|
||||
const isSimpleMacroText = (text: string) =>
|
||||
text.length > 0 && !/[\\{}#$%&_~^]/u.test(text)
|
||||
|
||||
export const countWordsInSelection = (
|
||||
content: string,
|
||||
sourceRange: SourceRange,
|
||||
segmenters: Segmenters,
|
||||
sentenceSegmenter: SentenceSegmenter
|
||||
): SelectedWordCountResult => {
|
||||
const result: SelectedWordCountResult = {
|
||||
totalWords: 0,
|
||||
sentences: 0,
|
||||
headers: 0,
|
||||
mathInline: 0,
|
||||
mathDisplay: 0,
|
||||
}
|
||||
|
||||
const range = {
|
||||
from: Math.max(0, Math.min(sourceRange.from, content.length)),
|
||||
to: Math.max(0, Math.min(sourceRange.to, content.length)),
|
||||
}
|
||||
|
||||
if (range.from >= range.to) {
|
||||
return result
|
||||
}
|
||||
|
||||
const tree = LaTeXLanguage.parser.parse(content)
|
||||
const textNodes: TextNode[] = []
|
||||
const transparentRanges: SourceRange[] = []
|
||||
const collapsedRanges: SourceRange[] = []
|
||||
const boundaryRanges: SourceRange[] = []
|
||||
const boundaryPositions: number[] = []
|
||||
const maketitlePositions: number[] = []
|
||||
const simpleMacroDefinitions = new Map<string, SimpleMacroDefinition[]>()
|
||||
|
||||
const addSimpleMacroDefinition = (
|
||||
name: string,
|
||||
definition: SimpleMacroDefinition
|
||||
) => {
|
||||
const definitions = simpleMacroDefinitions.get(name) ?? []
|
||||
definitions.push(definition)
|
||||
simpleMacroDefinitions.set(name, definitions)
|
||||
}
|
||||
|
||||
tree.iterate({
|
||||
enter(nodeRef: SyntaxNodeRef) {
|
||||
if (nodeRef.type.is('Maketitle')) {
|
||||
maketitlePositions.push(nodeRef.from)
|
||||
return false
|
||||
}
|
||||
|
||||
const isNewCommand =
|
||||
nodeRef.type.is('NewCommand') || nodeRef.type.is('RenewCommand')
|
||||
const isDef = nodeRef.type.is('Def')
|
||||
if (!isNewCommand && !isDef) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(isNewCommand &&
|
||||
nodeRef.node.getChildren('OptionalArgument').length > 0) ||
|
||||
(isDef && nodeRef.node.getChildren('MacroParameter').length > 0)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nameNode =
|
||||
nodeRef.node.getChild('Csname') ??
|
||||
nodeRef.node.getChild('LiteralArgContent')
|
||||
const definitionNode = nodeRef.node.getChild('DefinitionArgument')
|
||||
if (!nameNode || !definitionNode) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nameMatch = /\\([A-Za-z@]+)/u.exec(
|
||||
content.substring(nameNode.from, nameNode.to)
|
||||
)
|
||||
const definitionText = content.substring(
|
||||
definitionNode.from + 1,
|
||||
definitionNode.to - 1
|
||||
)
|
||||
|
||||
if (nameMatch && isSimpleMacroText(definitionText)) {
|
||||
addSimpleMacroDefinition(nameMatch[1], {
|
||||
from: nodeRef.to,
|
||||
text: definitionText,
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
const preambleExtent = findPreambleExtent(tree)
|
||||
|
||||
const intersectsRange = (span: SourceRange) =>
|
||||
span.to > range.from && span.from < range.to
|
||||
|
||||
const rangesOverlap = (left: SourceRange, right: SourceRange) =>
|
||||
left.to > right.from && left.from < right.to
|
||||
|
||||
const isContainedInRange = (span: SourceRange) =>
|
||||
span.from >= range.from && span.to <= range.to
|
||||
|
||||
const addBoundaryRange = (span: SourceRange) => {
|
||||
if (intersectsRange(span)) {
|
||||
boundaryRanges.push({ from: span.from, to: span.to })
|
||||
}
|
||||
}
|
||||
|
||||
const addBoundaryPositions = (span: SourceRange) => {
|
||||
if (intersectsRange(span)) {
|
||||
boundaryPositions.push(span.from, span.to)
|
||||
}
|
||||
}
|
||||
|
||||
const addTransparentRange = (span: SourceRange) => {
|
||||
if (intersectsRange(span)) {
|
||||
transparentRanges.push({ from: span.from, to: span.to })
|
||||
}
|
||||
}
|
||||
|
||||
const addCollapsedRange = (span: SourceRange) => {
|
||||
if (intersectsRange(span)) {
|
||||
collapsedRanges.push({ from: span.from, to: span.to })
|
||||
}
|
||||
}
|
||||
|
||||
const addSourceTextSpan = (span: SourceRange) => {
|
||||
if (!intersectsRange(span)) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = Math.max(span.from, range.from)
|
||||
const to = Math.min(span.to, range.to)
|
||||
|
||||
textNodes.push({
|
||||
from,
|
||||
to,
|
||||
text: content.substring(from, to),
|
||||
})
|
||||
}
|
||||
|
||||
const addSourceTextNode = (nodeRef: SyntaxNodeRef) => {
|
||||
addSourceTextSpan(nodeRef)
|
||||
}
|
||||
|
||||
const addSyntheticTextNode = (
|
||||
sourceSpan: SourceRange,
|
||||
text: string,
|
||||
textNodeSpan: SourceRange = sourceSpan
|
||||
) => {
|
||||
if (!isContainedInRange(sourceSpan)) {
|
||||
return
|
||||
}
|
||||
|
||||
textNodes.push({
|
||||
from: textNodeSpan.from,
|
||||
to: textNodeSpan.to,
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
const findSimpleMacroReplacement = (name: string, position: number) => {
|
||||
const definitions = simpleMacroDefinitions.get(name)
|
||||
if (!definitions) {
|
||||
return
|
||||
}
|
||||
|
||||
let replacement: string | undefined
|
||||
for (const definition of definitions) {
|
||||
if (definition.from > position) {
|
||||
break
|
||||
}
|
||||
replacement = definition.text
|
||||
}
|
||||
return replacement
|
||||
}
|
||||
|
||||
const visitBodyNode = (nodeRef: SyntaxNodeRef): boolean | void => {
|
||||
return bodyMatcher(nodeRef.type)?.(nodeRef)
|
||||
}
|
||||
|
||||
const iterateNode = (nodeRef: SyntaxNodeRef) => {
|
||||
nodeRef.node.cursor().iterate(childNodeRef => {
|
||||
if (childNodeRef.node !== nodeRef.node) {
|
||||
return visitBodyNode(childNodeRef)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleComment = (nodeRef: SyntaxNodeRef) => {
|
||||
addTransparentRange(nodeRef)
|
||||
addCollapsedRange(nodeRef)
|
||||
}
|
||||
|
||||
const addDelimitedLiteralContent = (nodeRef: SourceRange) => {
|
||||
let from = nodeRef.from
|
||||
let to = nodeRef.to
|
||||
|
||||
if (content[from] === '*') {
|
||||
from++
|
||||
}
|
||||
|
||||
if (to - from >= 2) {
|
||||
from++
|
||||
to--
|
||||
}
|
||||
|
||||
if (from < to) {
|
||||
addSourceTextSpan({ from, to })
|
||||
}
|
||||
}
|
||||
|
||||
const isRenderedByMaketitle = (nodeRef: SyntaxNodeRef) =>
|
||||
maketitlePositions.some(position => position >= nodeRef.to)
|
||||
|
||||
const handleMaketitleText = (
|
||||
nodeRef: SyntaxNodeRef,
|
||||
countAsHeader = false
|
||||
) => {
|
||||
if (!isRenderedByMaketitle(nodeRef)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (countAsHeader && intersectsRange(nodeRef)) {
|
||||
result.headers++
|
||||
}
|
||||
|
||||
if (nodeRef.type.is('Date')) {
|
||||
const dateArgument = nodeRef.node.getChild('ShortTextArgument')
|
||||
if (dateArgument) {
|
||||
iterateNode(dateArgument)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
iterateNode(nodeRef)
|
||||
return false
|
||||
}
|
||||
|
||||
const handleMathText = (nodeRef: SyntaxNodeRef) => {
|
||||
nodeRef.node.cursor().iterate(childNodeRef => {
|
||||
if (childNodeRef.node === nodeRef.node) {
|
||||
return
|
||||
}
|
||||
|
||||
if (childNodeRef.type.is('MathTextCommand')) {
|
||||
const textArgument = childNodeRef.node.getChild('TextArgument')
|
||||
if (textArgument) {
|
||||
iterateNode(textArgument)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (childNodeRef.type.is('MathUnknownCommand')) {
|
||||
const macro = childNodeRef.node.getChild('$CtrlSeq')
|
||||
const argument = childNodeRef.node.getChild('MathArgument')
|
||||
if (!macro || !argument) {
|
||||
return false
|
||||
}
|
||||
|
||||
const commandName = content.substring(macro.from + 1, macro.to)
|
||||
const argumentText = content.substring(
|
||||
argument.from + 1,
|
||||
argument.to - 1
|
||||
)
|
||||
if (
|
||||
plainMathTextCommands.has(commandName) &&
|
||||
isSimpleMacroText(argumentText)
|
||||
) {
|
||||
addSourceTextSpan({ from: argument.from + 1, to: argument.to - 1 })
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleEnvironment = (nodeRef: SyntaxNodeRef) => {
|
||||
const envNameGroup = nodeRef.node
|
||||
.getChild('BeginEnv')
|
||||
?.getChild('EnvNameGroup')
|
||||
|
||||
if (!envNameGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
const envName = content
|
||||
.substring(envNameGroup.from + 1, envNameGroup.to - 1)
|
||||
.replace(/\*$/, '')
|
||||
|
||||
if (nonRenderingEnvironments.has(envName)) {
|
||||
addBoundaryRange(nodeRef)
|
||||
return false
|
||||
}
|
||||
|
||||
if (verbatimEnvironments.has(envName)) {
|
||||
const verbatimContent = nodeRef.node
|
||||
.getChild('Content')
|
||||
?.getChild('VerbatimContent')
|
||||
if (verbatimContent) {
|
||||
addSourceTextSpan(verbatimContent)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (envName !== 'abstract') {
|
||||
return
|
||||
}
|
||||
|
||||
if (intersectsRange(nodeRef)) {
|
||||
result.headers++
|
||||
}
|
||||
|
||||
const contentNode = nodeRef.node.getChild('Content')
|
||||
if (contentNode) {
|
||||
iterateNode(contentNode)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const headMatcher = NodeType.match<
|
||||
(nodeRef: SyntaxNodeRef) => boolean | void
|
||||
>({
|
||||
Comment(nodeRef) {
|
||||
handleComment(nodeRef)
|
||||
return false
|
||||
},
|
||||
Title(nodeRef) {
|
||||
return handleMaketitleText(nodeRef, true)
|
||||
},
|
||||
'Author Affil Affiliation Date'(nodeRef) {
|
||||
return handleMaketitleText(nodeRef)
|
||||
},
|
||||
$Environment(nodeRef) {
|
||||
return handleEnvironment(nodeRef)
|
||||
},
|
||||
})
|
||||
|
||||
const bodyMatcher = NodeType.match<
|
||||
(nodeRef: SyntaxNodeRef) => boolean | void
|
||||
>({
|
||||
Comment(nodeRef) {
|
||||
handleComment(nodeRef)
|
||||
return false
|
||||
},
|
||||
Normal(nodeRef) {
|
||||
addSourceTextNode(nodeRef)
|
||||
},
|
||||
Cite(nodeRef) {
|
||||
addBoundaryRange(nodeRef)
|
||||
const optionalArgs = nodeRef.node.getChildren('OptionalArgument')
|
||||
for (const arg of optionalArgs) {
|
||||
const child = arg.getChild('ShortOptionalArg')
|
||||
if (child) {
|
||||
iterateNode(child)
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
UnknownCommand(nodeRef) {
|
||||
const macro =
|
||||
nodeRef.node.getChild('$CtrlSeq') ?? nodeRef.node.getChild('$CtrlSym')
|
||||
if (!macro) {
|
||||
return
|
||||
}
|
||||
|
||||
const commandName = content.substring(macro.from + 1, macro.to)
|
||||
if (!commandName) {
|
||||
return
|
||||
}
|
||||
|
||||
if (commandName === 'thanks') {
|
||||
iterateNode(nodeRef)
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
hiddenContentCommands.has(commandName) ||
|
||||
nonRenderingCommands.has(commandName)
|
||||
) {
|
||||
addBoundaryRange(nodeRef)
|
||||
return false
|
||||
}
|
||||
|
||||
if (boundaryTextCommands.has(commandName)) {
|
||||
addBoundaryPositions(nodeRef)
|
||||
return
|
||||
}
|
||||
|
||||
if (transparentTextCommands.has(commandName)) {
|
||||
addTransparentRange(nodeRef)
|
||||
return
|
||||
}
|
||||
|
||||
const simpleMacroReplacement = findSimpleMacroReplacement(
|
||||
commandName,
|
||||
macro.from
|
||||
)
|
||||
if (simpleMacroReplacement !== undefined) {
|
||||
addSyntheticTextNode(macro, simpleMacroReplacement)
|
||||
return false
|
||||
}
|
||||
|
||||
const replacement = replacementsMap.get(commandName)
|
||||
if (replacement === undefined) {
|
||||
addBoundaryRange(nodeRef)
|
||||
return false
|
||||
}
|
||||
|
||||
const commandTail = content.substring(macro.to, nodeRef.to)
|
||||
addSyntheticTextNode(
|
||||
macro,
|
||||
replacement,
|
||||
commandTail.trim() === '' ? macro : nodeRef
|
||||
)
|
||||
return false
|
||||
},
|
||||
$Environment(nodeRef) {
|
||||
return handleEnvironment(nodeRef)
|
||||
},
|
||||
'Title Author Affil Affiliation Date'(nodeRef) {
|
||||
if (nodeRef.to <= preambleExtent.to) {
|
||||
return false
|
||||
}
|
||||
return handleMaketitleText(nodeRef, nodeRef.type.is('Title'))
|
||||
},
|
||||
'$ToggleTextFormattingCommand $OtherTextFormattingCommand'(nodeRef) {
|
||||
addTransparentRange(nodeRef)
|
||||
},
|
||||
HrefCommand(nodeRef) {
|
||||
const label = nodeRef.node.getChild('ShortTextArgument')
|
||||
if (label) {
|
||||
iterateNode(label)
|
||||
}
|
||||
return false
|
||||
},
|
||||
UrlCommand(nodeRef) {
|
||||
const literalContent = nodeRef.node
|
||||
.getChild('UrlArgument')
|
||||
?.getChild('LiteralArgContent')
|
||||
if (literalContent) {
|
||||
addSourceTextSpan(literalContent)
|
||||
}
|
||||
return false
|
||||
},
|
||||
'VerbCommand LstInlineCommand'(nodeRef) {
|
||||
const literalContent =
|
||||
nodeRef.node.getChild('VerbContent') ??
|
||||
nodeRef.node.getChild('LstInlineContent')
|
||||
if (literalContent) {
|
||||
addDelimitedLiteralContent(literalContent)
|
||||
}
|
||||
return false
|
||||
},
|
||||
Item(nodeRef) {
|
||||
for (const optionalArgument of nodeRef.node.getChildren(
|
||||
'OptionalArgument'
|
||||
)) {
|
||||
const label = optionalArgument.getChild('ShortOptionalArg')
|
||||
if (label) {
|
||||
iterateNode(label)
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
'Def Let NewCommand RenewCommand NewEnvironment RenewEnvironment NewTheoremCommand TheoremStyleCommand'() {
|
||||
return false
|
||||
},
|
||||
'IncludeGraphics IncludeSvg SetLengthCommand'(nodeRef) {
|
||||
addBoundaryRange(nodeRef)
|
||||
return false
|
||||
},
|
||||
BeginEnv() {
|
||||
return false
|
||||
},
|
||||
Math(nodeRef) {
|
||||
if (!intersectsRange(nodeRef)) {
|
||||
return false
|
||||
}
|
||||
|
||||
addBoundaryRange(nodeRef)
|
||||
handleMathText(nodeRef)
|
||||
|
||||
const parent = nodeRef.node.parent
|
||||
if (parent?.type.is('InlineMath') || parent?.type.is('ParenMath')) {
|
||||
result.mathInline++
|
||||
} else {
|
||||
result.mathDisplay++
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
'ShortTextArgument ShortOptionalArg'(nodeRef) {
|
||||
addBoundaryRange(nodeRef)
|
||||
return false
|
||||
},
|
||||
SectioningArgument(nodeRef) {
|
||||
if (intersectsRange(nodeRef)) {
|
||||
result.headers++
|
||||
}
|
||||
iterateNode(nodeRef)
|
||||
return false
|
||||
},
|
||||
Caption(nodeRef) {
|
||||
iterateNode(nodeRef)
|
||||
return false
|
||||
},
|
||||
'FootnoteCommand EndnoteCommand'(nodeRef) {
|
||||
iterateNode(nodeRef)
|
||||
return false
|
||||
},
|
||||
'IncludeArgument InputArgument SubfileArgument'(nodeRef) {
|
||||
addBoundaryRange(nodeRef)
|
||||
return false
|
||||
},
|
||||
'BlankLine LineBreak'(nodeRef) {
|
||||
addSyntheticTextNode(nodeRef, '\n')
|
||||
},
|
||||
})
|
||||
|
||||
tree.iterate({
|
||||
from: 0,
|
||||
to: preambleExtent.to,
|
||||
enter(nodeRef: SyntaxNodeRef) {
|
||||
return headMatcher(nodeRef.type)?.(nodeRef)
|
||||
},
|
||||
})
|
||||
|
||||
tree.iterate({
|
||||
from: preambleExtent.to,
|
||||
enter(nodeRef: SyntaxNodeRef) {
|
||||
if (nodeRef.to <= preambleExtent.to) {
|
||||
return false
|
||||
}
|
||||
return visitBodyNode(nodeRef)
|
||||
},
|
||||
})
|
||||
|
||||
let text = ''
|
||||
let position = range.from
|
||||
const mergedTransparentRanges = mergeRanges(transparentRanges)
|
||||
const mergedCollapsedRanges = mergeRanges(collapsedRanges)
|
||||
|
||||
const gapHasVisibleWhitespace = (gap: SourceRange) => {
|
||||
let cursor = gap.from
|
||||
|
||||
for (const span of mergedCollapsedRanges) {
|
||||
if (span.to <= cursor) {
|
||||
continue
|
||||
}
|
||||
if (span.from >= gap.to) {
|
||||
break
|
||||
}
|
||||
|
||||
if (/\s/u.test(content.substring(cursor, Math.max(cursor, span.from)))) {
|
||||
return true
|
||||
}
|
||||
cursor = Math.max(cursor, Math.min(span.to, gap.to))
|
||||
}
|
||||
|
||||
return /\s/u.test(content.substring(cursor, gap.to))
|
||||
}
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const gap = {
|
||||
from: position,
|
||||
to: textNode.from,
|
||||
}
|
||||
const isTransparentGap =
|
||||
gap.from < gap.to &&
|
||||
mergedTransparentRanges.some(
|
||||
span => span.from <= gap.from && span.to >= gap.to
|
||||
) &&
|
||||
!gapHasVisibleWhitespace(gap) &&
|
||||
!boundaryPositions.some(
|
||||
position => position >= gap.from && position <= gap.to
|
||||
) &&
|
||||
!boundaryRanges.some(span => rangesOverlap(span, gap))
|
||||
|
||||
if (gap.from < gap.to && !isTransparentGap) {
|
||||
text += ' '
|
||||
}
|
||||
text += textNode.text
|
||||
position = textNode.to
|
||||
}
|
||||
|
||||
for (const value of segmenters.word.segment(
|
||||
text.replace(/\w[-_]\w/g, 'aaa')
|
||||
)) {
|
||||
if (value.isWordLike) {
|
||||
result.totalWords++
|
||||
}
|
||||
}
|
||||
|
||||
for (const sentence of sentenceSegmenter.segment(text)) {
|
||||
for (const word of segmenters.word.segment(sentence.segment)) {
|
||||
if (word.isWordLike) {
|
||||
result.sentences++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type SentenceSegment = {
|
||||
segment: string
|
||||
}
|
||||
|
||||
export type SentenceSegmenter = {
|
||||
segment(input: string): {
|
||||
[Symbol.iterator](): IterableIterator<SentenceSegment>
|
||||
}
|
||||
}
|
||||
|
||||
const sentenceRe = /[^.!?。!?…]+(?:[.!?。!?…]+|$)/gu
|
||||
|
||||
const fallbackSentenceSegmenter: SentenceSegmenter = {
|
||||
segment(input: string) {
|
||||
const segments: SentenceSegment[] = []
|
||||
|
||||
for (const match of input.matchAll(sentenceRe)) {
|
||||
segments.push({ segment: match[0] })
|
||||
}
|
||||
|
||||
return segments
|
||||
},
|
||||
}
|
||||
|
||||
export const createSentenceSegmenter = (locale?: string): SentenceSegmenter => {
|
||||
if (!Intl.Segmenter) {
|
||||
return fallbackSentenceSegmenter
|
||||
}
|
||||
|
||||
try {
|
||||
return new Intl.Segmenter(locale, {
|
||||
granularity: 'sentence',
|
||||
})
|
||||
} catch {
|
||||
return fallbackSentenceSegmenter
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { useEffect, type FC, type PropsWithChildren } from 'react'
|
||||
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
|
||||
import type { FileTreeDocumentFindResult } from '@/features/ide-react/types/file-tree'
|
||||
import {
|
||||
EditorProviders,
|
||||
makeEditorPropertiesProvider,
|
||||
} from '../../../../../test/frontend/helpers/editor-providers'
|
||||
import { EditorViewContext } from '../../../../../frontend/js/features/ide-react/context/editor-view-context'
|
||||
import SelectedWordCountController from '../../../frontend/components/selected-word-count-controller'
|
||||
import { WORD_COUNT_OPEN_REQUESTED_EVENT } from '../../../frontend/selected-word-count-events'
|
||||
|
||||
const ActiveDocument = () => {
|
||||
const { handleFileTreeSelect } = useFileTreeOpenContext()
|
||||
|
||||
useEffect(() => {
|
||||
const document: FileTreeDocumentFindResult = {
|
||||
type: 'doc',
|
||||
entity: { _id: 'test-doc', name: 'main.tex' },
|
||||
parent: [],
|
||||
parentFolderId: 'root-folder-id',
|
||||
path: ['main.tex'],
|
||||
index: 0,
|
||||
}
|
||||
|
||||
handleFileTreeSelect([document])
|
||||
}, [handleFileTreeSelect])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('<SelectedWordCountController />', function () {
|
||||
let view: EditorView | undefined
|
||||
|
||||
const mountController = (
|
||||
content: string,
|
||||
from: number,
|
||||
to: number,
|
||||
options: { activeDocument?: boolean; showVisual?: boolean } = {}
|
||||
) => {
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: content,
|
||||
selection: { anchor: from, head: to },
|
||||
}),
|
||||
})
|
||||
|
||||
const TestEditorViewProvider: FC<PropsWithChildren> = ({ children }) => (
|
||||
<EditorViewContext.Provider
|
||||
value={{ view: view ?? null, setView: () => undefined }}
|
||||
>
|
||||
{children}
|
||||
</EditorViewContext.Provider>
|
||||
)
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
providers={{
|
||||
EditorViewProvider: TestEditorViewProvider,
|
||||
EditorPropertiesProvider: makeEditorPropertiesProvider({
|
||||
showVisual: options.showVisual,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{options.activeDocument !== false && <ActiveDocument />}
|
||||
<SelectedWordCountController />
|
||||
</EditorProviders>
|
||||
)
|
||||
}
|
||||
|
||||
const requestWordCount = () => {
|
||||
const event = new Event(WORD_COUNT_OPEN_REQUESTED_EVENT, {
|
||||
cancelable: true,
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
view?.destroy()
|
||||
view = undefined
|
||||
})
|
||||
|
||||
it('counts the immutable selection snapshot and closes the modal', function () {
|
||||
const content = 'one two three four'
|
||||
const from = content.indexOf('two three')
|
||||
mountController(content, from, from + 'two three'.length)
|
||||
|
||||
cy.then(() => {
|
||||
const event = requestWordCount()
|
||||
expect(event.defaultPrevented).to.be.true
|
||||
|
||||
view?.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: 'changed after opening',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByText('Selected text word count').should('exist')
|
||||
cy.findByText('Total Words').should('exist')
|
||||
cy.findByText('Sentences').should('exist')
|
||||
cy.findByText('Headers').should('exist')
|
||||
cy.findByText('Math Inline').should('exist')
|
||||
cy.findByText('Math Display').should('exist')
|
||||
cy.findByTestId('selected-word-count-total').should('have.text', '2')
|
||||
cy.findByTestId('selected-word-count-sentences').should('have.text', '1')
|
||||
cy.findByTestId('selected-word-count-headers').should('have.text', '0')
|
||||
cy.findByTestId('selected-word-count-math-inline').should(
|
||||
'have.text',
|
||||
'0'
|
||||
)
|
||||
cy.findByTestId('selected-word-count-math-display').should(
|
||||
'have.text',
|
||||
'0'
|
||||
)
|
||||
cy.findByRole('button', { name: 'Close' }).click()
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows the selected header and math breakdown', function () {
|
||||
const content =
|
||||
'\\section{Heading words}\n' + 'Body text $x+y$ and \\[z=1\\]'
|
||||
mountController(content, 0, content.length)
|
||||
|
||||
cy.then(() => {
|
||||
expect(requestWordCount().defaultPrevented).to.be.true
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').within(() => {
|
||||
cy.findByTestId('selected-word-count-total').should('have.text', '5')
|
||||
cy.findByTestId('selected-word-count-sentences').should('have.text', '1')
|
||||
cy.findByTestId('selected-word-count-headers').should('have.text', '1')
|
||||
cy.findByTestId('selected-word-count-math-inline').should(
|
||||
'have.text',
|
||||
'1'
|
||||
)
|
||||
cy.findByTestId('selected-word-count-math-display').should(
|
||||
'have.text',
|
||||
'1'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows zero when the selection has no countable prose', function () {
|
||||
const content = '\\cite{smith2026}'
|
||||
mountController(content, 0, content.length)
|
||||
|
||||
cy.then(() => {
|
||||
expect(requestWordCount().defaultPrevented).to.be.true
|
||||
})
|
||||
|
||||
cy.findByTestId('selected-word-count-total').should('have.text', '0')
|
||||
cy.findByTestId('selected-word-count-sentences').should('have.text', '0')
|
||||
})
|
||||
|
||||
it('does not handle the request when the selection is empty', function () {
|
||||
mountController('one', 1, 1)
|
||||
|
||||
cy.then(() => {
|
||||
expect(requestWordCount().defaultPrevented).to.be.false
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
})
|
||||
|
||||
it('does not handle a selection when no document is active', function () {
|
||||
mountController('one two', 0, 3, { activeDocument: false })
|
||||
|
||||
cy.then(() => {
|
||||
expect(requestWordCount().defaultPrevented).to.be.false
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
})
|
||||
|
||||
it('does not handle a source selection while the visual editor is active', function () {
|
||||
mountController('one two', 0, 3, { showVisual: true })
|
||||
|
||||
cy.then(() => {
|
||||
expect(requestWordCount().defaultPrevented).to.be.false
|
||||
})
|
||||
|
||||
cy.findByRole('dialog').should('not.exist')
|
||||
})
|
||||
})
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
import { expect } from 'chai'
|
||||
import { createSegmenters } from '@/features/word-count-modal/utils/segmenters'
|
||||
import { countWordsInSelection } from '../../../frontend/utils/count-words-in-selection'
|
||||
import { createSentenceSegmenter } from '../../../frontend/utils/sentence-segmenter'
|
||||
|
||||
describe('selected-text word count', function () {
|
||||
const segmenters = createSegmenters('en-US')
|
||||
const sentenceSegmenter = createSentenceSegmenter('en-US')
|
||||
|
||||
const countSelectionResult = (
|
||||
content: string,
|
||||
from: number,
|
||||
to: number,
|
||||
localeSegmenters = segmenters,
|
||||
localeSentenceSegmenter = sentenceSegmenter
|
||||
) =>
|
||||
countWordsInSelection(
|
||||
content,
|
||||
{ from, to },
|
||||
localeSegmenters,
|
||||
localeSentenceSegmenter
|
||||
)
|
||||
|
||||
const countSelection = (
|
||||
content: string,
|
||||
from: number,
|
||||
to: number,
|
||||
localeSegmenters = segmenters
|
||||
) => countSelectionResult(content, from, to, localeSegmenters).totalWords
|
||||
|
||||
const countSelectedText = (content: string, selectedText: string) => {
|
||||
const from = content.indexOf(selectedText)
|
||||
expect(from).to.be.greaterThanOrEqual(0)
|
||||
return countSelection(content, from, from + selectedText.length)
|
||||
}
|
||||
|
||||
it('counts plain prose', function () {
|
||||
const content = 'Hello world from Overleaf.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(4)
|
||||
})
|
||||
|
||||
it('counts common sentence-ending punctuation', function () {
|
||||
const content = 'Hello world. This works! Is it ready?'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
|
||||
3
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a final sentence without ending punctuation', function () {
|
||||
const content = 'Hello world'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('does not count punctuation-only segments as sentences', function () {
|
||||
const content = '... ?!'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('handles decimal numbers and common abbreviations', function () {
|
||||
const content = 'Use e.g. version 3.14 today. It works.'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length).sentences).to.equal(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
it('counts sentences from visible text after filtering LaTeX', function () {
|
||||
const content =
|
||||
'First \\textbf{visible} sentence. % hidden sentence.\n' +
|
||||
'Second $x+y$ sentence!'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
|
||||
totalWords: 5,
|
||||
sentences: 2,
|
||||
headers: 0,
|
||||
mathInline: 1,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts sentences only inside the selected range', function () {
|
||||
const content = 'First sentence. Second sentence! Third sentence?'
|
||||
const selectedText = 'Second sentence!'
|
||||
const from = content.indexOf(selectedText)
|
||||
|
||||
expect(
|
||||
countSelectionResult(content, from, from + selectedText.length).sentences
|
||||
).to.equal(1)
|
||||
})
|
||||
|
||||
it('uses locale-aware sentence segmentation for English', function () {
|
||||
const content = 'This is sentence one. This is sentence two!'
|
||||
|
||||
expect(
|
||||
countSelectionResult(
|
||||
content,
|
||||
0,
|
||||
content.length,
|
||||
createSegmenters('en-US'),
|
||||
createSentenceSegmenter('en-US')
|
||||
).sentences
|
||||
).to.equal(2)
|
||||
})
|
||||
|
||||
it('falls back when the sentence locale is invalid', function () {
|
||||
const content = 'First sentence. Second sentence!'
|
||||
|
||||
expect(
|
||||
countSelectionResult(
|
||||
content,
|
||||
0,
|
||||
content.length,
|
||||
segmenters,
|
||||
createSentenceSegmenter('invalid_locale')
|
||||
).sentences
|
||||
).to.equal(2)
|
||||
})
|
||||
|
||||
it('returns word, header, and math counts for a mixed selection', function () {
|
||||
const content =
|
||||
'\\section{Heading words}\n' + 'Body text $x+y$ and \\[z=1\\]'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
|
||||
totalWords: 5,
|
||||
sentences: 1,
|
||||
headers: 1,
|
||||
mathInline: 1,
|
||||
mathDisplay: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not count header or math nodes outside the selection', function () {
|
||||
const content = '\\section{Outside heading}\n' + 'Selected words $x+y$'
|
||||
const selectedText = 'Selected words'
|
||||
const from = content.indexOf(selectedText)
|
||||
|
||||
expect(
|
||||
countSelectionResult(content, from, from + selectedText.length)
|
||||
).to.deep.equal({
|
||||
totalWords: 2,
|
||||
sentences: 1,
|
||||
headers: 0,
|
||||
mathInline: 0,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts only a partial range', function () {
|
||||
expect(countSelectedText('one two three four', 'two three')).to.equal(2)
|
||||
})
|
||||
|
||||
it('counts human-readable text inside formatting commands', function () {
|
||||
expect(
|
||||
countSelectedText('This is \\textbf{important text}.', 'important text')
|
||||
).to.equal(2)
|
||||
})
|
||||
|
||||
it('joins word fragments separated only by formatting markup', function () {
|
||||
const content = 'inter\\textbf{nal}formatting'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(1)
|
||||
})
|
||||
|
||||
it('joins fragments across adjacent formatting commands', function () {
|
||||
const content = 'inter\\textbf{n}\\emph{al}formatting'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(1)
|
||||
})
|
||||
|
||||
it('keeps real whitespace around formatting as a word boundary', function () {
|
||||
const content = 'one \\textbf{two}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('keeps excluded math inside formatting as a word boundary', function () {
|
||||
const content = 'before\\textbf{$x$}after'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
|
||||
totalWords: 2,
|
||||
sentences: 1,
|
||||
headers: 0,
|
||||
mathInline: 1,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an excluded citation inside formatting as a boundary', function () {
|
||||
const content = 'before\\textbf{\\cite{key}}after'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
|
||||
totalWords: 2,
|
||||
sentences: 1,
|
||||
headers: 0,
|
||||
mathInline: 0,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not count a citation key selected inside a citation', function () {
|
||||
expect(
|
||||
countSelectedText(
|
||||
'According to \\cite{smith2026}, results improve.',
|
||||
'smith2026'
|
||||
)
|
||||
).to.equal(0)
|
||||
})
|
||||
|
||||
it('counts prose in an optional citation argument', function () {
|
||||
expect(
|
||||
countSelectedText(
|
||||
'According to \\cite[see detailed discussion]{smith2026}.',
|
||||
'see detailed discussion'
|
||||
)
|
||||
).to.equal(3)
|
||||
})
|
||||
|
||||
it('does not count selected math as prose', function () {
|
||||
const content = 'The result is $E = mc^2$ today.'
|
||||
const selectedText = 'E = mc^2'
|
||||
const from = content.indexOf(selectedText)
|
||||
|
||||
expect(
|
||||
countSelectionResult(content, from, from + selectedText.length)
|
||||
).to.deep.equal({
|
||||
totalWords: 0,
|
||||
sentences: 0,
|
||||
headers: 0,
|
||||
mathInline: 1,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not count selected comment text', function () {
|
||||
expect(
|
||||
countSelectedText(
|
||||
'Visible text. % hidden comment words\n',
|
||||
'hidden comment words'
|
||||
)
|
||||
).to.equal(0)
|
||||
})
|
||||
|
||||
it('counts rendered text inside TeXcount ignore directives', function () {
|
||||
expect(
|
||||
countSelectedText(
|
||||
'%TC:ignore\nthese words are ignored\n%TC:endignore\nvisible',
|
||||
'these words are ignored'
|
||||
)
|
||||
).to.equal(4)
|
||||
})
|
||||
|
||||
it('counts rendered structures inside TeXcount ignore directives', function () {
|
||||
const content =
|
||||
'%TC:ignore\n' +
|
||||
'\\section{Visible heading}\n' +
|
||||
'$x+y$\n' +
|
||||
'\\[z=1\\]\n' +
|
||||
'%TC:endignore\n' +
|
||||
'visible'
|
||||
const selectedText = '\\section{Visible heading}\n$x+y$\n\\[z=1\\]'
|
||||
const from = content.indexOf(selectedText)
|
||||
|
||||
expect(
|
||||
countSelectionResult(content, from, from + selectedText.length)
|
||||
).to.deep.equal({
|
||||
totalWords: 2,
|
||||
sentences: 1,
|
||||
headers: 1,
|
||||
mathInline: 1,
|
||||
mathDisplay: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses comments in the same way as LaTeX', function () {
|
||||
const content = 'inter% comment removes the newline\nnational'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(1)
|
||||
})
|
||||
|
||||
it('does not follow input or include files or count their paths', function () {
|
||||
const content =
|
||||
'Before.\n\\input{chapter}\nMiddle.\n\\include{appendix}\nAfter.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(3)
|
||||
})
|
||||
|
||||
it('counts title, author, affiliation, and date rendered by maketitle', function () {
|
||||
const content =
|
||||
'\\title{Visible Title}\n' +
|
||||
'\\author{Alice Smith}\n' +
|
||||
'\\affil{Example University}\n' +
|
||||
'\\date{June 2026}\n' +
|
||||
'\\begin{document}\\maketitle\\end{document}'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
|
||||
totalWords: 8,
|
||||
sentences: 1,
|
||||
headers: 1,
|
||||
mathInline: 0,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not count title metadata when maketitle is absent', function () {
|
||||
const content =
|
||||
'\\title{Hidden Title}\\author{Hidden Author}\\begin{document}Body\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(1)
|
||||
})
|
||||
|
||||
it('counts IEEE author block contents rendered by maketitle', function () {
|
||||
const content =
|
||||
'\\begin{document}' +
|
||||
'\\author{\\IEEEauthorblockN{Alice Smith}\\IEEEauthorblockA{Example University}}' +
|
||||
'\\maketitle\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(4)
|
||||
})
|
||||
|
||||
it('does not count layout dimensions or drawing parameters', function () {
|
||||
const content = 'Before \\vspace{12pt} \\hspace{2em} \\cline{2-4} after.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('does not count phantom text', function () {
|
||||
const content = 'Before \\phantom{hidden words} after.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('counts visible href labels but not target URLs', function () {
|
||||
const content = '\\href{https://example.test/internal-path}{Visible link}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('counts URLs that are rendered by the url command', function () {
|
||||
const content = '\\url{visible-address}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(1)
|
||||
})
|
||||
|
||||
it('counts literal contents of verb and lstinline commands', function () {
|
||||
const content = 'Use \\verb|visible code| and \\lstinline!more code! here.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(7)
|
||||
})
|
||||
|
||||
it('counts visible verbatim environment content', function () {
|
||||
const content =
|
||||
'Before \\begin{verbatim}literal code words\\end{verbatim} after.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(5)
|
||||
})
|
||||
|
||||
it('does not count content in non-rendering environments', function () {
|
||||
const content = 'Before \\begin{comment}hidden words\\end{comment} after.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('counts text-producing commands inside math', function () {
|
||||
const content =
|
||||
'Before $x + \\text{visible words} + \\textrm{more text}$ after.'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
|
||||
totalWords: 6,
|
||||
sentences: 1,
|
||||
headers: 0,
|
||||
mathInline: 1,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts supported plain-text commands inside math', function () {
|
||||
const content = 'Before $\\mathrm{unit} + \\textnormal{normal text}$ after.'
|
||||
|
||||
expect(countSelectionResult(content, 0, content.length)).to.deep.equal({
|
||||
totalWords: 5,
|
||||
sentences: 1,
|
||||
headers: 0,
|
||||
mathInline: 1,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('counts built-in text-producing macros', function () {
|
||||
const content = 'Made with \\TeX, \\LaTeX, and \\BibTeX.'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(6)
|
||||
})
|
||||
|
||||
it('expands simple zero-argument text macros after their definition', function () {
|
||||
const content =
|
||||
'\\newcommand{\\project}{Visible Project Name}' +
|
||||
'\\begin{document}The \\project works.\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(5)
|
||||
})
|
||||
|
||||
it('expands simple def macros after their definition', function () {
|
||||
const content =
|
||||
'\\def\\project{Visible Project}' +
|
||||
'\\begin{document}The \\project works.\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(4)
|
||||
})
|
||||
|
||||
it('uses the latest simple renewcommand definition', function () {
|
||||
const content =
|
||||
'\\newcommand{\\project}{First Name}' +
|
||||
'\\renewcommand{\\project}{Second Name}' +
|
||||
'\\begin{document}\\project\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('skips parameterized custom macros conservatively', function () {
|
||||
const content =
|
||||
'\\newcommand{\\greet}[1]{Hello #1}' +
|
||||
'\\begin{document}Before \\greet{World} after.\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('does not count macro definitions as rendered text', function () {
|
||||
const content =
|
||||
'\\newcommand{\\project}{Definition words}' +
|
||||
'\\begin{document}Visible body\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(2)
|
||||
})
|
||||
|
||||
it('keeps table and bibliography prose in the word count', function () {
|
||||
const content =
|
||||
'\\begin{table}\\begin{tabular}{c}Visible table cell\\end{tabular}\\end{table}' +
|
||||
'\\begin{thebibliography}{1}\\bibitem{key}Visible reference words\\end{thebibliography}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(6)
|
||||
})
|
||||
|
||||
it('counts optional item labels because they are rendered', function () {
|
||||
const content =
|
||||
'\\begin{description}\\item[Visible label]Body text\\end{description}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(4)
|
||||
})
|
||||
|
||||
it('keeps formatted ordinal fragments joined inside IEEE author blocks', function () {
|
||||
const content =
|
||||
'\\begin{document}' +
|
||||
'\\author{' +
|
||||
'\\IEEEauthorblockN{1\\textsuperscript{st} Author}\\and' +
|
||||
'\\IEEEauthorblockN{2\\textsuperscript{nd} Author}}' +
|
||||
'\\maketitle\\end{document}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(4)
|
||||
})
|
||||
|
||||
it('counts headings, captions, and footnotes as selected words', function () {
|
||||
const content =
|
||||
'\\section{Heading words}\n' +
|
||||
'\\begin{figure}\\caption{Caption words}\\end{figure}\n' +
|
||||
'Body\\footnote{Footnote words}'
|
||||
|
||||
expect(countSelection(content, 0, content.length)).to.equal(7)
|
||||
})
|
||||
|
||||
it('uses Unicode-aware segmentation for English text', function () {
|
||||
const content = 'The café résumé is ready.'
|
||||
|
||||
expect(
|
||||
countSelection(content, 0, content.length, createSegmenters('en-US'))
|
||||
).to.equal(5)
|
||||
})
|
||||
|
||||
it('counts a complete synthetic replacement as one atomic word', function () {
|
||||
expect(countSelectedText('Made with \\LaTeX today.', '\\LaTeX')).to.equal(1)
|
||||
})
|
||||
|
||||
it('does not count a partial synthetic replacement', function () {
|
||||
expect(countSelectedText('Made with \\LaTeX today.', 'LaTeX')).to.equal(0)
|
||||
})
|
||||
|
||||
it('clips out-of-bounds ranges to the document', function () {
|
||||
expect(countSelection('one two', -100, 100)).to.equal(2)
|
||||
})
|
||||
|
||||
it('returns zero for an empty range', function () {
|
||||
expect(countSelection('one two', 3, 3)).to.equal(0)
|
||||
expect(countSelectionResult('one two', 3, 3)).to.deep.equal({
|
||||
totalWords: 0,
|
||||
sentences: 0,
|
||||
headers: 0,
|
||||
mathInline: 0,
|
||||
mathDisplay: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user