1   package org.daisy.pipeline.css.speech;
2   
3   import java.util.AbstractList;
4   import java.util.ArrayList;
5   import java.util.EnumSet;
6   import java.util.ListIterator;
7   import java.util.List;
8   import java.util.Optional;
9   
10  import cz.vutbr.web.css.Declaration;
11  import cz.vutbr.web.css.RuleFactory;
12  import cz.vutbr.web.css.SupportedCSS;
13  import cz.vutbr.web.css.Term;
14  import cz.vutbr.web.css.TermIdent;
15  import cz.vutbr.web.css.TermInteger;
16  import cz.vutbr.web.css.TermList;
17  import cz.vutbr.web.css.TermString;
18  import cz.vutbr.web.csskit.OutputUtil;
19  import cz.vutbr.web.csskit.RuleFactoryImpl;
20  import cz.vutbr.web.domassign.SupportedCSS21;
21  
22  import org.daisy.braille.css.BrailleCSSParserFactory;
23  import org.daisy.pipeline.css.CssSerializer;
24  import org.daisy.pipeline.css.impl.UnmodifiableTerm;
25  
26  import org.slf4j.Logger;
27  import org.slf4j.LoggerFactory;
28  
29  public class VoiceFamilyList extends AbstractList<Term<?>> implements TermList {
30  
31  	private static final String PROPERTY = "voice-family";
32  
33  	// using braille-css because of parseDeclaration() method
34  	private static final BrailleCSSParserFactory parserFactory = new BrailleCSSParserFactory();
35  	private static final RuleFactory ruleFactory = RuleFactoryImpl.getInstance();
36  	private static final SupportedCSS speechCSS = SupportedCSS21.getInstance();
37  	private static final Logger logger = LoggerFactory.getLogger(VoiceFamilyList.class);
38  
39  	public static VoiceFamilyList of(String list) throws IllegalArgumentException {
40  		Declaration d = parserFactory.parseDeclaration(PROPERTY + ": " + list);
41  		if (!PROPERTY.equals(d.getProperty()))
42  			throw new IllegalArgumentException();
43  		return of(d);
44  	}
45  
46  	public static VoiceFamilyList of(List<Term<?>> list) throws IllegalArgumentException {
47  		List<VoiceFamily> l = new ArrayList<>();
48  		ListIterator<Term<?>> i = list.listIterator();
49  		boolean ageWithoutGender = false;
50  		while (i.hasNext()) {
51  			VoiceFamily f = VoiceFamily.of(i);
52  			l.add(f);
53  			ageWithoutGender = ageWithoutGender || (f.age != null && f.gender == null);
54  		}
55  
56  		// Handle old syntax:
57  		// A comma separated list such as `engine, gender' normally means: "first select voice from
58  		// engine, and if that doesn't result in any matches, select voice with gender". It is
59  		// however very unlikely to be used for this purpose. So we could interpret it according to
60  		// the old syntax, for backwards compatibility.
61  		if (l.size() > 1 && l.size() <= 3) {
62  			String name = null;
63  			Age age = null;
64  			Gender gender = null;
65  			boolean extra = false;
66  			for (VoiceFamily f : l) {
67  				if (name == null && f.name != null && f.gender == null && f.age == null)
68  					name = f.name;
69  				else if (gender == null && f.gender != null && f.name == null && f.age == null)
70  					gender = f.gender;
71  				else if (age == null && f.age != null && f.name == null && f.gender == null)
72  					age = f.age;
73  				else {
74  					extra = true;
75  					break;
76  				}
77  			}
78  			if (!extra) {
79  				String oldSyntax = CssSerializer.serializeTermList(l);
80  				l.clear();
81  				if (age != null && gender == null) {
82  					// Handle age without gender:
83  					// (would also be done below, but do it here in order to improve the warning message)
84  					l.add(new VoiceFamily(name, age, Gender.MALE, null, null));
85  					l.add(new VoiceFamily(name, age, Gender.FEMALE, null, Operator.COMMA));
86  					ageWithoutGender = false;
87  				} else {
88  					l.add(new VoiceFamily(name, age, gender, null, null));
89  				}
90  				logger.warn("Use `font-family: " + CssSerializer.serializeTermList(l)
91  				            + "` instead of `font-family: " + oldSyntax + "`");
92  			}
93  		}
94  
95  		// Handle age without gender:
96  		// If an age was not specified as part of a list in the old comma separated syntax (see
97  		// above), combine it with both male and female in two separate items.
98  		if (ageWithoutGender) {
99  			List<VoiceFamily> l2 = new ArrayList<>();
100 			for (VoiceFamily f : l) {
101 				if (f.age != null && f.gender == null) {
102 					logger.warn("Use `font-family: " + f.age + " male, " + f.age + " female` "
103 					            + "instead of `font-family: " + f.age + "`");
104 					l2.add(new VoiceFamily(f.name, f.age, Gender.MALE, f.variant, f.comma));
105 					l2.add(new VoiceFamily(f.name, f.age, Gender.FEMALE, f.variant, Operator.COMMA));
106 				} else
107 					l2.add(f);
108 			}
109 			l = l2;
110 		}
111 		return new VoiceFamilyList(l);
112 	}
113 
114 	private List<VoiceFamily> list;
115 
116 	private VoiceFamilyList(List<VoiceFamily> list) {
117 		super();
118 		this.list = list;
119 	}
120 
121 	@Override
122 	public String toString() {
123 		return CssSerializer.toString(this);
124 	}
125 
126 	@Override
127 	public VoiceFamily get(int index) {
128 		return list.get(index);
129 	}
130 
131 	@Override
132 	public int size() {
133 		return list.size();
134 	}
135 
136 	@Override
137 	public List<Term<?>> getValue() {
138 		return this;
139 	}
140 
141 	@Override
142 	public Operator getOperator() {
143 		return null;
144 	}
145 
146 	@Override
147 	public VoiceFamilyList shallowClone() {
148 		try {
149 			return (VoiceFamilyList)super.clone();
150 		} catch (CloneNotSupportedException e) {
151 			throw new InternalError("coding error");
152 		}
153 	}
154 
155 	@Override
156 	public VoiceFamilyList clone() {
157 		VoiceFamilyList clone = shallowClone();
158 		clone.list = new ArrayList<>(list);
159 		return clone;
160 	}
161 
162 	@Override
163 	public VoiceFamilyList setValue(List<Term<?>> value) {
164 		throw new UnsupportedOperationException("Unmodifiable");
165 	}
166 
167 	@Override
168 	public VoiceFamilyList setOperator(Operator operator) {
169 		throw new UnsupportedOperationException("Unmodifiable");
170 	}
171 
172 	public static class VoiceFamily extends UnmodifiableTerm<VoiceFamily> {
173 
174 		private static VoiceFamily of(ListIterator<Term<?>> list) throws IllegalArgumentException {
175 			if (!list.hasNext())
176 				throw new IllegalArgumentException();
177 			Term<?> t = list.next();
178 			if (t instanceof VoiceFamily)
179 				return (VoiceFamily)t;
180 			Operator comma = t.getOperator();
181 			if (comma == Operator.SPACE)
182 				comma = null;
183 			else if (comma != null && comma != Operator.COMMA)
184 				throw new IllegalArgumentException();
185 			String name = null;
186 			Age age = null;
187 			Integer ageAsInt = null;
188 			Gender gender = null;
189 			Integer variant = null;
190 			do {
191 				if (name != null)
192 					throw new IllegalArgumentException();
193 				if (ageAsInt != null)
194 					throw new IllegalArgumentException();
195 				if (gender == null) {
196 					if (t instanceof TermIdent) {
197 						if (age == null) {
198 							try {
199 								age = Age.of((TermIdent)t);
200 								if (!age.toString().equals(((TermIdent)t).getValue())) {
201 									age = null;
202 									name = ((TermIdent)t).getValue();
203 								}
204 							} catch (IllegalArgumentException e) {
205 								try {
206 									gender = Gender.of((TermIdent)t);
207 								} catch (IllegalArgumentException ee) {
208 									name = ((TermIdent)t).getValue();
209 								}
210 							}
211 						} else {
212 							try {
213 								gender = Gender.of((TermIdent)t);
214 							} catch (IllegalArgumentException e) {
215 								name = ((TermIdent)t).getValue();
216 							}
217 						}
218 					} else if (t instanceof TermString) {
219 						name = ((TermString)t).getValue();
220 					} else if (t instanceof TermInteger) {
221 						if (age == null) {
222 							ageAsInt = ((TermInteger)t).getIntValue();
223 							if (ageAsInt < 0)
224 								throw new IllegalArgumentException();
225 						} else
226 							throw new IllegalArgumentException();
227 					} else
228 						throw new IllegalArgumentException();
229 				} else if (variant == null && t instanceof TermInteger) {
230 					variant = ((TermInteger)t).getIntValue();
231 					if (variant <= 0)
232 						throw new IllegalArgumentException();
233 				} else if (t instanceof TermIdent || t instanceof TermString) {
234 					name = ((Term<String>)t).getValue();
235 				} else
236 					throw new IllegalArgumentException();
237 				if (!list.hasNext())
238 					break;
239 				t = list.next();
240 				Operator o = t.getOperator();
241 				if (o == Operator.COMMA) {
242 					list.previous();
243 					break;
244 				} else if (o != null && o != Operator.SPACE)
245 					throw new IllegalArgumentException();
246 			} while (true);
247 			if (ageAsInt != null) {
248 				if (ageAsInt <= 16)
249 					age = Age.CHILD;
250 				else if (ageAsInt >= 70)
251 					age = Age.OLD;
252 				else
253 					age = Age.YOUNG;
254 				logger.warn("Specifying age as number is deprecated. "
255 				            + "Use `font-family: " + age + "` instead of `font-family: " + ageAsInt + "`");
256 			}
257 			if (name == null && gender == null && age == null)
258 				throw new IllegalArgumentException();
259 			return new VoiceFamily(name, age, gender, variant, comma);
260 		}
261 
262 		private final String name;
263 		private final Age age;
264 		private final Gender gender;
265 		private final Integer variant;
266 		private final Operator comma;
267 
268 		private VoiceFamily(String name, Age age, Gender gender, Integer variant, Operator comma) {
269 			this.name = name;
270 			this.age = age;
271 			this.gender = gender;
272 			this.variant = variant;
273 			this.comma = comma;
274 		}
275 
276 		public Optional<String> getFamilyName() {
277 			return Optional.ofNullable(name);
278 		}
279 
280 		public Optional<Age> getAge() {
281 			return Optional.ofNullable(age);
282 		}
283 
284 		public Optional<Gender> getGender() {
285 			return Optional.ofNullable(gender);
286 		}
287 
288 		public Optional<Integer> getVariant() {
289 			return Optional.ofNullable(variant);
290 		}
291 
292 		@Override
293 		public String toString() {
294 			StringBuilder s = new StringBuilder();
295 			if (comma != null) s.append(", ");
296 			if (age != null || gender != null) {
297 				if (age != null)
298 					s.append(age.toString());
299 				// gender may be null in a temporary VoiceFamily object (an object that does
300 				// not leave the VoiceFamilyList class)
301 				if (gender != null) {
302 					if (age != null) s.append(" ");
303 					s.append(gender.toString());
304 				}
305 				if (variant != null)
306 					s.append(" " + variant);
307 				if (name != null)
308 					s.append(" ");
309 			}
310 			if (name != null)
311 				s.append("'" + name.replaceAll("\n", "\\\\A ").replaceAll("'", "\\\\27 ") + "'");
312 			return s.toString();
313 		}
314 
315 		@Override
316 		public Operator getOperator() {
317 			return comma;
318 		}
319 	}
320 
321 	public static enum Age {
322 		CHILD,
323 		YOUNG,
324 		OLD;
325 
326 		private static Age of(TermIdent age) throws IllegalArgumentException {
327 			return of(age.getValue());
328 		}
329 
330 		private static Age of(String age) throws IllegalArgumentException {
331 			if (age == null)
332 				throw new IllegalArgumentException();
333 			age = age.toLowerCase();
334 			for (Age a : EnumSet.allOf(Age.class))
335 				if (a.toString().equals(age))
336 					return a;
337 			throw new IllegalArgumentException("Not an age: " + age);
338 		}
339 
340 		@Override
341 		public String toString() {
342 			return name().toLowerCase();
343 		}
344 	}
345 
346 	public static enum Gender {
347 		MALE,
348 		FEMALE,
349 		NEUTRAL;
350 
351 		private static Gender of(TermIdent gender) throws IllegalArgumentException {
352 			return of(gender.getValue());
353 		}
354 
355 		private static Gender of(String gender) throws IllegalArgumentException {
356 			if (gender == null)
357 				throw new IllegalArgumentException();
358 			gender = gender.toLowerCase();
359 			for (Gender g : EnumSet.allOf(Gender.class))
360 				if (g.toString().equals(gender))
361 					return g;
362 			throw new IllegalArgumentException("Not a gender: " + gender);
363 		}
364 
365 		@Override
366 		public String toString() {
367 			return name().toLowerCase();
368 		}
369 	}
370 }