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