1 /* 2 * Jalview - A Sequence Alignment Editor and Viewer (2.11.1.4) 3 * Copyright (C) 2021 The Jalview Authors 4 * 5 * This file is part of Jalview. 6 * 7 * Jalview is free software: you can redistribute it and/or 8 * modify it under the terms of the GNU General Public License 9 * as published by the Free Software Foundation, either version 3 10 * of the License, or (at your option) any later version. 11 * 12 * Jalview is distributed in the hope that it will be useful, but 13 * WITHOUT ANY WARRANTY; without even the implied warranty 14 * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 15 * PURPOSE. See the GNU General Public License for more details. 16 * 17 * You should have received a copy of the GNU General Public License 18 * along with Jalview. If not, see <http://www.gnu.org/licenses/>. 19 * The Jalview Authors are detailed in the 'AUTHORS' file. 20 */ 21 package jalview.io; 22 23 import java.awt.Color; 24 import java.io.IOException; 25 import java.util.ArrayList; 26 import java.util.Arrays; 27 import java.util.Collections; 28 import java.util.HashMap; 29 import java.util.LinkedHashMap; 30 import java.util.List; 31 import java.util.Locale; 32 import java.util.Map; 33 import java.util.Map.Entry; 34 import java.util.TreeMap; 35 36 import jalview.analysis.AlignmentUtils; 37 import jalview.analysis.SequenceIdMatcher; 38 import jalview.api.AlignViewportI; 39 import jalview.api.FeatureColourI; 40 import jalview.api.FeatureRenderer; 41 import jalview.api.FeaturesSourceI; 42 import jalview.datamodel.AlignedCodonFrame; 43 import jalview.datamodel.Alignment; 44 import jalview.datamodel.AlignmentI; 45 import jalview.datamodel.MappedFeatures; 46 import jalview.datamodel.SequenceDummy; 47 import jalview.datamodel.SequenceFeature; 48 import jalview.datamodel.SequenceI; 49 import jalview.datamodel.features.FeatureMatcherSet; 50 import jalview.datamodel.features.FeatureMatcherSetI; 51 import jalview.gui.Desktop; 52 import jalview.io.gff.GffHelperFactory; 53 import jalview.io.gff.GffHelperI; 54 import jalview.schemes.FeatureColour; 55 import jalview.util.ColorUtils; 56 import jalview.util.MapList; 57 import jalview.util.ParseHtmlBodyAndLinks; 58 import jalview.util.StringUtils; 59 60 /** 61 * Parses and writes features files, which may be in Jalview, GFF2 or GFF3 62 * format. These are tab-delimited formats but with differences in the use of 63 * columns. 64 * 65 * A Jalview feature file may define feature colours and then declare that the 66 * remainder of the file is in GFF format with the line 'GFF'. 67 * 68 * GFF3 files may include alignment mappings for features, which Jalview will 69 * attempt to model, and may include sequence data following a ##FASTA line. 70 * 71 * 72 * @author AMW 73 * @author jbprocter 74 * @author gmcarstairs 75 */ 76 public class FeaturesFile extends AlignFile implements FeaturesSourceI 77 { 78 private static final String EQUALS = "="; 79 80 private static final String TAB_REGEX = "\\t"; 81 82 private static final String STARTGROUP = "STARTGROUP"; 83 84 private static final String ENDGROUP = "ENDGROUP"; 85 86 private static final String STARTFILTERS = "STARTFILTERS"; 87 88 private static final String ENDFILTERS = "ENDFILTERS"; 89 90 private static final String ID_NOT_SPECIFIED = "ID_NOT_SPECIFIED"; 91 92 protected static final String GFF_VERSION = "##gff-version"; 93 94 private AlignmentI lastmatchedAl = null; 95 96 private SequenceIdMatcher matcher = null; 97 98 protected AlignmentI dataset; 99 100 protected int gffVersion; 101 102 /** 103 * Creates a new FeaturesFile object. 104 */ FeaturesFile()105 public FeaturesFile() 106 { 107 } 108 109 /** 110 * Constructor which does not parse the file immediately 111 * 112 * @param file 113 * @param paste 114 * @throws IOException 115 */ FeaturesFile(String file, DataSourceType paste)116 public FeaturesFile(String file, DataSourceType paste) 117 throws IOException 118 { 119 super(false, file, paste); 120 } 121 122 /** 123 * @param source 124 * @throws IOException 125 */ FeaturesFile(FileParse source)126 public FeaturesFile(FileParse source) throws IOException 127 { 128 super(source); 129 } 130 131 /** 132 * Constructor that optionally parses the file immediately 133 * 134 * @param parseImmediately 135 * @param file 136 * @param type 137 * @throws IOException 138 */ FeaturesFile(boolean parseImmediately, String file, DataSourceType type)139 public FeaturesFile(boolean parseImmediately, String file, 140 DataSourceType type) throws IOException 141 { 142 super(parseImmediately, file, type); 143 } 144 145 /** 146 * Parse GFF or sequence features file using case-independent matching, 147 * discarding URLs 148 * 149 * @param align 150 * - alignment/dataset containing sequences that are to be annotated 151 * @param colours 152 * - hashtable to store feature colour definitions 153 * @param removeHTML 154 * - process html strings into plain text 155 * @return true if features were added 156 */ parse(AlignmentI align, Map<String, FeatureColourI> colours, boolean removeHTML)157 public boolean parse(AlignmentI align, 158 Map<String, FeatureColourI> colours, boolean removeHTML) 159 { 160 return parse(align, colours, removeHTML, false); 161 } 162 163 /** 164 * Extends the default addProperties by also adding peptide-to-cDNA mappings 165 * (if any) derived while parsing a GFF file 166 */ 167 @Override addProperties(AlignmentI al)168 public void addProperties(AlignmentI al) 169 { 170 super.addProperties(al); 171 if (dataset != null && dataset.getCodonFrames() != null) 172 { 173 AlignmentI ds = (al.getDataset() == null) ? al : al.getDataset(); 174 for (AlignedCodonFrame codons : dataset.getCodonFrames()) 175 { 176 ds.addCodonFrame(codons); 177 } 178 } 179 } 180 181 /** 182 * Parse GFF or Jalview format sequence features file 183 * 184 * @param align 185 * - alignment/dataset containing sequences that are to be annotated 186 * @param colours 187 * - map to store feature colour definitions 188 * @param removeHTML 189 * - process html strings into plain text 190 * @param relaxedIdmatching 191 * - when true, ID matches to compound sequence IDs are allowed 192 * @return true if features were added 193 */ parse(AlignmentI align, Map<String, FeatureColourI> colours, boolean removeHTML, boolean relaxedIdmatching)194 public boolean parse(AlignmentI align, 195 Map<String, FeatureColourI> colours, boolean removeHTML, 196 boolean relaxedIdmatching) 197 { 198 return parse(align, colours, null, removeHTML, relaxedIdmatching); 199 } 200 201 /** 202 * Parse GFF or Jalview format sequence features file 203 * 204 * @param align 205 * - alignment/dataset containing sequences that are to be annotated 206 * @param colours 207 * - map to store feature colour definitions 208 * @param filters 209 * - map to store feature filter definitions 210 * @param removeHTML 211 * - process html strings into plain text 212 * @param relaxedIdmatching 213 * - when true, ID matches to compound sequence IDs are allowed 214 * @return true if features were added 215 */ parse(AlignmentI align, Map<String, FeatureColourI> colours, Map<String, FeatureMatcherSetI> filters, boolean removeHTML, boolean relaxedIdmatching)216 public boolean parse(AlignmentI align, 217 Map<String, FeatureColourI> colours, 218 Map<String, FeatureMatcherSetI> filters, boolean removeHTML, 219 boolean relaxedIdmatching) 220 { 221 Map<String, String> gffProps = new HashMap<>(); 222 /* 223 * keep track of any sequences we try to create from the data 224 */ 225 List<SequenceI> newseqs = new ArrayList<>(); 226 227 String line = null; 228 try 229 { 230 String[] gffColumns; 231 String featureGroup = null; 232 233 while ((line = nextLine()) != null) 234 { 235 // skip comments/process pragmas 236 if (line.length() == 0 || line.startsWith("#")) 237 { 238 if (line.toLowerCase().startsWith("##")) 239 { 240 processGffPragma(line, gffProps, align, newseqs); 241 } 242 continue; 243 } 244 245 gffColumns = line.split(TAB_REGEX); 246 if (gffColumns.length == 1) 247 { 248 if (line.trim().equalsIgnoreCase("GFF")) 249 { 250 /* 251 * Jalview features file with appended GFF 252 * assume GFF2 (though it may declare ##gff-version 3) 253 */ 254 gffVersion = 2; 255 continue; 256 } 257 } 258 259 if (gffColumns.length > 0 && gffColumns.length < 4) 260 { 261 /* 262 * if 2 or 3 tokens, we anticipate either 'startgroup', 'endgroup' or 263 * a feature type colour specification 264 */ 265 String ft = gffColumns[0]; 266 if (ft.equalsIgnoreCase(STARTFILTERS)) 267 { 268 parseFilters(filters); 269 continue; 270 } 271 if (ft.equalsIgnoreCase(STARTGROUP)) 272 { 273 featureGroup = gffColumns[1]; 274 } 275 else if (ft.equalsIgnoreCase(ENDGROUP)) 276 { 277 // We should check whether this is the current group, 278 // but at present there's no way of showing more than 1 group 279 featureGroup = null; 280 } 281 else 282 { 283 String colscheme = gffColumns[1]; 284 FeatureColourI colour = FeatureColour 285 .parseJalviewFeatureColour(colscheme); 286 if (colour != null) 287 { 288 colours.put(ft, colour); 289 } 290 } 291 continue; 292 } 293 294 /* 295 * if not a comment, GFF pragma, startgroup, endgroup or feature 296 * colour specification, that just leaves a feature details line 297 * in either Jalview or GFF format 298 */ 299 if (gffVersion == 0) 300 { 301 parseJalviewFeature(line, gffColumns, align, colours, removeHTML, 302 relaxedIdmatching, featureGroup); 303 } 304 else 305 { 306 parseGff(gffColumns, align, relaxedIdmatching, newseqs); 307 } 308 } 309 resetMatcher(); 310 } catch (Exception ex) 311 { 312 // should report somewhere useful for UI if necessary 313 warningMessage = ((warningMessage == null) ? "" : warningMessage) 314 + "Parsing error at\n" + line; 315 System.out.println("Error parsing feature file: " + ex + "\n" + line); 316 ex.printStackTrace(System.err); 317 resetMatcher(); 318 return false; 319 } 320 321 /* 322 * experimental - add any dummy sequences with features to the alignment 323 * - we need them for Ensembl feature extraction - though maybe not otherwise 324 */ 325 for (SequenceI newseq : newseqs) 326 { 327 if (newseq.getFeatures().hasFeatures()) 328 { 329 align.addSequence(newseq); 330 } 331 } 332 return true; 333 } 334 335 /** 336 * Reads input lines from STARTFILTERS to ENDFILTERS and adds a feature type 337 * filter to the map for each line parsed. After exit from this method, 338 * nextLine() should return the line after ENDFILTERS (or we are already at 339 * end of file if ENDFILTERS was missing). 340 * 341 * @param filters 342 * @throws IOException 343 */ parseFilters(Map<String, FeatureMatcherSetI> filters)344 protected void parseFilters(Map<String, FeatureMatcherSetI> filters) 345 throws IOException 346 { 347 String line; 348 while ((line = nextLine()) != null) 349 { 350 // TODO: use .trim().equalsIgnoreCase here instead ? 351 if (line.toUpperCase(Locale.ROOT).startsWith(ENDFILTERS)) 352 { 353 return; 354 } 355 String[] tokens = line.split(TAB_REGEX); 356 if (tokens.length != 2) 357 { 358 System.err.println(String.format("Invalid token count %d for %d", 359 tokens.length, line)); 360 } 361 else 362 { 363 String featureType = tokens[0]; 364 FeatureMatcherSetI fm = FeatureMatcherSet.fromString(tokens[1]); 365 if (fm != null && filters != null) 366 { 367 filters.put(featureType, fm); 368 } 369 } 370 } 371 } 372 373 /** 374 * Try to parse a Jalview format feature specification and add it as a 375 * sequence feature to any matching sequences in the alignment. Returns true 376 * if successful (a feature was added), or false if not. 377 * 378 * @param line 379 * @param gffColumns 380 * @param alignment 381 * @param featureColours 382 * @param removeHTML 383 * @param relaxedIdmatching 384 * @param featureGroup 385 */ parseJalviewFeature(String line, String[] gffColumns, AlignmentI alignment, Map<String, FeatureColourI> featureColours, boolean removeHTML, boolean relaxedIdMatching, String featureGroup)386 protected boolean parseJalviewFeature(String line, String[] gffColumns, 387 AlignmentI alignment, Map<String, FeatureColourI> featureColours, 388 boolean removeHTML, boolean relaxedIdMatching, 389 String featureGroup) 390 { 391 /* 392 * tokens: description seqid seqIndex start end type [score] 393 */ 394 if (gffColumns.length < 6) 395 { 396 System.err.println("Ignoring feature line '" + line 397 + "' with too few columns (" + gffColumns.length + ")"); 398 return false; 399 } 400 String desc = gffColumns[0]; 401 String seqId = gffColumns[1]; 402 SequenceI seq = findSequence(seqId, alignment, null, relaxedIdMatching); 403 404 if (!ID_NOT_SPECIFIED.equals(seqId)) 405 { 406 seq = findSequence(seqId, alignment, null, relaxedIdMatching); 407 } 408 else 409 { 410 seqId = null; 411 seq = null; 412 String seqIndex = gffColumns[2]; 413 try 414 { 415 int idx = Integer.parseInt(seqIndex); 416 seq = alignment.getSequenceAt(idx); 417 } catch (NumberFormatException ex) 418 { 419 System.err.println("Invalid sequence index: " + seqIndex); 420 } 421 } 422 423 if (seq == null) 424 { 425 System.out.println("Sequence not found: " + line); 426 return false; 427 } 428 429 int startPos = Integer.parseInt(gffColumns[3]); 430 int endPos = Integer.parseInt(gffColumns[4]); 431 432 String ft = gffColumns[5]; 433 434 if (!featureColours.containsKey(ft)) 435 { 436 /* 437 * Perhaps an old style groups file with no colours - 438 * synthesize a colour from the feature type 439 */ 440 Color colour = ColorUtils.createColourFromName(ft); 441 featureColours.put(ft, new FeatureColour(colour)); 442 } 443 SequenceFeature sf = null; 444 if (gffColumns.length > 6) 445 { 446 float score = Float.NaN; 447 try 448 { 449 score = Float.valueOf(gffColumns[6]).floatValue(); 450 } catch (NumberFormatException ex) 451 { 452 sf = new SequenceFeature(ft, desc, startPos, endPos, featureGroup); 453 } 454 sf = new SequenceFeature(ft, desc, startPos, endPos, score, 455 featureGroup); 456 } 457 else 458 { 459 sf = new SequenceFeature(ft, desc, startPos, endPos, featureGroup); 460 } 461 462 parseDescriptionHTML(sf, removeHTML); 463 464 seq.addSequenceFeature(sf); 465 466 while (seqId != null 467 && (seq = alignment.findName(seq, seqId, false)) != null) 468 { 469 seq.addSequenceFeature(new SequenceFeature(sf)); 470 } 471 return true; 472 } 473 474 /** 475 * clear any temporary handles used to speed up ID matching 476 */ resetMatcher()477 protected void resetMatcher() 478 { 479 lastmatchedAl = null; 480 matcher = null; 481 } 482 483 /** 484 * Returns a sequence matching the given id, as follows 485 * <ul> 486 * <li>strict matching is on exact sequence name</li> 487 * <li>relaxed matching allows matching on a token within the sequence name, 488 * or a dbxref</li> 489 * <li>first tries to find a match in the alignment sequences</li> 490 * <li>else tries to find a match in the new sequences already generated while 491 * parsing the features file</li> 492 * <li>else creates a new placeholder sequence, adds it to the new sequences 493 * list, and returns it</li> 494 * </ul> 495 * 496 * @param seqId 497 * @param align 498 * @param newseqs 499 * @param relaxedIdMatching 500 * 501 * @return 502 */ findSequence(String seqId, AlignmentI align, List<SequenceI> newseqs, boolean relaxedIdMatching)503 protected SequenceI findSequence(String seqId, AlignmentI align, 504 List<SequenceI> newseqs, boolean relaxedIdMatching) 505 { 506 // TODO encapsulate in SequenceIdMatcher, share the matcher 507 // with the GffHelper (removing code duplication) 508 SequenceI match = null; 509 if (relaxedIdMatching) 510 { 511 if (lastmatchedAl != align) 512 { 513 lastmatchedAl = align; 514 matcher = new SequenceIdMatcher(align.getSequencesArray()); 515 if (newseqs != null) 516 { 517 matcher.addAll(newseqs); 518 } 519 } 520 match = matcher.findIdMatch(seqId); 521 } 522 else 523 { 524 match = align.findName(seqId, true); 525 if (match == null && newseqs != null) 526 { 527 for (SequenceI m : newseqs) 528 { 529 if (seqId.equals(m.getName())) 530 { 531 return m; 532 } 533 } 534 } 535 536 } 537 if (match == null && newseqs != null) 538 { 539 match = new SequenceDummy(seqId); 540 if (relaxedIdMatching) 541 { 542 matcher.addAll(Arrays.asList(new SequenceI[] { match })); 543 } 544 // add dummy sequence to the newseqs list 545 newseqs.add(match); 546 } 547 return match; 548 } 549 parseDescriptionHTML(SequenceFeature sf, boolean removeHTML)550 public void parseDescriptionHTML(SequenceFeature sf, boolean removeHTML) 551 { 552 if (sf.getDescription() == null) 553 { 554 return; 555 } 556 ParseHtmlBodyAndLinks parsed = new ParseHtmlBodyAndLinks( 557 sf.getDescription(), removeHTML, newline); 558 559 if (removeHTML) 560 { 561 sf.setDescription(parsed.getNonHtmlContent()); 562 } 563 564 for (String link : parsed.getLinks()) 565 { 566 sf.addLink(link); 567 } 568 } 569 570 /** 571 * Returns contents of a Jalview format features file, for visible features, as 572 * filtered by type and group. Features with a null group are displayed if their 573 * feature type is visible. Non-positional features may optionally be included 574 * (with no check on type or group). 575 * 576 * @param sequences 577 * @param fr 578 * @param includeNonPositional 579 * if true, include non-positional features 580 * (regardless of group or type) 581 * @param includeComplement 582 * if true, include visible complementary 583 * (CDS/protein) positional features, with 584 * locations converted to local sequence 585 * coordinates 586 * @return 587 */ printJalviewFormat(SequenceI[] sequences, FeatureRenderer fr, boolean includeNonPositional, boolean includeComplement)588 public String printJalviewFormat(SequenceI[] sequences, 589 FeatureRenderer fr, boolean includeNonPositional, 590 boolean includeComplement) 591 { 592 Map<String, FeatureColourI> visibleColours = fr 593 .getDisplayedFeatureCols(); 594 Map<String, FeatureMatcherSetI> featureFilters = fr.getFeatureFilters(); 595 596 /* 597 * write out feature colours (if we know them) 598 */ 599 // TODO: decide if feature links should also be written here ? 600 StringBuilder out = new StringBuilder(256); 601 if (visibleColours != null) 602 { 603 for (Entry<String, FeatureColourI> featureColour : visibleColours 604 .entrySet()) 605 { 606 FeatureColourI colour = featureColour.getValue(); 607 out.append(colour.toJalviewFormat(featureColour.getKey())).append( 608 newline); 609 } 610 } 611 612 String[] types = visibleColours == null ? new String[0] 613 : visibleColours.keySet() 614 .toArray(new String[visibleColours.keySet().size()]); 615 616 /* 617 * feature filters if any 618 */ 619 outputFeatureFilters(out, visibleColours, featureFilters); 620 621 /* 622 * output features within groups 623 */ 624 int count = outputFeaturesByGroup(out, fr, types, sequences, 625 includeNonPositional); 626 627 if (includeComplement) 628 { 629 count += outputComplementFeatures(out, fr, sequences); 630 } 631 632 return count > 0 ? out.toString() : "No Features Visible"; 633 } 634 635 /** 636 * Outputs any visible complementary (CDS/peptide) positional features as 637 * Jalview format, within feature group. The coordinates of the linked features 638 * are converted to the corresponding positions of the local sequences. 639 * 640 * @param out 641 * @param fr 642 * @param sequences 643 * @return 644 */ outputComplementFeatures(StringBuilder out, FeatureRenderer fr, SequenceI[] sequences)645 private int outputComplementFeatures(StringBuilder out, 646 FeatureRenderer fr, SequenceI[] sequences) 647 { 648 AlignViewportI comp = fr.getViewport().getCodingComplement(); 649 FeatureRenderer fr2 = Desktop.getAlignFrameFor(comp) 650 .getFeatureRenderer(); 651 652 /* 653 * bin features by feature group and sequence 654 */ 655 Map<String, Map<String, List<SequenceFeature>>> map = new TreeMap<>( 656 String.CASE_INSENSITIVE_ORDER); 657 int count = 0; 658 659 for (SequenceI seq : sequences) 660 { 661 /* 662 * find complementary features 663 */ 664 List<SequenceFeature> complementary = findComplementaryFeatures(seq, 665 fr2); 666 String seqName = seq.getName(); 667 668 for (SequenceFeature sf : complementary) 669 { 670 String group = sf.getFeatureGroup(); 671 if (!map.containsKey(group)) 672 { 673 map.put(group, new LinkedHashMap<>()); // preserves sequence order 674 } 675 Map<String, List<SequenceFeature>> groupFeatures = map.get(group); 676 if (!groupFeatures.containsKey(seqName)) 677 { 678 groupFeatures.put(seqName, new ArrayList<>()); 679 } 680 List<SequenceFeature> foundFeatures = groupFeatures.get(seqName); 681 foundFeatures.add(sf); 682 count++; 683 } 684 } 685 686 /* 687 * output features by group 688 */ 689 for (Entry<String, Map<String, List<SequenceFeature>>> groupFeatures : map.entrySet()) 690 { 691 out.append(newline); 692 String group = groupFeatures.getKey(); 693 if (!"".equals(group)) 694 { 695 out.append(STARTGROUP).append(TAB).append(group).append(newline); 696 } 697 Map<String, List<SequenceFeature>> seqFeaturesMap = groupFeatures 698 .getValue(); 699 for (Entry<String, List<SequenceFeature>> seqFeatures : seqFeaturesMap 700 .entrySet()) 701 { 702 String sequenceName = seqFeatures.getKey(); 703 for (SequenceFeature sf : seqFeatures.getValue()) 704 { 705 formatJalviewFeature(out, sequenceName, sf); 706 } 707 } 708 if (!"".equals(group)) 709 { 710 out.append(ENDGROUP).append(TAB).append(group).append(newline); 711 } 712 } 713 714 return count; 715 } 716 717 /** 718 * Answers a list of mapped features visible in the (CDS/protein) complement, 719 * with feature positions translated to local sequence coordinates 720 * 721 * @param seq 722 * @param fr2 723 * @return 724 */ findComplementaryFeatures(SequenceI seq, FeatureRenderer fr2)725 protected List<SequenceFeature> findComplementaryFeatures(SequenceI seq, 726 FeatureRenderer fr2) 727 { 728 /* 729 * avoid duplication of features (e.g. peptide feature 730 * at all 3 mapped codon positions) 731 */ 732 List<SequenceFeature> found = new ArrayList<>(); 733 List<SequenceFeature> complementary = new ArrayList<>(); 734 735 for (int pos = seq.getStart(); pos <= seq.getEnd(); pos++) 736 { 737 MappedFeatures mf = fr2.findComplementFeaturesAtResidue(seq, pos); 738 739 if (mf != null) 740 { 741 for (SequenceFeature sf : mf.features) 742 { 743 /* 744 * make a virtual feature with local coordinates 745 */ 746 if (!found.contains(sf)) 747 { 748 String group = sf.getFeatureGroup(); 749 if (group == null) 750 { 751 group = ""; 752 } 753 found.add(sf); 754 int begin = sf.getBegin(); 755 int end = sf.getEnd(); 756 int[] range = mf.getMappedPositions(begin, end); 757 SequenceFeature sf2 = new SequenceFeature(sf, range[0], 758 range[1], group, sf.getScore()); 759 complementary.add(sf2); 760 } 761 } 762 } 763 } 764 765 return complementary; 766 } 767 768 /** 769 * Outputs any feature filters defined for visible feature types, sandwiched by 770 * STARTFILTERS and ENDFILTERS lines 771 * 772 * @param out 773 * @param visible 774 * @param featureFilters 775 */ outputFeatureFilters(StringBuilder out, Map<String, FeatureColourI> visible, Map<String, FeatureMatcherSetI> featureFilters)776 void outputFeatureFilters(StringBuilder out, 777 Map<String, FeatureColourI> visible, 778 Map<String, FeatureMatcherSetI> featureFilters) 779 { 780 if (visible == null || featureFilters == null 781 || featureFilters.isEmpty()) 782 { 783 return; 784 } 785 786 boolean first = true; 787 for (String featureType : visible.keySet()) 788 { 789 FeatureMatcherSetI filter = featureFilters.get(featureType); 790 if (filter != null) 791 { 792 if (first) 793 { 794 first = false; 795 out.append(newline).append(STARTFILTERS).append(newline); 796 } 797 out.append(featureType).append(TAB).append(filter.toStableString()) 798 .append(newline); 799 } 800 } 801 if (!first) 802 { 803 out.append(ENDFILTERS).append(newline); 804 } 805 806 } 807 808 /** 809 * Appends output of visible sequence features within feature groups to the 810 * output buffer. Groups other than the null or empty group are sandwiched by 811 * STARTGROUP and ENDGROUP lines. Answers the number of features written. 812 * 813 * @param out 814 * @param fr 815 * @param featureTypes 816 * @param sequences 817 * @param includeNonPositional 818 * @return 819 */ outputFeaturesByGroup(StringBuilder out, FeatureRenderer fr, String[] featureTypes, SequenceI[] sequences, boolean includeNonPositional)820 private int outputFeaturesByGroup(StringBuilder out, 821 FeatureRenderer fr, String[] featureTypes, 822 SequenceI[] sequences, boolean includeNonPositional) 823 { 824 List<String> featureGroups = fr.getFeatureGroups(); 825 826 /* 827 * sort groups alphabetically, and ensure that features with a 828 * null or empty group are output after those in named groups 829 */ 830 List<String> sortedGroups = new ArrayList<>(featureGroups); 831 sortedGroups.remove(null); 832 sortedGroups.remove(""); 833 Collections.sort(sortedGroups); 834 sortedGroups.add(null); 835 sortedGroups.add(""); 836 837 int count = 0; 838 List<String> visibleGroups = fr.getDisplayedFeatureGroups(); 839 840 /* 841 * loop over all groups (may be visible or not); 842 * non-positional features are output even if group is not visible 843 */ 844 for (String group : sortedGroups) 845 { 846 boolean firstInGroup = true; 847 boolean isNullGroup = group == null || "".equals(group); 848 849 for (int i = 0; i < sequences.length; i++) 850 { 851 String sequenceName = sequences[i].getName(); 852 List<SequenceFeature> features = new ArrayList<>(); 853 854 /* 855 * get any non-positional features in this group, if wanted 856 * (for any feature type, whether visible or not) 857 */ 858 if (includeNonPositional) 859 { 860 features.addAll(sequences[i].getFeatures() 861 .getFeaturesForGroup(false, group)); 862 } 863 864 /* 865 * add positional features for visible feature types, but 866 * (for named groups) only if feature group is visible 867 */ 868 if (featureTypes.length > 0 869 && (isNullGroup || visibleGroups.contains(group))) 870 { 871 features.addAll(sequences[i].getFeatures().getFeaturesForGroup( 872 true, group, featureTypes)); 873 } 874 875 for (SequenceFeature sf : features) 876 { 877 if (sf.isNonPositional() || fr.isVisible(sf)) 878 { 879 count++; 880 if (firstInGroup) 881 { 882 out.append(newline); 883 if (!isNullGroup) 884 { 885 out.append(STARTGROUP).append(TAB).append(group) 886 .append(newline); 887 } 888 } 889 firstInGroup = false; 890 formatJalviewFeature(out, sequenceName, sf); 891 } 892 } 893 } 894 895 if (!isNullGroup && !firstInGroup) 896 { 897 out.append(ENDGROUP).append(TAB).append(group).append(newline); 898 } 899 } 900 return count; 901 } 902 903 /** 904 * Formats one feature in Jalview format and appends to the string buffer 905 * 906 * @param out 907 * @param sequenceName 908 * @param sequenceFeature 909 */ formatJalviewFeature( StringBuilder out, String sequenceName, SequenceFeature sequenceFeature)910 protected void formatJalviewFeature( 911 StringBuilder out, String sequenceName, 912 SequenceFeature sequenceFeature) 913 { 914 if (sequenceFeature.description == null 915 || sequenceFeature.description.equals("")) 916 { 917 out.append(sequenceFeature.type).append(TAB); 918 } 919 else 920 { 921 if (sequenceFeature.links != null 922 && sequenceFeature.getDescription().indexOf("<html>") == -1) 923 { 924 out.append("<html>"); 925 } 926 927 out.append(sequenceFeature.description); 928 if (sequenceFeature.links != null) 929 { 930 for (int l = 0; l < sequenceFeature.links.size(); l++) 931 { 932 String label = sequenceFeature.links.elementAt(l); 933 String href = label.substring(label.indexOf("|") + 1); 934 label = label.substring(0, label.indexOf("|")); 935 936 if (sequenceFeature.description.indexOf(href) == -1) 937 { 938 out.append(" <a href=\"").append(href).append("\">") 939 .append(label).append("</a>"); 940 } 941 } 942 943 if (sequenceFeature.getDescription().indexOf("</html>") == -1) 944 { 945 out.append("</html>"); 946 } 947 } 948 949 out.append(TAB); 950 } 951 out.append(sequenceName); 952 out.append("\t-1\t"); 953 out.append(sequenceFeature.begin); 954 out.append(TAB); 955 out.append(sequenceFeature.end); 956 out.append(TAB); 957 out.append(sequenceFeature.type); 958 if (!Float.isNaN(sequenceFeature.score)) 959 { 960 out.append(TAB); 961 out.append(sequenceFeature.score); 962 } 963 out.append(newline); 964 } 965 966 /** 967 * Parse method that is called when a GFF file is dragged to the desktop 968 */ 969 @Override parse()970 public void parse() 971 { 972 AlignViewportI av = getViewport(); 973 if (av != null) 974 { 975 if (av.getAlignment() != null) 976 { 977 dataset = av.getAlignment().getDataset(); 978 } 979 if (dataset == null) 980 { 981 // working in the applet context ? 982 dataset = av.getAlignment(); 983 } 984 } 985 else 986 { 987 dataset = new Alignment(new SequenceI[] {}); 988 } 989 990 Map<String, FeatureColourI> featureColours = new HashMap<>(); 991 boolean parseResult = parse(dataset, featureColours, false, true); 992 if (!parseResult) 993 { 994 // pass error up somehow 995 } 996 if (av != null) 997 { 998 // update viewport with the dataset data ? 999 } 1000 else 1001 { 1002 setSeqs(dataset.getSequencesArray()); 1003 } 1004 } 1005 1006 /** 1007 * Implementation of unused abstract method 1008 * 1009 * @return error message 1010 */ 1011 @Override print(SequenceI[] sqs, boolean jvsuffix)1012 public String print(SequenceI[] sqs, boolean jvsuffix) 1013 { 1014 System.out.println("Use printGffFormat() or printJalviewFormat()"); 1015 return null; 1016 } 1017 1018 /** 1019 * Returns features output in GFF2 format 1020 * 1021 * @param sequences 1022 * the sequences whose features are to be 1023 * output 1024 * @param visible 1025 * a map whose keys are the type names of 1026 * visible features 1027 * @param visibleFeatureGroups 1028 * @param includeNonPositionalFeatures 1029 * @param includeComplement 1030 * @return 1031 */ printGffFormat(SequenceI[] sequences, FeatureRenderer fr, boolean includeNonPositionalFeatures, boolean includeComplement)1032 public String printGffFormat(SequenceI[] sequences, 1033 FeatureRenderer fr, boolean includeNonPositionalFeatures, 1034 boolean includeComplement) 1035 { 1036 FeatureRenderer fr2 = null; 1037 if (includeComplement) 1038 { 1039 AlignViewportI comp = fr.getViewport().getCodingComplement(); 1040 fr2 = Desktop.getAlignFrameFor(comp).getFeatureRenderer(); 1041 } 1042 1043 Map<String, FeatureColourI> visibleColours = fr.getDisplayedFeatureCols(); 1044 1045 StringBuilder out = new StringBuilder(256); 1046 1047 out.append(String.format("%s %d\n", GFF_VERSION, gffVersion == 0 ? 2 : gffVersion)); 1048 1049 String[] types = visibleColours == null ? new String[0] 1050 : visibleColours.keySet() 1051 .toArray(new String[visibleColours.keySet().size()]); 1052 1053 for (SequenceI seq : sequences) 1054 { 1055 List<SequenceFeature> seqFeatures = new ArrayList<>(); 1056 List<SequenceFeature> features = new ArrayList<>(); 1057 if (includeNonPositionalFeatures) 1058 { 1059 features.addAll(seq.getFeatures().getNonPositionalFeatures()); 1060 } 1061 if (visibleColours != null && !visibleColours.isEmpty()) 1062 { 1063 features.addAll(seq.getFeatures().getPositionalFeatures(types)); 1064 } 1065 for (SequenceFeature sf : features) 1066 { 1067 if (sf.isNonPositional() || fr.isVisible(sf)) 1068 { 1069 /* 1070 * drop features hidden by group visibility, colour threshold, 1071 * or feature filter condition 1072 */ 1073 seqFeatures.add(sf); 1074 } 1075 } 1076 1077 if (includeComplement) 1078 { 1079 seqFeatures.addAll(findComplementaryFeatures(seq, fr2)); 1080 } 1081 1082 /* 1083 * sort features here if wanted 1084 */ 1085 for (SequenceFeature sf : seqFeatures) 1086 { 1087 formatGffFeature(out, seq, sf); 1088 out.append(newline); 1089 } 1090 } 1091 1092 return out.toString(); 1093 } 1094 1095 /** 1096 * Formats one feature as GFF and appends to the string buffer 1097 */ formatGffFeature(StringBuilder out, SequenceI seq, SequenceFeature sf)1098 private void formatGffFeature(StringBuilder out, SequenceI seq, 1099 SequenceFeature sf) 1100 { 1101 String source = sf.featureGroup; 1102 if (source == null) 1103 { 1104 source = sf.getDescription(); 1105 } 1106 1107 out.append(seq.getName()); 1108 out.append(TAB); 1109 out.append(source); 1110 out.append(TAB); 1111 out.append(sf.type); 1112 out.append(TAB); 1113 out.append(sf.begin); 1114 out.append(TAB); 1115 out.append(sf.end); 1116 out.append(TAB); 1117 out.append(sf.score); 1118 out.append(TAB); 1119 1120 int strand = sf.getStrand(); 1121 out.append(strand == 1 ? "+" : (strand == -1 ? "-" : ".")); 1122 out.append(TAB); 1123 1124 String phase = sf.getPhase(); 1125 out.append(phase == null ? "." : phase); 1126 1127 if (sf.otherDetails != null && !sf.otherDetails.isEmpty()) 1128 { 1129 Map<String, Object> map = sf.otherDetails; 1130 formatAttributes(out, map); 1131 } 1132 } 1133 1134 /** 1135 * A helper method that outputs attributes stored in the map as 1136 * semicolon-delimited values e.g. 1137 * 1138 * <pre> 1139 * AC_Male=0;AF_NFE=0.00000e 00;Hom_FIN=0;GQ_MEDIAN=9 1140 * </pre> 1141 * 1142 * A map-valued attribute is formatted as a comma-delimited list within braces, 1143 * for example 1144 * 1145 * <pre> 1146 * jvmap_CSQ={ALLELE_NUM=1,UNIPARC=UPI0002841053,Feature=ENST00000585561} 1147 * </pre> 1148 * 1149 * The {@code jvmap_} prefix designates a values map and is removed if the value 1150 * is parsed when read in. (The GFF3 specification allows 'semi-structured data' 1151 * to be represented provided the attribute name begins with a lower case 1152 * letter.) 1153 * 1154 * @param sb 1155 * @param map 1156 * @see http://gmod.org/wiki/GFF3#GFF3_Format 1157 */ formatAttributes(StringBuilder sb, Map<String, Object> map)1158 void formatAttributes(StringBuilder sb, Map<String, Object> map) 1159 { 1160 sb.append(TAB); 1161 boolean first = true; 1162 for (String key : map.keySet()) 1163 { 1164 if (SequenceFeature.STRAND.equals(key) 1165 || SequenceFeature.PHASE.equals(key)) 1166 { 1167 /* 1168 * values stashed in map but output to their own columns 1169 */ 1170 continue; 1171 } 1172 { 1173 if (!first) 1174 { 1175 sb.append(";"); 1176 } 1177 } 1178 first = false; 1179 Object value = map.get(key); 1180 if (value instanceof Map<?, ?>) 1181 { 1182 formatMapAttribute(sb, key, (Map<?, ?>) value); 1183 } 1184 else 1185 { 1186 String formatted = StringUtils.urlEncode(value.toString(), 1187 GffHelperI.GFF_ENCODABLE); 1188 sb.append(key).append(EQUALS).append(formatted); 1189 } 1190 } 1191 } 1192 1193 /** 1194 * Formats the map entries as 1195 * 1196 * <pre> 1197 * key=key1=value1,key2=value2,... 1198 * </pre> 1199 * 1200 * and appends this to the string buffer 1201 * 1202 * @param sb 1203 * @param key 1204 * @param map 1205 */ formatMapAttribute(StringBuilder sb, String key, Map<?, ?> map)1206 private void formatMapAttribute(StringBuilder sb, String key, 1207 Map<?, ?> map) 1208 { 1209 if (map == null || map.isEmpty()) 1210 { 1211 return; 1212 } 1213 1214 /* 1215 * AbstractMap.toString would be a shortcut here, but more reliable 1216 * to code the required format in case toString changes in future 1217 */ 1218 sb.append(key).append(EQUALS); 1219 boolean first = true; 1220 for (Entry<?, ?> entry : map.entrySet()) 1221 { 1222 if (!first) 1223 { 1224 sb.append(","); 1225 } 1226 first = false; 1227 sb.append(entry.getKey().toString()).append(EQUALS); 1228 String formatted = StringUtils.urlEncode(entry.getValue().toString(), 1229 GffHelperI.GFF_ENCODABLE); 1230 sb.append(formatted); 1231 } 1232 } 1233 1234 /** 1235 * Returns a mapping given list of one or more Align descriptors (exonerate 1236 * format) 1237 * 1238 * @param alignedRegions 1239 * a list of "Align fromStart toStart fromCount" 1240 * @param mapIsFromCdna 1241 * if true, 'from' is dna, else 'from' is protein 1242 * @param strand 1243 * either 1 (forward) or -1 (reverse) 1244 * @return 1245 * @throws IOException 1246 */ constructCodonMappingFromAlign( List<String> alignedRegions, boolean mapIsFromCdna, int strand)1247 protected MapList constructCodonMappingFromAlign( 1248 List<String> alignedRegions, boolean mapIsFromCdna, int strand) 1249 throws IOException 1250 { 1251 if (strand == 0) 1252 { 1253 throw new IOException( 1254 "Invalid strand for a codon mapping (cannot be 0)"); 1255 } 1256 int regions = alignedRegions.size(); 1257 // arrays to hold [start, end] for each aligned region 1258 int[] fromRanges = new int[regions * 2]; // from dna 1259 int[] toRanges = new int[regions * 2]; // to protein 1260 int fromRangesIndex = 0; 1261 int toRangesIndex = 0; 1262 1263 for (String range : alignedRegions) 1264 { 1265 /* 1266 * Align mapFromStart mapToStart mapFromCount 1267 * e.g. if mapIsFromCdna 1268 * Align 11270 143 120 1269 * means: 1270 * 120 bases from pos 11270 align to pos 143 in peptide 1271 * if !mapIsFromCdna this would instead be 1272 * Align 143 11270 40 1273 */ 1274 String[] tokens = range.split(" "); 1275 if (tokens.length != 3) 1276 { 1277 throw new IOException("Wrong number of fields for Align"); 1278 } 1279 int fromStart = 0; 1280 int toStart = 0; 1281 int fromCount = 0; 1282 try 1283 { 1284 fromStart = Integer.parseInt(tokens[0]); 1285 toStart = Integer.parseInt(tokens[1]); 1286 fromCount = Integer.parseInt(tokens[2]); 1287 } catch (NumberFormatException nfe) 1288 { 1289 throw new IOException( 1290 "Invalid number in Align field: " + nfe.getMessage()); 1291 } 1292 1293 /* 1294 * Jalview always models from dna to protein, so adjust values if the 1295 * GFF mapping is from protein to dna 1296 */ 1297 if (!mapIsFromCdna) 1298 { 1299 fromCount *= 3; 1300 int temp = fromStart; 1301 fromStart = toStart; 1302 toStart = temp; 1303 } 1304 fromRanges[fromRangesIndex++] = fromStart; 1305 fromRanges[fromRangesIndex++] = fromStart + strand * (fromCount - 1); 1306 1307 /* 1308 * If a codon has an intron gap, there will be contiguous 'toRanges'; 1309 * this is handled for us by the MapList constructor. 1310 * (It is not clear that exonerate ever generates this case) 1311 */ 1312 toRanges[toRangesIndex++] = toStart; 1313 toRanges[toRangesIndex++] = toStart + (fromCount - 1) / 3; 1314 } 1315 1316 return new MapList(fromRanges, toRanges, 3, 1); 1317 } 1318 1319 /** 1320 * Parse a GFF format feature. This may include creating a 'dummy' sequence to 1321 * hold the feature, or for its mapped sequence, or both, to be resolved 1322 * either later in the GFF file (##FASTA section), or when the user loads 1323 * additional sequences. 1324 * 1325 * @param gffColumns 1326 * @param alignment 1327 * @param relaxedIdMatching 1328 * @param newseqs 1329 * @return 1330 */ parseGff(String[] gffColumns, AlignmentI alignment, boolean relaxedIdMatching, List<SequenceI> newseqs)1331 protected SequenceI parseGff(String[] gffColumns, AlignmentI alignment, 1332 boolean relaxedIdMatching, List<SequenceI> newseqs) 1333 { 1334 /* 1335 * GFF: seqid source type start end score strand phase [attributes] 1336 */ 1337 if (gffColumns.length < 5) 1338 { 1339 System.err.println("Ignoring GFF feature line with too few columns (" 1340 + gffColumns.length + ")"); 1341 return null; 1342 } 1343 1344 /* 1345 * locate referenced sequence in alignment _or_ 1346 * as a forward or external reference (SequenceDummy) 1347 */ 1348 String seqId = gffColumns[0]; 1349 SequenceI seq = findSequence(seqId, alignment, newseqs, 1350 relaxedIdMatching); 1351 1352 SequenceFeature sf = null; 1353 GffHelperI helper = GffHelperFactory.getHelper(gffColumns); 1354 if (helper != null) 1355 { 1356 try 1357 { 1358 sf = helper.processGff(seq, gffColumns, alignment, newseqs, 1359 relaxedIdMatching); 1360 if (sf != null) 1361 { 1362 seq.addSequenceFeature(sf); 1363 while ((seq = alignment.findName(seq, seqId, true)) != null) 1364 { 1365 seq.addSequenceFeature(new SequenceFeature(sf)); 1366 } 1367 } 1368 } catch (IOException e) 1369 { 1370 System.err.println("GFF parsing failed with: " + e.getMessage()); 1371 return null; 1372 } 1373 } 1374 1375 return seq; 1376 } 1377 1378 /** 1379 * After encountering ##fasta in a GFF3 file, process the remainder of the 1380 * file as FAST sequence data. Any placeholder sequences created during 1381 * feature parsing are updated with the actual sequences. 1382 * 1383 * @param align 1384 * @param newseqs 1385 * @throws IOException 1386 */ processAsFasta(AlignmentI align, List<SequenceI> newseqs)1387 protected void processAsFasta(AlignmentI align, List<SequenceI> newseqs) 1388 throws IOException 1389 { 1390 try 1391 { 1392 mark(); 1393 } catch (IOException q) 1394 { 1395 } 1396 FastaFile parser = new FastaFile(this); 1397 List<SequenceI> includedseqs = parser.getSeqs(); 1398 1399 SequenceIdMatcher smatcher = new SequenceIdMatcher(newseqs); 1400 1401 /* 1402 * iterate over includedseqs, and replacing matching ones with newseqs 1403 * sequences. Generic iterator not used here because we modify 1404 * includedseqs as we go 1405 */ 1406 for (int p = 0, pSize = includedseqs.size(); p < pSize; p++) 1407 { 1408 // search for any dummy seqs that this sequence can be used to update 1409 SequenceI includedSeq = includedseqs.get(p); 1410 SequenceI dummyseq = smatcher.findIdMatch(includedSeq); 1411 if (dummyseq != null && dummyseq instanceof SequenceDummy) 1412 { 1413 // probably have the pattern wrong 1414 // idea is that a flyweight proxy for a sequence ID can be created for 1415 // 1. stable reference creation 1416 // 2. addition of annotation 1417 // 3. future replacement by a real sequence 1418 // current pattern is to create SequenceDummy objects - a convenience 1419 // constructor for a Sequence. 1420 // problem is that when promoted to a real sequence, all references 1421 // need to be updated somehow. We avoid that by keeping the same object. 1422 ((SequenceDummy) dummyseq).become(includedSeq); 1423 dummyseq.createDatasetSequence(); 1424 1425 /* 1426 * Update mappings so they are now to the dataset sequence 1427 */ 1428 for (AlignedCodonFrame mapping : align.getCodonFrames()) 1429 { 1430 mapping.updateToDataset(dummyseq); 1431 } 1432 1433 /* 1434 * replace parsed sequence with the realised forward reference 1435 */ 1436 includedseqs.set(p, dummyseq); 1437 1438 /* 1439 * and remove from the newseqs list 1440 */ 1441 newseqs.remove(dummyseq); 1442 } 1443 } 1444 1445 /* 1446 * finally add sequences to the dataset 1447 */ 1448 for (SequenceI seq : includedseqs) 1449 { 1450 // experimental: mapping-based 'alignment' to query sequence 1451 AlignmentUtils.alignSequenceAs(seq, align, 1452 String.valueOf(align.getGapCharacter()), false, true); 1453 1454 // rename sequences if GFF handler requested this 1455 // TODO a more elegant way e.g. gffHelper.postProcess(newseqs) ? 1456 List<SequenceFeature> sfs = seq.getFeatures().getPositionalFeatures(); 1457 if (!sfs.isEmpty()) 1458 { 1459 String newName = (String) sfs.get(0).getValue( 1460 GffHelperI.RENAME_TOKEN); 1461 if (newName != null) 1462 { 1463 seq.setName(newName); 1464 } 1465 } 1466 align.addSequence(seq); 1467 } 1468 } 1469 1470 /** 1471 * Process a ## directive 1472 * 1473 * @param line 1474 * @param gffProps 1475 * @param align 1476 * @param newseqs 1477 * @throws IOException 1478 */ processGffPragma(String line, Map<String, String> gffProps, AlignmentI align, List<SequenceI> newseqs)1479 protected void processGffPragma(String line, Map<String, String> gffProps, 1480 AlignmentI align, List<SequenceI> newseqs) throws IOException 1481 { 1482 line = line.trim(); 1483 if ("###".equals(line)) 1484 { 1485 // close off any open 'forward references' 1486 return; 1487 } 1488 1489 String[] tokens = line.substring(2).split(" "); 1490 String pragma = tokens[0]; 1491 String value = tokens.length == 1 ? null : tokens[1]; 1492 1493 if ("gff-version".equalsIgnoreCase(pragma)) 1494 { 1495 if (value != null) 1496 { 1497 try 1498 { 1499 // value may be e.g. "3.1.2" 1500 gffVersion = Integer.parseInt(value.split("\\.")[0]); 1501 } catch (NumberFormatException e) 1502 { 1503 // ignore 1504 } 1505 } 1506 } 1507 else if ("sequence-region".equalsIgnoreCase(pragma)) 1508 { 1509 // could capture <seqid start end> if wanted here 1510 } 1511 else if ("feature-ontology".equalsIgnoreCase(pragma)) 1512 { 1513 // should resolve against the specified feature ontology URI 1514 } 1515 else if ("attribute-ontology".equalsIgnoreCase(pragma)) 1516 { 1517 // URI of attribute ontology - not currently used in GFF3 1518 } 1519 else if ("source-ontology".equalsIgnoreCase(pragma)) 1520 { 1521 // URI of source ontology - not currently used in GFF3 1522 } 1523 else if ("species-build".equalsIgnoreCase(pragma)) 1524 { 1525 // save URI of specific NCBI taxon version of annotations 1526 gffProps.put("species-build", value); 1527 } 1528 else if ("fasta".equalsIgnoreCase(pragma)) 1529 { 1530 // process the rest of the file as a fasta file and replace any dummy 1531 // sequence IDs 1532 processAsFasta(align, newseqs); 1533 } 1534 else 1535 { 1536 System.err.println("Ignoring unknown pragma: " + line); 1537 } 1538 } 1539 } 1540