DocumentJsonUtils.java
001 /*
002  *  DocumentJsonUtils.java
003  *
004  *  Copyright (c) 1995-2012, The University of Sheffield. See the file
005  *  COPYRIGHT.txt in the software or at http://gate.ac.uk/gate/COPYRIGHT.txt
006  *
007  *  This file is part of GATE (see http://gate.ac.uk/), and is free
008  *  software, licenced under the GNU Library General Public License,
009  *  Version 2, June 1991 (in the distribution as file licence.html,
010  *  and also available at http://gate.ac.uk/gate/licence.html).
011  *
012  *  Ian Roberts, 20/Dec/2013
013  *
014  *  $Id: DocumentJsonUtils.java 19041 2015-12-19 23:49:25Z domrout $
015  */
016 package gate.corpora;
017 
018 import java.io.File;
019 import java.io.IOException;
020 import java.io.OutputStream;
021 import java.io.StringWriter;
022 import java.io.Writer;
023 import java.util.Collection;
024 import java.util.Map;
025 import java.util.regex.Matcher;
026 import java.util.regex.Pattern;
027 
028 import com.fasterxml.jackson.core.JsonEncoding;
029 import com.fasterxml.jackson.core.JsonFactory;
030 import com.fasterxml.jackson.core.JsonGenerationException;
031 import com.fasterxml.jackson.core.JsonGenerator;
032 import com.fasterxml.jackson.databind.ObjectMapper;
033 import com.fasterxml.jackson.databind.ObjectWriter;
034 
035 import gate.Annotation;
036 import gate.Document;
037 import gate.util.GateRuntimeException;
038 import gate.util.InvalidOffsetException;
039 
040 /**
041  <p>
042  * This class contains utility methods to output GATE documents in a
043  * JSON format which is (deliberately) close to the format used by
044  * Twitter to represent entities such as user mentions and hashtags in
045  * Tweets.
046  </p>
047  
048  <pre>
049  * {
050  *   "text":"Text of the document",
051  *   "entities":{
052  *     "Person":[
053  *       {
054  *         "indices":[startOffset, endOffset],
055  *         // other features here
056  *       },
057  *       { ... }
058  *     ],
059  *     "Location":[
060  *       {
061  *         "indices":[startOffset, endOffset],
062  *         // other features here
063  *       },
064  *       { ... }
065  *     ]
066  *   }
067  * }
068  </pre>
069  
070  <p>
071  * The document is represented as a JSON object with two properties,
072  * "text" holding the text of the document and "entities" representing
073  * the annotations. The "entities" property is an object mapping each
074  * "annotation type" to an array of objects, one per annotation, that
075  * holds the annotation's start and end offsets as a property "indices"
076  * and the other features of the annotation as its remaining properties.
077  * Features are serialized using Jackson's ObjectMapper, so
078  * string-valued features become JSON strings, numeric features become
079  * JSON numbers, Boolean features become JSON booleans, and other types
080  * are serialized according to Jackson's normal rules (e.g. Map values
081  * become nested JSON objects).
082  </p>
083  
084  <p>
085  * The grouping of annotations into blocks is the responsibility of the
086  * caller - annotations are supplied as a Map&lt;String,
087  * Collection&lt;Annotation&gt;&gt;, the map keys become the property
088  * names within the "entities" object and the corresponding values
089  * become the annotation arrays. In particular the actual annotation
090  * type of an annotation within one of the collections is ignored - it
091  * is allowed to mix annotations of different types within one
092  * collection, the name of the group of annotations in the "entities"
093  * object comes from the map key. However some overloadings of
094  <code>writeDocument</code> provide the option to write the annotation
095  * type as if it were a feature, i.e. as one of the JSON properties of
096  * the annotation object.
097  </p>
098  
099  @author ian
100  
101  */
102 public class DocumentJsonUtils {
103 
104   private static final ObjectMapper MAPPER = new ObjectMapper();
105 
106   private static final JsonFactory JSON_FACTORY = new JsonFactory();
107 
108   /**
109    * Write a GATE document to the specified OutputStream. The document
110    * text will be written as a property named "text" and the specified
111    * annotations will be written as "entities".
112    
113    @param doc the document to write
114    @param annotationsMap annotations to write.
115    @param out the {@link OutputStream} to write to.
116    @throws JsonGenerationException if a problem occurs while
117    *           generating the JSON
118    @throws IOException if an I/O error occurs.
119    */
120   public static void writeDocument(Document doc,
121           Map<String, Collection<Annotation>> annotationsMap, OutputStream out)
122           throws JsonGenerationException, IOException {
123     try(JsonGenerator jsonG = JSON_FACTORY.createGenerator(out)) {
124       writeDocument(doc, annotationsMap, jsonG);
125     }
126   }
127 
128   /**
129    * Write a GATE document to the specified Writer. The document text
130    * will be written as a property named "text" and the specified
131    * annotations will be written as "entities".
132    
133    @param doc the document to write
134    @param annotationsMap annotations to write.
135    @param out the {@link Writer} to write to.
136    @throws JsonGenerationException if a problem occurs while
137    *           generating the JSON
138    @throws IOException if an I/O error occurs.
139    */
140   public static void writeDocument(Document doc,
141           Map<String, Collection<Annotation>> annotationsMap, Writer out)
142           throws JsonGenerationException, IOException {
143     try(JsonGenerator jsonG = JSON_FACTORY.createGenerator(out)) {
144       writeDocument(doc, annotationsMap, jsonG);
145     }
146   }
147 
148   /**
149    * Write a GATE document to the specified File. The document text will
150    * be written as a property named "text" and the specified annotations
151    * will be written as "entities".
152    
153    @param doc the document to write
154    @param annotationsMap annotations to write.
155    @param out the {@link File} to write to.
156    @throws JsonGenerationException if a problem occurs while
157    *           generating the JSON
158    @throws IOException if an I/O error occurs.
159    */
160   public static void writeDocument(Document doc,
161           Map<String, Collection<Annotation>> annotationsMap, File out)
162           throws JsonGenerationException, IOException {
163     try(JsonGenerator jsonG = JSON_FACTORY.createGenerator(out, JsonEncoding.UTF8)) {
164       writeDocument(doc, annotationsMap,jsonG);
165     }
166   }
167 
168   /**
169    * Convert a GATE document to JSON representation and return it as a
170    * string. The document text will be written as a property named
171    * "text" and the specified annotations will be written as "entities".
172    
173    @param doc the document to write
174    @param annotationsMap annotations to write.
175    @throws JsonGenerationException if a problem occurs while
176    *           generating the JSON
177    @throws IOException if an I/O error occurs.
178    @return the JSON as a String
179    */
180   public static String toJson(Document doc,
181           Map<String, Collection<Annotation>> annotationsMap)
182           throws JsonGenerationException, IOException {
183     StringWriter sw = new StringWriter();
184     JsonGenerator gen = JSON_FACTORY.createGenerator(sw);
185     writeDocument(doc, annotationsMap, gen);
186     gen.close();
187     return sw.toString();
188   }
189 
190   /**
191    * Write a GATE document to the specified JsonGenerator. The document
192    * text will be written as a property named "text" and the specified
193    * annotations will be written as "entities".
194    
195    @param doc the document to write
196    @param annotationsMap annotations to write.
197    @param json the {@link JsonGenerator} to write to.
198    @throws JsonGenerationException if a problem occurs while
199    *           generating the JSON
200    @throws IOException if an I/O error occurs.
201    */
202   public static void writeDocument(Document doc,
203           Map<String, Collection<Annotation>> annotationsMap, JsonGenerator json)
204           throws JsonGenerationException, IOException {
205     try {
206       writeDocument(doc, 0L, doc.getContent().size(), annotationsMap, json);
207     catch(InvalidOffsetException e) {
208       // shouldn't happen
209       throw new GateRuntimeException(
210               "Got invalid offset exception when passing "
211                       "offsets that are known to be valid");
212     }
213   }
214 
215   /**
216    * Write a substring of a GATE document to the specified
217    * JsonGenerator. The specified window of document text will be
218    * written as a property named "text" and the specified annotations
219    * will be written as "entities", with their offsets adjusted to be
220    * relative to the specified window.
221    
222    @param doc the document to write
223    @param start the start offset of the segment to write
224    @param end the end offset of the segment to write
225    @param annotationsMap annotations to write.
226    @param json the {@link JsonGenerator} to write to.
227    @throws JsonGenerationException if a problem occurs while
228    *           generating the JSON
229    @throws IOException if an I/O error occurs.
230    */
231   public static void writeDocument(Document doc, Long start, Long end,
232           Map<String, Collection<Annotation>> annotationsMap, JsonGenerator json)
233           throws JsonGenerationException, IOException, InvalidOffsetException {
234     writeDocument(doc, start, end, annotationsMap, null, null, json);
235   }
236 
237   /**
238    * Write a substring of a GATE document to the specified
239    * JsonGenerator. The specified window of document text will be
240    * written as a property named "text" and the specified annotations
241    * will be written as "entities", with their offsets adjusted to be
242    * relative to the specified window.
243    
244    @param doc the document to write
245    @param start the start offset of the segment to write
246    @param end the end offset of the segment to write
247    @param annotationsMap annotations to write.
248    @param extraFeatures additional properties to add to the generated
249    *          JSON. If the map includes a "text" key this will be
250    *          ignored, and if it contains a key "entities" whose value
251    *          is a map then these entities will be merged with the
252    *          generated ones derived from the annotationsMap. This would
253    *          typically be used for documents that were originally
254    *          derived from Twitter data, to re-create the original JSON.
255    @param json the {@link JsonGenerator} to write to.
256    @throws JsonGenerationException if a problem occurs while
257    *           generating the JSON
258    @throws IOException if an I/O error occurs.
259    */
260   public static void writeDocument(Document doc, Long start, Long end,
261           Map<String, Collection<Annotation>> annotationsMap,
262           Map<?, ?> extraFeatures, JsonGenerator json)
263           throws JsonGenerationException, IOException, InvalidOffsetException {
264     writeDocument(doc, start, end, annotationsMap, extraFeatures, null, json);
265   }
266 
267   /**
268    * Write a substring of a GATE document to the specified
269    * JsonGenerator. The specified window of document text will be
270    * written as a property named "text" and the specified annotations
271    * will be written as "entities", with their offsets adjusted to be
272    * relative to the specified window.
273    
274    @param doc the document to write
275    @param start the start offset of the segment to write
276    @param end the end offset of the segment to write
277    @param extraFeatures additional properties to add to the generated
278    *          JSON. If the map includes a "text" key this will be
279    *          ignored, and if it contains a key "entities" whose value
280    *          is a map then these entities will be merged with the
281    *          generated ones derived from the annotationsMap. This would
282    *          typically be used for documents that were originally
283    *          derived from Twitter data, to re-create the original JSON.
284    @param annotationTypeProperty if non-null, the annotation type will
285    *          be written as a property under this name, as if it were an
286    *          additional feature of each annotation.
287    @param json the {@link JsonGenerator} to write to.
288    @throws JsonGenerationException if a problem occurs while
289    *           generating the JSON
290    @throws IOException if an I/O error occurs.
291    */
292   public static void writeDocument(Document doc, Long start, Long end,
293           Map<String, Collection<Annotation>> annotationsMap,
294           Map<?, ?> extraFeatures, String annotationTypeProperty, JsonGenerator json
295           throws JsonGenerationException, IOException, InvalidOffsetException {
296     writeDocument(doc, start, end, annotationsMap, extraFeatures, annotationTypeProperty, null, json);
297 
298   
299   /**
300    * Write a substring of a GATE document to the specified
301    * JsonGenerator. The specified window of document text will be
302    * written as a property named "text" and the specified annotations
303    * will be written as "entities", with their offsets adjusted to be
304    * relative to the specified window.
305    
306    @param doc the document to write
307    @param start the start offset of the segment to write
308    @param end the end offset of the segment to write
309    @param extraFeatures additional properties to add to the generated
310    *          JSON. If the map includes a "text" key this will be
311    *          ignored, and if it contains a key "entities" whose value
312    *          is a map then these entities will be merged with the
313    *          generated ones derived from the annotationsMap. This would
314    *          typically be used for documents that were originally
315    *          derived from Twitter data, to re-create the original JSON.
316    @param annotationTypeProperty if non-null, the annotation type will
317    *          be written as a property under this name, as if it were an
318    *          additional feature of each annotation.
319    @param annotationIDProperty if non-null, the annotation ID will
320    *          be written as a property under this name, as if it were an
321    *          additional feature of each annotation.
322    @param json the {@link JsonGenerator} to write to.
323    @throws JsonGenerationException if a problem occurs while
324    *           generating the JSON
325    @throws IOException if an I/O error occurs.
326    */
327   public static void writeDocument(Document doc, Long start, Long end,
328           Map<String, Collection<Annotation>> annotationsMap,
329           Map<?, ?> extraFeatures, String annotationTypeProperty, 
330           String annotationIDProperty, JsonGenerator jsonthrows JsonGenerationException, IOException,
331           InvalidOffsetException {
332     ObjectWriter writer = MAPPER.writer();
333 
334     json.writeStartObject();
335     RepositioningInfo repos = new RepositioningInfo();
336     String text = escape(doc.getContent().getContent(start, end)
337             .toString(), repos);
338     json.writeStringField("text", text);
339     json.writeFieldName("entities");
340     json.writeStartObject();
341     // if the extraFeatures already includes entities, merge them with
342     // the new ones we create
343     Object entitiesExtraFeature =
344             (extraFeatures == nullnull : extraFeatures.get("entities");
345     Map<?, ?> entitiesMap = null;
346     if(entitiesExtraFeature instanceof Map) {
347       entitiesMap = (Map<?, ?>)entitiesExtraFeature;
348     }
349     for(Map.Entry<String, Collection<Annotation>> annsByType : annotationsMap
350             .entrySet()) {
351       String annotationType = annsByType.getKey();
352       Collection<Annotation> annotations = annsByType.getValue();
353       json.writeFieldName(annotationType);
354       json.writeStartArray();
355       for(Annotation a : annotations) {
356         json.writeStartObject();
357         // indices:[start, end], corrected to match the sub-range of
358         // text we're writing
359         json.writeArrayFieldStart("indices");
360         json.writeNumber(repos.getOriginalPos(a.getStartNode().getOffset() - start, true));
361         json.writeNumber(repos.getOriginalPos(a.getEndNode().getOffset() - start, false));
362         json.writeEndArray()// end of indices
363         if(annotationTypeProperty != null) {
364           json.writeStringField(annotationTypeProperty, a.getType());
365         
366         if (annotationIDProperty != null) {
367           json.writeNumberField(annotationIDProperty, a.getId());
368         }
369         // other features
370         for(Map.Entry<?, ?> feature : a.getFeatures().entrySet()) {
371           if(annotationTypeProperty != null
372                   && annotationTypeProperty.equals(feature.getKey())) {
373             // ignore a feature that has the same name as the
374             // annotationTypeProperty
375             continue;
376           }
377           json.writeFieldName(String.valueOf(feature.getKey()));
378           writer.writeValue(json, feature.getValue());
379         }
380         json.writeEndObject()// end of annotation
381       }
382       // add any entities from the extraFeatures map
383       if(entitiesMap != null
384               && entitiesMap.get(annotationTypeinstanceof Collection) {
385         for(Object ent : (Collection<?>)entitiesMap.get(annotationType)) {
386           writer.writeValue(json, ent);
387         }
388       }
389       json.writeEndArray();
390     }
391     if(entitiesMap != null) {
392       for(Map.Entry<?, ?> entitiesEntry : entitiesMap.entrySet()) {
393         if(!annotationsMap.containsKey(entitiesEntry.getKey())) {
394           // not an entity type we've already seen
395           json.writeFieldName(String.valueOf(entitiesEntry.getKey()));
396           writer.writeValue(json, entitiesEntry.getValue());
397         }
398       }
399     }
400 
401     json.writeEndObject()// end of entities
402 
403     if(extraFeatures != null) {
404       for(Map.Entry<?, ?> feature : extraFeatures.entrySet()) {
405         if("text".equals(feature.getKey())
406                 || "entities".equals(feature.getKey())) {
407           // already dealt with text and entities
408           continue;
409         }
410         json.writeFieldName(String.valueOf(feature.getKey()));
411         writer.writeValue(json, feature.getValue());
412       }
413     }
414     json.writeEndObject()// end of document
415 
416     // Make sure that everything we have generated is flushed to the
417     // underlying OutputStream. It seems that not doing this can easily
418     // lead to corrupt files that just end in the middle of a JSON
419     // object. This occurs even if you flush the OutputStream instance
420     // as the data never leaves the JsonGenerator
421     json.flush();
422   }
423 
424   /**
425    * Characters to account for when escaping - ampersand, angle brackets, and supplementaries
426    */
427   private static final Pattern CHARS_TO_ESCAPE = Pattern.compile("[<>&\\x{" +
428           Integer.toHexString(Character.MIN_SUPPLEMENTARY_CODE_POINT)"}-\\x{" +
429           Integer.toHexString(Character.MAX_CODE_POINT"}]");
430   
431   /**
432    * Escape all angle brackets and ampersands in the given string,
433    * recording the adjustments to character offsets within the
434    * given {@link RepositioningInfo}.  Also record supplementary
435    * characters (above U+FFFF), which count as two in terms of
436    * GATE annotation offsets (which count in Java chars) but one
437    * in terms of JSON (counting in Unicode characters).
438    */
439   private static String escape(String str, RepositioningInfo repos) {
440     StringBuffer buf = new StringBuffer();
441     int origOffset = 0;
442     int extractedOffset = 0;
443     Matcher mat = CHARS_TO_ESCAPE.matcher(str);
444     while(mat.find()) {
445       if(mat.start() != extractedOffset) {
446         // repositioning record for the span from end of previous match to start of this one
447         int nonMatchLen = mat.start() - extractedOffset;
448         repos.addPositionInfo(origOffset, nonMatchLen, extractedOffset, nonMatchLen);
449         origOffset += nonMatchLen;
450         extractedOffset += nonMatchLen;
451       }
452 
453       // the extracted length is the number of code units matched by the pattern
454       int extractedLen = mat.end() - mat.start();
455       int origLen = 0;
456       String replace = "?";
457       switch(mat.group()) {
458         case "&":
459           replace = "&amp;";
460           origLen = 5;
461           break;
462         case ">":
463           replace = "&gt;";
464           origLen = 4;
465           break;
466         case "<":
467           replace = "&lt;";
468           origLen = 4;
469           break;
470         default:
471           // supplementary character, so no escaping but need to account for
472           // it in repositioning info
473           replace = mat.group();
474           origLen = 1;
475       }
476       // repositioning record covering this match
477       repos.addPositionInfo(origOffset, origLen, extractedOffset, extractedLen);
478       mat.appendReplacement(buf, replace);
479       origOffset += origLen;
480       extractedOffset += extractedLen;
481 
482     }
483     int tailLen = str.length() - extractedOffset;
484     if(tailLen > 0) {
485       // repositioning record covering everything after the last match
486       repos.addPositionInfo(origOffset, tailLen + 1, extractedOffset, tailLen + 1);
487     }
488     mat.appendTail(buf);
489     return buf.toString();
490   }
491 }