From 1383e526743a522d334c04d45c846c492a5a4a6d Mon Sep 17 00:00:00 2001 From: trtshen Date: Fri, 9 Jan 2026 13:54:50 +0800 Subject: [PATCH] [CORE-7942] spacing preserved --- docs/fixes/CORE-7942-whitespace-fix.md | 128 ++++++++++++++++++ package-lock.json | 48 +++++++ projects/v3/src/app/services/utils.service.ts | 10 +- 3 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 docs/fixes/CORE-7942-whitespace-fix.md diff --git a/docs/fixes/CORE-7942-whitespace-fix.md b/docs/fixes/CORE-7942-whitespace-fix.md new file mode 100644 index 000000000..04cd10750 --- /dev/null +++ b/docs/fixes/CORE-7942-whitespace-fix.md @@ -0,0 +1,128 @@ +# CORE-7942: Whitespace Stripping Fix in Language Detection + +## Problem Summary + +When displaying HTML content with the `detectLanguage` pipe in description components, spaces between inline elements (like `` tags) and adjacent text were being stripped, causing text to run together. + +### Example +**API Response:** +```html +Privacy Policy click here +``` + +**Rendered Output (BEFORE FIX):** +``` +Privacy Policyclick here +``` +(no space between "Policy" and "click") + +**Expected Output (AFTER FIX):** +``` +Privacy Policy click here +``` +(space preserved) + +## Root Cause Analysis + +The issue was in the `addLanguageAttributes()` method in [utils.service.ts](projects/v3/src/app/services/utils.service.ts#L902-L945), which is called by the `detectLanguage` pipe for WCAG 3.1.2 Language of Parts compliance. + +### Technical Details + +When processing HTML content: +1. The DOM parser creates separate text nodes for text outside and between elements +2. For `Privacy Policy click here`, there are 2 text nodes: + - Text node 1 (inside ``): `"Privacy Policy"` + - Text node 2 (after ``): `" click here"` (with leading space) + +### The Bug (Lines 919-925) + +```typescript +const text = node.textContent?.trim() || ''; // ❌ Strips whitespace +if (text.length >= 10) { + const detectedLang = this.detectLanguage(text, baseLang); + if (detectedLang) { + const span = this.document.createElement('span'); + span.setAttribute('lang', detectedLang); + span.textContent = text; // ❌ Uses trimmed text, losing leading/trailing spaces + node.parentNode?.replaceChild(span, node); + } +} +``` + +**What Happened:** +- Line 919: `node.textContent?.trim()` converted `" click here"` → `"click here"` +- Line 921: Length check: `"click here".length = 10` ✅ passes threshold +- Line 924: Assigned trimmed text to new span element +- Line 925: Replaced original node (with space) with span (without space) + +**Result:** Space between "Policy" and "click" disappeared + +## Solution Implemented + +### 1. Preserve Original Whitespace + +Changed line 924 to use `node.textContent` (original) instead of `text` (trimmed): + +```typescript +const text = node.textContent?.trim() || ''; // ✅ Use for validation only +if (text.length >= 20) { + const detectedLang = this.detectLanguage(text, baseLang); + if (detectedLang) { + const span = this.document.createElement('span'); + span.setAttribute('lang', detectedLang); + span.textContent = node.textContent; // ✅ Preserve original whitespace + node.parentNode?.replaceChild(span, node); + } +} +``` + +**Strategy:** +- Use `text` (trimmed) for validation and language detection logic +- Use `node.textContent` (original with whitespace) for rendering + +### 2. Increase Minimum Length Threshold + +Changed minimum length from **10** to **20** characters: + +```typescript +// minimum text length for reliable detection (increased from 10 to 20 for better accuracy) +const minLength = 20; +``` + +**Benefits:** +1. **Better Accuracy**: Language detection algorithms are more reliable with longer text +2. **Avoid False Positives**: Short phrases like "click here" (10 chars) won't trigger detection +3. **Performance**: Reduces unnecessary processing of short text nodes +4. **WCAG Intent**: WCAG 3.1.2 is meant for substantial foreign language content, not individual words + +## Impact Assessment + +### Fixed Components +All components using the `detectLanguage` pipe will benefit: +- [description.component.html](projects/v3/src/app/components/description/description.component.html) +- [activity-desktop.component.html](projects/v3/src/app/desktop/activity-desktop/activity-desktop.component.html) +- [review-desktop.component.html](projects/v3/src/app/desktop/review-desktop/review-desktop.component.html) + +### Edge Cases Considered +- **Text < 20 chars**: Not processed (preserves original spacing by default) +- **Multiple spaces**: Preserved exactly as in original HTML +- **Non-breaking spaces (` `)**: Preserved as HTML entities +- **Mixed content**: Works correctly with inline elements + +## Testing Recommendations + +1. **Visual Test**: Verify spacing appears correctly in description content with links +2. **Language Detection**: Confirm foreign language passages (>20 chars) still get `lang` attributes +3. **Short Text**: Verify short phrases (<20 chars) don't get incorrectly wrapped in `` +4. **Regression**: Test existing descriptions with mixed English/foreign content + +## Files Modified + +- `/projects/v3/src/app/services/utils.service.ts` + - Line 849: Increased `minLength` from 10 to 20 + - Lines 918-926: Added comments and fixed whitespace preservation in `addLanguageAttributes()` + +## Related Tickets + +- CORE-7942: Description WYSIWYG spacing fix +- Original accessibility implementation for WCAG 3.1.2 compliance diff --git a/package-lock.json b/package-lock.json index 29e9d8fec..6f6554846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -705,6 +705,18 @@ "node": ">=12" } }, + "node_modules/@angular-devkit/build-angular/node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@angular-devkit/build-angular/node_modules/@vitejs/plugin-basic-ssl": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz", @@ -4361,6 +4373,33 @@ } } }, + "node_modules/@compodoc/compodoc/node_modules/@angular-devkit/schematics/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/@compodoc/compodoc/node_modules/@babel/core": { "version": "7.25.8", "dev": true, @@ -23951,6 +23990,15 @@ "through": "^2.3.8" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/uni-global": { "version": "1.0.0", "dev": true, diff --git a/projects/v3/src/app/services/utils.service.ts b/projects/v3/src/app/services/utils.service.ts index 8bca2437c..6063046b8 100644 --- a/projects/v3/src/app/services/utils.service.ts +++ b/projects/v3/src/app/services/utils.service.ts @@ -847,8 +847,8 @@ export class UtilsService { return null; } - // Minimum text length for reliable detection - const minLength = 10; + // minimum text length for reliable detection (increased from 10 to 20 for better accuracy) + const minLength = 20; const cleanText = text.trim().replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); if (cleanText.length < minLength) { @@ -915,13 +915,15 @@ export class UtilsService { const processNode = (node: Node): void => { if (node.nodeType === Node.TEXT_NODE) { + // use trimmed text for validation only, preserve original whitespace for rendering const text = node.textContent?.trim() || ''; - if (text.length >= 10) { + if (text.length >= 20) { const detectedLang = this.detectLanguage(text, baseLang); if (detectedLang) { const span = this.document.createElement('span'); span.setAttribute('lang', detectedLang); - span.textContent = text; + // preserve original whitespace from node.textContent (not trimmed) + span.textContent = node.textContent; node.parentNode?.replaceChild(span, node); } }