View Javadoc
1   /*===========================================================================
2     Copyright (C) 2009-2013 by the Okapi Framework contributors
3   -----------------------------------------------------------------------------
4     Licensed under the Apache License, Version 2.0 (the "License");
5     you may not use this file except in compliance with the License.
6     You may obtain a copy of the License at
7   
8     http://www.apache.org/licenses/LICENSE-2.0
9   
10    Unless required by applicable law or agreed to in writing, software
11    distributed under the License is distributed on an "AS IS" BASIS,
12    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13    See the License for the specific language governing permissions and
14    limitations under the License.
15  ===========================================================================*/
16  
17  package net.sf.okapi.lib.translation;
18  
19  import java.util.ArrayList;
20  import java.util.Collections;
21  import java.util.LinkedHashSet;
22  import java.util.List;
23  import java.util.Stack;
24  import java.util.regex.Matcher;
25  import java.util.regex.Pattern;
26  
27  import org.slf4j.Logger;
28  import org.slf4j.LoggerFactory;
29  import org.w3c.dom.Element;
30  import org.w3c.dom.NamedNodeMap;
31  import org.w3c.dom.Node;
32  import org.w3c.dom.NodeList;
33  
34  import net.sf.okapi.common.HTMLCharacterEntities;
35  import net.sf.okapi.common.Util;
36  import net.sf.okapi.common.filterwriter.XLIFFContent;
37  import net.sf.okapi.common.query.QueryResult;
38  import net.sf.okapi.common.resource.Code;
39  import net.sf.okapi.common.resource.TextFragment;
40  import net.sf.okapi.common.resource.TextFragment.TagType;
41  
42  /**
43   * Collection of helper method for preparing and querying translation resources.
44   * 
45   *  @version 1.47.0
46   *  SV: changed the exception in translateCodedHTMLToFragment() to a warning
47   */
48  public class QueryUtil {
49  	private static final Pattern TAG = Pattern.compile("(<(br|u)(\\s+)id=['\"](.*?)['\"](\\s*?)/?>)|(</u>)", Pattern.CASE_INSENSITIVE);
50  	// Check also <span> as some engines add their own in the results
51  	private static final Pattern HTML_SPAN = Pattern.compile("\\<span\\s*?(.*?)>|\\</span>",
52  			Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
53  
54  	private static final Pattern CR = Pattern.compile("&(?:#(\\S+?)|\\w*?);");
55  	private final Logger logger = LoggerFactory.getLogger(getClass());
56  
57  	private final StringBuilder codesMarkers;
58  	private final XLIFFContent fmt;
59  	private List<Code> codes;
60  	private HTMLCharacterEntities entities;
61  
62  	public QueryUtil () {
63  		codesMarkers = new StringBuilder();
64  		fmt = new XLIFFContent();
65  	}
66  
67  	/**
68  	 * Indicates if the last text fragment passed to {@link #separateCodesFromText(TextFragment)} has codes or not.
69  	 * 
70  	 * @return true if the fragment has one or more code, false if it does not.
71  	 */
72  	public boolean hasCode () {
73  		if ( codes == null ) return false;
74  		return !codes.isEmpty();
75  	}
76  
77  	/**
78  	 * Separate and store codes of a given text fragment.
79  	 * 
80  	 * @param frag
81  	 *            the fragment to process. Use {@link #createNewFragmentWithCodes(String)} to 
82  	 *            reconstruct the text back with its codes at the end.
83  	 * @return the fragment content stripped of its codes.
84  	 */
85  	public String separateCodesFromText (TextFragment frag) {
86  		// Reset
87  		codesMarkers.setLength(0);
88  		codes = frag.getCodes();
89  		// Get coded text
90  		String text = frag.getCodedText();
91  		if (!frag.hasCode()) {
92  			return text; // No codes
93  		}
94  
95  		// If there are codes: store them apart
96  		StringBuilder tmp = new StringBuilder(text.length() - (codes.size() * 2));
97  		for (int i = 0; i < text.length(); i++) {
98  			switch (text.charAt(i)) {
99  			case TextFragment.MARKER_OPENING:
100 			case TextFragment.MARKER_CLOSING:
101 			case TextFragment.MARKER_ISOLATED:
102 				codesMarkers.append(text.charAt(i));
103 				codesMarkers.append(text.charAt(++i));
104 				break;
105 			default:
106 				tmp.append(text.charAt(i));
107 			}
108 		}
109 		// Return text without codes
110 		return tmp.toString();
111 	}
112 
113 	/**
114 	 * Appends the codes stored apart using {@link #separateCodesFromText(TextFragment)} at the end of a given plain
115 	 * text. The text fragment provided must be the same and without code modifications, as the one used for the
116 	 * splitting.
117 	 * 
118 	 * @param plainText
119 	 *            new text to use (must be plain)
120 	 * @return the provided fragment, but with the new text and the original codes appended at the end.
121 	 */
122 	public TextFragment createNewFragmentWithCodes (String plainText) {
123 		return new TextFragment(plainText + codesMarkers, codes);
124 	}
125 
126     /**
127      * Converts from coded texts to coded HTML.
128      * The resulting strings are also valid XML.
129      * @param frags the fragments to convert.
130      * @return The resulting HTML string.
131      */
132     public List<String> toCodedHTML(List<TextFragment> frags) {
133         List<String> html = new ArrayList<>(frags.size());
134         for (TextFragment frag : frags) {
135             html.add(toCodedHTML(frag));
136         }
137         return html;
138     }
139 
140 	/**
141 	 * Converts from coded text to coded HTML.
142 	 * The resulting string is also valid XML.
143 	 * @param fragment
144 	 *            the fragment to convert.
145 	 * @return The resulting HTML string.
146 	 */
147 	public String toCodedHTML (TextFragment fragment) {
148 		if ( fragment == null ) {
149 			return "";
150 		}
151 		Code code;
152 		String text = fragment.getCodedText();
153 		StringBuilder sb = new StringBuilder();
154 		for ( int i = 0; i < text.length(); i++ ) {
155 			switch (text.charAt(i)) {
156 			case TextFragment.MARKER_OPENING:
157 				code = fragment.getCode(text.charAt(++i));
158 				sb.append("<u id='");
159 				sb.append(code.getId());
160 				sb.append("'>");
161 				break;
162 			case TextFragment.MARKER_CLOSING:
163 				i++;
164 				sb.append("</u>");
165 				break;
166 			case TextFragment.MARKER_ISOLATED:
167 				code = fragment.getCode(text.charAt(++i));
168 				switch ( code.getTagType() ) {
169 				case OPENING:
170 					sb.append("<br id='b");
171 					sb.append(code.getId());
172 					sb.append("'/>");
173 					break;
174 				case CLOSING:
175 					sb.append("<br id='e");
176 					sb.append(code.getId());
177 					sb.append("'/>");
178 					break;
179 				case PLACEHOLDER:
180 					sb.append("<br id='p");
181 					sb.append(code.getId());
182 					sb.append("'/>");
183 					break;
184 				}
185 				break;
186 			case '&':
187 				sb.append("&amp;");
188 				break;
189 			case '<':
190 				sb.append("&lt;");
191 				break;
192 			default:
193 				sb.append(text.charAt(i));
194 			}
195 		}
196 		return sb.toString();
197 	}
198 
199 	/**
200 	 * Converts an HTML string created with {@link #toCodedHTML(TextFragment)} back into a text fragment,
201 	 * but with empty inline codes.
202 	 * @param text the HTML string to convert.
203 	 * @param fragment an optional text fragment where to place the converted content. Any existing codes will be
204 	 * replaced or removed by the codes coming from the HTML string. Use null to create a new fragment.
205 	 * @return the modified or created text fragment.
206 	 */
207 	public TextFragment fromCodedHTMLToFragment (String text,
208 		TextFragment fragment)
209 	{
210 		if ( Util.isEmpty(text) ) {
211 			if ( fragment != null ) {
212 				fragment.setCodedText("", true);
213 				return fragment;
214 			}
215 			else {
216 				return new TextFragment();
217 			}
218 		}
219 
220 		text = unescapeCharacterReferences(text);
221 
222 		ArrayList<Code> codes = new ArrayList<>();
223 		text = this.translateCodedHTMLToFragment(text, codes);
224 
225 		// Remove any span elements that may have been added
226 		// (some MT engines mark up their output with extra info)
227 		StringBuilder sb = new StringBuilder(text);
228 		removeSpans(sb);
229 
230 		// Create the fragment or update the existing one
231 		if ( fragment != null ) {
232 			fragment.setCodedText(sb.toString(), codes, true);
233 			return fragment;
234 		}
235 		else {
236 			return new TextFragment(sb.toString(), codes);
237 		}
238 	}
239 
240 	private String unescapeCharacterReferences(String text) {
241 		initHTMLCharacterEntities();
242 
243 		Matcher crMatch = CR.matcher(text);
244 		StringBuilder result = new StringBuilder(text.length());
245 
246 		int textIndex = 0;
247 		String numeric;
248 		int character;
249 		while (crMatch.find()) {
250 			if (crMatch.start() > 0) {
251 				result.append(text, textIndex, crMatch.start());
252 			}
253 			textIndex = crMatch.end();
254 
255 			// numeric character references
256 			numeric = crMatch.group(1);
257 			if (numeric != null) {
258 				try {
259 					if (numeric.charAt(0) == 'x') {
260 						// Hexadecimal
261 						character = Integer.parseInt(numeric.substring(1), 16);
262 					} else {
263 						// Decimal
264 						character = Integer.parseInt(numeric);
265 					}
266 				} catch (NumberFormatException e) {
267 					character = '?';
268 				}
269 				result.append((char) character);
270 				continue;
271 			}
272 
273 			// character entity references
274 			character = entities.lookupReference(crMatch.group(0));
275 			if ( character == -1 ) {
276 				// Unknown entity
277 				// TODO: replace by something meaningful
278 				continue;
279 			}
280 			result.append((char) character);
281 		}
282 		result.append(text.substring(textIndex));
283 
284 		return result.toString();
285 	}
286 
287 	private String translateCodedHTMLToFragment(String text, ArrayList<Code> codes) {
288 		Matcher tagMatches = TAG.matcher(text);
289 		StringBuilder result = new StringBuilder(text.length());
290 
291 		Code code;
292 		int codeId;
293 		int textIndex = 0;
294 		while (tagMatches.find()) {
295 			if (tagMatches.start() > 0) {
296 				result.append(text, textIndex, tagMatches.start());
297 			}
298 			textIndex = tagMatches.end();
299 
300 			// Closing Tag
301 			if (tagMatches.group(1) == null) {
302 				// Code ID should be resolved automatically
303 				code = new Code(TagType.CLOSING, "Xpt", null);
304 				codes.add(code);
305 
306 				// Write code marker
307 				result.append((char) TextFragment.MARKER_CLOSING);
308 				result.append(TextFragment.toChar(codes.size() - 1));
309 				continue;
310 			}
311 
312 			// Opening Tag
313 			if ("u".equals(tagMatches.group(2))) {
314 				// Get code id from attribute
315 				codeId = Util.strToInt(tagMatches.group(4), -1);
316 
317 				// Create the code
318 				code = new Code(TagType.OPENING, "Xpt", null);
319 				code.setId(codeId);
320 				codes.add(code);
321 
322 				// Write code marker
323 				result.append((char) TextFragment.MARKER_OPENING);
324 				result.append(TextFragment.toChar(codes.size() - 1));
325 				continue;
326 			}
327 
328 			// Get code id from attribute
329 			codeId = Util.strToInt(tagMatches.group(4).substring(1), -1);
330 
331 			// Isolated Tag
332 			TagType tagType = TagType.PLACEHOLDER;
333 			char c = tagMatches.group(4).charAt(0);
334 			switch (c) {
335 				case 'b':
336 					tagType = TagType.OPENING;
337 					break;
338 				case 'e':
339 					tagType = TagType.CLOSING;
340 					break;
341 				case 'p':
342 					tagType = TagType.PLACEHOLDER;
343 					break;
344 				default:
345 					logger.warn(String.format("ID ('%s') of isolated code modified.", c));
346 			}
347 			code = new Code(tagType, "Xph", null);
348 			code.setId(codeId);
349 			codes.add(code);
350 
351 			// Write code marker
352 			result.append((char) TextFragment.MARKER_ISOLATED);
353 			result.append(TextFragment.toChar(codes.size() - 1));
354 		}
355 		result.append(text.substring(textIndex));
356 
357 		return result.toString();
358 	}
359 
360 	/**
361 	 * Converts back a coded HTML to a coded text.
362 	 * (extra span elements are removed).
363 	 * 
364 	 * @param htmlText the coded HTML to convert back.
365 	 * @param fragment the original text fragment.
366 	 * @param addMissingCodes true to added codes that are in the original fragment but not in the HTML string.
367 	 * @return the coded text with its code markers.
368 	 */
369 	public String fromCodedHTML (String htmlText,
370 		TextFragment fragment,
371 		boolean addMissingCodes)
372 	{
373 		return fromCodedHTML(htmlText, fragment, addMissingCodes, true);
374 	}
375 	
376 	/**
377 	 * Converts back a coded HTML to a coded text.
378 	 * 
379 	 * @param htmlText the coded HTML to convert back.
380 	 * @param fragment the original text fragment.
381 	 * @param addMissingCodes true to added codes that are in the original fragment but not in the HTML string.
382 	 * @param removeSpans true to remove extra span HTML codes.
383 	 * @return the coded text with its code markers.
384 	 */
385 	public String fromCodedHTML (String htmlText,
386 		TextFragment fragment,
387 		boolean addMissingCodes,
388 		boolean removeSpans)
389 	{
390 		if ( Util.isEmpty(htmlText) ) {
391 			return "";
392 		}
393 
394 		htmlText = unescapeCharacterReferences(htmlText);
395 
396 		// Create a lists to verify the codes
397 		ArrayList<String> newCodes = new ArrayList<>(fragment.getCodes().size());
398 		String codedText = translateCodedHTML(fragment, htmlText, newCodes);
399 
400 		StringBuilder sb = new StringBuilder(codedText);
401 
402 		// Remove any span elements that may have been added
403 		// (some MT engines mark up their output with extra info)
404 		if ( removeSpans ) {
405 			removeSpans(sb);
406 		}
407 
408 		// Try to correct missing codes
409 		if ( addMissingCodes) {
410 			ArrayList<String> oriCodes = getCodeIdAttrValues(fragment);
411 			if ( newCodes.size() < oriCodes.size() ) {
412 				for (String tmp : oriCodes) {
413 					if (!newCodes.contains(tmp)) {
414 						switch (tmp.charAt(0)) {
415 							case 'o':
416 								sb.append((char) TextFragment.MARKER_OPENING);
417 								sb.append(TextFragment.toChar(fragment.getIndex(Integer.parseInt(tmp.substring(1)))));
418 								break;
419 							case 'c':
420 								sb.append((char) TextFragment.MARKER_CLOSING);
421 								sb.append(TextFragment.toChar(fragment.getIndexForClosing(Integer.parseInt(tmp.substring(1)))));
422 								break;
423 							case 'b':
424 							case 'e':
425 							case 'p':
426 								sb.append((char) TextFragment.MARKER_ISOLATED);
427 								sb.append(TextFragment.toChar(fragment.getIndex(Integer.parseInt(tmp.substring(1)))));
428 								break;
429 						}
430 					}
431 				}
432 			}
433 		}
434 
435 		return sb.toString();
436 	}
437 
438 	private static void removeSpans(StringBuilder sb) {
439 		Matcher m = HTML_SPAN.matcher(sb.toString());
440 		int offset = 0;
441 		while ( m.find() ) {
442 			sb.delete(m.start() - offset, m.end() - offset);
443 			offset += m.end() - m.start();
444 		}
445 	}
446 
447 	private void initHTMLCharacterEntities() {
448 		if ( entities == null ) {
449 			entities = new HTMLCharacterEntities();
450 			entities.ensureInitialization(true);
451 		}
452 	}
453 
454 	private static String translateCodedHTML(TextFragment fragment, String text, ArrayList<String> newCodes) {
455 		Stack<Integer> stack = new Stack<>();
456 
457 		Matcher tagMatches = TAG.matcher(text);
458 		StringBuilder result = new StringBuilder(text.length());
459 
460 		int codeId;
461 		int textIndex = 0;
462 		char isoType;
463 		while (tagMatches.find()) {
464 			if (tagMatches.start() > 0) {
465 				result.append(text, textIndex, tagMatches.start());
466 			}
467 			textIndex = tagMatches.end();
468 
469 			// Closing Tag
470 			if (tagMatches.group(1) == null) {
471 				if ( stack.isEmpty() ) {
472 					// If the stack is empty it means the string is not well-formed, or a start tag is missing.
473 					// The two codes will be added automatically at the end of the entry
474 					continue;
475 				}
476 
477 				codeId = stack.pop();
478 				newCodes.add("c" + codeId);
479 
480 				result.append((char) TextFragment.MARKER_CLOSING);
481 				result.append(TextFragment.toChar(fragment.getIndexForClosing(codeId)));
482 				continue;
483 			}
484 
485 			// Opening Tag
486 			if ("u".equals(tagMatches.group(2))) {
487 				// It's an opening tag
488 				codeId = Util.strToInt(tagMatches.group(4), -1);
489 				stack.push(codeId);
490 				newCodes.add("o" + codeId);
491 
492 				result.append((char) TextFragment.MARKER_OPENING);
493 				result.append(TextFragment.toChar(fragment.getIndex(codeId)));
494 				continue;
495 			}
496 
497 			// Isolated Tag
498 			isoType = tagMatches.group(4).charAt(0);
499 			codeId = Util.strToInt(tagMatches.group(4).substring(1), -1);
500 			newCodes.add(isoType + Integer.toString(codeId));
501 
502 			result.append((char) TextFragment.MARKER_ISOLATED);
503 			switch (isoType) {
504 				case 'b':
505 					result.append(TextFragment.toChar(fragment.getIndexForOpening(codeId)));
506 					break;
507 				case 'e':
508 					result.append(TextFragment.toChar(fragment.getIndexForClosing(codeId)));
509 					break;
510 				default:
511 					result.append(TextFragment.toChar(fragment.getIndex(codeId)));
512 					break;
513 			}
514 		}
515 		result.append(text.substring(textIndex));
516 
517 		return result.toString();
518 	}
519 
520 	private static ArrayList<String> getCodeIdAttrValues(TextFragment fragment) {
521 		ArrayList<String> oriCodes = new ArrayList<>(fragment.getCodes().size());
522 		for ( Code code : fragment.getCodes() ) {
523 			switch ( code.getTagType() ) {
524 			case OPENING:
525 				oriCodes.add("o" + code.getId());
526 				break;
527 			case CLOSING:
528 				oriCodes.add("c" + code.getId());
529 				break;
530 			case PLACEHOLDER:
531 				switch ( code.getTagType() ) {
532 				case OPENING:
533 					oriCodes.add("b" + code.getId());
534 					break;
535 				case CLOSING:
536 					oriCodes.add("e" + code.getId());
537 					break;
538 				case PLACEHOLDER:
539 					oriCodes.add("p" + code.getId());
540 					break;
541 				}
542 				break;
543 			}
544 		}
545 		return oriCodes;
546 	}
547 
548 	/**
549 	 * Converts from coded text to XLIFF.
550 	 * 
551 	 * @param fragment
552 	 *            the fragment to convert.
553 	 * @return The resulting XLIFF string.
554 	 * @see #fromXLIFF(Element, TextFragment)
555 	 */
556 	public String toXLIFF (TextFragment fragment) {
557 		if ( fragment == null ) {
558 			return "";
559 		}
560 		fmt.setContent(fragment);
561 		return fmt.toString();
562 	}
563 
564 	// /**
565 	// * Converts back an XLIFF text to a coded text.
566 	// * @param text the XLIFF text to convert back.
567 	// * @param fragment the original text fragment.
568 	// * @return the coded text with its code markers.
569 	// */
570 	// public String fromXLIFF (String text,
571 	// TextFragment fragment)
572 	// {
573 	// if ( Util.isEmpty(text) ) return "";
574 	// // Un-escape first layer
575 	// text = text.replace("&apos;", "'");
576 	// text = text.replace("&lt;", "<");
577 	// text = text.replace("&gt;", ">");
578 	// text = text.replace("&quot;", "\"");
579 	// text = text.replace("&amp;", "&");
580 	// // Now we have XLIFF valid content
581 	//
582 	// // Read it to XML parser
583 	// // Un-escape XML
584 	//
585 	// //TODO: code conversion
586 	// return text;
587 	// }
588 
589 	/**
590 	 * Converts back an XLIFF text contained in a given element into a TextFragment.
591 	 * 
592 	 * @param elem
593 	 *            The element containing the XLIFF data.
594 	 * @param original
595 	 *            the original TextFragment (cannot be null).
596 	 * @see #toXLIFF(TextFragment)
597 	 * @return the newly created text fragment.
598 	 */
599 	public TextFragment fromXLIFF (Element elem,
600 		TextFragment original)
601 	{
602 		NodeList list = elem.getChildNodes();
603 		int lastId = -1;
604 		int id = -1;
605 		Node node;
606 		Stack<Integer> stack = new Stack<>();
607 		StringBuilder buffer = new StringBuilder();
608 
609 		// Note that this parsing assumes non-overlapping codes.
610 		for ( int i = 0; i < list.getLength(); i++ ) {
611 			node = list.item(i);
612 			switch ( node.getNodeType() ) {
613 			case Node.TEXT_NODE:
614 				buffer.append(node.getNodeValue());
615 				break;
616 			case Node.ELEMENT_NODE:
617 				NamedNodeMap map = node.getAttributes();
618 				switch (node.getNodeName()) {
619 					case "bpt":
620 						id = getRawIndex(lastId, map.getNamedItem("id"));
621 						stack.push(id);
622 						buffer.append(new String(new char[]{TextFragment.MARKER_OPENING,
623 								TextFragment.toChar(original.getIndex(id))}));
624 						break;
625 					case "ept":
626 						buffer.append(new String(new char[]{ TextFragment.MARKER_CLOSING,
627 								TextFragment.toChar(original.getIndexForClosing(stack.pop()))}));
628 						break;
629 					case "ph":
630 						id = getRawIndex(lastId, map.getNamedItem("id"));
631 						buffer.append(new String(new char[]{ TextFragment.MARKER_ISOLATED,
632 								TextFragment.toChar(original.getIndex(id))}));
633 						break;
634 					case "it":
635 						Node pos = map.getNamedItem("pos");
636 						if (pos == null) { // Error, but just treat it as a placeholder
637 							id = getRawIndex(lastId, map.getNamedItem("id"));
638 							buffer.append(new String(new char[]{ TextFragment.MARKER_ISOLATED,
639 									TextFragment.toChar(original.getIndex(id))}));
640 						} else if (pos.getNodeValue().equals("begin")) {
641 							id = getRawIndex(lastId, map.getNamedItem("id"));
642 							buffer.append(new String(new char[]{ TextFragment.MARKER_OPENING,
643 									TextFragment.toChar(original.getIndex(id))}));
644 						} else { // Assumes 'end'
645 							id = getRawIndex(lastId, map.getNamedItem("id"));
646 							buffer.append(new String(new char[]{ TextFragment.MARKER_CLOSING,
647 									TextFragment.toChar(original.getIndexForClosing(id))}));
648 						}
649 						break;
650 				}
651 				break;
652 			}
653 		}
654 
655 		return new TextFragment(buffer.toString(), original.getCodes());
656 	}
657 
658 	/**
659 	 * Removes duplicates based on the Equals method of {@link QueryResult}. 
660 	 * Preserve the highest ranked query results.
661 	 * For example, duplicate results with the newest creation date are always retained.<br>
662 	 * <b>WARNING:</b> order is not preserved!
663 	 * 
664 	 * @param queryResults
665 	 *            the list of QueryResults to process.
666 	 * @return a new list of the QueryResults without duplicates.
667 	 */
668 	public static ArrayList<QueryResult> removeDuplicates (List<QueryResult> queryResults) {
669 		// Add QueryResults to linked hash in ranked order
670 		// to make sure we keep the highest ranked duplicates
671 		LinkedHashSet<QueryResult> dupRemove = new LinkedHashSet<>(queryResults.size());
672 		Collections.sort(queryResults);
673 		dupRemove.addAll(queryResults);
674 		return new ArrayList<>(dupRemove);
675 	}
676 
677 	private int getRawIndex (int lastIndex, Node attr)
678 	{
679 		if ( attr == null ) {
680 			return ++lastIndex;
681 		}
682 		return Integer.parseInt(attr.getNodeValue());
683 	}
684 }