From f0ac2f4051ac80a0aaa489c416ed275e5f1b56b4 Mon Sep 17 00:00:00 2001 From: hnd0hng Date: Sat, 5 Sep 2026 15:36:52 +0700 Subject: [PATCH] Feat: add word and sentence counts for selected text --- services/web/config/settings.defaults.js | 4 + .../web/frontend/extracted-translations.json | 2 + .../ide-react/components/toolbar/menu-bar.tsx | 12 +- services/web/locales/de.json | 2 + services/web/locales/en.json | 2 + services/web/locales/ru.json | 2 + .../selected-word-count-controller.tsx | 113 +++ .../components/selected-word-count-modal.tsx | 106 +++ .../frontend/selected-word-count-events.ts | 1 + .../utils/count-words-in-selection.ts | 759 ++++++++++++++++++ .../frontend/utils/sentence-segmenter.ts | 37 + .../selected-word-count-controller.spec.tsx | 192 +++++ .../utils/count-words-in-selection.test.ts | 508 ++++++++++++ 13 files changed, 1739 insertions(+), 1 deletion(-) create mode 100644 services/web/modules/selected-word-count/frontend/components/selected-word-count-controller.tsx create mode 100644 services/web/modules/selected-word-count/frontend/components/selected-word-count-modal.tsx create mode 100644 services/web/modules/selected-word-count/frontend/selected-word-count-events.ts create mode 100644 services/web/modules/selected-word-count/frontend/utils/count-words-in-selection.ts create mode 100644 services/web/modules/selected-word-count/frontend/utils/sentence-segmenter.ts create mode 100644 services/web/modules/selected-word-count/test/frontend/components/selected-word-count-controller.spec.tsx create mode 100644 services/web/modules/selected-word-count/test/frontend/utils/count-words-in-selection.test.ts diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 139b3401e2..efcec369dc 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -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: [], diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 59da3ea233..5452ba0dc5 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx index 70fe5ee6ad..bc8f81bab6 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/menu-bar.tsx @@ -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', diff --git a/services/web/locales/de.json b/services/web/locales/de.json index a472d910e7..d399215d9c 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -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", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 32b1de603d..429abf848c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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__ Server Pro license", "server_pro_license_entitlement_line_2": "You currently have <0>__count__ active users. If you need to increase your license entitlement, please <1>contact Overleaf.", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index 02e63f43f9..28ea5bcaa2 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -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": "Сессия создана", diff --git a/services/web/modules/selected-word-count/frontend/components/selected-word-count-controller.tsx b/services/web/modules/selected-word-count/frontend/components/selected-word-count-controller.tsx new file mode 100644 index 0000000000..4e7d28556f --- /dev/null +++ b/services/web/modules/selected-word-count/frontend/components/selected-word-count-controller.tsx @@ -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(null) + const [data, setData] = useState(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 ( + + ) +} diff --git a/services/web/modules/selected-word-count/frontend/components/selected-word-count-modal.tsx b/services/web/modules/selected-word-count/frontend/components/selected-word-count-modal.tsx new file mode 100644 index 0000000000..1138f7cd21 --- /dev/null +++ b/services/web/modules/selected-word-count/frontend/components/selected-word-count-modal.tsx @@ -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 ( + + + {t('selected_text_word_count')} + + + + {data === null && !error && } + {error && } + {data !== null && ( + + {rows.map(({ key, label, value, testId }) => ( + + +
{label}:
+ + + {numberFormat.format(value)} + +
+ ))} +
+ )} +
+ + + + {t('close')} + + +
+ ) +} diff --git a/services/web/modules/selected-word-count/frontend/selected-word-count-events.ts b/services/web/modules/selected-word-count/frontend/selected-word-count-events.ts new file mode 100644 index 0000000000..8ea787d333 --- /dev/null +++ b/services/web/modules/selected-word-count/frontend/selected-word-count-events.ts @@ -0,0 +1 @@ +export const WORD_COUNT_OPEN_REQUESTED_EVENT = 'word-count:open-requested' diff --git a/services/web/modules/selected-word-count/frontend/utils/count-words-in-selection.ts b/services/web/modules/selected-word-count/frontend/utils/count-words-in-selection.ts new file mode 100644 index 0000000000..0cd9a68898 --- /dev/null +++ b/services/web/modules/selected-word-count/frontend/utils/count-words-in-selection.ts @@ -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([ + ['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() + + 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 +} diff --git a/services/web/modules/selected-word-count/frontend/utils/sentence-segmenter.ts b/services/web/modules/selected-word-count/frontend/utils/sentence-segmenter.ts new file mode 100644 index 0000000000..12d707b4a0 --- /dev/null +++ b/services/web/modules/selected-word-count/frontend/utils/sentence-segmenter.ts @@ -0,0 +1,37 @@ +export type SentenceSegment = { + segment: string +} + +export type SentenceSegmenter = { + segment(input: string): { + [Symbol.iterator](): IterableIterator + } +} + +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 + } +} diff --git a/services/web/modules/selected-word-count/test/frontend/components/selected-word-count-controller.spec.tsx b/services/web/modules/selected-word-count/test/frontend/components/selected-word-count-controller.spec.tsx new file mode 100644 index 0000000000..bf532aece4 --- /dev/null +++ b/services/web/modules/selected-word-count/test/frontend/components/selected-word-count-controller.spec.tsx @@ -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('', 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 = ({ children }) => ( + undefined }} + > + {children} + + ) + + cy.mount( + + {options.activeDocument !== false && } + + + ) + } + + 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') + }) +}) diff --git a/services/web/modules/selected-word-count/test/frontend/utils/count-words-in-selection.test.ts b/services/web/modules/selected-word-count/test/frontend/utils/count-words-in-selection.test.ts new file mode 100644 index 0000000000..238fc48ce4 --- /dev/null +++ b/services/web/modules/selected-word-count/test/frontend/utils/count-words-in-selection.test.ts @@ -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, + }) + }) +})