}
+ * - earlier was applied from code, for "CitationCharacterFormat"
+ *
+ * - {@code } start new paragraph
+ * - earlier was applied from code
+ *
+ * - {@code
} : start new paragraph and apply ParStyleName
+ * - earlier was applied from code
+ *
+ * - {@code }
+ * - earlier: known, but ignored
+ * - now: equivalent to {@code }
+ * - {@code } (self-closing)
+ *
+ * - closing tags try to properly restore state (in particular, the "not directly set" state)
+ * instead of dictating an "off" state. This makes a difference when the value inherited from
+ * another level (for example the paragraph) is not the "off" state.
+ *
+ * An example: a style with
+ * {@code ReferenceParagraphFormat="JR_bibentry"}
+ * Assume JR_bibentry in LibreOffice is a paragraph style that prescribes "bold" font.
+ * LAYOUT only prescribes bold around year.
+ * Which parts of the bibliography entries should come out as bold?
+ *
+ * - The user can format citation marks (it is enough to format their start) and the
+ * properties not (everywhere) dictated by the style are preserved (where they are not).
+ *
+ * @param position The cursor giving the insert location. Not modified.
+ * @param ootext The marked-up text to insert.
+ */
+ public static void write(XTextDocument doc, XTextCursor position, OOText ootext)
+ throws
+ WrappedTargetException,
+ CreationException {
+
+ Objects.requireNonNull(doc);
+ Objects.requireNonNull(ootext);
+ Objects.requireNonNull(position);
+
+ String lText = OOText.toString(ootext);
+
+ LOGGER.debug(lText);
+
+ XText text = position.getText();
+ XTextCursor cursor = text.createTextCursorByRange(position);
+ cursor.collapseToEnd();
+
+ MyPropertyStack formatStack = new MyPropertyStack(cursor);
+ Stack expectEnd = new Stack<>();
+
+ // We need to extract formatting. Use a simple regexp search iteration:
+ int piv = 0;
+ Matcher tagMatcher = HTML_TAG.matcher(lText);
+ while (tagMatcher.find()) {
+
+ String currentSubstring = lText.substring(piv, tagMatcher.start());
+ if (!currentSubstring.isEmpty()) {
+ cursor.setString(currentSubstring);
+ }
+ formatStack.apply(cursor);
+ cursor.collapseToEnd();
+
+ String endTagName = tagMatcher.group(1);
+ String startTagName = tagMatcher.group(2);
+ String attributeListPart = tagMatcher.group(3);
+ boolean isStartTag = StringUtil.isNullOrEmpty(endTagName);
+ String tagName = isStartTag ? startTagName : endTagName;
+ Objects.requireNonNull(tagName);
+
+ // Attibutes parsed into (name,value) pairs.
+ List> attributes = parseAttributes(attributeListPart);
+
+ // Handle tags:
+ switch (tagName) {
+ case "b":
+ formatStack.pushLayer(setCharWeight(FontWeight.BOLD));
+ expectEnd.push("/" + tagName);
+ break;
+ case "i":
+ case "em":
+ formatStack.pushLayer(setCharPosture(FontSlant.ITALIC));
+ expectEnd.push("/" + tagName);
+ break;
+ case "smallcaps":
+ formatStack.pushLayer(setCharCaseMap(CaseMap.SMALLCAPS));
+ expectEnd.push("/" + tagName);
+ break;
+ case "sup":
+ formatStack.pushLayer(setSuperScript(formatStack));
+ expectEnd.push("/" + tagName);
+ break;
+ case "sub":
+ formatStack.pushLayer(setSubScript(formatStack));
+ expectEnd.push("/" + tagName);
+ break;
+ case "u":
+ formatStack.pushLayer(setCharUnderline(FontUnderline.SINGLE));
+ expectEnd.push("/" + tagName);
+ break;
+ case "s":
+ formatStack.pushLayer(setCharStrikeout(FontStrikeout.SINGLE));
+ expectEnd.push("/" + tagName);
+ break;
+ case "/p":
+ // nop
+ break;
+ case "p":
+ insertParagraphBreak(text, cursor);
+ cursor.collapseToEnd();
+ for (OOPair pair : attributes) {
+ String key = pair.a;
+ String value = pair.b;
+ switch (key) {
+ case "oo:ParaStyleName":
+ //
+ if (StringUtil.isNullOrEmpty(value)) {
+ LOGGER.debug(String.format("oo:ParaStyleName inherited"));
+ } else {
+ if (setParagraphStyle(cursor, value)) {
+ // Presumably tested already:
+ LOGGER.debug(String.format("oo:ParaStyleName=\"%s\" failed", value));
+ }
+ }
+ break;
+ default:
+ LOGGER.warn(String.format("Unexpected attribute '%s' for <%s>", key, tagName));
+ break;
+ }
+ }
+ break;
+ case "oo:referenceToPageNumberOfReferenceMark":
+ for (OOPair pair : attributes) {
+ String key = pair.a;
+ String value = pair.b;
+ switch (key) {
+ case "target":
+ UnoCrossRef.insertReferenceToPageNumberOfReferenceMark(doc, value, cursor);
+ break;
+ default:
+ LOGGER.warn(String.format("Unexpected attribute '%s' for <%s>", key, tagName));
+ break;
+ }
+ }
+ break;
+ case "tt":
+ // Note: "Example" names a character style in LibreOffice.
+ formatStack.pushLayer(setCharStyleName("Example"));
+ expectEnd.push("/" + tagName);
+ break;
+ case "span":
+ List> settings = new ArrayList<>();
+ for (OOPair pair : attributes) {
+ String key = pair.a;
+ String value = pair.b;
+ switch (key) {
+ case "oo:CharStyleName":
+ //
+ settings.addAll(setCharStyleName(value));
+ break;
+ case "lang":
+ //
+ //
+ settings.addAll(setCharLocale(value));
+ break;
+ case "style":
+ // HTML-style small-caps
+ if ("font-variant: small-caps".equals(value)) {
+ settings.addAll(setCharCaseMap(CaseMap.SMALLCAPS));
+ break;
+ }
+ LOGGER.warn(String.format("Unexpected value %s for attribute '%s' for <%s>",
+ value, key, tagName));
+ break;
+ default:
+ LOGGER.warn(String.format("Unexpected attribute '%s' for <%s>", key, tagName));
+ break;
+ }
+ }
+ formatStack.pushLayer(settings);
+ expectEnd.push("/" + tagName);
+ break;
+ case "/b":
+ case "/i":
+ case "/em":
+ case "/tt":
+ case "/smallcaps":
+ case "/sup":
+ case "/sub":
+ case "/u":
+ case "/s":
+ case "/span":
+ formatStack.popLayer();
+ String expected = expectEnd.pop();
+ if (!tagName.equals(expected)) {
+ LOGGER.warn(String.format("expected '<%s>', found '<%s>' after '%s'",
+ expected,
+ tagName,
+ currentSubstring));
+ }
+ break;
+ default:
+ LOGGER.warn(String.format("ignoring unknown tag '<%s>'", tagName));
+ break;
+ }
+
+ piv = tagMatcher.end();
+ }
+
+ if (piv < lText.length()) {
+ cursor.setString(lText.substring(piv));
+ }
+ formatStack.apply(cursor);
+ cursor.collapseToEnd();
+
+ if (!expectEnd.empty()) {
+ String rest = "";
+ for (String s : expectEnd) {
+ rest = String.format("<%s>", s) + rest;
+ }
+ LOGGER.warn(String.format("OOTextIntoOO.write:"
+ + " expectEnd stack is not empty at the end: %s%n",
+ rest));
+ }
+ }
+
+ /**
+ * Purpose: in some cases we do not want to inherit direct
+ * formatting from the context.
+ *
+ * In particular, when filling the bibliography title and body.
+ */
+ public static void removeDirectFormatting(XTextCursor cursor) {
+
+ XMultiPropertyStates mpss = UnoCast.cast(XMultiPropertyStates.class, cursor).get();
+
+ XPropertySet propertySet = UnoCast.cast(XPropertySet.class, cursor).get();
+ XPropertyState xPropertyState = UnoCast.cast(XPropertyState.class, cursor).get();
+
+ try {
+ // Special handling
+ propertySet.setPropertyValue(CHAR_STYLE_NAME, "Standard");
+ xPropertyState.setPropertyToDefault("CharCaseMap");
+ } catch (UnknownPropertyException |
+ PropertyVetoException |
+ WrappedTargetException ex) {
+ LOGGER.warn("exception caught", ex);
+ }
+
+ mpss.setAllPropertiesToDefault();
+
+ /*
+ * Now that we have called setAllPropertiesToDefault, check which properties are not set to
+ * default and try to correct what we can and seem necessary.
+ *
+ * Note: tested with LibreOffice : 6.4.6.2
+ */
+
+ // Only report those we do not yet know about
+ final Set knownToFail = Set.of("ListAutoFormat",
+ "ListId",
+ "NumberingIsNumber",
+ "NumberingLevel",
+ "NumberingRules",
+ "NumberingStartValue",
+ "ParaChapterNumberingLevel",
+ "ParaIsNumberingRestart",
+ "ParaStyleName");
+
+ // query again, just in case it matters
+ propertySet = UnoCast.cast(XPropertySet.class, cursor).get();
+ XPropertySetInfo propertySetInfo = propertySet.getPropertySetInfo();
+
+ // check the result
+ for (Property p : propertySetInfo.getProperties()) {
+ if ((p.Attributes & PropertyAttribute.READONLY) != 0) {
+ continue;
+ }
+ try {
+ if (isPropertyDefault(cursor, p.Name)) {
+ continue;
+ }
+ } catch (UnknownPropertyException ex) {
+ throw new IllegalStateException("Unexpected UnknownPropertyException", ex);
+ }
+ if (knownToFail.contains(p.Name)) {
+ continue;
+ }
+ LOGGER.warn(String.format("OOTextIntoOO.removeDirectFormatting failed on '%s'", p.Name));
+ }
+ }
+
+ static class MyPropertyStack {
+
+ /*
+ * We only try to control these. Should include all character properties we set, and maybe
+ * their interdependencies.
+ *
+ * For a list of properties see:
+ * https://www.openoffice.org/api/docs/common/ref/com/sun/star/style/CharacterProperties.html
+ *
+ * For interdependencies between properties:
+ * https://wiki.openoffice.org/wiki/Documentation/DevGuide/Text/Formatting
+ * (at the end, under "Interdependencies between Properties")
+ *
+ */
+ static final Set CONTROLLED_PROPERTIES = Set.of(
+
+ /* Used for SuperScript, SubScript.
+ *
+ * These three are interdependent: changing one may change others.
+ */
+ "CharEscapement", "CharEscapementHeight", "CharAutoEscapement",
+
+ /* used for Bold */
+ "CharWeight",
+
+ /* Used for Italic */
+ "CharPosture",
+
+ /* Used for strikeout. These two are interdependent. */
+ "CharStrikeout", "CharCrossedOut",
+
+ /* Used for underline. These three are interdependent, but apparently
+ * we can leave out the last two.
+ */
+ "CharUnderline", // "CharUnderlineColor", "CharUnderlineHasColor",
+
+ /* Used for lang="zxx", to silence spellchecker. */
+ "CharLocale",
+
+ /* Used for CitationCharacterFormat. */
+ "CharStyleName",
+
+ /* Used for and */
+ "CharCaseMap");
+
+ /**
+ * The number of properties actually controlled.
+ */
+ final int goodSize;
+
+ /**
+ * From property name to index in goodNames.
+ */
+ final Map goodNameToIndex;
+
+ /**
+ * From index to property name.
+ */
+ final String[] goodNames;
+
+ /**
+ * Maintain a stack of layers, each containing a description of the desired state of
+ * properties. Each description is an ArrayList of property values, Optional.empty()
+ * encoding "not directly set".
+ */
+ final Stack>> layers;
+
+ MyPropertyStack(XTextCursor cursor) {
+
+ XPropertySet propertySet = UnoCast.cast(XPropertySet.class, cursor).get();
+ XPropertySetInfo propertySetInfo = propertySet.getPropertySetInfo();
+
+ /*
+ * On creation, initialize the property name -- index mapping.
+ */
+ this.goodNameToIndex = new HashMap<>();
+ int nextIndex = 0;
+ for (Property p : propertySetInfo.getProperties()) {
+ if ((p.Attributes & PropertyAttribute.READONLY) != 0) {
+ continue;
+ }
+ if (!CONTROLLED_PROPERTIES.contains(p.Name)) {
+ continue;
+ }
+ this.goodNameToIndex.put(p.Name, nextIndex);
+ nextIndex++;
+ }
+
+ this.goodSize = nextIndex;
+
+ this.goodNames = new String[goodSize];
+ for (Map.Entry entry : goodNameToIndex.entrySet()) {
+ goodNames[ entry.getValue() ] = entry.getKey();
+ }
+
+ // XMultiPropertySet.setPropertyValues() requires alphabetically sorted property names.
+ // We adjust here:
+ Arrays.sort(goodNames);
+ for (int i = 0; i < goodSize; i++) {
+ this.goodNameToIndex.put(goodNames[i], i);
+ }
+
+ /*
+ * Get the initial state of the properties and add the first layer.
+ */
+ XMultiPropertyStates mpss = UnoCast.cast(XMultiPropertyStates.class, cursor).get();
+ PropertyState[] propertyStates;
+ try {
+ propertyStates = mpss.getPropertyStates(goodNames);
+ } catch (UnknownPropertyException ex) {
+ throw new IllegalStateException("Caught unexpected UnknownPropertyException", ex);
+ }
+
+ XMultiPropertySet mps = UnoCast.cast(XMultiPropertySet.class, cursor).get();
+ Object[] initialValues = mps.getPropertyValues(goodNames);
+
+ ArrayList> initialValuesOpt = new ArrayList<>(goodSize);
+
+ for (int i = 0; i < goodSize; i++) {
+ if (propertyStates[i] == PropertyState.DIRECT_VALUE) {
+ initialValuesOpt.add(Optional.of(initialValues[i]));
+ } else {
+ initialValuesOpt.add(Optional.empty());
+ }
+ }
+
+ this.layers = new Stack<>();
+ this.layers.push(initialValuesOpt);
+ }
+
+ /**
+ * Given a list of property name, property value pairs, construct and push a new layer
+ * describing the intended state after these have been applied.
+ *
+ * Opening tags usually call this.
+ */
+ void pushLayer(List> settings) {
+ ArrayList> oldLayer = layers.peek();
+ ArrayList> newLayer = new ArrayList<>(oldLayer);
+ for (OOPair pair : settings) {
+ String name = pair.a;
+ Integer index = goodNameToIndex.get(name);
+ if (index == null) {
+ LOGGER.warn(String.format("pushLayer: '%s' is not in goodNameToIndex", name));
+ continue;
+ }
+ Object newValue = pair.b;
+ newLayer.set(index, Optional.ofNullable(newValue));
+ }
+ layers.push(newLayer);
+ }
+
+ /**
+ * Closing tags just pop a layer.
+ */
+ void popLayer() {
+ if (layers.size() <= 1) {
+ LOGGER.warn("popLayer: underflow");
+ return;
+ }
+ layers.pop();
+ }
+
+ /**
+ * Apply the current desired formatting state to a cursor.
+ *
+ * The idea is to minimize the number of calls to OpenOffice.
+ */
+ void apply(XTextCursor cursor) {
+ XMultiPropertySet mps = UnoCast.cast(XMultiPropertySet.class, cursor).get();
+ XMultiPropertyStates mpss = UnoCast.cast(XMultiPropertyStates.class, cursor).get();
+ ArrayList> topLayer = layers.peek();
+ try {
+ // select values to be set
+ ArrayList names = new ArrayList<>(goodSize);
+ ArrayList values = new ArrayList<>(goodSize);
+ // and those to be cleared
+ ArrayList delNames = new ArrayList<>(goodSize);
+ for (int i = 0; i < goodSize; i++) {
+ if (topLayer.get(i).isPresent()) {
+ names.add(goodNames[i]);
+ values.add(topLayer.get(i).get());
+ } else {
+ delNames.add(goodNames[i]);
+ }
+ }
+ // namesArray must be alphabetically sorted.
+ String[] namesArray = names.toArray(new String[0]);
+ String[] delNamesArray = delNames.toArray(new String[0]);
+ mpss.setPropertiesToDefault(delNamesArray);
+ mps.setPropertyValues(namesArray, values.toArray());
+ } catch (UnknownPropertyException ex) {
+ LOGGER.warn("UnknownPropertyException in MyPropertyStack.apply", ex);
+ } catch (PropertyVetoException ex) {
+ LOGGER.warn("PropertyVetoException in MyPropertyStack.apply");
+ } catch (WrappedTargetException ex) {
+ LOGGER.warn("WrappedTargetException in MyPropertyStack.apply");
+ }
+ }
+
+ // Relative CharEscapement needs to know current values.
+ Optional getPropertyValue(String name) {
+ if (goodNameToIndex.containsKey(name)) {
+ int index = goodNameToIndex.get(name);
+ ArrayList> topLayer = layers.peek();
+ return topLayer.get(index);
+ }
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Parse HTML-like attributes to a list of (name,value) pairs.
+ */
+ private static List> parseAttributes(String attributes) {
+ List> res = new ArrayList<>();
+ if (attributes == null) {
+ return res;
+ }
+ Matcher attributeMatcher = ATTRIBUTE_PATTERN.matcher(attributes);
+ while (attributeMatcher.find()) {
+ String key = attributeMatcher.group(1);
+ String value = attributeMatcher.group(2);
+ res.add(new OOPair(key, value));
+ }
+ return res;
+ }
+
+ /*
+ * We rely on property values being either DIRECT_VALUE or DEFAULT_VALUE (not
+ * AMBIGUOUS_VALUE). If the cursor covers a homogeneous region, or is collapsed, then this is
+ * true.
+ */
+ private static boolean isPropertyDefault(XTextCursor cursor, String propertyName)
+ throws
+ UnknownPropertyException {
+ XPropertyState xPropertyState = UnoCast.cast(XPropertyState.class, cursor).get();
+ PropertyState state = xPropertyState.getPropertyState(propertyName);
+ if (state == PropertyState.AMBIGUOUS_VALUE) {
+ throw new java.lang.IllegalArgumentException("PropertyState.AMBIGUOUS_VALUE"
+ + " (expected properties for a homogeneous cursor)");
+ }
+ return state == PropertyState.DEFAULT_VALUE;
+ }
+
+ /*
+ * Various property change requests. Their results are passed to MyPropertyStack.pushLayer()
+ */
+
+ private static List> setCharWeight(float value) {
+ List> settings = new ArrayList<>();
+ settings.add(new OOPair<>("CharWeight", (Float) value));
+ return settings;
+ }
+
+ private static List> setCharPosture(FontSlant value) {
+ List> settings = new ArrayList<>();
+ settings.add(new OOPair<>("CharPosture", (Object) value));
+ return settings;
+ }
+
+ private static List> setCharCaseMap(short value) {
+ List> settings = new ArrayList<>();
+ settings.add(new OOPair<>("CharCaseMap", (Short) value));
+ return settings;
+ }
+
+ // com.sun.star.awt.FontUnderline
+ private static List> setCharUnderline(short value) {
+ List> settings = new ArrayList<>();
+ settings.add(new OOPair<>(CHAR_UNDERLINE, (Short) value));
+ return settings;
+ }
+
+ // com.sun.star.awt.FontStrikeout
+ private static List> setCharStrikeout(short value) {
+ List> settings = new ArrayList<>();
+ settings.add(new OOPair<>(CHAR_STRIKEOUT, (Short) value));
+ return settings;
+ }
+
+ // CharStyleName
+ private static List> setCharStyleName(String value) {
+ List> settings = new ArrayList<>();
+ if (StringUtil.isNullOrEmpty(value)) {
+ LOGGER.warn("setCharStyleName: received null or empty value");
+ } else {
+ settings.add(new OOPair<>(CHAR_STYLE_NAME, value));
+ }
+ return settings;
+ }
+
+ // Locale
+ private static List> setCharLocale(Locale value) {
+ List> settings = new ArrayList<>();
+ settings.add(new OOPair<>("CharLocale", (Object) value));
+ return settings;
+ }
+
+ /**
+ * Locale from string encoding: language, language-country or language-country-variant
+ */
+ private static List> setCharLocale(String value) {
+ if (StringUtil.isNullOrEmpty(value)) {
+ throw new java.lang.IllegalArgumentException("setCharLocale \"\" or null");
+ }
+ String[] parts = value.split("-");
+ String language = (parts.length > 0) ? parts[0] : "";
+ String country = (parts.length > 1) ? parts[1] : "";
+ String variant = (parts.length > 2) ? parts[2] : "";
+ return setCharLocale(new Locale(language, country, variant));
+ }
+
+ /*
+ * SuperScript and SubScript.
+ *
+ * @param relative If true, calculate the new values relative to the current values. This allows
+ * subscript-in-superscript.
+ */
+ private static List> setCharEscapement(Optional value,
+ Optional height,
+ boolean relative,
+ MyPropertyStack formatStack) {
+ List> settings = new ArrayList<>();
+ Optional oldValue = (formatStack
+ .getPropertyValue(CHAR_ESCAPEMENT)
+ .map(e -> (short) e));
+
+ Optional oldHeight = (formatStack
+ .getPropertyValue(CHAR_ESCAPEMENT_HEIGHT)
+ .map(e -> (byte) e));
+
+ if (relative && (value.isPresent() || height.isPresent())) {
+ double oldHeightFloat = oldHeight.orElse(CHAR_ESCAPEMENT_HEIGHT_DEFAULT) * 0.01;
+ double oldValueFloat = oldValue.orElse(CHAR_ESCAPEMENT_VALUE_DEFAULT);
+ double heightFloat = height.orElse(CHAR_ESCAPEMENT_HEIGHT_DEFAULT);
+ double valueFloat = value.orElse(CHAR_ESCAPEMENT_VALUE_DEFAULT);
+ byte newHeight = (byte) Math.round(heightFloat * oldHeightFloat);
+ short newValue = (short) Math.round(valueFloat * oldHeightFloat + oldValueFloat);
+ if (value.isPresent()) {
+ settings.add(new OOPair<>(CHAR_ESCAPEMENT, (Short) newValue));
+ }
+ if (height.isPresent()) {
+ settings.add(new OOPair<>(CHAR_ESCAPEMENT_HEIGHT, (Byte) newHeight));
+ }
+ } else {
+ if (value.isPresent()) {
+ settings.add(new OOPair<>(CHAR_ESCAPEMENT, (Short) value.get()));
+ }
+ if (height.isPresent()) {
+ settings.add(new OOPair<>(CHAR_ESCAPEMENT_HEIGHT, (Byte) height.get()));
+ }
+ }
+ return settings;
+ }
+
+ private static List> setSubScript(MyPropertyStack formatStack) {
+ return setCharEscapement(Optional.of(SUBSCRIPT_VALUE),
+ Optional.of(SUBSCRIPT_HEIGHT),
+ true,
+ formatStack);
+ }
+
+ private static List> setSuperScript(MyPropertyStack formatStack) {
+ return setCharEscapement(Optional.of(SUPERSCRIPT_VALUE),
+ Optional.of(SUPERSCRIPT_HEIGHT),
+ true,
+ formatStack);
+ }
+
+ /*
+ * @return true on failure
+ */
+ public static boolean setParagraphStyle(XTextCursor cursor, String paragraphStyle) {
+ final boolean FAIL = true;
+ final boolean PASS = false;
+
+ XParagraphCursor paragraphCursor = UnoCast.cast(XParagraphCursor.class, cursor).get();
+ XPropertySet propertySet = UnoCast.cast(XPropertySet.class, paragraphCursor).get();
+ try {
+ propertySet.setPropertyValue(PARA_STYLE_NAME, paragraphStyle);
+ return PASS;
+ } catch (UnknownPropertyException
+ | PropertyVetoException
+ | com.sun.star.lang.IllegalArgumentException
+ | WrappedTargetException ex) {
+ return FAIL;
+ }
+ }
+
+ private static void insertParagraphBreak(XText text, XTextCursor cursor) {
+ try {
+ text.insertControlCharacter(cursor, ControlCharacter.PARAGRAPH_BREAK, true);
+ } catch (com.sun.star.lang.IllegalArgumentException ex) {
+ // Assuming it means wrong code for ControlCharacter.
+ // https://api.libreoffice.org/docs/idl/ref/ does not tell.
+ // If my assumption is correct, we never get here.
+ throw new java.lang.IllegalArgumentException("Caught unexpected com.sun.star.lang.IllegalArgumentException", ex);
+ }
+ }
+
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/FunctionalTextViewCursor.java b/src/main/java/org/jabref/model/openoffice/rangesort/FunctionalTextViewCursor.java
new file mode 100644
index 00000000000..e08a7f4a2ad
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/FunctionalTextViewCursor.java
@@ -0,0 +1,143 @@
+package org.jabref.model.openoffice.rangesort;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.jabref.model.openoffice.uno.UnoCursor;
+import org.jabref.model.openoffice.uno.UnoSelection;
+import org.jabref.model.openoffice.util.OOResult;
+
+import com.sun.star.lang.XServiceInfo;
+import com.sun.star.text.XTextDocument;
+import com.sun.star.text.XTextRange;
+import com.sun.star.text.XTextViewCursor;
+
+/*
+ * A problem with XTextViewCursor: if it is not in text, then we get a crippled version that does
+ * not support viewCursor.getStart() or viewCursor.gotoRange(range,false), and will throw an
+ * exception instead.
+ *
+ * Here we manipulate the cursor via XSelectionSupplier.getSelection and XSelectionSupplier.select
+ * to move it to the text.
+ *
+ * Seems to work when the user selected a frame or image.
+ * In these cases restoring the selection works, too.
+ *
+ * When the cursor is in a comment (referred to as "annotation" in OO API) then initialSelection is
+ * null, and select() fails to get a functional viewCursor.
+ *
+ * If FunctionalTextViewCursor.get() reports error, we have to ask the user to move the cursor into
+ * the text part of the document.
+ *
+ * Usage:
+ *
+ * OOResult fcursor = FunctionalTextViewCursor.get(doc, msg);
+ * if (fcursor.isError()) {
+ * ...
+ * } else {
+ * XTextViewCursor viewCursor = fcursor.get().getViewCursor();
+ * ...
+ * fc.restore();
+ * }
+ *
+ */
+public class FunctionalTextViewCursor {
+
+ /* The initial position of the cursor or null. */
+ private XTextRange initialPosition;
+
+ /* The initial selection in the document or null. */
+ private XServiceInfo initialSelection;
+
+ /* The view cursor, potentially moved from its original location. */
+ private XTextViewCursor viewCursor;
+
+ private FunctionalTextViewCursor(XTextRange initialPosition,
+ XServiceInfo initialSelection,
+ XTextViewCursor viewCursor) {
+ this.initialPosition = initialPosition;
+ this.initialSelection = initialSelection;
+ this.viewCursor = viewCursor;
+ }
+
+ /*
+ * Get a functional XTextViewCursor or an error message.
+ *
+ * The cursor position may differ from the location provided by the user.
+ *
+ * On failure the constructor restores the selection. On success, the caller may want to call
+ * instance.restore() after finished using the cursor.
+ */
+ public static OOResult get(XTextDocument doc) {
+
+ Objects.requireNonNull(doc);
+
+ XTextRange initialPosition = null;
+ XServiceInfo initialSelection = UnoSelection.getSelectionAsXServiceInfo(doc).orElse(null);
+ XTextViewCursor viewCursor = UnoCursor.getViewCursor(doc).orElse(null);
+ if (viewCursor != null) {
+ try {
+ initialPosition = UnoCursor.createTextCursorByRange(viewCursor);
+ viewCursor.getStart();
+ return OOResult.ok(new FunctionalTextViewCursor(initialPosition, initialSelection, viewCursor));
+ } catch (com.sun.star.uno.RuntimeException ex) {
+ // bad cursor
+ viewCursor = null;
+ initialPosition = null;
+ }
+ }
+
+ if (initialSelection == null) {
+ String errorMessage = ("Selection is not available: cannot provide a functional view cursor");
+ return OOResult.error(errorMessage);
+ } else if (!Arrays.stream(initialSelection.getSupportedServiceNames())
+ .anyMatch("com.sun.star.text.TextRanges"::equals)) {
+ // initialSelection does not support TextRanges.
+ // We need to change it (and the viewCursor with it).
+ XTextRange newSelection = doc.getText().getStart();
+ UnoSelection.select(doc, newSelection);
+ viewCursor = UnoCursor.getViewCursor(doc).orElse(null);
+ }
+
+ if (viewCursor == null) {
+ restore(doc, initialPosition, initialSelection);
+ String errorMessage = "Could not get the view cursor";
+ return OOResult.error(errorMessage);
+ }
+
+ try {
+ viewCursor.getStart();
+ } catch (com.sun.star.uno.RuntimeException ex) {
+ restore(doc, initialPosition, initialSelection);
+ String errorMessage = "The view cursor failed the functionality test";
+ return OOResult.error(errorMessage);
+ }
+
+ return OOResult.ok(new FunctionalTextViewCursor(initialPosition, initialSelection, viewCursor));
+ }
+
+ public XTextViewCursor getViewCursor() {
+ return viewCursor;
+ }
+
+ private static void restore(XTextDocument doc,
+ XTextRange initialPosition,
+ XServiceInfo initialSelection) {
+
+ if (initialPosition != null) {
+ XTextViewCursor viewCursor = UnoCursor.getViewCursor(doc).orElse(null);
+ if (viewCursor != null) {
+ viewCursor.gotoRange(initialPosition, false);
+ return;
+ }
+ }
+ if (initialSelection != null) {
+ UnoSelection.select(doc, initialSelection);
+ }
+ }
+
+ /* Restore initial state of viewCursor (possibly by restoring selection) if possible. */
+ public void restore(XTextDocument doc) {
+ FunctionalTextViewCursor.restore(doc, initialPosition, initialSelection);
+ }
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeHolder.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeHolder.java
new file mode 100644
index 00000000000..cae6f99e34e
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeHolder.java
@@ -0,0 +1,7 @@
+package org.jabref.model.openoffice.rangesort;
+
+import com.sun.star.text.XTextRange;
+
+public interface RangeHolder {
+ XTextRange getRange();
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlap.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlap.java
new file mode 100644
index 00000000000..3edd059d7ff
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlap.java
@@ -0,0 +1,16 @@
+package org.jabref.model.openoffice.rangesort;
+
+import java.util.List;
+
+/**
+ * Used in reporting range overlaps.
+ */
+public class RangeOverlap {
+ public final RangeOverlapKind kind;
+ public final List valuesForOverlappingRanges;
+
+ public RangeOverlap(RangeOverlapKind kind, List valuesForOverlappingRanges) {
+ this.kind = kind;
+ this.valuesForOverlappingRanges = valuesForOverlappingRanges;
+ }
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapBetween.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapBetween.java
new file mode 100644
index 00000000000..16b7735fb7a
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapBetween.java
@@ -0,0 +1,97 @@
+package org.jabref.model.openoffice.rangesort;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jabref.model.openoffice.uno.UnoCast;
+import org.jabref.model.openoffice.uno.UnoTextRange;
+import org.jabref.model.openoffice.util.OOTuple3;
+
+import com.sun.star.text.XText;
+import com.sun.star.text.XTextDocument;
+import com.sun.star.text.XTextRange;
+import com.sun.star.text.XTextRangeCompare;
+
+public class RangeOverlapBetween {
+
+ private RangeOverlapBetween() { }
+
+ /**
+ * Check for any overlap between two sets of XTextRange values.
+ *
+ * Assume fewHolders is small (usually a single element, for checking the cursor)
+ *
+ * Returns on first problem found.
+ */
+ public static
+ List> findFirst(XTextDocument doc,
+ List fewHolders,
+ List manyHolders,
+ boolean includeTouching) {
+
+ List> result = new ArrayList<>();
+
+ if (fewHolders.isEmpty()) {
+ return result;
+ }
+
+ /*
+ * Cache all we need to know about fewHolders. We are trying to minimize the number of calls
+ * to LO.
+ */
+ List> fewTuples = new ArrayList<>(fewHolders.size());
+
+ for (V aHolder : fewHolders) {
+ XText aText = aHolder.getRange().getText();
+ fewTuples.add(new OOTuple3<>(aText,
+ UnoCast.cast(XTextRangeCompare.class, aText).get(),
+ aHolder));
+ }
+
+ /*
+ * We only go through manyHolders once: fewTuples is in the inner loop.
+ */
+ for (V bHolder : manyHolders) {
+ XTextRange bRange = bHolder.getRange();
+ XText bText = bRange.getText();
+ XTextRange bRangeStart = bRange.getStart();
+ XTextRange bRangeEnd = bRange.getEnd();
+
+ for (OOTuple3 tup : fewTuples) {
+ XText aText = tup.a;
+ XTextRangeCompare cmp = tup.b;
+ V aHolder = tup.c;
+ XTextRange aRange = aHolder.getRange();
+ if (aText != bText) {
+ continue;
+ }
+ int abEndToStart = UnoTextRange.compareStartsUnsafe(cmp, aRange.getEnd(), bRangeStart);
+ if (abEndToStart < 0 || (!includeTouching && (abEndToStart == 0))) {
+ continue;
+ }
+ int baEndToStart = UnoTextRange.compareStartsUnsafe(cmp, bRangeEnd, aRange.getStart());
+ if (baEndToStart < 0 || (!includeTouching && (baEndToStart == 0))) {
+ continue;
+ }
+
+ boolean equal = UnoTextRange.compareStartsThenEndsUnsafe(cmp, aRange, bRange) == 0;
+ boolean touching = (abEndToStart == 0 || baEndToStart == 0);
+
+ // In case of two equal collapsed ranges there is an ambiguity : TOUCH or EQUAL_RANGE ?
+ //
+ // We return EQUAL_RANGE
+ RangeOverlapKind kind = (equal ? RangeOverlapKind.EQUAL_RANGE
+ : (touching ? RangeOverlapKind.TOUCH
+ : RangeOverlapKind.OVERLAP));
+
+ List valuesForOverlappingRanges = new ArrayList<>();
+ valuesForOverlappingRanges.add(aHolder);
+ valuesForOverlappingRanges.add(bHolder);
+
+ result.add(new RangeOverlap(kind, valuesForOverlappingRanges));
+ return result;
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapKind.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapKind.java
new file mode 100644
index 00000000000..2bb7f8f4af7
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapKind.java
@@ -0,0 +1,14 @@
+package org.jabref.model.openoffice.rangesort;
+
+public enum RangeOverlapKind {
+
+ /** The ranges share a boundary */
+ TOUCH,
+
+ /** They share some characters */
+ OVERLAP,
+
+ /** They cover the same XTextRange */
+ EQUAL_RANGE
+}
+
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapWithin.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapWithin.java
new file mode 100644
index 00000000000..a8534b630ae
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeOverlapWithin.java
@@ -0,0 +1,121 @@
+package org.jabref.model.openoffice.rangesort;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jabref.model.openoffice.uno.UnoCast;
+import org.jabref.model.openoffice.uno.UnoTextRange;
+
+import com.sun.star.text.XTextDocument;
+import com.sun.star.text.XTextRange;
+import com.sun.star.text.XTextRangeCompare;
+
+public class RangeOverlapWithin {
+
+ private RangeOverlapWithin() { }
+
+ /**
+ * Report identical, overlapping or touching ranges between elements of rangeHolders.
+ *
+ * For overlapping and touching, only report consecutive ranges and only with a single sample of
+ * otherwise identical ranges.
+ *
+ * @param rangeHolders represent the ranges to be checked.
+ *
+ * Note: for each rangeHolder, rangeHolder.getRange() is called multiple times.
+ * To avoid repeated work, they should keep a copy of the range instead of
+ * getting it each time from the document.
+ *
+ * @param reportAtMost Limit the number of records returned to atMost.
+ * Zero {@code reportAtMost} means no limit.
+ *
+ * @param includeTouching Should the result contain ranges sharing only a boundary?
+ */
+ public static
+ List> findOverlappingRanges(XTextDocument doc,
+ List rangeHolders,
+ boolean includeTouching,
+ int reportAtMost) {
+
+ RangeSort.RangePartitions partitions = RangeSort.partitionAndSortRanges(rangeHolders);
+
+ return findOverlappingRanges(partitions, reportAtMost, includeTouching);
+ }
+
+ /**
+ * Report identical, overlapping or touching ranges.
+ *
+ * For overlapping and touching, only report consecutive ranges and only with a single sample of
+ * otherwise identical ranges.
+ *
+ * @param atMost Limit the number of records returned to atMost.
+ * Zero {@code atMost} means no limit.
+ *
+ * @param includeTouching Should the result contain ranges sharing only a boundary?
+ */
+ public static
+ List> findOverlappingRanges(RangeSort.RangePartitions input,
+ int atMost,
+ boolean includeTouching) {
+ assert atMost >= 0;
+
+ List> result = new ArrayList<>();
+
+ for (List partition : input.getPartitions()) {
+ if (partition.isEmpty()) {
+ continue;
+ }
+ XTextRangeCompare cmp = UnoCast.cast(XTextRangeCompare.class,
+ partition.get(0).getRange().getText()).get();
+
+ for (int i = 0; i < (partition.size() - 1); i++) {
+ V aHolder = partition.get(i);
+ V bHolder = partition.get(i + 1);
+ XTextRange aRange = aHolder.getRange();
+ XTextRange bRange = bHolder.getRange();
+
+ // check equal values
+ int cmpResult = UnoTextRange.compareStartsThenEndsUnsafe(cmp, aRange, bRange);
+ if (cmpResult == 0) {
+ List aValues = new ArrayList<>();
+ aValues.add(aHolder);
+ // aValues.add(bHolder);
+ // collect those equal
+ while (i < (partition.size() - 1) &&
+ UnoTextRange.compareStartsThenEndsUnsafe(
+ cmp,
+ aRange,
+ partition.get(i + 1).getRange()) == 0) {
+ bHolder = partition.get(i + 1);
+ aValues.add(bHolder);
+ i++;
+ }
+ result.add(new RangeOverlap(RangeOverlapKind.EQUAL_RANGE, aValues));
+ if (atMost > 0 && result.size() >= atMost) {
+ return result;
+ }
+ continue;
+ }
+
+ // Not equal, and (a <= b) since sorted.
+ // Check if a.end >= b.start
+ cmpResult = UnoTextRange.compareStartsUnsafe(cmp, aRange.getEnd(), bRange.getStart());
+ if (cmpResult > 0 || (includeTouching && (cmpResult == 0))) {
+ // found overlap or touch
+ List valuesForOverlappingRanges = new ArrayList<>();
+ valuesForOverlappingRanges.add(aHolder);
+ valuesForOverlappingRanges.add(bHolder);
+ result.add(new RangeOverlap((cmpResult == 0)
+ ? RangeOverlapKind.TOUCH
+ : RangeOverlapKind.OVERLAP,
+ valuesForOverlappingRanges));
+ }
+ if (atMost > 0 && result.size() >= atMost) {
+ return result;
+ }
+ }
+ }
+ return result;
+ }
+
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeSort.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSort.java
new file mode 100644
index 00000000000..99ace5154e1
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSort.java
@@ -0,0 +1,106 @@
+package org.jabref.model.openoffice.rangesort;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.jabref.model.openoffice.uno.UnoCast;
+import org.jabref.model.openoffice.uno.UnoTextRange;
+
+import com.sun.star.text.XText;
+import com.sun.star.text.XTextRangeCompare;
+
+/**
+ * RangeSort provides sorting based on XTextRangeCompare, which only provides comparison
+ * between XTextRange values within the same XText.
+ */
+public class RangeSort {
+
+ private RangeSort() {
+ /**/
+ }
+
+ /**
+ * Compare two RangeHolders (using RangeHolder.getRange()) within an XText.
+ *
+ * Note: since we only look at the ranges, this comparison is generally not consistent with
+ * `equals` on the RangeHolders. Probably should not be used for key comparison in
+ * {@code TreeMap} or {@code Set}
+ *
+ */
+ private static class HolderComparatorWithinPartition implements Comparator {
+
+ private final XTextRangeCompare cmp;
+
+ HolderComparatorWithinPartition(XText text) {
+ cmp = (UnoCast.cast(XTextRangeCompare.class, text)
+ .orElseThrow(java.lang.IllegalArgumentException::new));
+ }
+
+ /**
+ * Assumes a and b belong to the same XText as cmp.
+ */
+ @Override
+ public int compare(RangeHolder a, RangeHolder b) {
+ return UnoTextRange.compareStartsThenEndsUnsafe(cmp, a.getRange(), b.getRange());
+ }
+ }
+
+ /**
+ * Sort a list of RangeHolder values known to share the same getText().
+ *
+ * Note: RangeHolder.getRange() is called many times.
+ */
+ public static void sortWithinPartition(List rangeHolders) {
+ if (rangeHolders.isEmpty()) {
+ return;
+ }
+ XText text = rangeHolders.get(0).getRange().getText();
+ rangeHolders.sort(new HolderComparatorWithinPartition(text));
+ }
+
+ /**
+ * Represent a partitioning of RangeHolders by XText
+ */
+ public static class RangePartitions {
+ private final Map> partitions;
+
+ public RangePartitions() {
+ this.partitions = new HashMap<>();
+ }
+
+ public void add(V holder) {
+ XText partitionKey = holder.getRange().getText();
+ List partition = partitions.computeIfAbsent(partitionKey, unused -> new ArrayList<>());
+ partition.add(holder);
+ }
+
+ public List> getPartitions() {
+ return new ArrayList<>(partitions.values());
+ }
+ }
+
+ /**
+ * Partition RangeHolders by the corresponding XText.
+ */
+ public static RangePartitions partitionRanges(List holders) {
+ RangePartitions result = new RangePartitions<>();
+ for (V holder : holders) {
+ result.add(holder);
+ }
+ return result;
+ }
+
+ /**
+ * Note: RangeHolder.getRange() is called many times.
+ */
+ public static RangePartitions partitionAndSortRanges(List holders) {
+ RangePartitions result = partitionRanges(holders);
+ for (List partition : result.getPartitions()) {
+ sortWithinPartition(partition);
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortEntry.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortEntry.java
new file mode 100644
index 00000000000..0ed686ee901
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortEntry.java
@@ -0,0 +1,42 @@
+package org.jabref.model.openoffice.rangesort;
+
+import com.sun.star.text.XTextRange;
+
+/**
+ * A simple implementation of {@code RangeSortable}
+ */
+public class RangeSortEntry implements RangeSortable {
+
+ private XTextRange range;
+ private int indexInPosition;
+ private T content;
+
+ public RangeSortEntry(XTextRange range, int indexInPosition, T content) {
+ this.range = range;
+ this.indexInPosition = indexInPosition;
+ this.content = content;
+ }
+
+ @Override
+ public XTextRange getRange() {
+ return range;
+ }
+
+ @Override
+ public int getIndexInPosition() {
+ return indexInPosition;
+ }
+
+ @Override
+ public T getContent() {
+ return content;
+ }
+
+ public void setRange(XTextRange range) {
+ this.range = range;
+ }
+
+ public void setIndexInPosition(int indexInPosition) {
+ this.indexInPosition = indexInPosition;
+ }
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortVisual.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortVisual.java
new file mode 100644
index 00000000000..e412f4ade6c
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortVisual.java
@@ -0,0 +1,144 @@
+package org.jabref.model.openoffice.rangesort;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jabref.model.openoffice.uno.UnoScreenRefresh;
+
+import com.sun.star.awt.Point;
+import com.sun.star.text.XTextDocument;
+import com.sun.star.text.XTextRange;
+import com.sun.star.text.XTextViewCursor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Sort XTextRange values visually (top-down,left-to-right).
+ *
+ * Requires functional XTextViewCursor.
+ *
+ * Problem: for multicolumn layout and when viewing pages side-by-side in LO, the
+ * (top-down,left-to-right) order interpreted as-on-the-screen: an XTextRange at the top of
+ * the second column or second page is sorted before an XTextRange at the bottom of the
+ * first column of the first page.
+ */
+public class RangeSortVisual {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(RangeSortVisual.class);
+
+ private RangeSortVisual() {
+ /**/
+ }
+
+ /**
+ * Sort the input {@code inputs} visually.
+ *
+ * Requires a functional {@code XTextViewCursor}.
+ *
+ * @return The input, sorted by the elements XTextRange and getIndexInPosition.
+ */
+ public static List> visualSort(List> inputs,
+ XTextDocument doc,
+ FunctionalTextViewCursor fcursor) {
+
+ if (UnoScreenRefresh.hasControllersLocked(doc)) {
+ final String msg = "visualSort: with ControllersLocked, viewCursor.gotoRange is probably useless";
+ LOGGER.warn(msg);
+ throw new IllegalStateException(msg);
+ }
+
+ XTextViewCursor viewCursor = fcursor.getViewCursor();
+
+ final int inputSize = inputs.size();
+
+ // find coordinates
+ List positions = new ArrayList<>(inputSize);
+ for (RangeSortable v : inputs) {
+ positions.add(findPositionOfTextRange(v.getRange(), viewCursor));
+ }
+ fcursor.restore(doc);
+
+ // order by position
+ ArrayList>> comparableMarks = new ArrayList<>(inputSize);
+ for (int i = 0; i < inputSize; i++) {
+ RangeSortable input = inputs.get(i);
+ comparableMarks.add(new ComparableMark<>(positions.get(i),
+ input.getIndexInPosition(),
+ input));
+ }
+ comparableMarks.sort(RangeSortVisual::compareTopToBottomLeftToRight);
+
+ // collect ordered result
+ List> result = new ArrayList<>(comparableMarks.size());
+ for (ComparableMark> mark : comparableMarks) {
+ result.add(mark.getContent());
+ }
+
+ if (result.size() != inputSize) {
+ throw new IllegalStateException("visualSort: result.size() != inputSize");
+ }
+
+ return result;
+ }
+
+ /**
+ * Given a location, return its position: coordinates relative to the top left position of the
+ * first page of the document.
+ *
+ * Note: for text layouts with two or more columns, this gives the wrong order:
+ * top-down/left-to-right does not match reading order.
+ *
+ * Note: The "relative to the top left position of the first page" is meant "as it appears on
+ * the screen".
+ *
+ * In particular: when viewing pages side-by-side, the top half of the right page is
+ * higher than the lower half of the left page. Again, top-down/left-to-right does not
+ * match reading order.
+ *
+ * @param range Location.
+ * @param cursor To get the position, we need az XTextViewCursor.
+ * It will be moved to the range.
+ */
+ private static Point findPositionOfTextRange(XTextRange range, XTextViewCursor cursor) {
+ cursor.gotoRange(range, false);
+ return cursor.getPosition();
+ }
+
+ private static int compareTopToBottomLeftToRight(ComparableMark a, ComparableMark b) {
+
+ if (a.position.Y != b.position.Y) {
+ return a.position.Y - b.position.Y;
+ }
+ if (a.position.X != b.position.X) {
+ return a.position.X - b.position.X;
+ }
+ return a.indexInPosition - b.indexInPosition;
+ }
+
+ /**
+ * A reference mark name paired with its visual position.
+ *
+ * Comparison is based on (Y,X,indexInPosition): vertical compared first, horizontal second,
+ * indexInPosition third.
+ *
+ * Used for sorting reference marks by their visual positions.
+ */
+ private static class ComparableMark {
+
+ private final Point position;
+ private final int indexInPosition;
+ private final T content;
+
+ public ComparableMark(Point position, int indexInPosition, T content) {
+ this.position = position;
+ this.indexInPosition = indexInPosition;
+ this.content = content;
+ }
+
+ public T getContent() {
+ return content;
+ }
+
+ }
+
+}
diff --git a/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortable.java b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortable.java
new file mode 100644
index 00000000000..59a4c3fa9af
--- /dev/null
+++ b/src/main/java/org/jabref/model/openoffice/rangesort/RangeSortable.java
@@ -0,0 +1,22 @@
+package org.jabref.model.openoffice.rangesort;
+
+import com.sun.star.text.XTextRange;
+
+/**
+ * This is what {@code visualSort} needs in its input.
+ */
+public interface RangeSortable extends RangeHolder {
+
+ /** The XTextRange
+ *
+ * For citation marks in footnotes this may be the range of the footnote mark.
+ */
+ XTextRange getRange();
+
+ /**
+ * For citation marks in footnotes this may provide order within the footnote.
+ */
+ int getIndexInPosition();
+
+ T getContent();
+}