1   package org.daisy.pipeline.braille.common;
2   
3   import static java.lang.Math.min;
4   import java.util.ArrayList;
5   import java.util.HashMap;
6   import java.util.Iterator;
7   import java.util.List;
8   import java.util.Map;
9   import java.util.NoSuchElementException;
10  import java.util.regex.Matcher;
11  import java.util.regex.Pattern;
12  
13  import static com.google.common.collect.Iterators.concat;
14  import static com.google.common.collect.Iterators.peekingIterator;
15  import com.google.common.collect.ImmutableList;
16  import static com.google.common.collect.Lists.charactersOf;
17  import com.google.common.collect.PeekingIterator;
18  
19  import cz.vutbr.web.css.CSSProperty;
20  import cz.vutbr.web.css.TermInteger;
21  import cz.vutbr.web.css.TermString;
22  
23  import org.daisy.braille.css.BrailleCSSProperty.HyphenateCharacter;
24  import org.daisy.braille.css.BrailleCSSProperty.Hyphens;
25  import org.daisy.braille.css.BrailleCSSProperty.TextTransform;
26  import org.daisy.braille.css.BrailleCSSProperty.WhiteSpace;
27  import org.daisy.braille.css.BrailleCSSProperty.WordSpacing;
28  import org.daisy.braille.css.SimpleInlineStyle;
29  import org.daisy.dotify.api.table.BrailleConverter;
30  import org.daisy.dotify.api.translator.UnsupportedMetricException;
31  import org.daisy.dotify.api.translator.BrailleTranslatorResult;
32  import static org.daisy.pipeline.braille.common.util.Strings.extractHyphens;
33  import static org.daisy.pipeline.braille.common.util.Tuple2;
34  import org.daisy.pipeline.braille.css.CSSStyledText;
35  
36  import org.slf4j.Logger;
37  import org.slf4j.LoggerFactory;
38  
39  public abstract class AbstractBrailleTranslator extends AbstractTransform implements BrailleTranslator {
40  	
41  	private final BrailleConverter brailleCharset;
42  
43  	private static final Logger logger = LoggerFactory.getLogger(AbstractBrailleTranslator.class);
44  
45  	protected AbstractBrailleTranslator() {
46  		this(null, null);
47  	}
48  
49  	/**
50  	 * @param brailleCharset Used by default implementation of {@link #lineBreakingFromStyledText()}
51  	 *                       to encode hyphen character when specified through "hyphenate-character"
52  	 *                       property.
53  	 */
54  	protected AbstractBrailleTranslator(BrailleConverter brailleCharset) {
55  		this(null, brailleCharset);
56  	}
57  
58  	protected AbstractBrailleTranslator(Hyphenator hyphenator, BrailleConverter brailleCharset) {
59  		this.brailleCharset = brailleCharset;
60  		withHyphenatorCache = new HashMap<>();
61  		withHyphenatorCache.put(hyphenator, this);
62  	}
63  
64  	/**
65  	 * Create a new {@link AbstractBrailleTranslator} object that is based on the same underlying
66  	 * caches and that has the same braille charset.
67  	 */
68  
69  	protected AbstractBrailleTranslator(AbstractBrailleTranslator from) {
70  		this.brailleCharset = from.brailleCharset;
71  		this.withHyphenatorCache = from.withHyphenatorCache;
72  	}
73  
74  	/**
75  	 * Return a new {@link BrailleTranslator} that uses the given {@link Hyphenator} to perform
76  	 * hyphenation.
77  	 *
78  	 * The returned object should be selectable based on its identifier from the same {@link
79  	 * BrailleTranslatorProvider} that provided this {@link BrailleTranslator}.
80  	 */
81  	public BrailleTranslator _withHyphenator(Hyphenator hyphenator) throws UnsupportedOperationException {
82  		throw new UnsupportedOperationException();
83  	}
84  
85  	private Map<Hyphenator,BrailleTranslator> withHyphenatorCache;
86  
87  	@Override
88  	public final BrailleTranslator withHyphenator(Hyphenator hyphenator) throws UnsupportedOperationException {
89  		BrailleTranslator t = null;
90  		if (withHyphenatorCache == null)
91  			withHyphenatorCache = new HashMap<>();
92  		else
93  			t = withHyphenatorCache.get(hyphenator);
94  		if (t == null) {
95  			t = _withHyphenator(hyphenator);
96  			withHyphenatorCache.put(hyphenator, t);
97  		}
98  		return t;
99  	}
100 
101 	@Override
102 	public FromStyledTextToBraille fromStyledTextToBraille() throws UnsupportedOperationException {
103 		throw new UnsupportedOperationException();
104 	}
105 	
106 	private LineBreakingFromStyledText lineBreakingFromStyledText = null;
107 	
108 	@Override
109 	public LineBreakingFromStyledText lineBreakingFromStyledText() throws UnsupportedOperationException {
110 		// default implementation based on fromStyledTextToBraille()
111 		if (lineBreakingFromStyledText == null) {
112 			FromStyledTextToBraille fromStyledTextToBraille = fromStyledTextToBraille();
113 			Character blankChar = brailleCharset == null
114 				? '\u2800'
115 				: brailleCharset.toText("\u2800").toCharArray()[0];
116 			Character defaultHyphenChar = brailleCharset == null
117 				? '\u2824'
118 				: brailleCharset.toText("\u2824").toCharArray()[0];
119 			lineBreakingFromStyledText = new StandardLineBreaker(fromStyledTextToBraille, blankChar, defaultHyphenChar, brailleCharset);
120 		}
121 		return lineBreakingFromStyledText;
122 	}
123 	
124 	/**
125 	 * {@link LineBreakingFromStyledText} implementation that is backed by a {@link
126 	 * FromStyledTextToBraille} and therefore can only support standard hyphenation.
127 	 */
128 	private static class StandardLineBreaker extends util.DefaultLineBreaker {
129 
130 		private final FromStyledTextToBraille fromStyledTextToBraille;
131 
132 		public StandardLineBreaker(FromStyledTextToBraille fromStyledTextToBraille, char blankChar, char defaultHyphenChar, BrailleConverter brailleCharset) {
133 			super(blankChar, defaultHyphenChar, brailleCharset, null);
134 			this.fromStyledTextToBraille = fromStyledTextToBraille;
135 		}
136 
137 		protected BrailleStream translateAndHyphenate(Iterable<CSSStyledText> styledText, int from, int to) {
138 			List<String> braille = new ArrayList<>();
139 			for (CSSStyledText t : fromStyledTextToBraille.transform(styledText)) {
140 				String b = t.getText();
141 				SimpleInlineStyle s = t.getStyle();
142 				if (s != null) {
143 					Hyphens h = s.getProperty("hyphens");
144 					if (h == Hyphens.NONE || h == Hyphens.MANUAL) {
145 						if (h == Hyphens.NONE)
146 							b = b.replaceAll("[\u00AD\u200B]","");
147 						s.removeProperty("hyphens"); }
148 					WhiteSpace ws = s.getProperty("white-space");
149 					if (ws != null) {
150 						if (ws == WhiteSpace.PRE_WRAP)
151 							b = b.replaceAll("[\\x20\t\\u2800]+", "$0\u200B")
152 								.replaceAll("[\\x20\t\\u2800]", "\u00A0");
153 						if (ws == WhiteSpace.PRE_WRAP || ws == WhiteSpace.PRE_LINE)
154 							b = b.replaceAll("[\\n\\r]", "\u2028");
155 						s.removeProperty("white-space"); }
156 					TextTransform tt = s.getProperty("text-transform");
157 					if (tt == TextTransform.NONE)
158 						s.removeProperty("text-transform");
159 					s.removeProperty("braille-charset");
160 					for (String prop : s.getPropertyNames())
161 						logger.warn("'{}: {}' not supported", prop, s.get(prop));
162 				}
163 				braille.add(b);
164 			}
165 			StringBuilder brailleString = new StringBuilder();
166 			int fromChar = 0;
167 			int toChar = to >= 0 ? 0 : -1;
168 			for (String s : braille) {
169 				brailleString.append(s);
170 				if (--from == 0)
171 					fromChar = brailleString.length();
172 				if (--to == 0)
173 					toChar = brailleString.length();
174 			}
175 			return new FullyHyphenatedAndTranslatedString(brailleString.toString(), fromChar, toChar);
176 		}
177 	}
178 
179 	/* ================== */
180 	/*       UTILS        */
181 	/* ================== */
182 	
183 	public static abstract class util {
184 		
185 		public static abstract class DefaultLineBreaker implements LineBreakingFromStyledText {
186 			
187 			private final static char SHY = '\u00ad';   // soft hyphen
188 			private final static char ZWSP = '\u200b';  // zero-width space
189 			private final static char SPACE = ' ';      // space
190 			private final static char CR = '\r';        // carriage return
191 			private final static char LF = '\n';        // line feed
192 			private final static char TAB = '\t';       // tab
193 			private final static char NBSP = '\u00a0';  // no-break space
194 			private final static char BLANK = '\u2800'; // blank braille pattern
195 			private final static char LS = '\u2028';    // line separator (preserved line breaks)
196 			private final static char RS = '\u001E';    // (for segmentation)
197 			
198 			private final char blankChar;
199 			
200 			private final char defaultHyphenChar;
201 			private final BrailleConverter brailleCharset;
202 			private final Logger logger;
203 			
204 			public DefaultLineBreaker() {
205 				this(null);
206 			}
207 			
208 			public DefaultLineBreaker(Logger logger) {
209 				this('\u2800', '\u2824', logger);
210 			}
211 			
212 			public DefaultLineBreaker(char blankChar, char defaultHyphenChar, Logger logger) {
213 				this(blankChar, defaultHyphenChar, null, logger);
214 			}
215 
216 			/**
217 			 * @param blankChar         character to use as blank pattern. Must already be encoded in the correct
218 			 *                          braille charset.
219 			 * @param defaultHyphenChar hyphen character to use in case of "hyphenate-character: auto". Must
220 			 *                          already be encoded in the correct braille charset.
221 			 * @param brailleCharset    for encoding hyphen character when specified through "hyphenate-character"
222 			 *                          property.
223 			 */
224 			public DefaultLineBreaker(char blankChar, char defaultHyphenChar, BrailleConverter brailleCharset, Logger logger) {
225 				this.blankChar = blankChar;
226 				this.defaultHyphenChar = defaultHyphenChar;
227 				this.brailleCharset = brailleCharset;
228 				this.logger = logger;
229 			}
230 			
231 			/**
232 			 * This method MUST translate to braille (the result may contain only <a
233 			 * href="http://braillespecs.github.io/braille-css/master/index.html#dfn-braille-character">braille
234 			 * characters</a> and <a
235 			 * href="http://braillespecs.github.io/braille-css/master/index.html#dfn-white-space-characters">white
236 			 * space</a>) and perform line breaking within words (hyphenate). It MAY also collapse
237 			 * white space and perform line breaking outside or at the boundaries of words
238 			 * (according to the CSS rules), but it doesn't need to because the result will be
239 			 * passed though a white space processing and line breaking stage anyway. Preserved
240 			 * spaces MUST be converted to NBSP characters. Line breaking may be "explicit" by not
241 			 * providing more characters than those that may be put on the current line. The
242 			 * "allowHyphens" argument must be respected, and a hyphen character at the end the line
243 			 * MUST be inserted when hyphenating. If it's a soft hyphen (SHY) it will be substituted
244 			 * with a real hyphen automatically. If line breaking is "implicit", it is left up to
245 			 * the line breaking stage. Preserved line breaks MUST be converted to LS characters in
246 			 * this case, and other break characters (SHY, ZWSP) MUST be included for all break
247 			 * opportunities, including those within words, regardless of the "allowHyphens"
248 			 * argument. There is a break opportunity after each string returned by the {@link
249 			 * BrailleStream}.
250 			 *
251 			 * @param text The {@link CSSStyledText} must not be modified.
252 			 */
253 			protected abstract BrailleStream translateAndHyphenate(Iterable<CSSStyledText> text, int from, int to);
254 			
255 			protected interface BrailleStream extends Cloneable {
256 				public boolean hasNext();
257 				/**
258 				 * @param limit The available space left on the current line. The returned string
259 				 *              does not have to fit in this space, but normally does in case of
260 				 *              "explicit" line breaking. If the returned string is longer, it will
261 				 *              be broken.
262 				 */
263 				public String next(int limit, boolean force, boolean allowHyphens);
264 				public Character peek();
265 				public String remainder();
266 				public Object clone();
267 				/**
268 				 * Whether the already streamed text, or the preceding segment if we are at the
269 				 * beginning of the stream, ended with a space.
270 				 *
271 				 * This method is only mandatory when we are the beginning of the stream. After the
272 				 * {@link #next(int, boolean, boolean)} method has been called,
273 				 * <code>hasPrecedingSpace</code> may throw a {@link UnsupportedOperationException}.
274 				 */
275 				public boolean hasPrecedingSpace();
276 			}
277 			
278 			public BrailleTranslator.LineIterator transform(final Iterable<CSSStyledText> text, int from, int to)
279 					throws TransformationException {
280 				List<Character> hyphenChars = new ArrayList<>();
281 				int wordSpacing; {
282 					wordSpacing = -1;
283 					for (CSSStyledText st : text) {
284 						SimpleInlineStyle style = st.getStyle();
285 						char hyphenChar = defaultHyphenChar;
286 						int spacing = 1;
287 						if (style != null) {
288 							CSSProperty val = style.getProperty("hyphenate-character");
289 							if (val != null) {
290 								if (val == HyphenateCharacter.braille_string) {
291 									String s = style.getValue(TermString.class, "hyphenate-character").getValue();
292 									if (s.length() == 1) {
293 										hyphenChar = s.charAt(0);
294 										if (brailleCharset != null)
295 											hyphenChar = brailleCharset.toText("" + hyphenChar).toCharArray()[0];
296 									} else
297 										logger.warn("The 'hyphenate-character' property must be a single character, "
298 										            + "but got {}", s); }
299 								style.removeProperty("hyphenate-character"); }
300 							val = style.getProperty("word-spacing");
301 							if (val != null) {
302 								if (val == WordSpacing.length) {
303 									spacing = style.getValue(TermInteger.class, "word-spacing").getIntValue();
304 									if (spacing < 0) {
305 										if (logger != null)
306 											logger.warn("word-spacing: {} not supported, must be non-negative", val);
307 										spacing = 1; }}
308 								
309 								// FIXME: assuming style is mutable and text.iterator() does not create copies
310 								style.removeProperty("word-spacing"); }}
311 						hyphenChars.add(hyphenChar);
312 						if (wordSpacing < 0)
313 							wordSpacing = spacing;
314 						else if (wordSpacing != spacing)
315 							throw new TransformationException("word-spacing must be constant, but both "
316 							                                  + wordSpacing + " and " + spacing + " specified"); }
317 					if (wordSpacing < 0) wordSpacing = 1;
318 				}
319 				List<BrailleTranslator.LineIterator> lineIterators = new ArrayList<>();
320 				Character hyphenChar = null;
321 				if (to < 0) to = hyphenChars.size();
322 				int i = from;
323 				while (i < to) {
324 					Character nextHyphenChar = hyphenChars.get(i);
325 					if (hyphenChar != nextHyphenChar) {
326 						if (i > from) {
327 							lineIterators.add(
328 								new LineIterator(translateAndHyphenate(text, from, i), blankChar, hyphenChar, wordSpacing));
329 							from = i; }
330 						hyphenChar = nextHyphenChar; }
331 					i++; }
332 				if (i > from)
333 					lineIterators.add(
334 						new LineIterator(translateAndHyphenate(text, from, i), blankChar, hyphenChar, wordSpacing));
335 				return CompoundBrailleTranslator.concatLineIterators(lineIterators);
336 			}
337 			
338 			protected static class FullyHyphenatedAndTranslatedString implements BrailleStream {
339 				private String next;
340 				private String lastWordPart; // if the last word continues in the next segment
341 				private String lastWordOtherPart;
342 				private final boolean precedingSpace;
343 				private final boolean alwaysEmpty;
344 				private final char hyphenChar;
345 				// SPACE, TAB, LF, CR, BLANK or LS
346 				private final static Pattern WORD_BOUNDARY = Pattern.compile("[\\x20\t\\n\\r\\u2800\u2028]");
347 				public FullyHyphenatedAndTranslatedString(String string) {
348 					this(string, 0, -1);
349 				}
350 				public FullyHyphenatedAndTranslatedString(String string, int from, int to) {
351 					this(string, from, to, SHY); // LineIterator converts SHY at the end of a line to a hyphen character
352 				}
353 				public FullyHyphenatedAndTranslatedString(String string, int from, int to, char hyphenChar) {
354 					// if there are no preceding segments, assume that we are at the beginning of a line,
355 					// so leading space can be stripped
356 					// FIXME: we can not make this assumption! see for example FormatterCoreContext,
357 					// which translates a space in order to obtain a value to use as margin
358 					// character, and it expects it to be a non-empty string
359 					//if (from == 0) precedingSpace = true; else
360 					precedingSpace = DefaultLineBreaker.hasPrecedingSpace(string, from);
361 					int len = string.length();
362 					if (from < 0 || from > len)
363 						throw new IllegalArgumentException();
364 					next = string.substring(from);
365 					lastWordPart = lastWordOtherPart = null;
366 					if (to >= 0 && to != len) {
367 						if (to > len || to < from)
368 							throw new IllegalArgumentException();
369 						to -= from;
370 						len -= from;
371 						int lastWordStart = lastIndexOf((" " + next.substring(0, to)), WORD_BOUNDARY);
372 						if (lastWordStart == to) {
373 							next = next.substring(0, to);
374 						} else {
375 							int lastWordEnd = to + indexOf((next.substring(to, len) + " "), WORD_BOUNDARY);
376 							if (lastWordEnd == to) {
377 								next = next.substring(0, to);
378 							} else {
379 								lastWordPart = next.substring(lastWordStart, to);
380 								lastWordOtherPart = next.substring(to, lastWordEnd);
381 								next = next.substring(0, lastWordStart);
382 								// ZWSP is not considered a WORD_BOUNDARY, but a word should not start with a ZWSP
383 								lastWordPart = lastWordPart.replaceAll("^\u200b*", "");
384 								if (lastWordPart.isEmpty())
385 									lastWordPart = lastWordOtherPart = null;
386 							}
387 						}
388 					} else {
389 						// remove SHY at end of stream because it would be converted to hyphen character
390 						next = next.replaceAll("\u00ad$", "");
391 					}
392 					if (next.replaceAll("[\u00ad\u200b]","").isEmpty())
393 						next = null;
394 					alwaysEmpty = (next == null);
395 					this.hyphenChar = hyphenChar;
396 				}
397 				public boolean hasNext() {
398 					return next != null || lastWordPart != null;
399 				}
400 				public String next(int limit, boolean force, boolean allowHyphens) {
401 					if (next != null) {
402 						String n = next;
403 						next = null;
404 						return n; }
405 					else if (lastWordPart != null) {
406 						if (force) {
407 							String n = lastWordPart;
408 							lastWordPart = null;
409 							return n; }
410 						Tuple2<String,byte[]> t = extractHyphens(lastWordPart + RS + lastWordOtherPart, false, SHY, ZWSP, RS);
411 						String lastWord = t._1;
412 						// if last word fits
413 						if (lastWord.length() <= limit) {
414 							String n = lastWordPart;
415 							lastWordPart = null;
416 							return n; }
417 						// else if the word can be hyphenated
418 						// assuming that if allowHyphens is true for this segment it will be true for the next segment
419 						else if (allowHyphens) {
420 							byte[] hyphens = t._2;
421 							boolean inFirstPart = false;
422 							String nextLastWordPart = null;
423 							for (int i = limit; i > 0; i--) {
424 								// FIXME: don't hard-code the number 4
425 								if ((hyphens[i - 1] & 4) == 4) {
426 									inFirstPart = true;
427 									nextLastWordPart = lastWord.substring(0, i); }
428 								// FIXME: don't hard-code these numbers
429 								if ((((hyphens[i - 1] & 1) == 1) && (i < limit)) || ((hyphens[i - 1] & 2) == 2)) {
430 									if (!inFirstPart) {
431 										// break point in second part of word, or in first part and first part does not fit on line
432 										// in both cases the implicit line breaking will break correctly
433 										String n = lastWordPart;
434 										lastWordPart = null;
435 										return n; }
436 									else {
437 										// break point in first part of word and first part fits on line
438 										// need to switch to "explicit" mode because otherwise the first part would not be broken
439 										String n = lastWord.substring(0, i);
440 										// FIXME: don't hard-code the number 1
441 										if ((hyphens[i - 1] & 1) == 1)
442 											n += hyphenChar;
443 										lastWordPart = nextLastWordPart.substring(i);
444 										if (lastWordPart.isEmpty()) lastWordPart = null;
445 										return n; }}}}
446 						// else move the word to the next line
447 						return "";
448 					}
449 					else
450 						throw new NoSuchElementException();
451 				}
452 				public Character peek() {
453 					if (next != null)
454 						return next.charAt(0);
455 					else if (lastWordPart != null)
456 						return lastWordPart.charAt(0);
457 					else
458 						throw new NoSuchElementException();
459 				}
460 				public String remainder() {
461 					if (next == null && lastWordPart == null)
462 						throw new NoSuchElementException();
463 					StringBuilder remainder = new StringBuilder();
464 					if (next != null)
465 						remainder.append(next);
466 					if (lastWordPart != null)
467 						remainder.append(lastWordPart);
468 					return remainder.toString();
469 				}
470 				public boolean hasPrecedingSpace() {
471 					if (next == null && !alwaysEmpty)
472 						throw new UnsupportedOperationException();
473 					else
474 						return precedingSpace;
475 				}
476 				@Override
477 				public Object clone() {
478 					try {
479 						return super.clone();
480 					} catch (CloneNotSupportedException e) {
481 						throw new InternalError("coding error");
482 					}
483 				}
484 				/**
485 				 * Version of {@link String#indexOf(int)} that searched for pattern instead of character.
486 				 */
487 				private static int indexOf(String string, Pattern pattern) {
488 					Matcher m = pattern.matcher(string);
489 					if (m.find())
490 						return m.start();
491 					else
492 						return -1;
493 				}
494 				/**
495 				 * Version of {@link String#lastIndexOf(int)} that searched for pattern instead of character.
496 				 */
497 				private static int lastIndexOf(String string, Pattern pattern) {
498 					Matcher m = pattern.matcher(string);
499 					int lastIndex = -1;
500 					while (m.find())
501 						lastIndex = m.start();
502 					return lastIndex;
503 				}
504 			}
505 			
506 			public static class LineIterator implements BrailleTranslator.LineIterator, Cloneable {
507 				
508 				private final char blankChar;
509 				private final char hyphenChar;
510 				private final int wordSpacing;
511 				
512 				public LineIterator(String fullyHyphenatedAndTranslatedString, char blankChar, char hyphenChar, int wordSpacing) {
513 					this(fullyHyphenatedAndTranslatedString, 0, -1, blankChar, hyphenChar, wordSpacing);
514 				}
515 				
516 				public LineIterator(String fullyHyphenatedAndTranslatedString, int from, int to,
517 				                    char blankChar, char hyphenChar, int wordSpacing) {
518 					this(new FullyHyphenatedAndTranslatedString(fullyHyphenatedAndTranslatedString, from, to, hyphenChar),
519 					     blankChar, hyphenChar, wordSpacing);
520 				}
521 				
522 				public LineIterator(BrailleStream inputStream, char blankChar, char hyphenChar, int wordSpacing) {
523 					this.inputStream = inputStream;
524 					this.blankChar = blankChar;
525 					this.hyphenChar = hyphenChar;
526 					this.wordSpacing = wordSpacing;
527 					this.lastCharIsSpace = inputStream.hasPrecedingSpace();
528 				}
529 				
530 				private BrailleStream inputStream;
531 				private PeekingIterator<Character> inputBuffer = null;
532 				private StringBuilder charBuffer = new StringBuilder();
533 				
534 				/**
535 				 * Array with soft wrap opportunity info
536 				 * - SPACE, LF, CR, TAB and ZWSP create normal soft wrap opportunities
537 				 * - SHY create soft wrap opportunities that insert a hyphen glyph
538 				 * - normal soft wrap opportunities override soft wrap opportunities that insert a hyphen glyph
539 				 *
540 				 * @see <a href="http://braillespecs.github.io/braille-css/#h3_line-breaking">Braille CSS – § 9.4 Line Breaking</a>
541 				 */
542 				// byte at index i corresponds with boundary between characters i and i+1 of charBuffer
543 				// or end of string if i == charBuffer.length()
544 				private ArrayList<Byte> wrapInfo = new ArrayList<Byte>();
545 				// from lowest to highest precedence:
546 				private final static byte NO_SOFT_WRAP = (byte)0x0;
547 				private final static byte SOFT_WRAP_WITH_HYPHEN = (byte)0x1;     //    x
548 				private final static byte SOFT_WRAP_WITHOUT_HYPHEN = (byte)0x3;  //   xx
549 				private final static byte SOFT_WRAP_AFTER_SPACE = (byte)0x7;     //  xxx
550 				private final static byte HARD_WRAP = (byte)0x15;                // xxxx
551 				
552 				// for collapsing spaces
553 				private boolean lastCharIsSpace = false;
554 				private int forcedBreakCount = 0;
555 				
556 				/**
557 				 * Fill the character (charBuffer) and soft wrap opportunity (wrapInfo) buffers while normalising and collapsing spaces
558 				 * - until the buffers are at least 'limit' long
559 				 * - or until the current row is full according to the input feed (BrailleStream)
560 				 * - and while the remaining input starts with SPACE, LF, CR, TAB, NBSP, BRAILLE PATTERN BLANK, SHY, ZWSP or LS
561 				 */
562 				private void fillRow(int limit, boolean force, boolean allowHyphens) {
563 					int bufSize = charBuffer.length();
564 					while (true) {
565 						if (inputBuffer != null && !inputBuffer.hasNext()) {
566 							// there is a break opportunity after each string returned by the stream
567 							// we keep soft hyphens at the end of the string (also if we are at the very end of the stream)
568 							if (bufSize > 0 && wrapInfo.get(bufSize - 1) != SOFT_WRAP_WITH_HYPHEN)
569 								wrapInfo.set(bufSize - 1, (byte)(wrapInfo.get(bufSize - 1) | SOFT_WRAP_WITHOUT_HYPHEN));
570 							inputBuffer = null;
571 						}
572 						if (inputBuffer == null) {
573 							if (!inputStream.hasNext()) // end of stream
574 								return;
575 							if (bufSize < limit) {
576 								String next = inputStream.next(limit - bufSize, force && (bufSize == 0), allowHyphens);
577 								if (next.isEmpty()) // row full according to input feed
578 									return;
579 								inputBuffer = peekingIterator(charactersOf(next).iterator()); }
580 							if (inputBuffer == null) {
581 								switch (inputStream.peek()) {
582 								case SHY:    case TAB:
583 								case ZWSP:   case BLANK:
584 								case SPACE:  case NBSP:
585 								case LF:     case LS:
586 								case CR:
587 									String next = inputStream.next(1, true, allowHyphens);
588 									if (next.isEmpty())
589 										throw new RuntimeException("coding error");
590 									inputBuffer = peekingIterator(charactersOf(next).iterator());
591 									break;
592 								default:
593 									return; }}}
594 						char next = inputBuffer.peek();
595 						switch (next) {
596 						case SHY:
597 							if (bufSize > 0)
598 								wrapInfo.set(bufSize - 1, (byte)(wrapInfo.get(bufSize - 1) | SOFT_WRAP_WITH_HYPHEN));
599 							lastCharIsSpace = false;
600 							break;
601 						case ZWSP:
602 							if (bufSize > 0)
603 								wrapInfo.set(bufSize - 1, (byte)(wrapInfo.get(bufSize - 1) | SOFT_WRAP_WITHOUT_HYPHEN));
604 							break;
605 						case SPACE:
606 						case LF:
607 						case CR:
608 						case TAB:
609 						case BLANK:
610 							if (lastCharIsSpace)
611 								break;
612 							if (bufSize > 0)
613 								wrapInfo.set(bufSize - 1, (byte)(wrapInfo.get(bufSize - 1) | SOFT_WRAP_WITHOUT_HYPHEN));
614 							for (int i = 0; i < wordSpacing; i++) {
615 								charBuffer.append(blankChar);
616 								bufSize ++;
617 								wrapInfo.add(SOFT_WRAP_AFTER_SPACE);
618 							}
619 							lastCharIsSpace = true;
620 							break;
621 						case NBSP:
622 							charBuffer.append(blankChar);
623 							bufSize ++;
624 							wrapInfo.add(NO_SOFT_WRAP);
625 							lastCharIsSpace = false;
626 							break;
627 						case LS:
628 							if (bufSize > 0 && (wrapInfo.get(bufSize - 1) & HARD_WRAP) != HARD_WRAP)
629 								wrapInfo.set(bufSize - 1, (byte)(wrapInfo.get(bufSize - 1) | HARD_WRAP));
630 							else {
631 								// add a blank to attach the HARD_WRAP info to
632 								// otherwise we can't preserve empty lines
633 								charBuffer.append(blankChar);
634 								bufSize ++;
635 								wrapInfo.add(HARD_WRAP);
636 							}
637 							lastCharIsSpace = true;
638 							break;
639 						default:
640 							if (bufSize >= limit) return;
641 							charBuffer.append(next);
642 							bufSize ++;
643 							wrapInfo.add(NO_SOFT_WRAP);
644 							lastCharIsSpace = false; }
645 						inputBuffer.next();
646 					}
647 				}
648 				
649 				/**
650 				 * Flush the first 'size' elements of the character and soft wrap opportunity buffers
651 				 * Assumes that 'size &lt;= charBuffer.length()'
652 				 */
653 				private void flushBuffers(int size) {
654 					charBuffer = new StringBuilder(charBuffer.substring(size));
655 					wrapInfo = new ArrayList<Byte>(wrapInfo.subList(size, wrapInfo.size()));
656 				}
657 				
658 				/**
659 				 * @param limit specifies the maximum number of characters allowed in the result
660 				 * @param force specifies if the translator should force a break at the limit
661 				 *              if no natural break point is found
662 				 * @param wholeWordsOnly specifies that the row may not end on a break point inside a word.
663 				 * @return returns the translated string preceding the row break, including a translated hyphen
664 				 *                 at the end if needed.
665 				 */
666 				public String nextTranslatedRow(int limit, boolean force, boolean wholeWordsOnly) {
667 					fillRow(limit, force, !wholeWordsOnly);
668 					int bufSize = charBuffer.length();
669 					
670 					// charBuffer may be empty (even if hasNext() was true)
671 					if (bufSize == 0)
672 						return "";
673 					
674 					// always break at preserved line breaks
675 					for (int i = 0; i < min(bufSize, limit); i++) {
676 						if (wrapInfo.get(i) == HARD_WRAP) {
677 							int cut = i + 1;
678 							
679 							// strip trailing SPACE/LF/CR/TAB/BLANK/NBSP (all except NBSP are already collapsed into one)
680 							while (cut > 0 && charBuffer.charAt(cut - 1) == blankChar) cut--;
681 							
682 							// preserve if at beginning of stream
683 							if (cut == 0)
684 								cut = i + 1;
685 							String rv = charBuffer.substring(0, cut);
686 							
687 							// strip leading SPACE/LF/CR/TAB/BLANK in remaining text (are already collapsed into one)
688 							cut = i + 1;
689 							while (cut < bufSize && wrapInfo.get(cut) == SOFT_WRAP_AFTER_SPACE) cut++;
690 							flushBuffers(cut);
691 							return rv; }}
692 					
693 					// no need to break if remaining text is shorter than line
694 					if (bufSize < limit) {
695 						String rv = charBuffer.substring(0, bufSize);
696 							
697 						// replace soft hyphen with real hyphen
698 						if (wrapInfo.get(rv.length() - 1) == SOFT_WRAP_WITH_HYPHEN)
699 							rv += hyphenChar;
700 						charBuffer.setLength(0);
701 						wrapInfo.clear();
702 						
703 						// strip trailing SPACE/LF/CR/TAB/BLANK/NBSP (all except NBSP are already collapsed into one)
704 						int cut = bufSize;
705 						while (cut > 0 && rv.charAt(cut - 1) == blankChar) cut--;
706 						
707 						// preserve if at beginning of stream (because we can not assume that we are at the beginning of
708 						// a line) or end of stream (because we can not know for sure whether more segments will follow
709 						// on the same line)
710 						if (cut > 0 && cut < bufSize && hasNext())
711 							rv = rv.substring(0, cut);
712 						return rv; }
713 					
714 					// return nothing if limit is 0 (limit should not be less than 0, but check anyway)
715 					if (limit <= 0) {
716 						
717 						// strip leading SPACE/LF/CR/TAB/BLANK in remaining text (are already collapsed into one)
718 						int cut = 0;
719 						while (cut < bufSize && wrapInfo.get(cut) == SOFT_WRAP_AFTER_SPACE) cut++;
720 						flushBuffers(cut);
721 						return ""; }
722 					
723 					// break at SPACE or ZWSP
724 					// FIXME: ignore ZWSP if it comes from hyphenation (how to find out?) and wholeWordsOnly==true
725 					if ((wrapInfo.get(limit - 1) & SOFT_WRAP_WITHOUT_HYPHEN) == SOFT_WRAP_WITHOUT_HYPHEN) {
726 						int cut = limit;
727 						
728 						// strip trailing SPACE/LF/CR/TAB/BLANK/NBSP (all except NBSP are already collapsed into one)
729 						while (cut > 0 && charBuffer.charAt(cut - 1) == blankChar) cut--;
730 						
731 						// strip leading SPACE/LF/CR/TAB/BLANK in remaining text (are already collapsed into one)
732 						int cut2 = limit;
733 						while (cut2 < bufSize && wrapInfo.get(cut2) == SOFT_WRAP_AFTER_SPACE) cut2++;
734 						String rv = charBuffer.substring(0, cut2);
735 						flushBuffers(cut2);
736 						
737 						// preserve if at beginning of stream or end of stream and not overflowing
738 						if (cut > 0 && cut < cut2 && hasNext())
739 							rv = rv.substring(0, cut);
740 						else if (cut2 > limit)
741 							rv = rv.substring(0, limit);
742 						return rv; }
743 					
744 					// try to break later if the overflowing characters are blank
745 					for (int i = limit + 1; i - 1 < bufSize && charBuffer.charAt(i - 1) == blankChar; i++)
746 						if ((wrapInfo.get(i - 1) & SOFT_WRAP_WITHOUT_HYPHEN) == SOFT_WRAP_WITHOUT_HYPHEN) {
747 							
748 							// strip trailing SPACE/LF/CR/TAB/BLANK/NBSP (all except NBSP are already collapsed into one)
749 							int cut = limit;
750 							while (cut > 0 && charBuffer.charAt(cut - 1) == blankChar) cut--;
751 							
752 							// strip leading SPACE/LF/CR/TAB/BLANK in remaining text (are already collapsed into one)
753 							int cut2 = i;
754 							while (cut2 < bufSize && wrapInfo.get(cut2) == SOFT_WRAP_AFTER_SPACE) cut2++;
755 							String rv = charBuffer.substring(0, cut2);
756 							flushBuffers(cut2);
757 							
758 							// preserve if at end of stream and not overflowing
759 							if (cut < cut2 && hasNext())
760 								rv = rv.substring(0, cut);
761 							else if (cut2 > limit)
762 								rv = rv.substring(0, limit);
763 							return rv; }
764 					
765 					// try to break sooner
766 					for (int i = limit - 1; i > 0; i--) {
767 						
768 						// break at SPACE, ZWSP or SHY
769 						// FIXME: ignore ZWSP if it comes from hyphenation (how to find out?) and wholeWordsOnly==true
770 						if ((wrapInfo.get(i - 1) & SOFT_WRAP_WITHOUT_HYPHEN) == SOFT_WRAP_WITHOUT_HYPHEN
771 						    || (!wholeWordsOnly && (wrapInfo.get(i - 1) == SOFT_WRAP_WITH_HYPHEN))) {
772 							int cut = i;
773 							String rv;
774 							
775 							// insert hyphen glyph at SHY
776 							if (wrapInfo.get(cut - 1) == 0x1) {
777 								rv = charBuffer.substring(0, cut);
778 								rv += hyphenChar; }
779 							else {
780 								
781 								// strip trailing SPACE/LF/CR/TAB/BLANK/NBSP (all except NBSP are already collapsed into one)
782 								while (cut > 0 && charBuffer.charAt(cut - 1) == blankChar) cut--;
783 								
784 								// preserve if at beginning of stream
785 								if (cut == 0)
786 									cut = i;
787 								rv = charBuffer.substring(0, cut); }
788 							
789 							// strip leading SPACE/LF/CR/TAB/BLANK in remaining text (are already collapsed into one)
790 							// FIXME: breaks cases where space after SHY is not from letter-spacing
791 							cut = i;
792 							while(cut < bufSize && charBuffer.charAt(cut) == blankChar) cut++;
793 							flushBuffers(cut);
794 							return rv; }}
795 					
796 					// force hard break
797 					if (force) {
798 						forcedBreakCount++;
799 						String rv = charBuffer.substring(0, limit);
800 						flushBuffers(limit);
801 						return rv; }
802 					
803 					return "";
804 				}
805 				
806 				/**
807 				 * @return returns true if there are characters not yet extracted with nextTranslatedRow
808 				 */
809 				public boolean hasNext() {
810 					return charBuffer.length() > 0
811 						|| (inputBuffer != null && inputBuffer.hasNext())
812 						|| inputStream.hasNext();
813 				}
814 				
815 				/**
816 				 * @return returns the characters not yet extracted with nextTranslatedRow
817 				 */
818 				public String getTranslatedRemainder() {
819 					
820 					// Note that I could use clone() here, but the current code is slightly more efficient
821 					BrailleStream save_inputStream = inputStream;
822 					inputStream = emptyBrailleStream;
823 					List<Character> save_inputBuffer = inputBuffer != null ? ImmutableList.copyOf(inputBuffer) : null;
824 					if (save_inputBuffer != null) {
825 						if (save_inputStream.hasNext()) {
826 							inputBuffer = peekingIterator(
827 									concat(save_inputBuffer.iterator(), charactersOf(save_inputStream.remainder()).iterator()));
828 						} else {
829 							inputBuffer = peekingIterator(save_inputBuffer.iterator());
830 						}
831 					} else if (save_inputStream.hasNext()) {
832 						inputBuffer = peekingIterator(charactersOf(save_inputStream.remainder()).iterator());
833 					} else {
834 						inputBuffer = null;
835 					}
836 					StringBuilder save_charBuffer = charBuffer;
837 					charBuffer = new StringBuilder(charBuffer.toString());
838 					ArrayList<Byte> save_wrapInfo = wrapInfo;
839 					wrapInfo = new ArrayList<Byte>(wrapInfo);
840 					boolean save_lastCharIsSpace = lastCharIsSpace;
841 					int save_forcedBreakCount = forcedBreakCount;
842 					forcedBreakCount = 0;
843 					fillRow(Integer.MAX_VALUE, true, false);
844 					String remainder = charBuffer.toString();
845 					inputStream = save_inputStream;
846 					inputBuffer = save_inputBuffer != null ? peekingIterator(save_inputBuffer.iterator()) : null;
847 					charBuffer = save_charBuffer;
848 					wrapInfo = save_wrapInfo;
849 					lastCharIsSpace = save_lastCharIsSpace;
850 					forcedBreakCount = save_forcedBreakCount;
851 					return remainder;
852 				}
853 				
854 				/**
855 				 * @return returns the number of characters in getTranslatedRemainder
856 				 */
857 				public int countRemaining() {
858 					return getTranslatedRemainder().length();
859 				}
860 				
861 				/**
862 				 * @return returns a copy of this result in the current state
863 				 */
864 				public BrailleTranslatorResult copy() {
865 					return (BrailleTranslatorResult)clone();
866 				}
867 				
868 				@Override
869 				// FIXME: could this be done more efficiently/lazily?
870 				public Object clone() {
871 					LineIterator clone; {
872 						try {
873 							clone = (LineIterator)super.clone();
874 						} catch (CloneNotSupportedException e) {
875 							throw new InternalError("coding error");
876 						}
877 					}
878 					clone.inputStream = (BrailleStream)inputStream.clone();
879 					if (inputBuffer != null) {
880 						List<Character> list = ImmutableList.copyOf(inputBuffer);
881 						inputBuffer = peekingIterator(list.iterator());
882 						clone.inputBuffer = peekingIterator(list.iterator());
883 					}
884 					clone.charBuffer = new StringBuilder(charBuffer.toString());
885 					clone.wrapInfo = new ArrayList<Byte>(wrapInfo);
886 					return clone;
887 				}
888 				
889 				// FIXME: support METRIC_HYPHEN_COUNT
890 				public boolean supportsMetric(String metric) {
891 					return METRIC_FORCED_BREAK.equals(metric);
892 				}
893 				
894 				public double getMetric(String metric) {
895 					if (METRIC_FORCED_BREAK.equals(metric))
896 						return forcedBreakCount;
897 					else
898 						throw new UnsupportedMetricException("Metric not supported: " + metric);
899 				}
900 				
901 				private static final BrailleStream emptyBrailleStream = new BrailleStream() {
902 					public boolean hasNext() { return false; }
903 					public String next(int limit, boolean force, boolean allowHyphens) { throw new NoSuchElementException(); }
904 					public Character peek() { throw new NoSuchElementException(); }
905 					public String remainder() { throw new NoSuchElementException(); }
906 					public boolean hasPrecedingSpace() { return false; }
907 					@Override
908 					public Object clone() { return this; }
909 				};
910 			}
911 			
912 			/**
913 			 * Whether there is a space immediately before the substring starting at <code>before</code>.
914 			 */
915 			protected static boolean hasPrecedingSpace(String string, int before) {
916 				while (before > 0)
917 					switch (string.charAt(--before)) {
918 					case SPACE:
919 					case BLANK:
920 					case CR:
921 					case LF:
922 					case TAB:
923 					case LS:
924 						return true;
925 					case ZWSP:
926 						continue;
927 					default:
928 						return false;
929 					}
930 				return false;
931 			}
932 		}
933 	}
934 }