diff --git a/src/main/java/org/jabref/model/openoffice/ootext/OOFormat.java b/src/main/java/org/jabref/model/openoffice/ootext/OOFormat.java new file mode 100644 index 00000000000..6b4a96de644 --- /dev/null +++ b/src/main/java/org/jabref/model/openoffice/ootext/OOFormat.java @@ -0,0 +1,84 @@ +package org.jabref.model.openoffice.ootext; + +import org.jabref.model.strings.StringUtil; + +/** + * Helper functions to produce some of the markup as understood by OOTextIntoOO.write + * + * These do not cover all tags, only those needed to embed markup + * from Layout and citation marker formatters into citation markers and + * bibliography. + */ +public class OOFormat { + + private OOFormat() { + /* */ + } + + /** + * Mark {@code ootext} as using a character locale known to OO. + * + * @param locale language[-country[-territory]] + * + * https://www.openoffice.org/api/docs/common/ref/com/sun/star/lang/Locale.html + * + * The country part is optional. + * + * The territory part is not only optional, the allowed "codes are vendor and browser-specific", + * so probably best to avoid them if possible. + * + */ + public static OOText setLocale(OOText ootext, String locale) { + return OOText.fromString(String.format("", locale) + ootext.toString() + ""); + } + + /** + * Mark {@code ootext} as using the character locale "zxx", which means "no language", "no + * linguistic content". + * + * Used around citation marks, probably to turn off spellchecking. + * + */ + public static OOText setLocaleNone(OOText ootext) { + return OOFormat.setLocale(ootext, "zxx"); + } + + /** + * Mark {@code ootext} using a character style {@code charStyle} + * + * @param charStyle Name of a character style known to OO. May be empty for "Standard", which in + * turn means do not override any properties. + * + */ + public static OOText setCharStyle(OOText ootext, String charStyle) { + return OOText.fromString(String.format("", charStyle) + + ootext.toString() + + ""); + } + + /** + * Mark {@code ootext} as part of a paragraph with style {@code paraStyle} + */ + public static OOText paragraph(OOText ootext, String paraStyle) { + if (StringUtil.isNullOrEmpty(paraStyle)) { + return paragraph(ootext); + } + String startTag = String.format("

", paraStyle); + return OOText.fromString(startTag + ootext.toString() + "

"); + } + + /** + * Mark {@code ootext} as part of a paragraph. + */ + public static OOText paragraph(OOText ootext) { + return OOText.fromString("

" + ootext.toString() + "

"); + } + + /** + * Format an OO cross-reference showing the target's page number as label to a reference mark. + */ + public static OOText formatReferenceToPageNumberOfReferenceMark(String referenceMarkName) { + String string = String.format("", referenceMarkName); + return OOText.fromString(string); + } + } diff --git a/src/main/java/org/jabref/model/openoffice/ootext/OOText.java b/src/main/java/org/jabref/model/openoffice/ootext/OOText.java new file mode 100644 index 00000000000..5b5fa4caba9 --- /dev/null +++ b/src/main/java/org/jabref/model/openoffice/ootext/OOText.java @@ -0,0 +1,61 @@ +package org.jabref.model.openoffice.ootext; + +import java.util.Objects; + +/** + * Text with HTML-like markup as understood by OOTextIntoOO.write + * + * Some of the tags can be added using OOFormat methods. Others come from the layout engine, either + * by interpreting LaTeX markup or from settings in the jstyle file. + */ +public class OOText { + + private final String data; + + private OOText(String data) { + Objects.requireNonNull(data); + this.data = data; + } + + /** @return null for null input, otherwise the argument wrapped into a new OOText */ + public static OOText fromString(String string) { + if (string == null) { + return null; + } + return new OOText(string); + } + + /** @return null for null input, otherwise the string inside the argument */ + public static String toString(OOText ootext) { + if (ootext == null) { + return null; + } + return ootext.data; + } + + @Override + public String toString() { + return data; + } + + @Override + public boolean equals(Object object) { + + if (object == this) { + return true; + } + + if (!(object instanceof OOText)) { + return false; + } + + OOText other = (OOText) object; + + return data.equals(other.data); + } + + @Override + public int hashCode() { + return data.hashCode(); + } +} diff --git a/src/main/java/org/jabref/model/openoffice/ootext/OOTextIntoOO.java b/src/main/java/org/jabref/model/openoffice/ootext/OOTextIntoOO.java new file mode 100644 index 00000000000..6e61faed147 --- /dev/null +++ b/src/main/java/org/jabref/model/openoffice/ootext/OOTextIntoOO.java @@ -0,0 +1,810 @@ +package org.jabref.model.openoffice.ootext; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.Stack; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jabref.architecture.AllowedToUseAwt; +import org.jabref.model.openoffice.uno.CreationException; +import org.jabref.model.openoffice.uno.UnoCast; +import org.jabref.model.openoffice.uno.UnoCrossRef; +import org.jabref.model.openoffice.util.OOPair; +import org.jabref.model.strings.StringUtil; + +import com.sun.star.awt.FontSlant; +import com.sun.star.awt.FontStrikeout; +import com.sun.star.awt.FontUnderline; +import com.sun.star.awt.FontWeight; +import com.sun.star.beans.Property; +import com.sun.star.beans.PropertyAttribute; +import com.sun.star.beans.PropertyState; +import com.sun.star.beans.PropertyVetoException; +import com.sun.star.beans.UnknownPropertyException; +import com.sun.star.beans.XMultiPropertySet; +import com.sun.star.beans.XMultiPropertyStates; +import com.sun.star.beans.XPropertySet; +import com.sun.star.beans.XPropertySetInfo; +import com.sun.star.beans.XPropertyState; +import com.sun.star.lang.Locale; +import com.sun.star.lang.WrappedTargetException; +import com.sun.star.style.CaseMap; +import com.sun.star.text.ControlCharacter; +import com.sun.star.text.XParagraphCursor; +import com.sun.star.text.XText; +import com.sun.star.text.XTextCursor; +import com.sun.star.text.XTextDocument; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Interpret OOText into an OpenOffice or LibreOffice writer document. + */ +@AllowedToUseAwt("Requires AWT for changing document properties") +public class OOTextIntoOO { + + private static final Logger LOGGER = LoggerFactory.getLogger(OOTextIntoOO.class); + + /** + * "ParaStyleName" is an OpenOffice Property name. + */ + private static final String PARA_STYLE_NAME = "ParaStyleName"; + + /* + * Character property names used in multiple locations below. + */ + private static final String CHAR_ESCAPEMENT_HEIGHT = "CharEscapementHeight"; + private static final String CHAR_ESCAPEMENT = "CharEscapement"; + private static final String CHAR_STYLE_NAME = "CharStyleName"; + private static final String CHAR_UNDERLINE = "CharUnderline"; + private static final String CHAR_STRIKEOUT = "CharStrikeout"; + + /* + * SUPERSCRIPT_VALUE and SUPERSCRIPT_HEIGHT are percents of the normal character height + */ + private static final short CHAR_ESCAPEMENT_VALUE_DEFAULT = (short) 0; + private static final short SUPERSCRIPT_VALUE = (short) 33; + private static final short SUBSCRIPT_VALUE = (short) -10; + private static final byte CHAR_ESCAPEMENT_HEIGHT_DEFAULT = (byte) 100; + private static final byte SUPERSCRIPT_HEIGHT = (byte) 58; + private static final byte SUBSCRIPT_HEIGHT = (byte) 58; + + private static final String TAG_NAME_REGEXP = + "(?:b|i|em|tt|smallcaps|sup|sub|u|s|p|span|oo:referenceToPageNumberOfReferenceMark)"; + + private static final String ATTRIBUTE_NAME_REGEXP = + "(?:oo:ParaStyleName|oo:CharStyleName|lang|style|target)"; + + private static final String ATTRIBUTE_VALUE_REGEXP = "\"([^\"]*)\""; + + private static final Pattern HTML_TAG = + Pattern.compile("<(/" + TAG_NAME_REGEXP + ")>" + + "|" + + "<(" + TAG_NAME_REGEXP + ")" + + "((?:\\s+(" + ATTRIBUTE_NAME_REGEXP + ")=" + ATTRIBUTE_VALUE_REGEXP + ")*)" + + ">"); + + private static final Pattern ATTRIBUTE_PATTERN = + Pattern.compile("\\s+(" + ATTRIBUTE_NAME_REGEXP + ")=" + ATTRIBUTE_VALUE_REGEXP); + + private OOTextIntoOO() { + // Hide the public constructor + } + + /** + * Insert a text with formatting indicated by HTML-like tags, into + * a text at the position given by a cursor. + * + * Limitation: understands no entities. It does not receive any either, unless + * the user provides it. + * + * To limit the damage {@code TAG_NAME_REGEXP} and {@code ATTRIBUTE_NAME_REGEXP} + * explicitly lists the names we care about. + * + * Notable changes w.r.t insertOOFormattedTextAtCurrentLocation: + * + * - new tags: + * + * - {@code } + * - earlier was applied from code + * + * - {@code } + * - 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(); +}