1   package org.daisy.pipeline.braille.liblouis.impl;
2   
3   import java.text.Normalizer;
4   import java.util.ArrayList;
5   import java.util.Arrays;
6   import java.util.HashMap;
7   import static java.util.Collections.singleton;
8   import java.util.List;
9   import java.util.Locale;
10  import java.util.Map;
11  import java.util.stream.Collectors;
12  import java.util.regex.Matcher;
13  import java.util.regex.Pattern;
14  
15  import com.google.common.base.MoreObjects;
16  import com.google.common.base.MoreObjects.ToStringHelper;
17  import com.google.common.base.Splitter;
18  import com.google.common.collect.ImmutableList;
19  import com.google.common.collect.ImmutableMap;
20  
21  import static com.google.common.collect.Iterables.any;
22  import static com.google.common.collect.Iterables.size;
23  import static com.google.common.collect.Iterables.toArray;
24  
25  import cz.vutbr.web.css.CSSProperty;
26  import cz.vutbr.web.css.CSSProperty.FontStyle;
27  import cz.vutbr.web.css.CSSProperty.FontWeight;
28  import cz.vutbr.web.css.CSSProperty.TextDecoration;
29  import cz.vutbr.web.css.Term;
30  import cz.vutbr.web.css.TermIdent;
31  import cz.vutbr.web.css.TermInteger;
32  import cz.vutbr.web.css.TermList;
33  
34  import org.daisy.braille.css.BrailleCSSProperty.BrailleCharset;
35  import org.daisy.braille.css.BrailleCSSProperty.Hyphens;
36  import org.daisy.braille.css.BrailleCSSProperty.LetterSpacing;
37  import org.daisy.braille.css.BrailleCSSProperty.TextTransform;
38  import org.daisy.braille.css.BrailleCSSProperty.WhiteSpace;
39  import org.daisy.braille.css.PropertyValue;
40  import org.daisy.braille.css.SimpleInlineStyle;
41  
42  import org.daisy.pipeline.braille.common.AbstractBrailleTranslator;
43  import org.daisy.pipeline.braille.common.AbstractBrailleTranslator.util.DefaultLineBreaker;
44  import org.daisy.pipeline.braille.common.AbstractHyphenator.util.NoHyphenator;
45  import org.daisy.pipeline.braille.common.AbstractTransformProvider;
46  import org.daisy.pipeline.braille.common.AbstractTransformProvider.util.Iterables;
47  import org.daisy.pipeline.braille.common.AbstractTransformProvider.util.Function;
48  import static org.daisy.pipeline.braille.common.AbstractTransformProvider.util.Iterables.memoize;
49  import static org.daisy.pipeline.braille.common.AbstractTransformProvider.util.Iterables.transform;
50  import static org.daisy.pipeline.braille.common.AbstractTransformProvider.util.logCreate;
51  import static org.daisy.pipeline.braille.common.AbstractTransformProvider.util.logSelect;
52  import org.daisy.pipeline.braille.common.BrailleCode;
53  import org.daisy.pipeline.braille.common.BrailleTranslator;
54  import org.daisy.pipeline.braille.common.BrailleTranslatorProvider;
55  import org.daisy.pipeline.braille.common.CompoundBrailleTranslator;
56  import org.daisy.pipeline.braille.common.Hyphenator;
57  import org.daisy.pipeline.braille.common.Hyphenator.NonStandardHyphenationException;
58  import org.daisy.pipeline.braille.common.Query;
59  import org.daisy.pipeline.braille.common.Query.Feature;
60  import org.daisy.pipeline.braille.common.Query.MutableQuery;
61  import static org.daisy.pipeline.braille.common.Query.util.mutableQuery;
62  import org.daisy.pipeline.braille.common.TransformationException;
63  import org.daisy.pipeline.braille.common.TransformProvider;
64  import org.daisy.pipeline.braille.common.UnityBrailleTranslator;
65  import static org.daisy.pipeline.braille.common.util.Strings.extractHyphens;
66  import static org.daisy.pipeline.braille.common.util.Strings.insertHyphens;
67  import static org.daisy.pipeline.braille.common.util.Strings.join;
68  import static org.daisy.pipeline.braille.common.util.Strings.splitInclDelimiter;
69  import static org.daisy.pipeline.braille.common.util.Tuple2;
70  import org.daisy.pipeline.braille.css.CSSStyledText;
71  import org.daisy.pipeline.braille.css.TextStyleParser;
72  import org.daisy.pipeline.braille.liblouis.LiblouisTable;
73  import org.daisy.pipeline.braille.liblouis.LiblouisTranslator;
74  import org.daisy.pipeline.braille.liblouis.impl.LiblouisTableJnaImplProvider.LiblouisTableJnaImpl;
75  import org.daisy.pipeline.braille.liblouis.pef.LiblouisDisplayTableBrailleConverter;
76  
77  import org.liblouis.DisplayException;
78  import org.liblouis.DisplayTable;
79  import org.liblouis.TableInfo;
80  import org.liblouis.TranslationException;
81  import org.liblouis.TranslationResult;
82  import org.liblouis.Translator;
83  import org.liblouis.Typeform;
84  
85  import org.osgi.service.component.annotations.Component;
86  import org.osgi.service.component.annotations.Reference;
87  import org.osgi.service.component.annotations.ReferenceCardinality;
88  import org.osgi.service.component.annotations.ReferencePolicy;
89  
90  import static org.slf4j.helpers.NOPLogger.NOP_LOGGER;
91  import org.slf4j.Logger;
92  import org.slf4j.LoggerFactory;
93  
94  /**
95   * @see <a href="../../../../../../../../../doc/">User documentation</a>.
96   */
97  @Component(
98  	name = "org.daisy.pipeline.braille.liblouis.impl.LiblouisTranslatorJnaImplProvider",
99  	service = {
100 		LiblouisTranslator.Provider.class,
101 		BrailleTranslatorProvider.class,
102 		TransformProvider.class
103 	}
104 )
105 public class LiblouisTranslatorJnaImplProvider extends AbstractTransformProvider<LiblouisTranslator> implements LiblouisTranslator.Provider {
106 	
107 	private final static char SHY = '\u00AD';  // soft hyphen
108 	private final static char ZWSP = '\u200B'; // zero-width space
109 	private final static char NBSP = '\u00A0'; // no-break space
110 	private final static char LS = '\u2028';   // line separator
111 	private final static char RS = '\u001E';   // (for segmentation)
112 	private final static char US = '\u001F';   // (for segmentation)
113 	private final static Splitter SEGMENT_SPLITTER = Splitter.on(RS);
114 	private final static Pattern ON_NBSP_SPLITTER = Pattern.compile("[" + SHY + ZWSP + "]*" + NBSP + "[" + SHY + ZWSP + NBSP + "]*");
115 	private final static Pattern ON_SPACE_SPLITTER = Pattern.compile("[" + SHY + ZWSP + "]*[\\x20\t\\n\\r\\u2800" + NBSP + "][" + SHY + ZWSP + "\\x20\t\\n\\r\\u2800" + NBSP+ "]*");
116 	private final static Pattern LINE_SPLITTER = Pattern.compile("[" + SHY + ZWSP + "]*[\\n\\r][" + SHY + ZWSP + "\\n\\r]*");
117 	private final static Pattern WORD_SPLITTER = Pattern.compile("[\\x20\t\\n\\r\\u2800" + NBSP + "]+");
118 	
119 	private LiblouisTableJnaImplProvider tableProvider;
120 	
121 	@Reference(
122 		name = "LiblouisTableJnaImplProvider",
123 		unbind = "-",
124 		service = LiblouisTableJnaImplProvider.class,
125 		cardinality = ReferenceCardinality.MANDATORY,
126 		policy = ReferencePolicy.STATIC
127 	)
128 	protected void bindLiblouisTableJnaImplProvider(LiblouisTableJnaImplProvider provider) {
129 		tableProvider = provider;
130 		logger.debug("Registering Liblouis table provider: " + provider);
131 	}
132 	
133 	protected void unbindLiblouisTableJnaImplProvider(LiblouisTableJnaImplProvider provider) {
134 		tableProvider = null;
135 	}
136 
137 	private final static Iterable<LiblouisTranslator> empty
138 	= Iterables.<LiblouisTranslator>empty();
139 	
140 	private final static List<String> supportedInput = ImmutableList.of("text-css");
141 	
142 	@Override
143 	protected final Iterable<LiblouisTranslator> _get(Query query) {
144 		MutableQuery q = mutableQuery(query);
145 		if (q.containsKey("braille-code")) {
146 			// FIXME: for now not supported in combination with other features
147 			// FIXME: implement this in Liblouis table metadata
148 			String code = q.removeOnly("braille-code").getValue().get();
149 			if (!q.isEmpty())
150 				return empty;
151 			try {
152 				return get(BrailleCode.parse(code));
153 			} catch (IllegalArgumentException e) {
154 				return empty;
155 			}
156 		}
157 		for (Feature f : q.removeAll("input"))
158 			if (!supportedInput.contains(f.getValue().get()))
159 				return empty;
160 		if (q.containsKey("output")) {
161 			String v = q.removeOnly("output").getValue().get();
162 			if ("braille".equals(v)) {}
163 			else
164 				return empty; }
165 		if (q.containsKey("translator"))
166 			if (!"liblouis".equals(q.removeOnly("translator").getValue().get()))
167 				return empty;
168 		String table = null;
169 		if (q.containsKey("liblouis-table"))
170 			table = q.removeOnly("liblouis-table").getValue().get();
171 		if (q.containsKey("table"))
172 			if (table != null) {
173 				logger.warn("A query with both 'table' and 'liblouis-table' never matches anything");
174 				return empty; }
175 			else
176 				table = q.removeOnly("table").getValue().get();
177 		String v = null;
178 		if (q.containsKey("handle-non-standard-hyphenation"))
179 			v = q.removeOnly("handle-non-standard-hyphenation").getValue().get();
180 		else
181 			v = "ignore";
182 		final int handleNonStandardHyphenation = v.equalsIgnoreCase("fail") ?
183 			LiblouisTranslatorImpl.NON_STANDARD_HYPH_FAIL : v.equalsIgnoreCase("defer") ?
184 			LiblouisTranslatorImpl.NON_STANDARD_HYPH_DEFER :
185 			LiblouisTranslatorImpl.NON_STANDARD_HYPH_IGNORE;
186 		if (q.containsKey("include-braille-code-in-language"))
187 			v = q.removeOnly("include-braille-code-in-language").getValue().orElse("true");
188 		else
189 			v = "false";
190 		boolean includeBrailleCodeInLanguage = v.equalsIgnoreCase("true");
191 		if (table != null)
192 			q.add("table", table);
193 		q.add("white-space");
194 		Iterable<LiblouisTranslator> translators = memoize(
195 			getSimpleTranslator(
196 				q.asImmutable(),
197 				handleNonStandardHyphenation,
198 				includeBrailleCodeInLanguage));
199 		if (translators.apply(NOP_LOGGER).iterator().hasNext()) {
200 			// all translators use the same display table
201 			// FIXME: table has already been computed in the getSimpleTranslator() call above
202 			LiblouisTableJnaImpl t = tableProvider.withContext(NOP_LOGGER).get(q).iterator().next();
203 			BrailleTranslator unityTranslator = new UnityBrailleTranslator(
204 				t.usesCustomDisplayTable()
205 					? new LiblouisDisplayTableBrailleConverter(t.getDisplayTable())
206 					: null,
207 				false);
208 			return Iterables.transform(
209 				translators,
210 				new Function<LiblouisTranslator,LiblouisTranslator>() {
211 					public LiblouisTranslator _apply(LiblouisTranslator t) {
212 						return __apply(logCreate(new HandleTextTransformNone(t, unityTranslator))); }});
213 		} else
214 			return translators;
215 	}
216 
217 	private Iterable<LiblouisTranslator> getSimpleTranslator(Query query,
218 	                                                         int handleNonStandardHyphenation,
219 	                                                         boolean includeBrailleCodeInLanguage) {
220 		return transform(
221 			logSelect(query, tableProvider),
222 			new Function<LiblouisTableJnaImpl,LiblouisTranslator>() {
223 				public LiblouisTranslator _apply(LiblouisTableJnaImpl table) {
224 					return __apply(
225 						logCreate((LiblouisTranslator)new LiblouisTranslatorImpl(
226 									table,
227 									null,
228 									handleNonStandardHyphenation,
229 									includeBrailleCodeInLanguage)));
230 				}
231 			}
232 		);
233 	}
234 	
235 	// HACK!
236 	// For now, because the BrailleCode feature is only used in the context of dtbook-to-ebraille to
237 	// pass braille code information from LiblouisTranslatorImpl through language tags, we don't
238 	// need to parse the codes, but we can rather cache them when they are returned from
239 	// LiblouisTranslatorImpl.getBrailleCode().
240 	private final Map<BrailleCode,String> brailleCodeCache = new HashMap<>();
241 	
242 	private Iterable<LiblouisTranslator> get(BrailleCode code) {
243 		String id = brailleCodeCache.get(code);
244 		if (id == null)
245 			throw new IllegalStateException();
246 		return Iterables.of(fromId(id));
247 	}
248 	
249 	@Override
250 	public ToStringHelper toStringHelper() {
251 		return MoreObjects.toStringHelper("LiblouisTranslatorJnaImplProvider");
252 	}
253 
254 	private final static TextStyleParser cssParser = TextStyleParser.getInstance();
255 	private final static PropertyValue TEXT_TRANSFORM_NONE
256 		= cssParser.parse("text-transform: none").get("text-transform");
257 	private final static PropertyValue BRAILLE_CHARSET_CUSTOM
258 		= cssParser.parse("braille-charset: custom").get("braille-charset");
259 
260 	class LiblouisTranslatorImpl extends AbstractBrailleTranslator implements LiblouisTranslator {
261 		
262 		private final LiblouisTableJnaImpl table;
263 		protected final Translator translator;
264 		private final DisplayTable displayTable;
265 		private Hyphenator hyphenator;
266 		protected FullHyphenator fullHyphenator;
267 		private Hyphenator.LineBreaker lineBreaker;
268 		private final Map<String,Typeform> supportedTypeforms;
269 		private Map<String,String> infoMap = null; // computed lazily
270 		private BrailleCode brailleCode = null; // computed lazily
271 		
272 		// how to handle non-standard hyphenation in pre-translation mode
273 
274 		// FIXME: DEFER should not be supported in FromStyledTextToBraille because that interface is
275 		// supposed to return only braille. However this is currently the only way to do
276 		// it. Components that use this translator should be aware of this when they request the
277 		// (handle-non-standard-hyphenation:defer) feature. They can assume that if the result
278 		// contains non-braille characters, the result is an exact copy of the input with no styles
279 		// applied.
280 		private final int handleNonStandardHyphenation;
281 		private final boolean includeBrailleCodeInLanguage;
282 		private final Normalizer.Form unicodeNormalization;
283 		
284 		public final static int NON_STANDARD_HYPH_IGNORE = 0;
285 		public final static int NON_STANDARD_HYPH_FAIL = 1;
286 		public final static int NON_STANDARD_HYPH_DEFER = 2;
287 		
288 		/**
289 		 * The following assumptions are made for <code>displayTable</code>:
290 		 * <ul>
291 		 *   <li> the NBSP character, if present in the display table, represents a preserved space, and no other
292 		 *        characters do </li>
293 		 *   <li> the LS character, if present in the display table, represents a preserved line break, and no other
294 		 *        characters do </li>
295 		 *   <li> the SPACE, TAB, CR, LF and BLANK BRAILLE PATTERN characters, if present in the display table, all
296 		 *        represent white space, and no other characters do </li>
297 		 * </ul>
298 		 */
299 		LiblouisTranslatorImpl(LiblouisTableJnaImpl table,
300 		                       Hyphenator hyphenator,
301 		                       int handleNonStandardHyphenation,
302 		                       boolean includeBrailleCodeInLanguage) {
303 			super(hyphenator, null);
304 			this.table = table;
305 			this.translator = table.getTranslator();
306 			this.displayTable = table.getDisplayTable();
307 			this.handleNonStandardHyphenation = handleNonStandardHyphenation;
308 			this.includeBrailleCodeInLanguage = includeBrailleCodeInLanguage;
309 			this.supportedTypeforms
310 				= translator.getSupportedTypeforms().stream().collect(Collectors.toMap(Typeform::getName, e -> e));
311 			this.unicodeNormalization = table.getUnicodeNormalizationForm();
312 			this.hyphenator = hyphenator;
313 			if (hyphenator == null)
314 				fullHyphenator = compoundWordHyphenator;
315 			else {
316 				try {
317 					fullHyphenator = new HyphenatorAsFullHyphenator(hyphenator); }
318 				catch (UnsupportedOperationException e) {}
319 				try {
320 					lineBreaker = hyphenator.asLineBreaker(); }
321 				catch (UnsupportedOperationException e) {}}
322 		}
323 		
324 		private LiblouisTranslatorImpl(LiblouisTranslatorImpl from, Hyphenator hyphenator) {
325 			super(from);
326 			this.table = from.table;
327 			this.translator = from.translator;
328 			this.displayTable = from.displayTable;
329 			this.handleNonStandardHyphenation = from.handleNonStandardHyphenation;
330 			this.includeBrailleCodeInLanguage = from.includeBrailleCodeInLanguage;
331 			this.supportedTypeforms = from.supportedTypeforms;
332 			this.unicodeNormalization = from.unicodeNormalization;
333 			this.hyphenator = hyphenator;
334 			if (hyphenator == null)
335 				fullHyphenator = compoundWordHyphenator;
336 			else {
337 				try {
338 					fullHyphenator = new HyphenatorAsFullHyphenator(hyphenator); }
339 				catch (UnsupportedOperationException e) {}
340 				try {
341 					lineBreaker = hyphenator.asLineBreaker(); }
342 				catch (UnsupportedOperationException e) {}}
343 		}
344 		
345 		// FIXME: not if (input:text-css)
346 		@Override
347 		public LiblouisTable asLiblouisTable() {
348 			return table;
349 		}
350 		
351 		private BrailleCode getBrailleCode() {
352 			if (brailleCode == null) {
353 				// For now, there are no registered braille codes yet, so we can use whatever we
354 				// want for the "system" part of the braille code identifier. For ease of
355 				// implementation, we start from the index-name metadata field, and drop any grade
356 				// and specialization info from it.
357 				Map<String,String> info = getInfo();
358 				String indexName = info.get("index-name");
359 				if (indexName == null)
360 					throw new IllegalStateException();
361 				// some special cases (for Liblouis v3.33)
362 				if ("International Phonetic Alphabet".equals(indexName))
363 					brailleCode = new BrailleCode("IPA", BrailleCode.Grade.NO_GRADE, BrailleCode.Specialization.PHONETIC);
364 				else if ("Russian, for program sources".equals(indexName))
365 					// metadata should be fixed, see https://github.com/liblouis/liblouis/pull/1746
366 					brailleCode = new BrailleCode("Russian", BrailleCode.Grade.GRADE0, BrailleCode.Specialization.COMP6);
367 				else {
368 					if (indexName.startsWith("English, unified"))
369 						indexName = indexName.replace("English, unified", "UEB");
370 					else if ("Serbian, Cyrillic".equals(indexName))
371 						// "Cyrillic" is only about the table, not about the braille code
372 						indexName = "Serbian";
373 					else if ("Mandarin, Taiwan".equals(indexName))
374 						indexName = "Taiwanese";
375 					else if (indexName.startsWith("Mandarin, mainland China"))
376 						indexName = indexName.replace("Mandarin, mainland China", "Chinese");
377 					BrailleCode.Grade grade = BrailleCode.Grade.NO_GRADE; {
378 						String g = info.get("grade");
379 						if (g != null)
380 							try {
381 								grade = BrailleCode.Grade.valueOf("GRADE" + g);
382 							} catch (IllegalArgumentException e) {
383 								throw new IllegalStateException(
384 									"grade not supported by eBraille braille code identifier: " + g, e);
385 							}
386 					}
387 					BrailleCode.Specialization specialization = null; {
388 						String t = info.get("type");
389 						if (t == null);
390 						else if ("computer".equals(t)) {
391 							String d = info.get("dots");
392 							if ("8".equals(d))
393 								specialization = BrailleCode.Specialization.COMP8;
394 							else if ("6".equals(d))
395 								specialization = BrailleCode.Specialization.COMP6;
396 							else
397 								throw new IllegalStateException();
398 						}
399 					}
400 					List<String> system = new ArrayList<>();
401 					for (String part : indexName.split(", ")) {
402 						if (grade != BrailleCode.Grade.NO_GRADE
403 						    && ("uncontracted".equals(part) || "contracted".equals(part) || "partially contracted".equals(part)
404 						        || part.startsWith("grade ")))
405 							continue;
406 						if ((specialization == BrailleCode.Specialization.COMP6
407 						    || specialization == BrailleCode.Specialization.COMP8)
408 						    && "computer".equals(part))
409 							continue;
410 						if (specialization == BrailleCode.Specialization.COMP6 && "6-dot".equals(part))
411 							continue;
412 						if (specialization == BrailleCode.Specialization.COMP8 && "8-dot".equals(part))
413 							continue;
414 						system.add(part.replace(" ", "-"));
415 					}
416 					brailleCode = new BrailleCode(join(system, "-"), grade, specialization);
417 				}
418 				LiblouisTranslatorJnaImplProvider.this.rememberId(this); // otherwise only HandleTextTransformNone
419 				                                                         // wrapper may be remembered
420 				brailleCodeCache.put(brailleCode, getIdentifier());
421 			}
422 			return brailleCode;
423 		}
424 		
425 		@Override
426 		public Map<String,String> getInfo() {
427 			if (infoMap == null) {
428 				TableInfo info = table.getInfo();
429 				ImmutableMap.Builder<String,String> m = new ImmutableMap.Builder<>();
430 				if (info != null) {
431 					String type = info.get("type");
432 					if (type == null)
433 						type = "literary";
434 					m.put("type", type);
435 					String dots = info.get("dots");
436 					if (dots == null)
437 						dots = type.equals("computer") ? "8" : "6";
438 					m.put("dots", dots);
439 					for (String k : new String[]{"display-name",
440 					                             "index-name",
441 					                             "contraction",
442 					                             "grade"}) {
443 						String v = info.get(k);
444 						if (v != null)
445 							m.put(k, v);
446 					}
447 				}
448 				infoMap = m.build();
449 			}
450 			return infoMap;
451 		}
452 		
453 		@Override
454 		public LiblouisTranslatorImpl _withHyphenator(Hyphenator hyphenator) {
455 			if (hyphenator == this.hyphenator)
456 				return this;
457 			LiblouisTranslatorImpl t = new LiblouisTranslatorImpl(this, hyphenator);
458 			LiblouisTranslatorJnaImplProvider.this.rememberId(t);
459 			return t;
460 		}
461 		
462 		private FromTypeformedTextToBraille fromTypeformedTextToBraille;
463 
464 		@Override
465 		public FromTypeformedTextToBraille fromTypeformedTextToBraille() {
466 			if (fromTypeformedTextToBraille == null)
467 				fromTypeformedTextToBraille = new FromTypeformedTextToBraille() {
468 					public String[] transform(String[] text, String[] emphClasses) {
469 						Typeform[] typeform = new Typeform[emphClasses.length];
470 						for (int i = 0; i < typeform.length; i++) {
471 							typeform[i] = supportedTypeforms.get(emphClasses[i]);
472 							if (typeform[i] == null)
473 								logger.warn("emphclass 'italic' not defined in table {}", translator.getTable());
474 						}
475 						return LiblouisTranslatorImpl.this.transform(text, typeform);
476 					}
477 					@Override
478 					public String toString() {
479 						return LiblouisTranslatorImpl.this.toString();
480 					}
481 				};
482 			return fromTypeformedTextToBraille;
483 		}
484 		
485 		private FromStyledTextToBraille fromStyledTextToBraille;
486 		
487 		/**
488 		 * @throw TransformationException if underlying hyphenator throws {@link
489 		 *                                NonStandardHyphenationException}
490 		 */
491 		@Override
492 		public FromStyledTextToBraille fromStyledTextToBraille() {
493 			if (fromStyledTextToBraille == null)
494 				fromStyledTextToBraille = new FromStyledTextToBraille() {
495 					public java.lang.Iterable<CSSStyledText> transform(java.lang.Iterable<CSSStyledText> styledText, int from, int to)
496 							throws TransformationException {
497 						try {
498 							List<CSSStyledText> result = LiblouisTranslatorImpl.this.transform(styledText, false, false);
499 							if (to < 0) to = result.size();
500 							if (from > 0 || to < result.size())
501 								return result.subList(from, to);
502 							else
503 								return result;
504 						} catch (NonStandardHyphenationException e) {
505 							throw new TransformationException(e);
506 						}
507 					}
508 					@Override
509 					public String toString() {
510 						return LiblouisTranslatorImpl.this.toString();
511 					}
512 				};
513 			return fromStyledTextToBraille;
514 		}
515 		
516 		private LineBreakingFromStyledText lineBreakingFromStyledText;
517 		
518 		@Override
519 		public LineBreakingFromStyledText lineBreakingFromStyledText() {
520 			if (lineBreakingFromStyledText == null)
521 				lineBreakingFromStyledText = new LineBreaker(
522 					new FromStyledTextToBraille() {
523 						public java.lang.Iterable<CSSStyledText> transform(java.lang.Iterable<CSSStyledText> styledText, int from, int to) {
524 							List<CSSStyledText> result = LiblouisTranslatorImpl.this.transform(styledText, true, true);
525 							if (to < 0) to = result.size();
526 							if (from > 0 || to < result.size())
527 								return result.subList(from, to);
528 							else
529 								return result;
530 						}
531 					}) {
532 						@Override
533 						public String toString() {
534 							return LiblouisTranslatorImpl.this.toString();
535 						}
536 					};
537 			return lineBreakingFromStyledText;
538 		}
539 		
540 		class LineBreaker extends DefaultLineBreaker {
541 			
542 			final FromStyledTextToBraille fullTranslator;
543 			
544 			protected LineBreaker(FromStyledTextToBraille fullTranslator) {
545 				// Note that `displayTable.encode('\u2800')` is always a space because of the addition of spaces.dis
546 				super(displayTable.encode('\u2800'),
547 				      displayTable.encode('\u2824'),
548 				      new LiblouisDisplayTableBrailleConverter(displayTable),
549 				      logger);
550 				this.fullTranslator = fullTranslator;
551 			}
552 			
553 			protected BrailleStream translateAndHyphenate(java.lang.Iterable<CSSStyledText> styledText, int from, int to) {
554 				java.lang.Iterable<CSSStyledText> braille;
555 				try {
556 					braille = fullTranslator.transform(styledText); }
557 				catch (NonStandardHyphenationException e) {
558 					return new BrailleStreamImpl(styledText,
559 					                             from,
560 					                             to); }
561 				// style is mutated and may not be empty
562 				List<String> brailleWithPreservedWS = new ArrayList<>(); {
563 					for (CSSStyledText b : braille) {
564 						String t = b.getText();
565 						// the only property expected in the output is white-space
566 						// ignore other properties
567 						SimpleInlineStyle s = b.getStyle();
568 						if (s != null) {
569 							Hyphens h = s.getProperty("hyphens");
570 							if (h == Hyphens.NONE || h == Hyphens.MANUAL) {
571 								if (h == Hyphens.NONE)
572 									t = t.replaceAll("[\u00AD\u200B]","");
573 								s.removeProperty("hyphens"); }
574 							WhiteSpace ws = s.getProperty("white-space");
575 							if (ws != null) {
576 								if (ws == WhiteSpace.PRE_WRAP)
577 									t = t.replaceAll("[\\x20\t\\u2800]+", "$0"+ZWSP)
578 									     .replaceAll("[\\x20\t\\u2800]", ""+NBSP);
579 								if (ws == WhiteSpace.PRE_WRAP || ws == WhiteSpace.PRE_LINE)
580 									t = t.replaceAll("[\\n\\r]", ""+LS);
581 								s.removeProperty("white-space"); }
582 							TextTransform tt = s.getProperty("text-transform");
583 							if (tt == TextTransform.NONE)
584 								s.removeProperty("text-transform");
585 							s.removeProperty("braille-charset");
586 							for (String prop : s.getPropertyNames())
587 								logger.warn("{} not supported", s.get(prop));
588 						}
589 						brailleWithPreservedWS.add(t);
590 					}
591 				}
592 				StringBuilder joined = new StringBuilder();
593 				int fromChar = 0;
594 				int toChar = to >= 0 ? 0 : -1;
595 				for (String s : brailleWithPreservedWS) {
596 					joined.append(s);
597 					if (--from == 0)
598 						fromChar = joined.length();
599 					if (--to == 0)
600 						toChar = joined.length();
601 				}
602 				return new FullyHyphenatedAndTranslatedString(joined.toString(), fromChar, toChar);
603 			}
604 			
605 			class BrailleStreamImpl implements BrailleStream {
606 				
607 				final Locale[] languages;
608 				
609 				// FIXME: remove duplication!!
610 				
611 				// convert style into typeform, hyphenate, preserveLines, preserveSpace and letterSpacing arrays
612 				final Typeform[] typeform;
613 				final boolean[] hyphenate;
614 				final boolean[] preserveLines;
615 				final boolean[] preserveSpace;
616 				final int[] letterSpacing;
617 				
618 				// text with some segments split up into white space segments that need to be preserved
619 				// in the output and other segments
620 				final String[] textWithWs;
621 				
622 				// boolean array for tracking which (non-empty white space) segments in textWithWs need
623 				// to be preserved
624 				final boolean[] pre;
625 				
626 				// mapping from index in textWithWs to index in text
627 				final int[] textWithWsMapping;
628 				
629 				// textWithWs segments joined together with hyphens removed and sequences of preserved
630 				// white space replaced with a nbsp
631 				String joinedText;
632 				
633 				// mapping from character (codepoint) index in joinedText to segment index in textWithWs
634 				int[] joinedTextMapping;
635 				
636 				// byte array for tracking hyphenation positions
637 				byte[] manualHyphens;
638 				
639 				// translation result without hyphens and with preserved white space not restored
640 				String joinedBraille;
641 				
642 				// mapping from character (codepoint) index in joinedBraille to character (codepoint) index in joinedText
643 				int[] characterIndicesInBraille;
644 				
645 				// mapping from inter-character (codepoint) index in joinedBraille to inter-character (codepoint) index in joinedText
646 				// 1-based because a inter-character attribute value of "0" in the output of Liblouis means "no attribute"
647 				int[] interCharacterIndicesInBraille;
648 				
649 				// current position (codepoint index) in input and output (joinedText and joinedBraille)
650 				int curPos = -1;
651 				int curPosInBraille = -1;
652 				int endPos = -1;
653 				int endPosInBraille = -1;
654 				final int to;
655 				
656 				BrailleStreamImpl(java.lang.Iterable<CSSStyledText> styledText,
657 				                  int from,
658 				                  int to) {
659 					
660 					// convert Iterable<CSSStyledText> into an text array and a style array
661 					int size = size(styledText);
662 					if (to < 0) to = size;
663 					this.to = to;
664 					
665 					// FIXME: handle from and to properly
666 					String[] text = new String[size];
667 					SimpleInlineStyle[] styles = new SimpleInlineStyle[size];
668 					languages = new Locale[size]; {
669 						int i = 0;
670 						for (CSSStyledText t : styledText) {
671 							text[i] = t.getText();
672 							styles[i] = t.getStyle();
673 							if (styles[i] != null)
674 								styles[i] = (SimpleInlineStyle)styles[i].clone();
675 							languages[i] = t.getLanguage();
676 							i++; }}
677 					
678 					// perform Unicode normalization
679 					if (unicodeNormalization != null)
680 						for (int k = 0; k < text.length; k++)
681 							text[k] = Normalizer.normalize(text[k], unicodeNormalization);
682 					
683 					{ // compute typeform, hyphenate, preserveLines, preserveSpace and letterSpacing
684 						typeform = new Typeform[size];
685 						hyphenate = new boolean[size];
686 						preserveLines = new boolean[size];
687 						preserveSpace = new boolean[size];
688 						letterSpacing = new int[size];
689 						for (int i = 0; i < size; i++) {
690 							typeform[i] = Typeform.PLAIN_TEXT;
691 							hyphenate[i] = false;
692 							preserveLines[i] = preserveSpace[i] = false;
693 							letterSpacing[i] = 0;
694 							SimpleInlineStyle style = styles[i];
695 							if (style != null) {
696 								CSSProperty val = style.getProperty("white-space");
697 								if (val != null) {
698 									if (val == WhiteSpace.PRE_WRAP)
699 										preserveLines[i] = preserveSpace[i] = true;
700 									else if (val == WhiteSpace.PRE_LINE)
701 										preserveLines[i] = true;
702 									style.removeProperty("white-space"); }
703 								val = style.getProperty("text-transform");
704 								if (val != null) {
705 									if (val == TextTransform.NONE) {
706 										// "text-transform: none" is handled by HandleTextTransformNone, but
707 										// HandleTextTransformNone is a CompoundBrailleTranslator and
708 										// CompoundBrailleTranslator puts "text-transform: none" on (already translated)
709 										// context segments. We assume that all Liblouis tables correctly handle Unicode
710 										// braille. If this is not the case, it is not the end of the words because this
711 										// is a context segment.
712 										val = style.getProperty("braille-charset");
713 										if (val != null) {
714 											if (val == BrailleCharset.CUSTOM)
715 												// translate to Unicode braille
716 												text[i] = displayTable.decode(text[i]);
717 											style.removeProperty("braille-charset"); }
718 										style.removeProperty("text-transform");
719 										continue; }
720 									else if (val == TextTransform.AUTO) {}
721 									else if (val == TextTransform.list_values) {
722 										TermList values = style.getValue(TermList.class, "text-transform");
723 										text[i] = textFromTextTransform(text[i], values);
724 										typeform[i] = typeform[i].add(typeformFromTextTransform(values, translator, supportedTypeforms)); }
725 									style.removeProperty("text-transform"); }
726 								val = style.getProperty("hyphens");
727 								if (val != null) {
728 									if (val == Hyphens.AUTO)
729 										hyphenate[i] = true;
730 									else if (val == Hyphens.NONE)
731 										text[i] = extractHyphens(text[i], false, SHY, ZWSP)._1;
732 									style.removeProperty("hyphens"); }
733 								val = style.getProperty("letter-spacing");
734 								if (val != null) {
735 									if (val == LetterSpacing.length) {
736 										letterSpacing[i] = style.getValue(TermInteger.class, "letter-spacing").getIntValue();
737 										if (letterSpacing[i] < 0) {
738 											logger.warn("{} not supported, must be non-negative", val);
739 											letterSpacing[i] = 0; }}
740 									style.removeProperty("letter-spacing"); }
741 								typeform[i] = typeform[i].add(typeformFromInlineCSS(style, translator, supportedTypeforms));
742 								for (String prop : style.getPropertyNames())
743 									logger.warn("{} not supported", style.get(prop)); }}
744 					}
745 					{ // compute preserved white space segments (textWithWs, textWithWsMapping, pre)
746 						List<String> l1 = new ArrayList<String>();
747 						List<Boolean> l2 = new ArrayList<Boolean>();
748 						List<Integer> l3 = new ArrayList<Integer>();
749 						for (int i = 0; i < text.length; i++) {
750 							String t = text[i];
751 							if (t.isEmpty()) {
752 								l1.add(t);
753 								l2.add(false);
754 								l3.add(i); }
755 							else {
756 								Pattern ws;
757 								if (preserveSpace[i])
758 									ws = ON_SPACE_SPLITTER;
759 								else if (preserveLines[i])
760 									ws = LINE_SPLITTER;
761 								else
762 									ws = ON_NBSP_SPLITTER;
763 								boolean p = false;
764 								for (String s : splitInclDelimiter(t, ws)) {
765 									if (!s.isEmpty()) {
766 										l1.add(s);
767 										l2.add(p);
768 										l3.add(i); }
769 									p = !p; }}}
770 						int len = l1.size();
771 						textWithWs = new String[len];
772 						pre = new boolean[len];
773 						textWithWsMapping = new int[len];
774 						for (int i = 0; i < len; i++) {
775 							textWithWs[i] = l1.get(i);
776 							pre[i] = l2.get(i);
777 							textWithWsMapping[i] = l3.get(i); }
778 					}
779 					{ // compute joined text and manual hyphens array (joinedText, joinedTextMapping, manualHyphens)
780 						String[] textWithWsReplaced = new String[textWithWs.length];
781 						for (int i = 0; i < textWithWs.length; i++)
782 							textWithWsReplaced[i] = pre[i] ? ""+NBSP : textWithWs[i];
783 						Tuple2<String,byte[]> t = extractHyphens(join(textWithWsReplaced, RS), true, SHY, ZWSP);
784 						manualHyphens = t._2;
785 						String[] nohyph = toArray(SEGMENT_SPLITTER.split(t._1), String.class);
786 						joinedTextMapping = new int[lengthByCodePoints(join(nohyph))];
787 						int i = 0;
788 						int j = 0;
789 						for (String s : nohyph) {
790 							int l = lengthByCodePoints(s);
791 							for (int k = 0; k < l; k++)
792 								joinedTextMapping[i++] = j;
793 							j++; }
794 						t = extractHyphens(manualHyphens, t._1, true, null, null, null, RS);
795 						joinedText = t._1;
796 					}
797 					{ // compute initial curPos and endPos from from and to
798 						int fromChar = -1;
799 						int toChar = -1;
800 						for (int i = 0; i < joinedTextMapping.length; i++) {
801 							if (fromChar < 0 || (toChar < 0 && to >= 0)) {
802 								int indexInText = textWithWsMapping[joinedTextMapping[i]];
803 								if (fromChar < 0 && indexInText >= from)
804 									fromChar = i;
805 								if (toChar < 0 && indexInText >= to)
806 									toChar = i;
807 							} else
808 								break;
809 						}
810 						if (toChar < 0) toChar = joinedTextMapping.length;
811 						this.curPos = fromChar;
812 						this.endPos = toChar;
813 					}
814 				}
815 				
816 				public String next(final int limit, final boolean force, boolean allowHyphens) {
817 					String next = "";
818 					if (limit > 0) {
819 					int available = limit;
820 				  segments: while (true) {
821 						if (curPos == endPos)
822 							break;
823 						if (joinedBraille == null)
824 							updateBraille();
825 						int curSegment = joinedTextMapping[curPos];
826 						int curSegmentEnd; {
827 							int i = curPos;
828 							for (; i < endPos; i++)
829 								if (joinedTextMapping[i] > curSegment)
830 									break;
831 							curSegmentEnd = i; }
832 						int curSegmentEndInBraille = positionInBraille(curSegmentEnd);
833 						if (curSegmentEndInBraille == curPosInBraille)
834 							continue segments;
835 						String segment = substringByCodePoints(joinedText, curPos, curSegmentEnd);
836 						String segmentInBraille = joinedBraille.substring(curPosInBraille, curSegmentEndInBraille);
837 						byte[] segmentManualHyphens = manualHyphens != null
838 							? Arrays.copyOfRange(manualHyphens, curPos, curSegmentEnd - 1)
839 							: null;
840 						
841 						// restore preserved white space segments
842 						if (pre[curSegment]) {
843 							Matcher m = Pattern.compile("\\xA0([\\xAD\\u200B]*)").matcher(segmentInBraille);
844 							if (m.matches()) {
845 								String restoredSpace = segment.replaceAll("[\\x20\t\\u2800]", ""+NBSP)
846 								                              .replaceAll("[\\n\\r]", ""+LS) + m.group(1);
847 								next += restoredSpace;
848 								available -= lengthByCodePoints(restoredSpace);
849 								curPos = curSegmentEnd;
850 								curPosInBraille = curSegmentEndInBraille;
851 								continue segments; }}
852 						
853 						// don't hyphenate if the segment fits in the available space
854 						// FIXME: we don't know for sure that the segment fits because there might be letter spacing!
855 						if (segmentInBraille.length() <= available) {
856 							segmentInBraille = addLetterSpacing(segment, segmentInBraille, curPos, curPosInBraille,
857 							                                    letterSpacing[textWithWsMapping[curSegment]]);
858 							next += segmentInBraille;
859 							available -= segmentInBraille.length();
860 							curPos = curSegmentEnd;
861 							curPosInBraille = curSegmentEndInBraille;
862 							continue segments; }
863 						
864 						// don't hyphenate if hyphenation is disabled for this segment, but do break compound words with a hyphen
865 						Locale language = languages[textWithWsMapping[curSegment]];
866 						if (!hyphenate[textWithWsMapping[curSegment]]) {
867 							segmentInBraille = addHyphensAndLetterSpacing(compoundWordHyphenator,
868 							                                              segment, segmentInBraille, curPos, curPosInBraille,
869 							                                              segmentManualHyphens, language,
870 							                                              letterSpacing[textWithWsMapping[curSegment]]);
871 							next += segmentInBraille;
872 							available -= segmentInBraille.length();
873 							curPos = curSegmentEnd;
874 							curPosInBraille = curSegmentEndInBraille;
875 							continue segments; }
876 						
877 						// try standard hyphenation of the whole segment
878 						if (fullHyphenator != null) {
879 							if (fullHyphenator == compoundWordHyphenator)
880 								logger.warn("hyphens: auto not supported");
881 							try {
882 								segmentInBraille = addHyphensAndLetterSpacing(fullHyphenator, segment, segmentInBraille, curPos, curPosInBraille,
883 								                                              segmentManualHyphens, language,
884 								                                              letterSpacing[textWithWsMapping[curSegment]]);
885 								next += segmentInBraille;
886 								available -= segmentInBraille.length();
887 								curPos = curSegmentEnd;
888 								curPosInBraille = curSegmentEndInBraille;
889 								continue segments; }
890 							catch (NonStandardHyphenationException e) {}}
891 						
892 						// loop over words in segment
893 						Matcher m = WORD_SPLITTER.matcher(segment);
894 						int segmentStart = curPos;
895 						boolean foundSpace;
896 						while ((foundSpace = m.find()) || curPos < curSegmentEnd) {
897 							int wordEnd = foundSpace ? segmentStart + m.start() : curSegmentEnd;
898 							if (wordEnd > curPos) {
899 								int wordEndInBraille = positionInBraille(wordEnd);
900 								if (wordEndInBraille > curPosInBraille) {
901 									String word = substringByCodePoints(joinedText, curPos, wordEnd);
902 									String wordInBraille = joinedBraille.substring(curPosInBraille, wordEndInBraille);
903 									byte[] wordManualHyphens = manualHyphens != null
904 										? Arrays.copyOfRange(manualHyphens, curPos, wordEnd - 1)
905 										: null;
906 									
907 									// don't hyphenate if word fits in the available space
908 									if (wordInBraille.length() <= available) {
909 										next += wordInBraille;
910 										available -= wordInBraille.length();
911 										curPos = wordEnd;
912 										curPosInBraille = wordEndInBraille; }
913 									else {
914 										
915 										// try standard hyphenation of the whole word
916 										try {
917 											if (fullHyphenator == null) throw new NonStandardHyphenationException();
918 											wordInBraille = addHyphensAndLetterSpacing(fullHyphenator, word, wordInBraille, curPos, curPosInBraille,
919 											                                           wordManualHyphens, language,
920 											                                           letterSpacing[textWithWsMapping[curSegment]]);
921 											next += wordInBraille;
922 											available -= wordInBraille.length();
923 											curPos = wordEnd;
924 											curPosInBraille = wordEndInBraille; }
925 										catch (NonStandardHyphenationException ee) {
926 											
927 											// before we try non-standard hyphenation, return what we have already
928 											// we do this because we are not sure that the value of "available" is accurate
929 											// this way we leave the responsibility of white space normalisation to DefaultLineBreaker
930 											// FIXME: remove the "available" variable
931 											if (!next.isEmpty())
932 												break segments;
933 											
934 											// try non-standard hyphenation
935 											if (lineBreaker == null) throw ee;
936 											Hyphenator.LineIterator lines = lineBreaker.transform(word, language);
937 											
938 											// do a binary search for the optimal break point
939 											LineBreakSolution bestSolution = null;
940 											int left = 1;
941 											int right = lengthByCodePoints(word) - 1;
942 											int textAvailable = available;
943 											if (textAvailable > right)
944 												textAvailable = right;
945 											if (textAvailable < left)
946 												break segments;
947 											while (true) {
948 												String line = lines.nextLine(textAvailable, force && next.isEmpty(), allowHyphens);
949 												String replacementWord = line + lines.remainder();
950 												if (updateInput(curPos, wordEnd, replacementWord)) {
951 													wordEnd = curPos + lengthByCodePoints(replacementWord);
952 													updateBraille(); }
953 												int lineEnd = curPos + lengthByCodePoints(line);
954 												int lineEndInBraille = positionInBraille(lineEnd);
955 												String lineInBraille = joinedBraille.substring(curPosInBraille, lineEndInBraille);
956 												lineInBraille = addLetterSpacing(line, lineInBraille, curPos, curPosInBraille,
957 												                                 letterSpacing[textWithWsMapping[curSegment]]);
958 												int lineInBrailleLength = lineInBraille.length();
959 												if (lines.lineHasHyphen()) {
960 													lineInBraille += "\u00ad";
961 													lineInBrailleLength++; }
962 												if (lineInBrailleLength == available) {
963 													bestSolution = new LineBreakSolution(); {
964 														bestSolution.line = line;
965 														bestSolution.replacementWord = replacementWord;
966 														bestSolution.lineInBraille = lineInBraille;
967 														bestSolution.lineInBrailleLength = lineInBrailleLength; }
968 													left = textAvailable + 1;
969 													right = textAvailable - 1; }
970 												else if (lineInBrailleLength < available) {
971 													left = textAvailable + 1;
972 													if (bestSolution == null || lineInBrailleLength > bestSolution.lineInBrailleLength) {
973 														bestSolution = new LineBreakSolution(); {
974 															bestSolution.line = line;
975 															bestSolution.replacementWord = replacementWord;
976 															bestSolution.lineInBraille = lineInBraille;
977 															bestSolution.lineInBrailleLength = lineInBrailleLength; }}}
978 												else
979 													right = textAvailable - 1;
980 												lines.reset();
981 												textAvailable = (right + left) / 2;
982 												if (textAvailable < left || textAvailable > right) {
983 													if (bestSolution != null) {
984 														next += bestSolution.lineInBraille;
985 														available = 0;
986 														if (updateInput(curPos, wordEnd, bestSolution.replacementWord))
987 															updateBraille();
988 														curPos += lengthByCodePoints(bestSolution.line);
989 														curPosInBraille = positionInBraille(curPos); }
990 													else if (force && next.isEmpty()) {
991 														next = wordInBraille;
992 														available = 0;
993 														curPos = wordEnd;
994 														curPosInBraille = wordEndInBraille; }
995 													break segments; } }}}}}
996 							if (foundSpace) {
997 								int spaceEnd = segmentStart + m.end();
998 								int spaceEndInBraille = positionInBraille(spaceEnd);
999 								if (spaceEndInBraille > curPosInBraille) {
1000 									String spaceInBraille = joinedBraille.substring(curPosInBraille, spaceEndInBraille);
1001 									next += spaceInBraille;
1002 									available -= spaceInBraille.length();
1003 									curPos = spaceEnd;
1004 									curPosInBraille = spaceEndInBraille; }}}}
1005 					}
1006 					if (lastPeek != null && !next.isEmpty() && next.charAt(0) != lastPeek)
1007 						throw new IllegalStateException();
1008 					lastPeek = null;
1009 					return next;
1010 				}
1011 				
1012 				public boolean hasNext() {
1013 					if (joinedBraille == null)
1014 						updateBraille();
1015 					boolean hasNextOutput = curPosInBraille < endPosInBraille;
1016 					boolean hasNextInput = curPos < endPos;
1017 					if (hasNextInput != hasNextOutput)
1018 						throw new RuntimeException("coding error");
1019 					return hasNextOutput;
1020 				}
1021 				
1022 				Character lastPeek = null;
1023 				
1024 				public Character peek() {
1025 					if (joinedBraille == null)
1026 						updateBraille();
1027 					lastPeek = joinedBraille.charAt(curPosInBraille);
1028 					return lastPeek;
1029 				}
1030 				
1031 				// FIXME: does not take into account white-space property of segments: if "white-space: pre",
1032 				// spaces are not replaced with NBSP yet at this point (this only happens in next() method)
1033 				public String remainder() {
1034 					if (joinedBraille == null)
1035 						updateBraille();
1036 					return joinedBraille.substring(curPosInBraille, endPosInBraille);
1037 				}
1038 				
1039 				// FIXME: does not take into account white-space property of segments: if "white-space: pre",
1040 				// spaces are not replaced with NBSP yet at this point (this only happens in next() method)
1041 				public boolean hasPrecedingSpace() {
1042 					if (joinedBraille == null)
1043 						updateBraille();
1044 					return DefaultLineBreaker.hasPrecedingSpace(joinedBraille, curPosInBraille);
1045 				}
1046 				
1047 				@Override
1048 				public Object clone() {
1049 					try {
1050 						BrailleStreamImpl clone = (BrailleStreamImpl)super.clone();
1051 						if (joinedTextMapping != null)
1052 							clone.joinedTextMapping = joinedTextMapping.clone();
1053 						if (manualHyphens != null)
1054 							clone.manualHyphens = manualHyphens.clone();
1055 						if (characterIndicesInBraille != null)
1056 							clone.characterIndicesInBraille = characterIndicesInBraille.clone();
1057 						if (interCharacterIndicesInBraille != null)
1058 							clone.interCharacterIndicesInBraille = interCharacterIndicesInBraille.clone();
1059 						return clone;
1060 					} catch (CloneNotSupportedException e) {
1061 						throw new InternalError("coding error");
1062 					}
1063 				}
1064 				
1065 				private int positionInBraille(int pos) {
1066 					int posInBraille = curPosInBraille;
1067 					if (posInBraille < 0) posInBraille = 0;
1068 					for (; posInBraille < joinedBraille.length(); posInBraille++)
1069 						if (characterIndicesInBraille[posInBraille] >= pos)
1070 							break;
1071 					return posInBraille;
1072 				}
1073 				
1074 				private String addHyphensAndLetterSpacing(FullHyphenator fullHyphenator,
1075 				                                          String segment,
1076 				                                          String segmentInBraille,
1077 				                                          int curPos,
1078 				                                          int curPosInBraille,
1079 				                                          byte[] manualHyphens,
1080 				                                          Locale language,
1081 				                                          int letterSpacing) {
1082 					byte[] hyphens = fullHyphenator.hyphenate(
1083 						// insert manual hyphens first so that hyphenator knows which words to skip
1084 						insertHyphens(segment, manualHyphens, true, SHY, ZWSP),
1085 						language);
1086 					// FIXME: don't hard-code the number 4
1087 					byte[] hyphensAndLetterBoundaries
1088 						= (letterSpacing > 0) ? detectLetterBoundaries(hyphens, segment, (byte)4) : hyphens;
1089 					if (hyphensAndLetterBoundaries == null && manualHyphens == null)
1090 						return segment;
1091 					byte[] hyphensAndLetterBoundariesInBraille = new byte[segmentInBraille.length() - 1];
1092 					if (hyphensAndLetterBoundaries != null) {
1093 						for (int i = 0; i < hyphensAndLetterBoundariesInBraille.length; i++) {
1094 							int pos = interCharacterIndicesInBraille[curPosInBraille + i] - 1;
1095 							if (pos >= 0)
1096 								hyphensAndLetterBoundariesInBraille[i] = hyphensAndLetterBoundaries[pos - curPos];
1097 						}
1098 					}
1099 					String r = insertHyphens(segmentInBraille, hyphensAndLetterBoundariesInBraille, false, SHY, ZWSP, US);
1100 					return (letterSpacing > 0) ? applyLetterSpacing(r, letterSpacing) : r;
1101 				}
1102 				
1103 				private String addLetterSpacing(String segment,
1104 				                                String segmentInBraille,
1105 				                                int curPos,
1106 				                                int curPosInBraille,
1107 				                                int letterSpacing) {
1108 					if (letterSpacing > 0) {
1109 						// FIXME: don't hard-code the number 1
1110 						byte[] letterBoundaries = detectLetterBoundaries(null, segment, (byte)1);
1111 						byte[] letterBoundariesInBraille = new byte[segmentInBraille.length() - 1];
1112 						for (int i = 0; i < letterBoundariesInBraille.length; i++) {
1113 							int pos = interCharacterIndicesInBraille[curPosInBraille + i] - 1;
1114 							if (pos >= 0)
1115 								letterBoundariesInBraille[i] = letterBoundaries[pos - curPos];
1116 						}
1117 						return applyLetterSpacing(insertHyphens(segmentInBraille, letterBoundariesInBraille, false, US), letterSpacing); }
1118 					else
1119 						return segmentInBraille;
1120 				}
1121 				
1122 				private boolean updateInput(int start, int end, String replacement) {
1123 					if (substringByCodePoints(joinedText, start, end).equals(replacement))
1124 						return false;
1125 					joinedText = substringByCodePoints(joinedText, 0, start) + replacement + substringByCodePoints(joinedText, end);
1126 					{ // recompute joinedTextMapping
1127 						int[] updatedJoinedTextMapping = new int[lengthByCodePoints(joinedText)];
1128 						int i = 0;
1129 						int j = 0;
1130 						while (i < start)
1131 							updatedJoinedTextMapping[j++] = joinedTextMapping[i++];
1132 						int startSegment = joinedTextMapping[start];
1133 						while (i < end)
1134 							if (joinedTextMapping[i++] != startSegment)
1135 								throw new RuntimeException("Coding error");
1136 						while (j < start + lengthByCodePoints(replacement))
1137 							updatedJoinedTextMapping[j++] = startSegment;
1138 						while (j < updatedJoinedTextMapping.length)
1139 							updatedJoinedTextMapping[j++] = joinedTextMapping[i++];
1140 						joinedTextMapping = updatedJoinedTextMapping;
1141 					}
1142 					// recompute manualHyphens
1143 					if (manualHyphens != null) {
1144 						byte[] updatedManualHyphens = new byte[lengthByCodePoints(joinedText) - 1];
1145 						int i = 0;
1146 						int j = 0;
1147 						while (i < start)
1148 							updatedManualHyphens[j++] = manualHyphens[i++];
1149 						while (j < start + lengthByCodePoints(replacement) - 1)
1150 							updatedManualHyphens[j++] = 0;
1151 						i = end - 1;
1152 						while (j < updatedManualHyphens.length)
1153 							updatedManualHyphens[j++] = manualHyphens[i++];
1154 						manualHyphens = updatedManualHyphens;
1155 					}
1156 					{ // recompute endPos
1157 						int toChar = -1;
1158 						if (to >= 0)
1159 							for (int i = 0; i < joinedTextMapping.length; i++)
1160 								if (textWithWsMapping[joinedTextMapping[i]] >= to) {
1161 									toChar = i;
1162 									break; }
1163 						this.endPos = toChar > 0 ? toChar : joinedTextMapping.length;
1164 					}
1165 					return true;
1166 				}
1167 				
1168 				// TODO: warn about lost white space?
1169 				private void updateBraille() {
1170 					int joinedTextLength = lengthByCodePoints(joinedText);
1171 
1172 					String partBeforeCurPos = curPosInBraille > 0 ? joinedBraille.substring(0, curPosInBraille): null;
1173 					int[] characterIndices = new int[joinedTextLength]; {
1174 						for (int i = 0; i < joinedTextLength; i++)
1175 							characterIndices[i] = i; }
1176 					int[] interCharacterIndices = new int[joinedTextLength - 1]; {
1177 						for (int i = 0; i < joinedTextLength - 1; i++)
1178 							interCharacterIndices[i] = i + 1; }
1179 					
1180 					// typeform var with the same length as joinedText
1181 					Typeform[] _typeform = null;
1182 					for (Typeform t : typeform)
1183 						if (t != Typeform.PLAIN_TEXT) {
1184 							_typeform = new Typeform[joinedTextLength];
1185 							for (int i = 0; i < _typeform.length; i++)
1186 								_typeform[i] = typeform[textWithWsMapping[joinedTextMapping[i]]];
1187 							break; }
1188 					try {
1189 						TranslationResult r = translator.translate(joinedText, _typeform, characterIndices, interCharacterIndices, displayTable);
1190 						joinedBraille = r.getBraille();
1191 						if (lengthByCodePoints(joinedBraille) != joinedBraille.length())
1192 							throw new RuntimeException(); // assuming there are no characters above U+FFFF in braille
1193 						characterIndicesInBraille = r.getCharacterAttributes();
1194 						interCharacterIndicesInBraille = r.getInterCharacterAttributes(); }
1195 					catch (TranslationException e) {
1196 						throw new RuntimeException(e); }
1197 					catch (DisplayException e) {
1198 						throw new RuntimeException(e); }
1199 					if (partBeforeCurPos != null)
1200 						if (!joinedBraille.substring(0, curPosInBraille).equals(partBeforeCurPos))
1201 							throw new IllegalStateException();
1202 					int newCurPosInBraille = positionInBraille(curPos);
1203 					if (curPosInBraille >= 0) {
1204 						if (curPosInBraille != newCurPosInBraille)
1205 							throw new IllegalStateException();
1206 					} else
1207 						curPosInBraille = newCurPosInBraille;
1208 					endPosInBraille = positionInBraille(endPos);
1209 				}
1210 			}
1211 		}
1212 
1213 		private List<CSSStyledText> transform(java.lang.Iterable<CSSStyledText> styledText,
1214 		                                      boolean forceBraille,
1215 		                                      boolean failWhenNonStandardHyphenation) throws NonStandardHyphenationException {
1216 			try {
1217 				if (fullHyphenator == compoundWordHyphenator)
1218 					if (any(styledText, t -> {
1219 								SimpleInlineStyle style = t.getStyle();
1220 								return style != null && style.getProperty("hyphens") == Hyphens.AUTO; }))
1221 						logger.warn("hyphens: auto not supported");
1222 				styledText = fullHyphenator.transform(styledText); }
1223 			catch (NonStandardHyphenationException e) {
1224 				if (failWhenNonStandardHyphenation)
1225 					throw e;
1226 				else
1227 					switch (handleNonStandardHyphenation) {
1228 					case NON_STANDARD_HYPH_IGNORE:
1229 						logger.warn("hyphens: auto can not be applied due to non-standard hyphenation points.");
1230 						break;
1231 					case NON_STANDARD_HYPH_FAIL:
1232 						logger.error("hyphens: auto can not be applied due to non-standard hyphenation points.");
1233 						throw e;
1234 					case NON_STANDARD_HYPH_DEFER:
1235 						if (forceBraille) {
1236 							logger.error("hyphens: auto can not be applied due to non-standard hyphenation points.");
1237 							throw e; }
1238 						logger.debug("Deferring hyphenation to formatting phase due to non-standard hyphenation points.");
1239 						
1240 						// TODO: split up text in words and only defer the words with non-standard hyphenation
1241 						List<CSSStyledText> result = new ArrayList<>();
1242 						for (CSSStyledText t : styledText) result.add(t);
1243 						return result; }}
1244 			int size = size(styledText);
1245 			String[] text = new String[size];
1246 			SimpleInlineStyle[] style = new SimpleInlineStyle[size];
1247 			int i = 0;
1248 			for (CSSStyledText t : styledText) {
1249 				text[i] = t.getText();
1250 				style[i] = t.getStyle();
1251 				if (style[i] != null) {
1252 					style[i] = (SimpleInlineStyle)style[i].clone();
1253 					style[i].removeProperty("hyphens"); } // handled above
1254 				i++; }
1255 			String[] braille = transform(text, style);
1256 			List<CSSStyledText> result = new ArrayList<>();
1257 			i = 0;
1258 			for (CSSStyledText t : styledText) {
1259 				// update/add text-transform and braille-charset properties
1260 				List<PropertyValue> s = null; {
1261 					if (style[i] == null) {
1262 						if (s == null) s = new ArrayList<>();
1263 						s.add(TEXT_TRANSFORM_NONE);
1264 						if (table.usesCustomDisplayTable())
1265 							s.add(BRAILLE_CHARSET_CUSTOM);
1266 					} else {
1267 						if (style[i].getProperty("text-transform") != TextTransform.NONE) {
1268 							style[i].removeProperty("text-transform");
1269 							if (s == null) s = new ArrayList<>();
1270 							s.add(TEXT_TRANSFORM_NONE);
1271 						}
1272 						BrailleCharset bc = style[i].getProperty("braille-charset");
1273 						if (table.usesCustomDisplayTable()) {
1274 							if (bc != BrailleCharset.CUSTOM) {
1275 								if (bc != null)
1276 									style[i].removeProperty("braille-charset");
1277 								if (s == null) s = new ArrayList<>();
1278 								s.add(BRAILLE_CHARSET_CUSTOM);
1279 							}
1280 						} else
1281 							if (bc != null && bc != BrailleCharset.UNICODE)
1282 								style[i].removeProperty("braille-charset");
1283 					}
1284 				}
1285 				if (s != null) {
1286 					if (style[i] != null)
1287 						style[i].forEach(s::add);
1288 					style[i] = new SimpleInlineStyle(s);
1289 				}
1290 				Map<String,String> a = t.getTextAttributes();
1291 				if (a != null)
1292 					a = new HashMap<>(a);
1293 				Locale lang = t.getLanguage();
1294 				if (lang != null)
1295 					if (includeBrailleCodeInLanguage)
1296 						lang = getBrailleCode().toLanguageTag(lang);
1297 					else
1298 						lang = new Locale.Builder().setLocale(lang)
1299 						                           .setScript("Brai")
1300 						                           .build();
1301 				result.add(new CSSStyledText(braille[i], style[i], lang, a));
1302 				i++;
1303 			}
1304 			return result;
1305 		}
1306 
1307 		/**
1308 		 * The {@link SimpleInlineStyle} objects are modified so that on return they represent the
1309 		 * output style.
1310 		 */
1311 		private String[] transform(String[] text, SimpleInlineStyle[] styles) {
1312 			int size = text.length;
1313 			Typeform[] typeform = new Typeform[size];
1314 			boolean[] preserveLines = new boolean[size];
1315 			boolean[] preserveSpace = new boolean[size];
1316 			int[] letterSpacing = new int[size];
1317 			for (int i = 0; i < size; i++) {
1318 				typeform[i] = Typeform.PLAIN_TEXT;
1319 				preserveLines[i] = preserveSpace[i] = false;
1320 				letterSpacing[i] = 0;
1321 				SimpleInlineStyle style = styles[i];
1322 				if (style != null) {
1323 					CSSProperty val = style.getProperty("white-space");
1324 					if (val != null) {
1325 						if (val == WhiteSpace.PRE_WRAP)
1326 							preserveLines[i] = preserveSpace[i] = true;
1327 						else if (val == WhiteSpace.PRE_LINE)
1328 							preserveLines[i] = true;
1329 						// don't remove "white-space" property because it has not been fully handled
1330 					}
1331 					val = style.getProperty("text-transform");
1332 					if (val != null) {
1333 						if (val == TextTransform.NONE) {
1334 							// "text-transform: none" is handled by HandleTextTransformNone, but HandleTextTransformNone
1335 							// is a CompoundBrailleTranslator and CompoundBrailleTranslator puts "text-transform: none"
1336 							// on (already translated) context segments. We assume that all Liblouis tables correctly
1337 							// handle Unicode braille. If this is not the case, it is not the end of the world because
1338 							// this is a context segment.
1339 							val = style.getProperty("braille-charset");
1340 							if (val != null) {
1341 								if (val == BrailleCharset.CUSTOM)
1342 									// translate to Unicode braille
1343 									text[i] = displayTable.decode(text[i]); }
1344 							continue; }
1345 						else if (val == TextTransform.AUTO) {}
1346 						else if (val == TextTransform.list_values) {
1347 							TermList values = style.getValue(TermList.class, "text-transform");
1348 							text[i] = textFromTextTransform(text[i], values);
1349 							typeform[i] = typeform[i].add(typeformFromTextTransform(values, translator, supportedTypeforms)); }}
1350 					val = style.getProperty("letter-spacing");
1351 					if (val != null) {
1352 						if (val == LetterSpacing.length) {
1353 							letterSpacing[i] = style.getValue(TermInteger.class, "letter-spacing").getIntValue();
1354 							if (letterSpacing[i] < 0) {
1355 								logger.warn("{} not supported, must be non-negative", val);
1356 								letterSpacing[i] = 0; }}
1357 						style.removeProperty("letter-spacing"); }
1358 					typeform[i] = typeform[i].add(typeformFromInlineCSS(style, translator, supportedTypeforms));}}
1359 			
1360 			return transform(text, typeform, preserveLines, preserveSpace, letterSpacing);
1361 		}
1362 		
1363 		private String[] transform(String[] text, Typeform[] typeform) {
1364 			int size = text.length;
1365 			boolean[] preserveLines = new boolean[size];
1366 			boolean[] preserveSpace = new boolean[size];
1367 			int[] letterSpacing = new int[size];
1368 			for (int i = 0; i < text.length; i++) {
1369 				preserveLines[i] = preserveSpace[i] = false;
1370 				letterSpacing[i] = 0; }
1371 			return transform(text, typeform, preserveLines, preserveSpace, letterSpacing);
1372 		}
1373 		
1374 		// the positions in the text where spacing must be inserted have been previously indicated with a US control character
1375 		private String applyLetterSpacing(String text, int letterSpacing) {
1376 			String space = "";
1377 			for (int i = 0; i < letterSpacing; i++)
1378 				space += NBSP;
1379 			return text.replaceAll("\u001F", space);
1380 		}
1381 		
1382 		private String[] transform(String[] text,
1383 		                           Typeform[] typeform,
1384 		                           boolean[] preserveLines,
1385 		                           boolean[] preserveSpace,
1386 		                           int[] letterSpacing) {
1387 			
1388 			// perform Unicode normalization
1389 			if (unicodeNormalization != null)
1390 				for (int k = 0; k < text.length; k++)
1391 					text[k] = Normalizer.normalize(text[k], unicodeNormalization);
1392 			
1393 			// text with some segments split up into white space segments that need to be preserved
1394 			// in the output and other segments
1395 			String[] textWithWs;
1396 			// boolean array for tracking which (non-empty white space) segments in textWithWs need
1397 			// to be preserved
1398 			boolean[] pre;
1399 			// mapping from index in textWithWs to index in text
1400 			int[] textWithWsMapping; {
1401 				List<String> l1 = new ArrayList<String>();
1402 				List<Boolean> l2 = new ArrayList<Boolean>();
1403 				List<Integer> l3 = new ArrayList<Integer>();
1404 				for (int i = 0; i < text.length; i++) {
1405 					String t = text[i];
1406 					if (t.isEmpty()) {
1407 						l1.add(t);
1408 						l2.add(false);
1409 						l3.add(i); }
1410 					else {
1411 						Pattern ws;
1412 						if (preserveSpace[i])
1413 							ws = ON_SPACE_SPLITTER;
1414 						else if (preserveLines[i])
1415 							ws = LINE_SPLITTER;
1416 						else
1417 							ws = ON_NBSP_SPLITTER;
1418 						boolean p = false;
1419 						for (String s : splitInclDelimiter(t, ws)) {
1420 							if (!s.isEmpty()) {
1421 								l1.add(s);
1422 								l2.add(p);
1423 								l3.add(i); }
1424 							p = !p; }}}
1425 				int len = l1.size();
1426 				textWithWs = new String[len];
1427 				pre = new boolean[len];
1428 				textWithWsMapping = new int[len];
1429 				for (int i = 0; i < len; i++) {
1430 					textWithWs[i] = l1.get(i);
1431 					pre[i] = l2.get(i);
1432 					textWithWsMapping[i] = l3.get(i); }
1433 			}
1434 			
1435 			// textWithWs segments joined together with hyphens removed and sequences of preserved
1436 			// white space replaced with a nbsp
1437 			String joinedText;
1438 			// mapping from character index in joinedText to segment index in textWithWs
1439 			int[] joinedTextMapping;
1440 			// byte array for tracking hyphenation positions, segment boundaries and boundaries of
1441 			// sequences of preserved white space
1442 			byte[] inputAttrs; {
1443 				String[] textWithWsReplaced = new String[textWithWs.length];
1444 				for (int i = 0; i < textWithWs.length; i++)
1445 					textWithWsReplaced[i] = pre[i] ? ""+NBSP : textWithWs[i];
1446 				Tuple2<String,byte[]> t = extractHyphens(join(textWithWsReplaced, RS), true, SHY, ZWSP);
1447 				joinedText = t._1;
1448 				inputAttrs = t._2;
1449 				String[] nohyph = toArray(SEGMENT_SPLITTER.split(joinedText), String.class);
1450 				joinedTextMapping = new int[lengthByCodePoints(join(nohyph))];
1451 				int i = 0;
1452 				int j = 0;
1453 				for (String s : nohyph) {
1454 					int l = lengthByCodePoints(s);
1455 					for (int k = 0; k < l; k++)
1456 						joinedTextMapping[i++] = j;
1457 					j++; }
1458 				t = extractHyphens(inputAttrs, joinedText, true, null, null, null, RS);
1459 				joinedText = t._1;
1460 				inputAttrs = t._2;
1461 				if (joinedText.matches("\\xA0*"))
1462 					return text;
1463 				if (inputAttrs == null)
1464 					inputAttrs = new byte[lengthByCodePoints(joinedText) - 1];
1465 			}
1466 			
1467 			// add letter information to inputAttrs array
1468 			boolean someLetterSpacing = false; {
1469 				for (int i = 0; i < letterSpacing.length; i++)
1470 					if (letterSpacing[i] > 0) someLetterSpacing = true; }
1471 			if (someLetterSpacing)
1472 				// FIXME: don't hard-code the number 4
1473 				inputAttrs = detectLetterBoundaries(inputAttrs, joinedText, (byte)4);
1474 			
1475 			// typeform var with the same length as joinedText
1476 			Typeform[] _typeform = null;
1477 			for (Typeform t : typeform)
1478 				if (t != Typeform.PLAIN_TEXT) {
1479 					_typeform = new Typeform[lengthByCodePoints(joinedText)];
1480 					for (int i = 0; i < _typeform.length; i++)
1481 						_typeform[i] = typeform[textWithWsMapping[joinedTextMapping[i]]];
1482 					break; }
1483 			
1484 			// translate to braille with hyphens and restored white space
1485 			String[] brailleWithWs;
1486 			try {
1487 				
1488 				// translation result with hyphens and segment boundary marks
1489 				String joinedBrailleWithoutHyphens;
1490 				String joinedBraille;
1491 				byte[] outputAttrs; {
1492 					int[] inputAttrsAsInt = new int[inputAttrs.length];
1493 					for (int i = 0; i < inputAttrs.length; i++)
1494 						inputAttrsAsInt[i] = inputAttrs[i];
1495 					TranslationResult r = translator.translate(joinedText, _typeform, null, inputAttrsAsInt, displayTable);
1496 					joinedBrailleWithoutHyphens = r.getBraille();
1497 					if (lengthByCodePoints(joinedBrailleWithoutHyphens) != joinedBrailleWithoutHyphens.length())
1498 						throw new RuntimeException(); // assuming there are no characters above U+FFFF in braille
1499 					int [] outputAttrsAsInt = r.getInterCharacterAttributes();
1500 					if (outputAttrsAsInt != null) {
1501 						outputAttrs = new byte[outputAttrsAsInt.length];
1502 						for (int i = 0; i < outputAttrs.length; i++)
1503 							outputAttrs[i] = (byte)outputAttrsAsInt[i];
1504 						joinedBraille = insertHyphens(joinedBrailleWithoutHyphens, outputAttrs, false, SHY, ZWSP, US, RS); }
1505 					else {
1506 						joinedBraille = joinedBrailleWithoutHyphens;
1507 						outputAttrs = null; }
1508 				}
1509 				
1510 				// single segment
1511 				if (textWithWs.length == 1)
1512 					brailleWithWs = new String[]{joinedBraille};
1513 				else {
1514 					
1515 					// split into segments
1516 					{
1517 						brailleWithWs = new String[textWithWs.length];
1518 						int i = 0;
1519 						int imax = lengthByCodePoints(joinedText);
1520 						int kmax = textWithWs.length;
1521 						int k = (i < imax) ? joinedTextMapping[i] : kmax;
1522 						int l = 0;
1523 						while (l < k) brailleWithWs[l++] = "";
1524 						for (String s : SEGMENT_SPLITTER.split(joinedBraille)) {
1525 							brailleWithWs[l++] = s;
1526 							while (k < l)
1527 								k = (++i < imax) ? joinedTextMapping[i] : kmax;
1528 							while (l < k)
1529 								brailleWithWs[l++] = ""; }
1530 						if (l == kmax) {
1531 							boolean wsLost = false;
1532 							for (k = 0; k < kmax; k++)
1533 								if (pre[k]) {
1534 									Matcher m = Pattern.compile("\\xA0([\\xAD\\u200B]*)").matcher(brailleWithWs[k]);
1535 									if (m.matches())
1536 										brailleWithWs[k] = textWithWs[k] + m.group(1);
1537 									else
1538 										wsLost = true; }
1539 							if (wsLost) {
1540 								logger.warn("White space was not preserved (see detailed log for more info)");
1541 								logger.debug("White space was lost in the output.\n"
1542 								             + "Input: " + Arrays.toString(textWithWs) + "\n"
1543 								             + "Output: " + Arrays.toString(brailleWithWs)); }}
1544 						else {
1545 							logger.warn("Text segmentation was lost (see detailed log for more info)");
1546 							logger.debug("Text segmentation was lost in the output. Falling back to fuzzy mode.\n"
1547 							             + "=> input segments: " + Arrays.toString(textWithWs) + "\n"
1548 							             + "=> output segments: " + Arrays.toString(Arrays.copyOf(brailleWithWs, l)));
1549 							brailleWithWs = null; }
1550 					}
1551 					
1552 					// if some segment breaks were discarded, fall back on a fuzzy split method
1553 					if (brailleWithWs == null) {
1554 						
1555 						// int array for tracking segment numbers
1556 						// Note that we make the assumption here that the text is not longer than Integer.MAX_VALUE!
1557 						int[] inputSegmentNumbers = joinedTextMapping;
1558 						
1559 						// split at all positions where the segment number is increased in the output
1560 						TranslationResult r = translator.translate(joinedText, _typeform, inputSegmentNumbers, null, displayTable);
1561 						if (!r.getBraille().equals(joinedBrailleWithoutHyphens))
1562 							throw new RuntimeException("Coding error");
1563 						int[] outputSegmentNumbers = r.getCharacterAttributes();
1564 						brailleWithWs = new String[textWithWs.length];
1565 						boolean wsLost = false;
1566 						StringBuffer b = new StringBuffer();
1567 						int jmax = joinedBrailleWithoutHyphens.length();
1568 						int kmax = textWithWs.length;
1569 						int k = joinedTextMapping[0];
1570 						int l = 0;
1571 						while (l < k)
1572 							brailleWithWs[l++] = "";
1573 						for (int j = 0; j < jmax; j++) {
1574 							if (outputSegmentNumbers[j] > l) {
1575 								brailleWithWs[l] = b.toString();
1576 								b = new StringBuffer();
1577 								// FIXME: don't hard-code the number 8
1578 								if (j > 0 && (outputAttrs[j - 1] & 8) == 8) {
1579 									if (pre[l]) {
1580 										Matcher m = Pattern.compile("\\xA0([\\xAD\\u200B]*)").matcher(brailleWithWs[l]);
1581 										if (m.matches())
1582 											brailleWithWs[l] = textWithWs[l] + m.group(1);
1583 										else
1584 											wsLost = true; }}
1585 								else {
1586 									if (pre[l])
1587 										wsLost = true;
1588 									if (l <= kmax && pre[l + 1]) {
1589 										pre[l + 1] = false;
1590 										wsLost = true; }}
1591 								l++;
1592 								while (outputSegmentNumbers[j] > l) {
1593 									brailleWithWs[l] = "";
1594 									if (pre[l])
1595 										wsLost = true;
1596 									l++; }}
1597 							b.append(joinedBrailleWithoutHyphens.charAt(j));
1598 							if (j < jmax - 1) {
1599 								// FIXME: don't hard-code these numbers
1600 								if ((outputAttrs[j] & 1) == 1)
1601 									b.append(SHY);
1602 								if ((outputAttrs[j] & 2) == 2)
1603 									b.append(ZWSP);
1604 								if ((outputAttrs[j] & 4) == 4)
1605 									b.append(US); }}
1606 						brailleWithWs[l] = b.toString();
1607 						if (pre[l])
1608 							if (brailleWithWs[l].equals(""+NBSP))
1609 								brailleWithWs[l] = textWithWs[l];
1610 							else
1611 								wsLost = true;
1612 						l++;
1613 						while (l < kmax) {
1614 							if (pre[l])
1615 								wsLost = true;
1616 							brailleWithWs[l++] = ""; }
1617 						if (wsLost) {
1618 							logger.warn("White space was not preserved: " + joinedText.replaceAll("\\s+"," "));
1619 							logger.debug("White space was lost in the output.\n"
1620 							             + "Input: " + Arrays.toString(textWithWs) + "\n"
1621 							             + "Output: " + Arrays.toString(brailleWithWs)); }
1622 					}
1623 				}
1624 			} catch (TranslationException e) {
1625 				throw new RuntimeException(e);
1626 			} catch (DisplayException e) {
1627 				throw new RuntimeException(e); }
1628 			
1629 			// recombine white space segments with other segments
1630 			String braille[] = new String[text.length];
1631 			for (int i = 0; i < braille.length; i++)
1632 				braille[i] = "";
1633 			for (int j = 0; j < brailleWithWs.length; j++)
1634 				braille[textWithWsMapping[j]] += brailleWithWs[j];
1635 			
1636 			// apply letter spacing
1637 			if (someLetterSpacing)
1638 				for (int i = 0; i < braille.length; i++)
1639 					braille[i] = applyLetterSpacing(braille[i], letterSpacing[i]);
1640 			
1641 			return braille;
1642 		}
1643 		
1644 		/*
1645 		 * Detect where letter boundaries meet. Length of addTo must be one less than length of text.
1646 		 */
1647 		private byte[] detectLetterBoundaries(byte[] addTo, String text, byte val) {
1648 			if (addTo == null)
1649 				addTo = new byte[lengthByCodePoints(text) - 1];
1650 			int i = 0;
1651 			int prev = -1;
1652 			for (int c : text.codePoints().toArray()) {
1653 				if (i > 0 && ((Character.isLetter(c) && Character.isLetter(prev)) ||
1654 				              c == '-' ||
1655 				              prev == '-'))
1656 					addTo[i - 1] |= val;
1657 				if (i < addTo.length && c == '\u00ad') // SHY is not actual character, so boundary only after SHY
1658 					addTo[i] |= val;
1659 				prev = c;
1660 				i++;
1661 			}
1662 			return addTo;
1663 		}
1664 		
1665 		@Override
1666 		public ToStringHelper toStringHelper() {
1667 			return MoreObjects.toStringHelper("LiblouisTranslatorJnaImplProvider$LiblouisTranslatorImpl")
1668 				.add("translator", translator)
1669 				.add("displayTable", displayTable)
1670 				.add("hyphenator", hyphenator);
1671 		}
1672 	
1673 		@Override
1674 		public int hashCode() {
1675 			final int prime = 31;
1676 			int hash = 1;
1677 			hash = prime * hash + translator.hashCode();
1678 			hash = prime * hash + ((hyphenator == null) ? 0 : hyphenator.hashCode());
1679 			hash = prime * hash + handleNonStandardHyphenation;
1680 			hash = prime * hash + (includeBrailleCodeInLanguage ? 1 : 0);
1681 			return hash;
1682 		}
1683 	
1684 		@Override
1685 		public boolean equals(Object object) {
1686 			if (this == object)
1687 				return true;
1688 			if (object == null)
1689 				return false;
1690 			if (object.getClass() != LiblouisTranslatorImpl.class)
1691 				return false;
1692 			LiblouisTranslatorImpl that = (LiblouisTranslatorImpl)object;
1693 			if (!this.translator.equals(that.translator))
1694 				return false;
1695 			if (!this.displayTable.equals(that.displayTable))
1696 				return false;
1697 			if (this.hyphenator == null && that.hyphenator != null)
1698 				return false;
1699 			if (this.hyphenator != null && that.hyphenator == null)
1700 				return false;
1701 			if (!this.hyphenator.equals(that.hyphenator))
1702 				return false;
1703 			return true;
1704 		}
1705 	}
1706 	
1707 	private class HandleTextTransformNone extends CompoundBrailleTranslator implements LiblouisTranslator {
1708 		
1709 		final LiblouisTranslator translator;
1710 		
1711 		HandleTextTransformNone(LiblouisTranslator translator, BrailleTranslator unityTranslator) {
1712 			super(translator, ImmutableMap.of("none", () -> unityTranslator));
1713 			this.translator = translator;
1714 		}
1715 
1716 		private HandleTextTransformNone(CompoundBrailleTranslator from, LiblouisTranslator translator) {
1717 			super(from);
1718 			this.translator = translator;
1719 		}
1720 
1721 		@Override
1722 		public HandleTextTransformNone _withHyphenator(Hyphenator hyphenator) {
1723 			HandleTextTransformNone t = new HandleTextTransformNone(
1724 				(CompoundBrailleTranslator)super._withHyphenator(hyphenator),
1725 				translator);
1726 			LiblouisTranslatorJnaImplProvider.this.rememberId(t);
1727 			return t;
1728 		}
1729 		
1730 		@Override
1731 		public LiblouisTable asLiblouisTable() {
1732 			return translator.asLiblouisTable();
1733 		}
1734 
1735 		@Override
1736 		public FromTypeformedTextToBraille fromTypeformedTextToBraille() {
1737 			return translator.fromTypeformedTextToBraille();
1738 		}
1739 	}
1740 	
1741 	private interface FullHyphenator extends Hyphenator.FullHyphenator {
1742 		public byte[] hyphenate(String text, Locale language);
1743 	}
1744 	
1745 	/**
1746 	  * Hyphenator that add a zero-width space after hard hyphens ("-" followed and preceded
1747 	  * by a letter or number)
1748 	  */
1749 	private final static FullHyphenator compoundWordHyphenator = new CompoundWordHyphenator();
1750 	
1751 	private static class CompoundWordHyphenator extends NoHyphenator implements FullHyphenator {
1752 
1753 		public byte[] hyphenate(String text, Locale language) {
1754 			if (text.isEmpty())
1755 				return null;
1756 			Tuple2<String,byte[]> t = extractHyphens(text, true, SHY, ZWSP);
1757 			if (t._1.isEmpty())
1758 				return null;
1759 			return transform(t._2, t._1, language);
1760 		}
1761 	}
1762 	
1763 	private static class HyphenatorAsFullHyphenator implements FullHyphenator {
1764 		
1765 		private final Hyphenator.FullHyphenator hyphenator;
1766 		
1767 		private HyphenatorAsFullHyphenator(Hyphenator hyphenator) {
1768 			this.hyphenator = hyphenator.asFullHyphenator();
1769 		}
1770 		
1771 		public java.lang.Iterable<CSSStyledText> transform(java.lang.Iterable<CSSStyledText> text) {
1772 			return hyphenator.transform(text);
1773 		}
1774 		
1775 		private final static SimpleInlineStyle HYPHENS_AUTO = cssParser.parse("hyphens: auto");
1776 		
1777 		public byte[] hyphenate(String text, Locale language) {
1778 			return extractHyphens(
1779 				hyphenator.transform(singleton(new CSSStyledText(text, HYPHENS_AUTO, language))).iterator().next().getText(),
1780 				true, SHY, ZWSP)._2;
1781 		}
1782 	}
1783 	
1784 	/**
1785 	 * @param style An inline CSS style
1786 	 * @return the corresponding Typeform object.
1787 	 * @see <a href="http://liblouis.googlecode.com/svn/documentation/liblouis.html#lou_translateString">lou_translateString</a>
1788 	 */
1789 	protected static Typeform typeformFromInlineCSS(SimpleInlineStyle style, Translator table, Map<String,Typeform> supportedTypeforms) {
1790 		Typeform typeform = Typeform.PLAIN_TEXT;
1791 		for (String prop : style.getPropertyNames()) {
1792 			if (prop.equals("font-style")) {
1793 				CSSProperty value = style.getProperty(prop);
1794 				if (value == FontStyle.ITALIC || value == FontStyle.OBLIQUE) {
1795 					Typeform t = supportedTypeforms.get("italic");
1796 					if (t != null)
1797 						typeform = typeform.add(t);
1798 					else
1799 						logger.warn("{} not supported: emphclass 'italic' not defined in table {}",
1800 						            style.get(prop),
1801 						            table.getTable());
1802 					style.removeProperty(prop);
1803 					continue; }}
1804 			else if (prop.equals("font-weight")) {
1805 				CSSProperty value = style.getProperty(prop);
1806 				if (value == FontWeight.BOLD) {
1807 					Typeform t = supportedTypeforms.get("bold");
1808 					if (t != null)
1809 						typeform = typeform.add(t);
1810 					else
1811 						logger.warn("{} not supported: emphclass 'bold' not defined in table {}",
1812 						            style.get(prop),
1813 						            table.getTable());
1814 					style.removeProperty(prop);
1815 					continue; }}
1816 			else if (prop.equals("text-decoration")) {
1817 				CSSProperty value = style.getProperty(prop);
1818 				if (value == TextDecoration.UNDERLINE) {
1819 					Typeform t = supportedTypeforms.get("underline");
1820 					if (t != null)
1821 						typeform = typeform.add(t);
1822 					else
1823 						logger.warn("{} not supported: emphclass 'underline' not defined in table {}",
1824 						            style.get(prop),
1825 						            table.getTable());
1826 					style.removeProperty(prop);
1827 					continue; }}}
1828 		return typeform;
1829 	}
1830 	
1831 	/**
1832 	 * @param text The text to be transformed.
1833 	 * @param textTransform A text-transform value as a space separated list of keywords.
1834 	 * @return the transformed text, or the original text if no transformations were performed.
1835 	 */
1836 	protected static String textFromTextTransform(String text, TermList textTransform) {
1837 		for (Term<?> t : textTransform) {
1838 			String tt = ((TermIdent)t).getValue();
1839 			if (tt.equals("uppercase"))
1840 				text = text.toUpperCase();
1841 			else if (tt.equals("lowercase"))
1842 				text = text.toLowerCase();
1843 			else if (!LOUIS_TEXT_TRANSFORM.matcher(tt).matches())
1844 				logger.warn("text-transform: {} not supported", tt);
1845 		}
1846 		return text;
1847 	}
1848 	
1849 	/**
1850 	 * @param textTransform A text-transform value as a space separated list of keywords.
1851 	 * @return the corresponding Typeform object. Recognized values are:
1852 	 * * -louis-italic
1853 	 * * -louis-underline
1854 	 * * -louis-bold
1855 	 * * -louis-computer
1856 	 * * -louis-...
1857 	 * These values can be added for multiple emphasis.
1858 	 * @see <a href="http://liblouis.googlecode.com/svn/documentation/liblouis.html#lou_translateString">lou_translateString</a>
1859 	 */
1860 	protected static Typeform typeformFromTextTransform(TermList textTransform, Translator table, Map<String,Typeform> supportedTypeforms) {
1861 		Typeform typeform = Typeform.PLAIN_TEXT;
1862 		for (Term<?> t : textTransform) {
1863 			String tt = ((TermIdent)t).getValue();
1864 			Matcher m = LOUIS_TEXT_TRANSFORM.matcher(tt);
1865 			if (m.matches()) {
1866 				String emphClass = m.group("class");
1867 				if (emphClass.equals("computer") || emphClass.equals("comp")) {
1868 					typeform = typeform.add(Typeform.COMPUTER);
1869 				} else {
1870 					if (emphClass.equals("ital"))
1871 						emphClass= "italic";
1872 					else if (emphClass.equals("under"))
1873 						emphClass = "underline";
1874 					Typeform tf = supportedTypeforms.get(emphClass);
1875 					if (tf != null)
1876 						typeform = typeform.add(tf);
1877 					else
1878 						logger.warn("text-transform: {} not supported: emphclass '{}' not defined in table {}",
1879 						            tt,
1880 						            emphClass,
1881 						            table.getTable());
1882 				}
1883 				continue;
1884 			} else if (tt.equals("uppercase") || tt.equals("lowercase")) {
1885 				// handled in textFromTextTransform
1886 				continue;
1887 			}
1888 			logger.warn("text-transform: {} not supported", tt);
1889 		}
1890 		return typeform;
1891 	}
1892 	
1893 	private final static Pattern LOUIS_TEXT_TRANSFORM = Pattern.compile("^-?(lib)?louis-(?<class>.+)$");
1894 	
1895 	@SuppressWarnings("unused")
1896 	private static int mod(int a, int n) {
1897 		int result = a % n;
1898 		if (result < 0)
1899 			result += n;
1900 		return result;
1901 	}
1902 	
1903 	private static int lengthByCodePoints(String s) {
1904 		return s.codePointCount(0, s.length());
1905 	}
1906 	
1907 	private static String substringByCodePoints(String s, int beginIndex) {
1908 		return s.substring(s.offsetByCodePoints(0, beginIndex));
1909 	}
1910 	
1911 	private static String substringByCodePoints(String s, int beginIndex, int endIndex) {
1912 		return s.substring(s.offsetByCodePoints(0, beginIndex), s.offsetByCodePoints(0, endIndex));
1913 	}
1914 
1915 	private static class LineBreakSolution {
1916 		String line;
1917 		String replacementWord;
1918 		String lineInBraille;
1919 		int lineInBrailleLength;
1920 	}
1921 
1922 	private static final Logger logger = LoggerFactory.getLogger(LiblouisTranslatorJnaImplProvider.class);
1923 	
1924 }