001package ca.uhn.fhir.narrative; 002 003/* 004 * #%L 005 * HAPI FHIR - Core Library 006 * %% 007 * Copyright (C) 2014 - 2016 University Health Network 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022import static org.apache.commons.lang3.StringUtils.isBlank; 023 024import java.io.File; 025import java.io.FileInputStream; 026import java.io.IOException; 027import java.io.InputStream; 028import java.io.Reader; 029import java.io.StringReader; 030import java.util.HashMap; 031import java.util.HashSet; 032import java.util.List; 033import java.util.Properties; 034 035import org.apache.commons.io.IOUtils; 036import org.apache.commons.io.input.ReaderInputStream; 037import org.apache.commons.lang3.StringUtils; 038import org.hl7.fhir.instance.model.api.IBaseDatatype; 039import org.hl7.fhir.instance.model.api.IBaseResource; 040import org.hl7.fhir.instance.model.api.INarrative; 041import org.thymeleaf.Arguments; 042import org.thymeleaf.Configuration; 043import org.thymeleaf.TemplateEngine; 044import org.thymeleaf.TemplateProcessingParameters; 045import org.thymeleaf.context.Context; 046import org.thymeleaf.dom.Document; 047import org.thymeleaf.dom.Element; 048import org.thymeleaf.dom.Node; 049import org.thymeleaf.exceptions.TemplateInputException; 050import org.thymeleaf.messageresolver.StandardMessageResolver; 051import org.thymeleaf.processor.IProcessor; 052import org.thymeleaf.processor.ProcessorResult; 053import org.thymeleaf.processor.attr.AbstractAttrProcessor; 054import org.thymeleaf.resourceresolver.IResourceResolver; 055import org.thymeleaf.standard.StandardDialect; 056import org.thymeleaf.standard.expression.IStandardExpression; 057import org.thymeleaf.standard.expression.IStandardExpressionParser; 058import org.thymeleaf.standard.expression.StandardExpressions; 059import org.thymeleaf.templatemode.StandardTemplateModeHandlers; 060import org.thymeleaf.templateparser.xmlsax.XhtmlAndHtml5NonValidatingSAXTemplateParser; 061import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver; 062import org.thymeleaf.templateresolver.TemplateResolver; 063 064import ca.uhn.fhir.context.ConfigurationException; 065import ca.uhn.fhir.context.FhirContext; 066import ca.uhn.fhir.model.api.IDatatype; 067import ca.uhn.fhir.model.api.annotation.ResourceDef; 068import ca.uhn.fhir.parser.DataFormatException; 069 070public abstract class BaseThymeleafNarrativeGenerator implements INarrativeGenerator { 071 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseThymeleafNarrativeGenerator.class); 072 private static final XhtmlAndHtml5NonValidatingSAXTemplateParser PARSER = new XhtmlAndHtml5NonValidatingSAXTemplateParser(1); 073 074 private Configuration myThymeleafConfig; 075 private boolean myApplyDefaultDatatypeTemplates = true; 076 private HashMap<Class<?>, String> myClassToName; 077 private boolean myCleanWhitespace = true; 078 private boolean myIgnoreFailures = true; 079 private boolean myIgnoreMissingTemplates = true; 080 private volatile boolean myInitialized; 081 private HashMap<String, String> myNameToNarrativeTemplate; 082 private TemplateEngine myProfileTemplateEngine; 083 084 public BaseThymeleafNarrativeGenerator() { 085 myThymeleafConfig = new Configuration(); 086 myThymeleafConfig.addTemplateResolver(new ClassLoaderTemplateResolver()); 087 myThymeleafConfig.addMessageResolver(new StandardMessageResolver()); 088 myThymeleafConfig.setTemplateModeHandlers(StandardTemplateModeHandlers.ALL_TEMPLATE_MODE_HANDLERS); 089 myThymeleafConfig.initialize(); 090 } 091 092 093 094 @Override 095 public void generateNarrative(FhirContext theContext, IBaseResource theResource, INarrative theNarrative) { 096 if (!myInitialized) { 097 initialize(theContext); 098 } 099 100 String name = null; 101 if (name == null) { 102 name = myClassToName.get(theResource.getClass()); 103 } 104 if (name == null) { 105 name = theContext.getResourceDefinition(theResource).getName().toLowerCase(); 106 } 107 108 if (name == null) { 109 if (myIgnoreMissingTemplates) { 110 ourLog.debug("No narrative template available for resorce: {}", name); 111 try { 112 theNarrative.setDivAsString("<div>No narrative template available for resource : " + name + "</div>"); 113 } catch (Exception e) { 114 // last resort.. 115 } 116 theNarrative.setStatusAsString("empty"); 117 return; 118 } else { 119 throw new DataFormatException("No narrative template for class " + theResource.getClass().getCanonicalName()); 120 } 121 } 122 123 try { 124 Context context = new Context(); 125 context.setVariable("resource", theResource); 126 context.setVariable("fhirVersion", theContext.getVersion().getVersion().name()); 127 128 String result = myProfileTemplateEngine.process(name, context); 129 130 if (myCleanWhitespace) { 131 ourLog.trace("Pre-whitespace cleaning: ", result); 132 result = cleanWhitespace(result); 133 ourLog.trace("Post-whitespace cleaning: ", result); 134 } 135 136 if (isBlank(result)) { 137 return; 138 } 139 140 theNarrative.setDivAsString(result); 141 theNarrative.setStatusAsString("generated"); 142 return; 143 } catch (Exception e) { 144 if (myIgnoreFailures) { 145 ourLog.error("Failed to generate narrative", e); 146 try { 147 theNarrative.setDivAsString("<div>No narrative available - Error: " + e.getMessage() + "</div>"); 148 } catch (Exception e1) { 149 // last resort.. 150 } 151 theNarrative.setStatusAsString("empty"); 152 return; 153 } else { 154 throw new DataFormatException(e); 155 } 156 } 157 } 158 159 160 161 protected abstract List<String> getPropertyFile(); 162 163 private Document getXhtmlDOMFor(final Reader source) { 164 final Configuration configuration1 = myThymeleafConfig; 165 try { 166 return PARSER.parseTemplate(configuration1, "input", source); 167 } catch (final Exception e) { 168 throw new TemplateInputException("Exception during parsing of source", e); 169 } 170 } 171 172 private synchronized void initialize(FhirContext theContext) { 173 if (myInitialized) { 174 return; 175 } 176 177 ourLog.info("Initializing narrative generator"); 178 179 myClassToName = new HashMap<Class<?>, String>(); 180 myNameToNarrativeTemplate = new HashMap<String, String>(); 181 182 List<String> propFileName = getPropertyFile(); 183 184 try { 185 if (myApplyDefaultDatatypeTemplates) { 186 loadProperties(DefaultThymeleafNarrativeGenerator.NARRATIVES_PROPERTIES); 187 } 188 for (String next : propFileName) { 189 loadProperties(next); 190 } 191 } catch (IOException e) { 192 ourLog.info("Failed to load property file " + propFileName, e); 193 throw new ConfigurationException("Can not load property file " + propFileName, e); 194 } 195 196 { 197 myProfileTemplateEngine = new TemplateEngine(); 198 TemplateResolver resolver = new TemplateResolver(); 199 resolver.setResourceResolver(new ProfileResourceResolver()); 200 myProfileTemplateEngine.setTemplateResolver(resolver); 201 StandardDialect dialect = new StandardDialect(); 202 HashSet<IProcessor> additionalProcessors = new HashSet<IProcessor>(); 203 additionalProcessors.add(new NarrativeAttributeProcessor(theContext)); 204 dialect.setAdditionalProcessors(additionalProcessors); 205 myProfileTemplateEngine.setDialect(dialect); 206 myProfileTemplateEngine.initialize(); 207 } 208 209 myInitialized = true; 210 } 211 212 /** 213 * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative 214 * before it is returned. 215 * <p> 216 * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g. 217 * "\n \n ") will be trimmed to a single space. 218 * </p> 219 */ 220 public boolean isCleanWhitespace() { 221 return myCleanWhitespace; 222 } 223 224 /** 225 * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the 226 * generator will suppress any generated exceptions, and simply return a default narrative indicating that no 227 * narrative is available. 228 */ 229 public boolean isIgnoreFailures() { 230 return myIgnoreFailures; 231 } 232 233 /** 234 * If set to true, will return an empty narrative block for any profiles where no template is available 235 */ 236 public boolean isIgnoreMissingTemplates() { 237 return myIgnoreMissingTemplates; 238 } 239 240 private void loadProperties(String propFileName) throws IOException { 241 ourLog.debug("Loading narrative properties file: {}", propFileName); 242 243 Properties file = new Properties(); 244 245 InputStream resource = loadResource(propFileName); 246 file.load(resource); 247 for (Object nextKeyObj : file.keySet()) { 248 String nextKey = (String) nextKeyObj; 249 if (nextKey.endsWith(".profile")) { 250 String name = nextKey.substring(0, nextKey.indexOf(".profile")); 251 if (isBlank(name)) { 252 continue; 253 } 254 255 String narrativePropName = name + ".narrative"; 256 String narrativeName = file.getProperty(narrativePropName); 257 if (isBlank(narrativeName)) { 258 throw new ConfigurationException("Found property '" + nextKey + "' but no corresponding property '" + narrativePropName + "' in file " + propFileName); 259 } 260 261 if (StringUtils.isNotBlank(narrativeName)) { 262 String narrative = IOUtils.toString(loadResource(narrativeName)); 263 myNameToNarrativeTemplate.put(name, narrative); 264 } 265 266 } else if (nextKey.endsWith(".class")) { 267 268 String name = nextKey.substring(0, nextKey.indexOf(".class")); 269 if (isBlank(name)) { 270 continue; 271 } 272 273 String className = file.getProperty(nextKey); 274 275 Class<?> clazz; 276 try { 277 clazz = Class.forName(className); 278 } catch (ClassNotFoundException e) { 279 ourLog.debug("Unknown datatype class '{}' identified in narrative file {}", name, propFileName); 280 clazz = null; 281 } 282 283 if (clazz != null) { 284 myClassToName.put(clazz, name); 285 } 286 287 } else if (nextKey.endsWith(".narrative")) { 288 String name = nextKey.substring(0, nextKey.indexOf(".narrative")); 289 if (isBlank(name)) { 290 continue; 291 } 292 String narrativePropName = name + ".narrative"; 293 String narrativeName = file.getProperty(narrativePropName); 294 if (StringUtils.isNotBlank(narrativeName)) { 295 String narrative = IOUtils.toString(loadResource(narrativeName)); 296 myNameToNarrativeTemplate.put(name, narrative); 297 } 298 continue; 299 } else if (nextKey.endsWith(".title")) { 300 ourLog.debug("Ignoring title property as narrative generator no longer generates titles: {}", nextKey); 301 } else { 302 throw new ConfigurationException("Invalid property name: " + nextKey); 303 } 304 305 } 306 } 307 308 private InputStream loadResource(String name) throws IOException { 309 if (name.startsWith("classpath:")) { 310 String cpName = name.substring("classpath:".length()); 311 InputStream resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream(cpName); 312 if (resource == null) { 313 resource = DefaultThymeleafNarrativeGenerator.class.getResourceAsStream("/" + cpName); 314 if (resource == null) { 315 throw new IOException("Can not find '" + cpName + "' on classpath"); 316 } 317 } 318 return resource; 319 } else if (name.startsWith("file:")) { 320 File file = new File(name.substring("file:".length())); 321 if (file.exists() == false) { 322 throw new IOException("File not found: " + file.getAbsolutePath()); 323 } 324 return new FileInputStream(file); 325 } else { 326 throw new IOException("Invalid resource name: '" + name + "' (must start with classpath: or file: )"); 327 } 328 } 329 330 /** 331 * If set to <code>true</code> (which is the default), most whitespace will be trimmed from the generated narrative 332 * before it is returned. 333 * <p> 334 * Note that in order to preserve formatting, not all whitespace is trimmed. Repeated whitespace characters (e.g. 335 * "\n \n ") will be trimmed to a single space. 336 * </p> 337 */ 338 public void setCleanWhitespace(boolean theCleanWhitespace) { 339 myCleanWhitespace = theCleanWhitespace; 340 } 341 342 /** 343 * If set to <code>true</code>, which is the default, if any failure occurs during narrative generation the 344 * generator will suppress any generated exceptions, and simply return a default narrative indicating that no 345 * narrative is available. 346 */ 347 public void setIgnoreFailures(boolean theIgnoreFailures) { 348 myIgnoreFailures = theIgnoreFailures; 349 } 350 351 /** 352 * If set to true, will return an empty narrative block for any profiles where no template is available 353 */ 354 public void setIgnoreMissingTemplates(boolean theIgnoreMissingTemplates) { 355 myIgnoreMissingTemplates = theIgnoreMissingTemplates; 356 } 357 358 static String cleanWhitespace(String theResult) { 359 StringBuilder b = new StringBuilder(); 360 boolean inWhitespace = false; 361 boolean betweenTags = false; 362 boolean lastNonWhitespaceCharWasTagEnd = false; 363 boolean inPre = false; 364 for (int i = 0; i < theResult.length(); i++) { 365 char nextChar = theResult.charAt(i); 366 if (inPre) { 367 b.append(nextChar); 368 continue; 369 } else if (nextChar == '>') { 370 b.append(nextChar); 371 betweenTags = true; 372 lastNonWhitespaceCharWasTagEnd = true; 373 continue; 374 } else if (nextChar == '\n' || nextChar == '\r') { 375 // if (inWhitespace) { 376 // b.append(' '); 377 // inWhitespace = false; 378 // } 379 continue; 380 } 381 382 if (betweenTags) { 383 if (Character.isWhitespace(nextChar)) { 384 inWhitespace = true; 385 } else if (nextChar == '<') { 386 if (inWhitespace && !lastNonWhitespaceCharWasTagEnd) { 387 b.append(' '); 388 } 389 inWhitespace = false; 390 b.append(nextChar); 391 inWhitespace = false; 392 betweenTags = false; 393 lastNonWhitespaceCharWasTagEnd = false; 394 if (i + 3 < theResult.length()) { 395 char char1 = Character.toLowerCase(theResult.charAt(i + 1)); 396 char char2 = Character.toLowerCase(theResult.charAt(i + 2)); 397 char char3 = Character.toLowerCase(theResult.charAt(i + 3)); 398 char char4 = Character.toLowerCase((i + 4 < theResult.length()) ? theResult.charAt(i + 4) : ' '); 399 if (char1 == 'p' && char2 == 'r' && char3 == 'e') { 400 inPre = true; 401 } else if (char1 == '/' && char2 == 'p' && char3 == 'r'&&char4=='e') { 402 inPre = false; 403 } 404 } 405 } else { 406 lastNonWhitespaceCharWasTagEnd = false; 407 if (inWhitespace) { 408 b.append(' '); 409 inWhitespace = false; 410 } 411 b.append(nextChar); 412 } 413 } else { 414 b.append(nextChar); 415 } 416 } 417 return b.toString(); 418 } 419 420 public class NarrativeAttributeProcessor extends AbstractAttrProcessor { 421 422 private FhirContext myContext; 423 424 protected NarrativeAttributeProcessor(FhirContext theContext) { 425 super("narrative"); 426 myContext = theContext; 427 } 428 429 @Override 430 public int getPrecedence() { 431 return 0; 432 } 433 434 @SuppressWarnings("unchecked") 435 @Override 436 protected ProcessorResult processAttribute(Arguments theArguments, Element theElement, String theAttributeName) { 437 final String attributeValue = theElement.getAttributeValue(theAttributeName); 438 439 final Configuration configuration = theArguments.getConfiguration(); 440 final IStandardExpressionParser expressionParser = StandardExpressions.getExpressionParser(configuration); 441 442 final IStandardExpression expression = expressionParser.parseExpression(configuration, theArguments, attributeValue); 443 final Object value = expression.execute(configuration, theArguments); 444 445 theElement.removeAttribute(theAttributeName); 446 theElement.clearChildren(); 447 448 if (value == null) { 449 return ProcessorResult.ok(); 450 } 451 452 Context context = new Context(); 453 context.setVariable("fhirVersion", myContext.getVersion().getVersion().name()); 454 context.setVariable("resource", value); 455 456 String name = null; 457 if (value != null) { 458 Class<? extends Object> nextClass = value.getClass(); 459 do { 460 name = myClassToName.get(nextClass); 461 nextClass = nextClass.getSuperclass(); 462 } while (name == null && nextClass.equals(Object.class) == false); 463 464 if (name == null) { 465 if (value instanceof IBaseResource) { 466 name = myContext.getResourceDefinition((Class<? extends IBaseResource>) value).getName(); 467 } else if (value instanceof IDatatype) { 468 name = value.getClass().getSimpleName(); 469 name = name.substring(0, name.length() - 2); 470 } else if (value instanceof IBaseDatatype) { 471 name = value.getClass().getSimpleName(); 472 if (name.endsWith("Type")) { 473 name = name.substring(0, name.length() - 4); 474 } 475 } else { 476 throw new DataFormatException("Don't know how to determine name for type: " + value.getClass()); 477 } 478 name = name.toLowerCase(); 479 if (!myNameToNarrativeTemplate.containsKey(name)) { 480 name = null; 481 } 482 } 483 } 484 485 if (name == null) { 486 if (myIgnoreMissingTemplates) { 487 ourLog.debug("No narrative template available for type: {}", value.getClass()); 488 return ProcessorResult.ok(); 489 } else { 490 throw new DataFormatException("No narrative template for class " + value.getClass()); 491 } 492 } 493 494 String result = myProfileTemplateEngine.process(name, context); 495 String trim = result.trim(); 496 if (!isBlank(trim + "AAA")) { 497 Document dom = getXhtmlDOMFor(new StringReader(trim)); 498 499 Element firstChild = (Element) dom.getFirstChild(); 500 for (int i = 0; i < firstChild.getChildren().size(); i++) { 501 Node next = firstChild.getChildren().get(i); 502 if (i == 0 && firstChild.getChildren().size() == 1) { 503 if (next instanceof org.thymeleaf.dom.Text) { 504 org.thymeleaf.dom.Text nextText = (org.thymeleaf.dom.Text) next; 505 nextText.setContent(nextText.getContent().trim()); 506 } 507 } 508 theElement.addChild(next); 509 } 510 511 } 512 513 514 return ProcessorResult.ok(); 515 } 516 517 } 518 519 // public String generateString(Patient theValue) { 520 // 521 // Context context = new Context(); 522 // context.setVariable("resource", theValue); 523 // String result = 524 // myProfileTemplateEngine.process("ca/uhn/fhir/narrative/Patient.html", 525 // context); 526 // 527 // ourLog.info("Result: {}", result); 528 // 529 // return result; 530 // } 531 532 private final class ProfileResourceResolver implements IResourceResolver { 533 @Override 534 public String getName() { 535 return getClass().getCanonicalName(); 536 } 537 538 @Override 539 public InputStream getResourceAsStream(TemplateProcessingParameters theTemplateProcessingParameters, String theName) { 540 String template = myNameToNarrativeTemplate.get(theName); 541 if (template == null) { 542 ourLog.info("No narative template for resource profile: {}", theName); 543 return new ReaderInputStream(new StringReader("")); 544 } 545 return new ReaderInputStream(new StringReader(template)); 546 } 547 } 548 549}