1+ package ca .bc .gov .educ .api .graduation .util ;
2+
3+ import java .lang .reflect .Field ;
4+ import java .text .Normalizer ;
5+ import java .util .*;
6+
7+ /**
8+ * Utility to recursively normalize all String fields in an object and its children.
9+ * This ensures Indigenous language combining diacritics (like ̓) are preserved.
10+ *
11+ * Usage:
12+ * MyReportData data = createReportData();
13+ * TextNormalizer.normalizeObject(data);
14+ * String json = objectMapper.writeValueAsString(data);
15+ */
16+ public class TextNormalizer {
17+
18+ private static final Set <Class <?>> WRAPPER_TYPES = new HashSet <>(Arrays .asList (
19+ Boolean .class , Character .class , Byte .class , Short .class ,
20+ Integer .class , Long .class , Float .class , Double .class , Void .class
21+ ));
22+
23+ /**
24+ * Normalize all String fields in an object and its children recursively.
25+ * Handles: POJOs, Collections, Maps, Arrays
26+ *
27+ * @param obj The object to normalize (modified in-place)
28+ * @return The same object (for chaining)
29+ */
30+ public static <T > T normalizeObject (T obj ) {
31+ if (obj == null ) {
32+ return null ;
33+ }
34+
35+ try {
36+ normalizeRecursive (obj , new HashSet <>());
37+ } catch (Exception e ) {
38+ throw new RuntimeException ("Failed to normalize object" , e );
39+ }
40+
41+ return obj ;
42+ }
43+
44+ /**
45+ * Internal recursive normalization with cycle detection
46+ */
47+ private static void normalizeRecursive (Object obj , Set <Object > visited ) throws Exception {
48+ if (obj == null || visited .contains (obj )) {
49+ return ; // Prevent infinite loops
50+ }
51+
52+ Class <?> clazz = obj .getClass ();
53+
54+ // Skip primitives and wrappers
55+ if (clazz .isPrimitive () || WRAPPER_TYPES .contains (clazz )) {
56+ return ;
57+ }
58+
59+ // Skip Java internal classes (UUID, LocalDate, etc.)
60+ String className = clazz .getName ();
61+ if (className .startsWith ("java." ) || className .startsWith ("javax." ) ||
62+ className .startsWith ("jdk." ) || className .startsWith ("sun." )) {
63+ return ;
64+ }
65+
66+ // Handle String
67+ if (obj instanceof String ) {
68+ // Can't modify String in-place, caller must handle
69+ return ;
70+ }
71+
72+ visited .add (obj );
73+
74+ // Handle Collections
75+ if (obj instanceof Collection ) {
76+ normalizeCollection ((Collection <?>) obj , visited );
77+ return ;
78+ }
79+
80+ // Handle Maps
81+ if (obj instanceof Map ) {
82+ normalizeMap ((Map <?, ?>) obj , visited );
83+ return ;
84+ }
85+
86+ // Handle Arrays
87+ if (clazz .isArray ()) {
88+ normalizeArray (obj , visited );
89+ return ;
90+ }
91+
92+ // Handle POJOs - normalize all fields
93+ normalizeFields (obj , visited );
94+ }
95+
96+ /**
97+ * Normalize all fields in a POJO
98+ */
99+ private static void normalizeFields (Object obj , Set <Object > visited ) throws Exception {
100+ Class <?> clazz = obj .getClass ();
101+
102+ // Walk up the inheritance hierarchy
103+ while (clazz != null && clazz != Object .class ) {
104+ for (Field field : clazz .getDeclaredFields ()) {
105+ field .setAccessible (true );
106+
107+ Object value = field .get (obj );
108+ if (value == null ) {
109+ continue ;
110+ }
111+
112+ // If it's a String, normalize and set back
113+ if (value instanceof String ) {
114+ String normalized = normalize ((String ) value );
115+ field .set (obj , normalized );
116+ }
117+ // Otherwise recurse into the object
118+ else {
119+ normalizeRecursive (value , visited );
120+ }
121+ }
122+
123+ clazz = clazz .getSuperclass ();
124+ }
125+ }
126+
127+ /**
128+ * Normalize strings in a Collection
129+ */
130+ private static void normalizeCollection (Collection <?> collection , Set <Object > visited ) throws Exception {
131+ // For Lists, we can replace elements
132+ if (collection instanceof List ) {
133+ List <Object > list = (List <Object >) collection ;
134+ for (int i = 0 ; i < list .size (); i ++) {
135+ Object item = list .get (i );
136+ if (item instanceof String ) {
137+ list .set (i , normalize ((String ) item ));
138+ } else if (item != null ) {
139+ normalizeRecursive (item , visited );
140+ }
141+ }
142+ } else {
143+ // For other collections, just recurse (can't modify elements)
144+ for (Object item : collection ) {
145+ if (item != null && !(item instanceof String )) {
146+ normalizeRecursive (item , visited );
147+ }
148+ }
149+ }
150+ }
151+
152+ /**
153+ * Normalize strings in a Map
154+ */
155+ private static void normalizeMap (Map <?, ?> map , Set <Object > visited ) throws Exception {
156+ Map <Object , Object > mutableMap = (Map <Object , Object >) map ;
157+
158+ // Normalize both keys and values
159+ List <Object > keysToUpdate = new ArrayList <>();
160+ Map <Object , Object > updates = new HashMap <>();
161+
162+ for (Map .Entry <?, ?> entry : map .entrySet ()) {
163+ Object key = entry .getKey ();
164+ Object value = entry .getValue ();
165+
166+ // Normalize key if it's a string
167+ Object normalizedKey = key ;
168+ if (key instanceof String ) {
169+ normalizedKey = normalize ((String ) key );
170+ if (!normalizedKey .equals (key )) {
171+ keysToUpdate .add (key );
172+ updates .put (normalizedKey , value );
173+ }
174+ }
175+
176+ // Normalize value
177+ if (value instanceof String ) {
178+ mutableMap .put (key , normalize ((String ) value ));
179+ } else if (value != null ) {
180+ normalizeRecursive (value , visited );
181+ }
182+ }
183+
184+ // Update keys that changed (remove old, add new)
185+ for (Object oldKey : keysToUpdate ) {
186+ mutableMap .remove (oldKey );
187+ }
188+ mutableMap .putAll (updates );
189+ }
190+
191+ /**
192+ * Normalize strings in an Array
193+ */
194+ private static void normalizeArray (Object array , Set <Object > visited ) throws Exception {
195+ int length = java .lang .reflect .Array .getLength (array );
196+
197+ for (int i = 0 ; i < length ; i ++) {
198+ Object item = java .lang .reflect .Array .get (array , i );
199+
200+ if (item instanceof String ) {
201+ String normalized = normalize ((String ) item );
202+ java .lang .reflect .Array .set (array , i , normalized );
203+ } else if (item != null ) {
204+ normalizeRecursive (item , visited );
205+ }
206+ }
207+ }
208+
209+ /**
210+ * Normalize a single string to NFC form
211+ * NFC = Canonical Composition (keeps combining marks attached to base characters)
212+ */
213+ private static String normalize (String text ) {
214+ if (text == null || text .isEmpty ()) {
215+ return text ;
216+ }
217+ return Normalizer .normalize (text , Normalizer .Form .NFC );
218+ }
219+ }
0 commit comments