close

Filter

loading table of contents...

Blueprint Developer Manual / Version 2110

Table Of Contents
5.5.3.4.4 XLIFF Customization

You may customize XLIFF handling at various extension points:

  • you may modify strategies to identify translatable properties, or

  • you may change representation in XLIFF export, or

  • you may adapt XLIFF import for special property types.

Assumptions

In here you will see a small use case which leads you through all required adaptions to take to your system. The use case is based on the following assumptions:

  • you introduced a markup property xhtml with XHTML grammar (http://www.w3.org/1999/xhtml) to your content type model,

  • you do not support attributes for XHTML elements, and

  • only markup properties named xhtml should be considered for translation (in other words: you do not use the translatable attribute inside your content type model).

In Section “Handling Attributes” this simple example is continued by adding the attributes, as this is an expert scenario which requires taking care of XLIFF validation.

Translatable Predicate

In order to add the property to the translation process, you have to mark it as translatable. There are several ways of doing so, where the default one is to mark the property as translatable within the content type model (using extension:translatable=true). As alternative to this you may add a custom bean of type TranslatablePredicate to your application context, which will then be ORed with all other existing beans of this type. To completely override the behavior, you need to override the bean named `translatablePredicate` in TranslatablePredicateConfiguration.

The Blueprint contains a default predicate that can be configured with property translate.xliff.translatableExpressions and that makes it possible to mark only certain nested properties of Struct properties as translatable. See Section “XLIFF Configuration Beans” for details.

Translation Items

Having marked the property xhtml as translatable, as described in Section “Translatable Predicate” and trying to export a document containing the xhtml property, you will see a CapTranslateItemException as in Example 5.20, “Example for CapTranslateItemException”.

com.coremedia.translate.item.CapTranslateItemException:

Property 'xhtml' (type: MARKUP) of content type 'MyDocument' is configured
to be considered during translation but a corresponding transformer of type
'com.coremedia.translate.item.transform.TranslatePropertyTransformer'
cannot be found.

Please consider to add an appropriate transformer bean to your Spring context.

Example 5.20. Example for CapTranslateItemException


The exception tells you which steps to do next, in order to support the xhtml property during XLIFF export: You have to add a TranslatePropertyTransformer to your Spring context.

Some default transformers for standard CoreMedia property types are configured in TranslateItemConfiguration. One of them is the AtomicRichtextTranslatePropertyTransformer for CoreMedia RichText properties.

Just as the AtomicRichtextTranslatePropertyTransformer the transformer can be based on AbstractAtomicMarkupTranslatePropertyTransformer which ends up that you only have to specify how to match the grammar name and to add a predicate to decide if the property should be considered empty or not.

public class AtomicXhtmlTranslatePropertyTransformer
        extends AbstractAtomicMarkupTranslatePropertyTransformer {
  private static final String XHTML_GRAMMAR_NAME =
          "http://www.w3.org/1999/xhtml";

  private AtomicXhtmlTranslatePropertyTransformer(
          TranslatablePredicate translatablePredicate,
          Predicate<? super Markup> emptyPredicate) {
    super(
            translatablePredicate,
            AtomicXhtmlTranslatePropertyTransformer::isXhtml,
            emptyPredicate
    );
  }

  public AtomicXhtmlTranslatePropertyTransformer(
          TranslatablePredicate translatablePredicate) {
    this(translatablePredicate,
         AtomicXhtmlTranslatePropertyTransformer::isEmpty);
  }

  private static boolean isEmpty(@Nullable Markup value) {
    return !MarkupUtil.hasText(value, true);
  }

  private static boolean isXhtml(XmlGrammar grammar) {
    return XHTML_GRAMMAR_NAME.equals(grammar.getName());
  }
}

Example 5.21. TranslatePropertyTransformer for XHTML


Now you have a ready-to-use strategy to add your xhtml property to your translation items.

Note

Property Hints

Transformed properties may hold meta information which are required for later processing. Typical scenarios are to tell the translation service how many characters are allowed within a String or to tell the XLIFF importer which grammar is used for the contained Markup.

For details have a look at TranslateProperty and PropertyHint.

XLIFF Export

As before, an exception provides further guidance how to continue as can be seen in Example 5.22, “Example for CapXliffExportException”.

com.coremedia.cap.translate.xliff.CapXliffExportException:

Missing XLIFF export handler for property
  'TranslateProperty[
          id = property:markup:xml,
          propertyHints =
              [GrammarNameHint[value = http://www.w3.org/1999/xhtml]],
          value = <html xml:lang="en" ...]'.

Please consider adding an appropriate handler of type
  'com.coremedia.cap.translate.xliff.handler.XliffPropertyExportHandler'
  to your Spring context.

Example 5.22. Example for CapXliffExportException


You now have to implement a strategy to transform XHTML into a representation which contains the texts to translate as XLIFF <trans-unit> elements. The strategy is created by implementing XliffPropertyExportHandler.

public class XliffXhtmlPropertyExportHandler
        implements XliffPropertyExportHandler {
  private static final String XHTML_GRAMMAR_NAME =
          "http://www.w3.org/1999/xhtml";

  @Override
  public boolean isApplicable(TranslateProperty property) {
    return property.getValue() instanceof Markup &&
            property.getGrammarName()
                    .map(XHTML_GRAMMAR_NAME::equals)
                    .orElse(false);
  }

  @Override
  public Optional<Group> toGroup(TranslateProperty property,
                                 Supplier<String> idProvider,
                                 XliffExportOptions xliffExportOptions) {
    return Optional.ofNullable(
            toGroup(property.getId(),
                    (Markup) property.getValue(),
                    idProvider,
                    xliffExportOptions
            )
    );
  }

  @Nullable
  private static Group toGroup(String id,
                               Markup value,
                               Supplier<String> idProvider,
                               XliffExportOptions xliffExportOptions) {
    if (!MarkupUtil.hasText(value, true) &&
            xliffExportOptions.isEmptyIgnore()) {
      // Don't add anything to translate for empty XHTML.
      return null;
    }

    Group markupGroup =
            new XhtmlToXliffConverter(idProvider, xliffExportOptions)
                    .apply(value);

    Group result = new Group();
    // Required: Set the property name. This is important for
    // the import process later on, to identify the property
    // to change.
    result.setResname(id);
    result.setDatatype(DatatypeValueList.XHTML.value());

    result.getGroupOrTransUnitOrBinUnit().add(markupGroup);

    return result;
  }

}

Example 5.23. PropertyExportHandler for XHTML


Example 5.23, “PropertyExportHandler for XHTML” contains the base for such a handler. It especially creates an XLIFF group object, which contains the property name. For mapping XHTML to XLIFF the handler delegates to another class in this case, as this is the most complex task during XLIFF export.

public class XhtmlToXliffConverter implements Function<Markup, Group> {
  private final Supplier<String> transUnitIdProvider;
  private final XliffExportOptions xliffExportOptions;

  public XhtmlToXliffConverter(Supplier<String> transUnitIdProvider,
                               XliffExportOptions ) {
    this.transUnitIdProvider = transUnitIdProvider;
    this.xliffExportOptions = xliffExportOptions;
  }

  @Override
  public Group apply(Markup markup) {
    XhtmlContentHandler handler = new XhtmlContentHandler();
    markup.writeOn(handler);
    return handler.getResult();
  }

  private class XhtmlContentHandler extends DefaultHandler {
    // First element in stack is the currently modified element.
    private final Deque<Object> xliffStack = new LinkedList<>();
    private Group result;

    @Override
    public void startElement(String uri,
                             String localName,
                             String qName,
                             Attributes attributes) {
      String typeId = localName.toLowerCase(Locale.ROOT);
      Group elementWrapper = new Group();
      if (result == null) {
        result = elementWrapper;
      }
      elementWrapper.setType(getType(typeId));
      addElement(elementWrapper);
    }

    private void addElement(Object elementWrapper) {
      Object first = xliffStack.peekFirst();
      if (first instanceof ContentHolder) {
        ContentHolder contentHolder = (ContentHolder) first;
        contentHolder.getContent().add(elementWrapper);
      }
      xliffStack.push(elementWrapper);
    }

    @Override
    public void endElement(String uri,
                           String localName,
                           String qName) {
      // remove element from xliffStack

      while (!xliffStack.isEmpty()) {
        Object object = xliffStack.pop();
        if (object instanceof TypeHolder) {
          TypeHolder typeHolder = (TypeHolder) object;
          if (getType(localName).equals(typeHolder.getType())) {
            break;
          }
        }
      }
    }

    @Override
    public void characters(char[] ch,
                           int start,
                           int length) {
      String content = new String(ch, start, length);

      TransUnit transUnit = requireTransUnit();

      transUnit.getSource().getContent().add(content);
      if (xliffExportOptions.getTargetOption() ==
              XliffExportOptions.TargetOption.TARGET_SOURCE) {
        transUnit.getTarget().getContent().add(content);
      }
    }

    private TransUnit requireTransUnit() {
      Object first = xliffStack.peekFirst();
      TransUnit transUnit;
      if (first instanceof TransUnit) {
        transUnit = (TransUnit) first;
      } else {
        transUnit = new TransUnit();
        transUnit.setId(transUnitIdProvider.get());
        transUnit.setSource(new Source());
        addElement(transUnit);
        if (xliffExportOptions.getTargetOption() !=
                XliffExportOptions.TargetOption.TARGET_OMIT) {
          transUnit.setTarget(new Target());
        }
      }
      return transUnit;
    }

    private String getType(String typeId) {
      return "x-html-" + typeId.toLowerCase(Locale.ROOT);
    }

    private Group getResult() {
      // possibly check that the result is actually set
      return result;
    }
  }
}

Example 5.24. XhtmlToXliffConverter


Example 5.24, “XhtmlToXliffConverter” is a very basic implementation of such a converter (it ignores for example any attributes). It contains the most relevant aspects, that you have to take into account while generating XLIFF structures.

Elements of XhtmlToXliffConverter

transUnitIdProvider

Each <trans-unit> has to get a unique ID. This ID is supplied by the transUnitIdProvider.

xliffExportOptions

The options will especially tell you, how to deal with target nodes. You may for example have to create empty target nodes in <trans-unit> elements or you have to copy the value contained in the source node to the target node.

XhtmlContentHandler

Markup can be written to a SAX ContentHandler. Use this handler, for going through the elements and creating a parallel XLIFF structure. For convenience you use the implementation SAX DefaultHandler, so that you only have to implement the methods you are interested in.

startElement

For any element in XHTML, you need to create a representation in XLIFF. Regarding the type identifier you can use any String value - you need this value later on to create the corresponding XHTML element. Thus, it makes sense, that the ID contains a reference to the corresponding XHTML element.

addElement

The XLIFF JAXB Classes contain some convenience methods and interfaces such as ContentHolder to easily identify XLIFF elements which may have some content.

endElement

This method just updates the element stack, so that new elements get added at the expected location.

characters

Obviously, characters are those elements of your XHTML which you want to translate. Thus, you need to add the characters to a <trans-unit> node. Depending on the configured XLIFF export options, you also need to add them to the target node.

Do not forget to set the <trans-unit> ID here.

Note

Marshalling Failures

If marshalling the XLIFF structure fails, ensure that you check that your generated XLIFF structure is valid. The JAXB wrapper classes do not provide much support there. Thus, it is always recommended having your XLIFF 1.2 specification at hand.

If everything is implemented correctly, an XHTML property as in Example 5.25, “XHTML Example Input” will be transformed to XLIFF as in Example 5.26, “XHTML as XLIFF Example Output”, which again will be part of the XLIFF file representing the content containing the property.

<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>abcdefg</title>
  </head>
  <body>
    <p>abcdefg</p>
  </body>
</html>

Example 5.25. XHTML Example Input


<group datatype="xhtml" resname="property:markup:xml">
  <group restype="x-html-html">
    <group restype="x-html-head">
      <group restype="x-html-title">
        <trans-unit id="12">
          <source>abcdefg</source>
          <target>abcdefg</target>
        </trans-unit>
      </group>
    </group>
    <group restype="x-html-body">
      <group restype="x-html-p">
        <trans-unit id="13">
          <source>abcdefg</source>
          <target>abcdefg</target>
        </trans-unit>
      </group>
    </group>
  </group>
</group>

Example 5.26. XHTML as XLIFF Example Output


XLIFF Import

Having implemented all the above, let's handle the XLIFF import now. Without further changes, the just exported property will be ignored on XLIFF import, and no change will be applied to the target document.

Note

Extending XLIFF Schema

XLIFF 1.2 specification may be extended by custom attributes. If you have enabled XLIFF schema validation for XLIFF importer, and if you generated extended attributes you may have to add additional XLIFF schema sources. For details have a look at Section “Handling Attributes”.

In order to import the xhtml property, you have to implement an XliffPropertyImportHandler and add it as bean to the Spring context.

public class XliffXhtmlPropertyImportHandler
        implements XliffPropertyImportHandler {

  private static final String XHTML_GRAMMAR_NAME =
          "http://www.w3.org/1999/xhtml";

  @Override
  public boolean isAccepting(CapPropertyDescriptor property) {

    if (property.getType() != CapPropertyDescriptorType.MARKUP) {
      return false;
    }

    XmlGrammar grammar = ((MarkupPropertyDescriptor) property).getGrammar();
    return grammar != null && XHTML_GRAMMAR_NAME.equals(grammar.getName());
  }

  @Nullable
  @Override
  public Object convertXliffToProperty(
          CapPropertyDescriptor descriptor,
          Group group,
          @Nullable Object originalPropertyValue,
          String targetLocale,
          XliffImportResultCollector result) {

    Group markupGroup = (Group) group.getContent().get(0);
    Markup markup = new XliffToXhtmlConverter().apply(markupGroup);

    if (!descriptor.isValidValue(markup)) {
      result.addItem(XliffImportResultCode.INVALID_MARKUP);
    }

    return markup;
  }
}

Example 5.27. XliffXhtmlPropertyImportHandler


Example 5.27, “XliffXhtmlPropertyImportHandler” shows a very simplistic implementation of an import handler. It decides if it is responsible for importing into the given property, and if it is, the method convertXliffToProperty will be called. Many of the arguments provide context information which may be used for further validation. You only use the group to parse the XLIFF and the descriptor to validate the resulting value. The group handed over is the group node you added before, which contains the reference to the property. Thus, the relevant content for you to parse is the first group contained in it.

As you can see, it is important that XLIFF export and import go hand in hand, to understand each other. Adapting XLIFF export almost always means to adapt the XLIFF import, too.

The XliffXhtmlPropertyImportHandler delegates further processing to a converter which will now generate the Markup as can be seen in Example 5.28, “XliffToXhtmlConverter”.

public class XliffToXhtmlConverter
        implements Function<Group, Markup> {
  private static final String XHTML_GRAMMAR_NAME =
          "http://www.w3.org/1999/xhtml";

  @Override
  public Markup apply(Group group) {
    Document document = MarkupFactory.newDocument(
            XHTML_GRAMMAR_NAME,
            elementName(group),
            null
    );
    Element documentElement = document.getDocumentElement();
    documentElement.setAttribute("xmlns", XHTML_GRAMMAR_NAME);

    processContents(group, documentElement);

    return MarkupFactory.fromDOM(document);
  }

  private static void processContents(ContentHolder contentHolder,
                                      Element parent) {
    for (Object content : contentHolder.getContent()) {
      if (content instanceof String) {
        processString(parent, (String) content);
      } else if (content instanceof Group) {
        processGroup(parent, (Group) content);
      } else if (content instanceof TransUnit) {
        TransUnit transUnit = (TransUnit) content;
        processContents(transUnit.getTarget(), parent);
      }
    }
  }

  private static void processGroup(Node parent, Group group) {
    Document document = parent.getOwnerDocument();
    Element node = document.createElement(elementName(group));
    parent.appendChild(node);
    processContents(group, node);
  }

  private static void processString(Node parent, String text) {
    Document document = parent.getOwnerDocument();
    Node node = document.createTextNode(text);
    parent.appendChild(node);
  }

  private static String elementName(TypeHolder group) {
    return group.getType().replace("x-html-", "");
  }
}

Example 5.28. XliffToXhtmlConverter


Methods of XliffXhtmlPropertyImportHandler

apply

Create a document from the main group which you may later transform to Markup.

processContents

Steps through the elements of a ContentHolder (again a convenience interface for XLIFF) and decides based on the (XLIFF) class type what to do. Strings will be the translated text, Groups represent nested elements and TransUnits contain the translated text in their target nodes.

The other methods just create the corresponding nodes for elements or texts.

Note

Backward Compatibility

Especially for the import handler it is important to respect backward compatibility if necessary. The import handler may have to deal with XLIFF documents which were sent to a translation service weeks or month before.

Done

Having all this, you are done with this initial example and you got to know most of the API entry points you will need for customized XLIFF export. You learned about the initial filtering, the transformation to XLIFF and the transformation from XLIFF to your new translated property value.

Handling Attributes

In the previous sections, handling attributes was skipped in order to keep the example simple. But when it comes to attributes, you not only need to decide which of them should be translatable and which not, you also need to care of XLIFF validation, if you turned XLIFF validation on.

Start extending Example 5.24, “XhtmlToXliffConverter” by a method to map arguments.

public class XhtmlToXliffConverter implements Function<Markup, Group> {

  /* ... */

  private class XhtmlContentHandler extends DefaultHandler {

    /* ... */

    @Override
    public void startElement(String uri,
                             String localName,
                             String qName,
                             Attributes attributes) {
      /* ... */
      mapAttributes(attributes, elementWrapper);
      addElement(elementWrapper);
    }

    private void mapAttributes(Attributes attributes, Group elementWrapper) {
      for (int i = 0; i < attributes.getLength(); i++) {
        String localName = attributes.getLocalName(i);
        String attributeValue = attributes.getValue(i);

        switch (localName) {
          case "title":
          case "alt":
          case "summary":
            elementWrapper
                    .getContent()
                    .add(
                            createTransUnit(
                                    "x-html-attr-" + localName,
                                    attributeValue
                            )
                    );
            break;
          default:
            // Wraps the attribute to a custom namespace.
            QName xliffAttrQName =
                    new QName(
                            "http://www.mycompany.com/custom-xliff-1.0",
                            localName
                    );
            elementWrapper.getOtherAttributes().put(xliffAttrQName, attributeValue);
        }
      }
    }

    private TransUnit createTransUnit(String resType, String value) {
      TransUnit transUnit = new TransUnit();
      transUnit.setId(transUnitIdProvider.get());
      transUnit.setType(resType);

      Source source = new Source();
      transUnit.setSource(source);
      source.getContent().add(value);

      if (xliffExportOptions.getTargetOption() !=
              XliffExportOptions.TargetOption.TARGET_OMIT) {
        Target target = new Target();
        transUnit.setTarget(target);
        if (xliffExportOptions.getTargetOption() ==
                XliffExportOptions.TargetOption.TARGET_SOURCE) {
          target.getContent().add(value);
        }
      }
      return transUnit;
    }

    /* ... */

  }
}

Example 5.29. Attribute Export


In Example 5.29, “Attribute Export” a new method mapAttributes is added, which distinguishes between attributes to translate, and attributes not to translate.

Translatable Attributes: For translatable attributes you just add a <trans-unit> element which also specifies a restype attribute. This attribute has to be resolved later on to a property. The rest is very similar to the characters method in the initial example.

Non-Translatable Attributes: Non-translatable attributes, are represented as custom attributes within XLIFF, as XLIFF Schema allows adding such custom attributes at many places. In order to be prepared for XLIFF validation, CoreMedia recommends adding a namespace URI to the attribute, here http://www.mycompany.com/custom-xliff-1.0.

<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>abcdefg</title>
  </head>
  <body>
    <p class="some--class" title="some title">abcdefg</p>
  </body>
</html>

Example 5.30. XHTML Example Input (Attributes)


<group restype="x-html-p"
       ns4:class="some--class"
       xmlns:ns4="http://www.mycompany.com/custom-xliff-1.0">
  <trans-unit id="14" restype="x-html-attr-title">
    <source>some title</source>
    <target>some title</target>
  </trans-unit>
  <trans-unit id="15">
    <source>abcdefg</source>
    <target>abcdefg</target>
  </trans-unit>
</group>

Example 5.31. XHTML as XLIFF Example Output (Attributes)


Given an example XHTML like in Example 5.30, “XHTML Example Input (Attributes)” the paragraph will be transformed as given in Example 5.31, “XHTML as XLIFF Example Output (Attributes)”. As you can see, the class attribute is added with a custom namespace, and the title attribute is added as trans-unit at first place within the group.

[Fatal Error] cvc-complex-type.3.2.2: Attribute 'ns4:class' is not allowed
to appear in element 'group'. (23, 8)

Example 5.32. XLIFF Validation Error


XLIFF Validation: If XLIFF validation is turned on, the XLIFF import will fail to recognize the custom workspace and raises an error like in Example 5.32, “XLIFF Validation Error”. What you need to do now, is to create a schema defining your new attributes and to add the schema to the Spring application context.

<?xml version="1.0" encoding="UTF-8"?>
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"
            elementFormDefault="qualified"
            targetNamespace="http://www.mycompany.com/custom-xliff-1.0"
            xml:lang="en">

  <xsd:attribute name="class" type="xsd:string"/>

</xsd:schema>

Example 5.33. Custom XLIFF XSD


@Bean
@Scope(BeanDefinition.SCOPE_SINGLETON)
public XliffSchemaSource<Source> customXliffSchema() {
  return () -> new StreamSource(
      getClass().getResourceAsStream("/custom-xliff-1.0.xsd")
  );
}

Example 5.34. Custom XLIFF XSD (Bean)


Example 5.33, “Custom XLIFF XSD” adds the class attribute to the new custom namespace. Note, that you have to do this for every attribute, you want to support. In Example 5.34, “Custom XLIFF XSD (Bean)” you see, how to add the new schema to the application context, so that it can be used for validation. Having this, you are prepared for the actual XLIFF import handling.

For more details on XLIFF Validation Schemes, see the corresponding Javadoc of XliffSchemaSource and the configuration class adding standard schema required for validation XliffSchemaConfiguration.

XLIFF Import: Now you are going to extend the XliffToXhtmlConverter as given in Example 5.28, “XliffToXhtmlConverter”.

public class XliffToXhtmlConverter
        implements Function<Group, Markup> {

  /* ... */

  private static void processContents(ContentHolder contentHolder,
                                      Element parent) {
    for (Object content : contentHolder.getContent()) {
      if (content instanceof String) {
        processString(parent, (String) content);
      } else if (content instanceof Group) {
        processGroup(parent, (Group) content);
      } else if (content instanceof TransUnit) {
        TransUnit transUnit = (TransUnit) content;
        String type = attributeName(transUnit);
        if (type.isEmpty()) {
          processContents(transUnit.getTarget(), parent);
        } else {
          processTranslatableAttribute(type, transUnit.getTarget(), parent);
        }
      }
    }
  }

  private static void processTranslatableAttribute(String type, Target target, Element parent) {
    StringBuilder value = new StringBuilder();
    for (Object content : target.getContent()) {
      value.append(content);
    }
    parent.setAttribute(type, value.toString());
  }

  private static String attributeName(TypeHolder group) {
    String type = group.getType();
    if (type == null || type.isEmpty()) {
      return "";
    }
    return type.replace("x-html-attr-", "");
  }

  /* ... */

}

Example 5.35. Importing Translatable Attributes


In Example 5.35, “Importing Translatable Attributes” add importing the translatable attributes. Add a branch for <trans-unit> elements, to detect that they represent a translatable attribute, if the type is set.

public class XliffToXhtmlConverter
        implements Function<Group, Markup> {

  /* ... */

  private static void processGroup(Node parent, Group group) {
    Document document = parent.getOwnerDocument();
    Element node = document.createElement(elementName(group));
    processNonTranslatableAttributes(node, group);
    parent.appendChild(node);
    processContents(group, node);
  }

  private static void processNonTranslatableAttributes(Element node,
                                                       Group group) {
    Map<QName, String> otherAttributes = group.getOtherAttributes();
    for (Map.Entry<QName, String> entry : otherAttributes.entrySet()) {
      QName attributeQName = entry.getKey();
      String localPart = attributeQName.getLocalPart();
      node.setAttribute(localPart, entry.getValue());
    }
  }

  /* ... */

}

Example 5.36. Importing Non-Translatable Attributes


In Example 5.36, “Importing Non-Translatable Attributes” the custom attributes handed over to the group element are parsed and set as corresponding attribute for the DOM element. Note, that in order to shorten the code, the namespace URI is not checked.

Now you are done, and can support custom attributes, translatable or non-translatable.

Search Results

Table Of Contents
warning

Your Internet Explorer is no longer supported.

Please use Mozilla Firefox, Google Chrome, or Microsoft Edge.