1   package org.daisy.pipeline.braille.liblouis.impl;
2   
3   import java.io.ByteArrayInputStream;
4   import java.io.File;
5   import java.io.FileOutputStream;
6   import java.io.InputStream;
7   import java.io.IOException;
8   import java.net.URI;
9   import java.net.URL;
10  import java.nio.charset.StandardCharsets;
11  import java.nio.file.Files;
12  import static java.nio.file.Files.createTempDirectory;
13  import java.text.Normalizer;
14  import java.util.HashMap;
15  import java.util.Locale;
16  import java.util.Map;
17  import java.util.NoSuchElementException;
18  import java.util.Set;
19  
20  import com.google.common.base.Function;
21  import static com.google.common.base.Functions.toStringFunction;
22  import com.google.common.base.MoreObjects;
23  import com.google.common.base.MoreObjects.ToStringHelper;
24  import static com.google.common.collect.Iterables.transform;
25  import static com.google.common.collect.Sets.newHashSet;
26  
27  import org.daisy.common.file.URLs;
28  import org.daisy.pipeline.braille.common.AbstractTransformProvider;
29  import org.daisy.pipeline.braille.common.AbstractTransformProvider.util.Iterables;
30  import static org.daisy.pipeline.braille.common.AbstractTransformProvider.util.logSelect;
31  import static org.daisy.pipeline.braille.common.AbstractTransformProvider.util.warn;
32  import org.daisy.pipeline.braille.common.NativePath;
33  import org.daisy.pipeline.braille.common.Query;
34  import org.daisy.pipeline.braille.common.Query.Feature;
35  import org.daisy.pipeline.braille.common.Query.MutableQuery;
36  import static org.daisy.pipeline.braille.common.Query.util.mutableQuery;
37  import org.daisy.pipeline.braille.common.Transform;
38  import org.daisy.pipeline.braille.common.TransformProvider;
39  import static org.daisy.pipeline.braille.common.TransformProvider.util.varyLocale;
40  import static org.daisy.pipeline.braille.common.util.Files.unpack;
41  import static org.daisy.pipeline.braille.common.util.Files.asFile;
42  import static org.daisy.pipeline.braille.common.util.Files.normalize;
43  import static org.daisy.pipeline.braille.common.util.Locales.parseLocale;
44  import static org.daisy.pipeline.braille.common.util.Strings.join;
45  import org.daisy.pipeline.braille.common.WithSideEffect;
46  import org.daisy.pipeline.braille.liblouis.LiblouisTable;
47  
48  import org.liblouis.CompilationException;
49  import org.liblouis.DisplayTable;
50  import org.liblouis.DisplayTable.Fallback;
51  import org.liblouis.Louis;
52  import org.liblouis.Table;
53  import org.liblouis.TableInfo;
54  import org.liblouis.TableResolver;
55  import org.liblouis.Translator;
56  
57  import org.osgi.service.component.annotations.Activate;
58  import org.osgi.service.component.annotations.Component;
59  import org.osgi.service.component.annotations.Deactivate;
60  import org.osgi.service.component.annotations.Reference;
61  import org.osgi.service.component.annotations.ReferenceCardinality;
62  import org.osgi.service.component.annotations.ReferencePolicy;
63  
64  import org.slf4j.Logger;
65  import org.slf4j.LoggerFactory;
66  
67  @Component(
68  	name = "org.daisy.pipeline.braille.liblouis.impl.LiblouisTableJnaImplProvider",
69  	service = {
70  		LiblouisTableJnaImplProvider.class
71  	}
72  )
73  public class LiblouisTableJnaImplProvider extends AbstractTransformProvider<LiblouisTableJnaImplProvider.LiblouisTableJnaImpl> {
74  
75  	// FIXME: isn't really a Transform but implements it so that we can use TransformProvider
76  	public class LiblouisTableJnaImpl extends LiblouisTable implements Transform {
77  		
78  		private final Translator translator;
79  		private final DisplayTable displayTable;
80  		private final TableInfo info;
81  		
82  		LiblouisTableJnaImpl(Translator translator, DisplayTable displayTable, TableInfo info) {
83  			super(translator.getTable());
84  			this.translator = translator;
85  			this.displayTable = displayTable;
86  			this.info = info;
87  		}
88  		
89  		public Translator getTranslator() {
90  			return translator;
91  		}
92  		
93  		public DisplayTable getDisplayTable() {
94  			return displayTable;
95  		}
96  		
97  		public String getIdentifier() {
98  			return toString();
99  		}
100 		
101 		public String getDisplayName() {
102 			return info != null ? info.get("display-name") : null;
103 		}
104 		
105 		public Normalizer.Form getUnicodeNormalizationForm() {
106 			if (info != null) {
107 				String form = info.get("unicode-form");
108 				if (form != null)
109 					try {
110 						return Normalizer.Form.valueOf(form.toUpperCase());
111 					} catch (IllegalArgumentException e) {}}
112 			return null;
113 		}
114 
115 		@Override
116 		public String toString() {
117 			return MoreObjects.toStringHelper("LiblouisTableJnaImpl")
118 			                  .add("translator", super.toString())
119 			                  .add("displayTable", displayTable)
120 			                  .toString();
121 		}
122 	}
123 	
124 	private LiblouisTableRegistry tableRegistry;
125 	
126 	private DisplayTable unicodeDisplayTable;
127 	private DisplayTable unicodeDisplayTableWithNoBreakSpace;
128 	private File spacesFile;
129 	private File spacesDisFile;
130 	private File tempDir;
131 	
132 	private void registerTableResolver() {
133 		Louis.setTableResolver(new TableResolver() {
134 				private final Map<String,URL> aggregatorTables = new HashMap<String,URL>();
135 				@Override
136 				public URL resolve(String table, URL base) {
137 					logger.debug("Resolving " + table + (base != null ? " against base " + base : ""));
138 					// if we are resolving an include rule from a generated aggregator table, resolve without base
139 					if (aggregatorTables.containsValue(base))
140 						base = null;
141 					File baseFile = base == null ? null : asFile(base); // base is expected to be a file
142 					File[] resolved = tableRegistry.resolveLiblouisTable(new LiblouisTable(table), baseFile);
143 					if (resolved != null) {
144 						logger.debug("Resolved to " + join(resolved, ","));
145 						if (resolved.length == 1)
146 							return URLs.asURL(resolved[0]);
147 						else {
148 							// if it is a comma separated table list, create a single file that includes all the sub-tables
149 							if (aggregatorTables.containsKey(table)) {
150 								URL u = aggregatorTables.get(table);
151 								logger.debug("... aggregated into " + u);
152 								return u;
153 							}
154 							try {
155 								StringBuilder b = new StringBuilder();
156 								for (File f : resolved)
157 									b.append("include ").append(URLs.asURI(f.getCanonicalFile()).toASCIIString()).append('\n');
158 								InputStream in = new ByteArrayInputStream(b.toString().getBytes(StandardCharsets.UTF_8));
159 								File f = File.createTempFile("aggregator-", ".tbl", tempDir);
160 								f.deleteOnExit();
161 								f.delete();
162 								Files.copy(in, f.toPath());
163 								f = f.getCanonicalFile();
164 								URL u = URLs.asURL(f);
165 								aggregatorTables.put(table, u);
166 								logger.debug("... aggregated into " + u);
167 								return u;
168 							} catch (IOException e) {
169 								throw new RuntimeException(e); // should not happen
170 							}
171 						}
172 					}
173 					logger.error("Table could not be resolved");
174 					return null;
175 				}
176 				@Override
177 				public Set<String> list() {
178 					return newHashSet(
179 						transform(
180 							tableRegistry.listAllTableFiles(),
181 							toStringFunction()));
182 				}
183 			}
184 		);
185 	}
186 	
187 	// WARNING: only one instance of LiblouisTableJnaImplProvider should be created because
188 	// setLibraryPath, setTableResolver and setLogger are global functions
189 	@Activate
190 	protected void activate() {
191 		logger.debug("Loading liblouis service");
192 		try {
193 			tempDir = normalize(createTempDirectory("pipeline-").toFile());
194 			tempDir.deleteOnExit();
195 		} catch (Exception e) {
196 			throw new RuntimeException("Could not create temporary directory", e);
197 		}
198 		try {
199 			tableRegistry.onPathChange(
200 				new Function<LiblouisTableRegistry,Void>() {
201 					public Void apply(LiblouisTableRegistry r) {
202 						// re-register table resolver so that liblouis-java re-indexes tables
203 						registerTableResolver();
204 						invalidateCache();
205 						return null; }});
206 			registerTableResolver();
207 			// invoke after table resolver registered because otherwise default tables will be unpacked for no reason
208 			logger.debug("liblouis version: {}", Louis.getVersion());
209 			File unicodeDisFile = new File(tempDir, "unicode.dis");
210 			unpack(
211 				URLs.getResourceFromJAR("/tables/unicode.dis", LiblouisTableJnaImplProvider.class),
212 				unicodeDisFile);
213 			unicodeDisFile.deleteOnExit();
214 			unicodeDisplayTable = DisplayTable.fromTable("" + URLs.asURI(unicodeDisFile), Fallback.MASK);
215 			spacesFile = new File(tempDir, "spaces.cti");
216 			unpack(
217 				URLs.getResourceFromJAR("/tables/spaces.cti", LiblouisTableJnaImplProvider.class),
218 				spacesFile);
219 			spacesFile.deleteOnExit();
220 			spacesDisFile = new File(tempDir, "spaces.dis");
221 			unpack(
222 				URLs.getResourceFromJAR("/tables/spaces.dis", LiblouisTableJnaImplProvider.class),
223 				spacesDisFile);
224 			spacesDisFile.deleteOnExit();
225 			unicodeDisplayTableWithNoBreakSpace = DisplayTable.fromTable(
226 				"" + URLs.asURI(unicodeDisFile) + "," + URLs.asURI(spacesDisFile), Fallback.MASK);
227 			Louis.setLogger(new org.liblouis.Logger() {
228 					@Override
229 					public void log(Level level, String message) {
230 						switch (level) {
231 						case ALL: logger.trace(message); break;
232 						case DEBUG: logger.debug(message); break;
233 						case INFO: logger.debug("INFO: " + message); break;
234 						case WARN: logger.debug("WARN: " + message); break;
235 						// FIXME: capture these and include them into CompilationException or TranslationException
236 						case ERROR: logger.error(message); break;
237 						case FATAL: logger.error(message); break; }}}); }
238 		catch (Throwable e) {
239 			logger.error("liblouis service could not be loaded", e);
240 			throw e; }
241 	}
242 	
243 	@Deactivate
244 	protected void deactivate() {
245 		logger.debug("Unloading liblouis service");
246 	}
247 	
248 	@Reference(
249 		name = "LiblouisLibrary",
250 		unbind = "-",
251 		service = NativePath.class,
252 		target = "(identifier=http://www.liblouis.org/native/*)",
253 		cardinality = ReferenceCardinality.MANDATORY,
254 		policy = ReferencePolicy.STATIC
255 	)
256 	protected void bindLibrary(NativePath path) {
257 		if (LiblouisExternalNativePath.LIBLOUIS_EXTERNAL)
258 			logger.info("Using external liblouis");
259 		else {
260 			URI libraryPath = path.get("liblouis").iterator().next();
261 			Louis.setLibraryPath(asFile(path.resolve(libraryPath)));
262 			logger.debug("Registering liblouis library: " + libraryPath); }
263 	}
264 	
265 	@Reference(
266 		name = "LiblouisTableRegistry",
267 		unbind = "-",
268 		service = LiblouisTableRegistry.class,
269 		cardinality = ReferenceCardinality.MANDATORY,
270 		policy = ReferencePolicy.STATIC
271 	)
272 	protected void bindTableRegistry(LiblouisTableRegistry registry) {
273 		tableRegistry = registry;
274 		logger.debug("Registering Liblouis table registry: " + registry);
275 	}
276 	
277 	public Iterable<LiblouisTableJnaImpl> _get(Query query) {
278 		return logSelect(query, _provider);
279 	}
280 	
281 	@Override
282 	public ToStringHelper toStringHelper() {
283 		return MoreObjects.toStringHelper("LiblouisTableJnaImplProvider");
284 	}
285 	
286 	private TransformProvider<LiblouisTableJnaImpl> _provider
287 	= varyLocale(
288 		new AbstractTransformProvider<LiblouisTableJnaImpl>() {
289 			public Iterable<LiblouisTableJnaImpl> _get(final Query query) {
290 				return Iterables.of(
291 					new WithSideEffect<LiblouisTableJnaImpl,Logger>() {
292 						public LiblouisTableJnaImpl _apply() {
293 							MutableQuery q = mutableQuery(query);
294 							String table = null;
295 							String charset = null;
296 							TableInfo tableInfo = null;
297 							boolean whiteSpace = false;
298 							String dotsForUndefinedChar = null;
299 							Locale documentLocale = null;
300 							if (q.containsKey("white-space")) {
301 								q.removeOnly("white-space");
302 								whiteSpace = true; }
303 							if (q.containsKey("dots-for-undefined-char")) {
304 								dotsForUndefinedChar = q.removeOnly("dots-for-undefined-char").getValue().get();
305 								if (!dotsForUndefinedChar.matches("[\u2800-\u28FF]+")) {
306 									logger.warn(dotsForUndefinedChar + " is not a valid dot pattern string.");
307 									throw new NoSuchElementException();
308 								}
309 							}
310 							if (q.containsKey("document-locale"))
311 								documentLocale = parseLocale(q.removeOnly("document-locale").getValue().get());
312 							if (q.containsKey("charset") || q.containsKey("braille-charset"))
313 								charset = q.containsKey("charset")
314 									? q.removeOnly("charset").getValue().get()
315 									: q.removeOnly("braille-charset").getValue().get();
316 							if (q.containsKey("table") || q.containsKey("liblouis-table")) {
317 								table = q.containsKey("table")
318 									? q.removeOnly("table").getValue().get()
319 									: q.removeOnly("liblouis-table").getValue().get();
320 								tableInfo = new TableInfo(table);
321 								if (q.containsKey("locale")) {
322 									// locale is shorthand for language + region
323 									String locale = q.removeOnly("locale").getValue().get();
324 									q.add("language", locale);
325 									q.add("region", locale);
326 								}
327 								for (Feature f : q)
328 									if (!f.getValue().orElse("yes").equals(tableInfo.get(f.getKey()))) {
329 										logger.warn("Table " + table + " does not match " + f);
330 										throw new NoSuchElementException(); }
331 							} else {
332 								if (documentLocale != null && !q.containsKey("locale")) {
333 									// Liblouis table selection happens based on a language tag (primary target language
334 									// of the braille code) and an optional region tag (region or community in which the
335 									// braille code applies).
336 									if (!documentLocale.equals(new Locale(documentLocale.getLanguage(), documentLocale.getCountry()))) {
337 										// If the document locale has other subtags than language and region, we
338 										// interpret the locale as a language.
339 										if (!q.containsKey("language"))
340 											q.add("language", documentLocale.toLanguageTag());
341 									} else if (q.containsKey("region")) {
342 										// If the region is already specified in the query, we ignore the region subtag
343 										// of the document locale.
344 										if (!q.containsKey("language"))
345 											q.add("language", documentLocale.getLanguage());
346 									} else {
347 										// Otherwise we use the language subtag of the document locale as the language,
348 										// and the region subtag (if specified) as the region in which the braille code
349 										// applies.
350 										if (!q.containsKey("language"))
351 											q.add("language", documentLocale.getLanguage());
352 										if (!"".equals(documentLocale.getCountry()))
353 											q.add("region", documentLocale.toLanguageTag());
354 									}
355 								}
356 								if (q.isEmpty())
357 									throw new NoSuchElementException();
358 								StringBuilder b = new StringBuilder();
359 								
360 								// FIXME: if query does not contain (type:display), need to match for absence of "type:display"
361 								// -> i.e. Liblouis query syntax must support negation!
362 								// -> this used to be solved by matching "type:translation" but the downside of this
363 								//    is that this feature had to be added to every table which is not desired
364 								// -> another solution would be to let Liblouis return a list of possible matches and
365 								//    select the first match that does not end with ".dis" (or that does not have the
366 								//    feature "type:display")
367 								
368 								for (Feature f : q) {
369 									String k = f.getKey();
370 									if (!k.matches("[a-zA-Z0-9_-]+")) {
371 										__apply(
372 											warn("Invalid syntax for feature key: " + k));
373 										throw new NoSuchElementException(); }
374 									b.append(k);
375 									if (f.hasValue()) {
376 										String v = f.getValue().get();
377 										if (!v.matches("[a-zA-Z0-9_-]+")) {
378 											__apply(
379 												warn("Invalid syntax for feature value: " + v));
380 											throw new NoSuchElementException(); }
381 										b.append(":" + v); }
382 									b.append(" "); }
383 								try {
384 									Table t = Table.find(b.toString());
385 									table = t.getIdentifier();
386 									tableInfo = t.getInfo(); }
387 								catch (IllegalArgumentException e) {}
388 								catch (NoSuchElementException e) {}}
389 							if (table != null) {
390 								if (whiteSpace)
391 									table = URLs.asURI(spacesFile) + "," + table;
392 								DisplayTable displayTable = null;
393 								if (charset == null)
394 									displayTable = whiteSpace ? unicodeDisplayTableWithNoBreakSpace : unicodeDisplayTable;
395 								else
396 									try {
397 										if (whiteSpace)
398 											charset = "" + URLs.asURI(spacesDisFile) + "," + charset;
399 										// using Translator.asDisplayTable() and not DisplayTable.fromTable() so we can
400 										// catch CompilationException
401 										displayTable = new Translator(charset).asDisplayTable(); }
402 									catch (CompilationException e) {
403 										// the specified table is not a Liblouis table
404 										throw new NoSuchElementException(); }
405 								if (dotsForUndefinedChar != null) {
406 									try {
407 										File undefinedFile = File.createTempFile("undefined-", ".uti", tempDir);
408 										undefinedFile.deleteOnExit();
409 										undefinedFile.createNewFile();
410 										FileOutputStream writer = new FileOutputStream(undefinedFile);
411 										String dotPattern; {
412 											StringBuilder b = new StringBuilder();
413 											for (char c : dotsForUndefinedChar.toCharArray()) {
414 												b.append("-");
415 												c &= (char)0xFF;
416 												if (c == 0)
417 													b.append("0");
418 												else
419 													for (int k = 1; k <= 8; k++) {
420 														if ((c & (char)1) != 0)
421 															b.append(k);
422 														c = (char)(c >> 1); }}
423 											dotPattern = b.toString().substring(1); }
424 										writer.write(("undefined " + dotPattern + "\n").getBytes());
425 										writer.flush();
426 										writer.close();
427 										// adding the "undefined" rule to the end overwrites any previous rules
428 										table = table + "," + URLs.asURI(undefinedFile);
429 									} catch (IOException e) {
430 										throw new RuntimeException(e);
431 									}
432 								}
433 								try {
434 									return new LiblouisTableJnaImpl(new Translator(table), displayTable, tableInfo); }
435 								catch (CompilationException e) {
436 									__apply(
437 										warn("Could not compile table " + table));
438 									logger.warn("Could not compile table", e); }}
439 							throw new NoSuchElementException();
440 						}
441 					}
442 				);
443 			}
444 		}
445 	);
446 	
447 	private static final Logger logger = LoggerFactory.getLogger(LiblouisTableJnaImplProvider.class);
448 	
449 }