/* LanguageTool, a natural language style checker * Copyright (C) 2005 Daniel Naber (http://www.danielnaber.de) * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 * USA */ package org.languagetool.openoffice; /** OpenOffice 3.x Integration * * @author Marcin Miłkowski */ import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.ResourceBundle; import java.util.Set; import javax.swing.JOptionPane; import javax.swing.UIManager; import org.languagetool.JLanguageTool; import org.languagetool.Language; import org.languagetool.gui.Configuration; import org.languagetool.rules.Rule; import org.languagetool.rules.RuleMatch; import org.languagetool.tools.StringTools; import org.languagetool.tools.Tools; import com.sun.star.awt.XWindow; import com.sun.star.awt.XWindowPeer; import com.sun.star.beans.PropertyState; import com.sun.star.beans.PropertyValue; import com.sun.star.beans.XPropertySet; import com.sun.star.frame.XDesktop; import com.sun.star.frame.XModel; import com.sun.star.lang.IllegalArgumentException; import com.sun.star.lang.Locale; import com.sun.star.lang.XComponent; import com.sun.star.lang.XMultiComponentFactory; import com.sun.star.lang.XServiceDisplayName; import com.sun.star.lang.XServiceInfo; import com.sun.star.lang.XSingleComponentFactory; import com.sun.star.lib.uno.helper.Factory; import com.sun.star.lib.uno.helper.WeakBase; import com.sun.star.linguistic2.ProofreadingResult; import com.sun.star.linguistic2.SingleProofreadingError; import com.sun.star.linguistic2.XLinguServiceEventBroadcaster; import com.sun.star.linguistic2.XLinguServiceEventListener; import com.sun.star.linguistic2.XProofreader; import com.sun.star.registry.XRegistryKey; import com.sun.star.task.XJobExecutor; import com.sun.star.text.XTextViewCursor; import com.sun.star.text.XTextViewCursorSupplier; import com.sun.star.uno.UnoRuntime; import com.sun.star.uno.XComponentContext; public class Main extends WeakBase implements XJobExecutor, XServiceDisplayName, XServiceInfo, XProofreader, XLinguServiceEventBroadcaster { private Configuration config; private JLanguageTool langTool; private Language docLanguage; private String docID; /* * Rules disabled using the config dialog box rather than Spelling dialog box * or the context menu. */ private Set<String> disabledRules; private Set<String> disabledRulesUI; private List<XLinguServiceEventListener> xEventListeners; /** * Make another instance of JLanguageTool and assign it to langTool if true. */ private boolean recheck; /** * Sentence tokenization-related members. */ private String currentPara; private List<String> tokenizedSentences; private int position; private List<RuleMatch> paragraphMatches; /** * Service name required by the OOo API && our own name. */ private static final String[] SERVICE_NAMES = { "com.sun.star.linguistic2.Proofreader", "org.languagetool.openoffice.Main" }; // use a different name than the stand-alone version to avoid conflicts: private static final String CONFIG_FILE = ".languagetool-ooo.cfg"; private static final ResourceBundle MESSAGES = JLanguageTool.getMessageBundle(); private XComponentContext xContext; public Main(final XComponentContext xCompContext) { changeContext(xCompContext); xEventListeners = new ArrayList<XLinguServiceEventListener>(); } private void prepareConfig(final Language lang) { try { final File homeDir = getHomeDir(); config = new Configuration(homeDir, CONFIG_FILE, lang); disabledRules = config.getDisabledRuleIds(); if (disabledRules == null) { disabledRules = new HashSet<String>(); } disabledRulesUI = new HashSet<String>(disabledRules); } catch (final Throwable t) { showError(t); } } public final void changeContext(final XComponentContext xCompContext) { xContext = xCompContext; } private XComponent getXComponent() { try { final XMultiComponentFactory xMCF = xContext.getServiceManager(); final Object desktop = xMCF.createInstanceWithContext( "com.sun.star.frame.Desktop", xContext); final XDesktop xDesktop = (XDesktop) UnoRuntime.queryInterface( XDesktop.class, desktop); return xDesktop.getCurrentComponent(); } catch (final Throwable t) { showError(t); return null; } } /** * Checks the language under the cursor. Used for opening the configuration * dialog. * * @return Language the language under the visible cursor */ private Language getLanguage() { final XComponent xComponent = getXComponent(); final Locale charLocale; final XPropertySet xCursorProps; try { final XModel model = (XModel) UnoRuntime.queryInterface(XModel.class, xComponent); final XTextViewCursorSupplier xViewCursorSupplier = (XTextViewCursorSupplier) UnoRuntime .queryInterface(XTextViewCursorSupplier.class, model .getCurrentController()); final XTextViewCursor xCursor = xViewCursorSupplier.getViewCursor(); if (xCursor.isCollapsed()) { // no text selection xCursorProps = (XPropertySet) UnoRuntime.queryInterface( XPropertySet.class, xCursor); } else { // text is selected, need to create another cursor // as multiple languages can occur here - we care only // about character under the cursor, which might be wrong // but it applies only to the checking dialog to be removed xCursorProps = (XPropertySet) UnoRuntime.queryInterface( XPropertySet.class, xCursor.getText().createTextCursorByRange( xCursor.getStart())); } // The CharLocale and CharLocaleComplex properties may both be set, so we still cannot know // whether the text is Khmer (the only "complex text layout (CTL)" language we support so far). // Thus we check the text itself: final KhmerDetector khmerDetector = new KhmerDetector(); if (khmerDetector.isKhmer(xCursor.getText().getString())) { return Language.getLanguageForShortName("km"); // Khmer } final Object obj = xCursorProps.getPropertyValue("CharLocale"); if (obj == null) { return Language.getLanguageForShortName("en-US"); } charLocale = (Locale) obj; boolean langIsSupported = false; for (Language element : Language.LANGUAGES) { if (element.getShortName().equals(charLocale.Language)) { langIsSupported = true; break; } } if (!langIsSupported) { final String message = org.languagetool.gui.Tools.makeTexti18n(MESSAGES, "language_not_supported", charLocale.Language); JOptionPane.showMessageDialog(null, message); return null; } } catch (final Throwable t) { showError(t); return null; } try { return Language.getLanguageForShortName(charLocale.Language + "-" + charLocale.Variant); } catch (java.lang.IllegalArgumentException e) { return Language.getLanguageForShortName(charLocale.Language); } } /** * Runs the grammar checker on paragraph text. * * @param docID - document ID * @param paraText - paragraph text * @param locale Locale - the text Locale * @param startOfSentencePos start of sentence position * @param nSuggestedBehindEndOfSentencePosition end of sentence position * @param props - properties * @return ProofreadingResult containing the results of the check. */ @Override public final ProofreadingResult doProofreading(final String docID, final String paraText, final Locale locale, final int startOfSentencePos, final int nSuggestedBehindEndOfSentencePosition, final PropertyValue[] props) { final ProofreadingResult paRes = new ProofreadingResult(); try { paRes.nStartOfSentencePosition = startOfSentencePos; paRes.xProofreader = this; paRes.aLocale = locale; paRes.aDocumentIdentifier = docID; paRes.aText = paraText; paRes.aProperties = props; return doGrammarCheckingInternal(paraText, locale, paRes); } catch (final Throwable t) { showError(t); return paRes; } } private synchronized ProofreadingResult doGrammarCheckingInternal( final String paraText, final Locale locale, final ProofreadingResult paRes) { if (!StringTools.isEmpty(paraText) && hasLocale(locale)) { Language langForShortName; try { langForShortName = Language.getLanguageForShortName(locale.Language + "-" + locale.Variant); } catch (java.lang.IllegalArgumentException e) { langForShortName = Language.getLanguageForShortName(locale.Language); } if (!langForShortName.equals(docLanguage) || langTool == null || recheck) { docLanguage = langForShortName; if (docLanguage == null) { return paRes; } initLanguageTool(); } final Set<String> disabledRuleIds = config.getDisabledRuleIds(); if (disabledRuleIds != null) { // copy as the config thread may access this as well final ArrayList<String> list = new ArrayList<String>(disabledRuleIds); for (final String id : list) { langTool.disableRule(id); } } final Set<String> disabledCategories = config.getDisabledCategoryNames(); if (disabledCategories != null) { // copy as the config thread may access this as well final ArrayList<String> list = new ArrayList<String>(disabledCategories); for (final String categoryName : list) { langTool.disableCategory(categoryName); } } final Set<String> enabledRuleIds = config.getEnabledRuleIds(); if (enabledRuleIds != null) { // copy as the config thread may access this as well final ArrayList<String> list = new ArrayList<String>(enabledRuleIds); for (String ruleName : list) { langTool.enableDefaultOffRule(ruleName); langTool.enableRule(ruleName); } } try { final String sentence = getSentence(paraText, paRes.nStartOfSentencePosition); paRes.nStartOfSentencePosition = position; paRes.nStartOfNextSentencePosition = position + sentence.length(); paRes.nBehindEndOfSentencePosition = paRes.nStartOfNextSentencePosition; if (!StringTools.isEmpty(sentence)) { final List<RuleMatch> ruleMatches = langTool.check(sentence, false, JLanguageTool.ParagraphHandling.ONLYNONPARA); final SingleProofreadingError[] pErrors = checkParaRules(paraText, locale, paRes.nStartOfSentencePosition, paRes.nStartOfNextSentencePosition, paRes.aDocumentIdentifier); int pErrorCount = 0; if (pErrors != null) { pErrorCount = pErrors.length; } if (!ruleMatches.isEmpty()) { final SingleProofreadingError[] errorArray = new SingleProofreadingError[ruleMatches .size() + pErrorCount]; int i = 0; for (final RuleMatch myRuleMatch : ruleMatches) { errorArray[i] = createOOoError(myRuleMatch, paRes.nStartOfSentencePosition); i++; } // add para matches if (pErrors != null) { for (SingleProofreadingError paraError : pErrors) { if (paraError != null) { errorArray[i] = paraError; i++; } } } Arrays.sort(errorArray, new ErrorPositionComparator()); paRes.aErrors = errorArray; } else { if (pErrors != null) { paRes.aErrors = pErrors; } } } } catch (final Throwable t) { showError(t); paRes.nBehindEndOfSentencePosition = paraText.length(); } } return paRes; } private void initLanguageTool() { try { prepareConfig(docLanguage); langTool = new JLanguageTool(docLanguage, config.getMotherTongue()); langTool.activateDefaultPatternRules(); langTool.activateDefaultFalseFriendRules(); for (Rule rule : langTool.getAllActiveRules()) { if (rule.isSpellingRule()) { langTool.disableRule(rule.getId()); } } recheck = false; } catch (final Throwable t) { showError(t); } } private synchronized String getSentence(final String paraText, final int startPos) { if (paraText.equals(currentPara) && tokenizedSentences != null) { int i = 0; int index = -1; while (index < startPos && i < tokenizedSentences.size()) { index += tokenizedSentences.get(i).length(); if (index < startPos) { i++; } } position = index + 1; if (i < tokenizedSentences.size()) { position -= tokenizedSentences.get(i).length(); return tokenizedSentences.get(i); } return ""; } currentPara = paraText; tokenizedSentences = langTool.sentenceTokenize(paraText); position = 0; if (!tokenizedSentences.isEmpty()) { return tokenizedSentences.get(0); } return ""; } private synchronized SingleProofreadingError[] checkParaRules( final String paraText, final Locale locale, final int startPos, final int endPos, final String docID) { if (startPos == 0) { try { paragraphMatches = langTool.check(paraText, false, JLanguageTool.ParagraphHandling.ONLYPARA); this.docID = docID; } catch (final Throwable t) { showError(t); } } if (paragraphMatches != null && !paragraphMatches.isEmpty() && docID.equals(this.docID)) { final List<SingleProofreadingError> errorList = new ArrayList<SingleProofreadingError>( paragraphMatches.size()); for (final RuleMatch myRuleMatch : paragraphMatches) { final int startErrPos = myRuleMatch.getFromPos(); final int endErrPos = myRuleMatch.getToPos(); if (startErrPos >= startPos && startErrPos < endPos && endErrPos >= startPos && endErrPos < endPos) { errorList.add(createOOoError(myRuleMatch, 0)); } } if (!errorList.isEmpty()) { final SingleProofreadingError[] errorArray = errorList.toArray(new SingleProofreadingError[errorList.size()]); Arrays.sort(errorArray, new ErrorPositionComparator()); return errorArray; } } return null; } /** * LibO shortens menu items with more than ~100 characters by dropping text in the middle. * That isn't really sensible, so we shorten the text here in order to preserve the important parts. */ String shortenComment(String comment) { final int maxCommentLength = 100; if(comment.length() > maxCommentLength) { // if there is text in brackets, drop it (beginning at the end) while (comment.lastIndexOf(" [") > 0 && comment.lastIndexOf("]") > comment.lastIndexOf(" [") && comment.length() > maxCommentLength) { comment = comment.substring(0,comment.lastIndexOf(" [")) + comment.substring(comment.lastIndexOf("]")+1); } while (comment.lastIndexOf(" (") > 0 && comment.lastIndexOf(")") > comment.lastIndexOf(" (") && comment.length() > maxCommentLength) { comment = comment.substring(0,comment.lastIndexOf(" (")) + comment.substring(comment.lastIndexOf(")")+1); } // in case it's still not short enough, shorten at the end if(comment.length() > maxCommentLength) { comment = comment.substring(0,maxCommentLength-1) + "…"; } } return comment; } /** * Creates a SingleGrammarError object for use in OOo. * * @param myMatch LT rule match * @return SingleGrammarError - object for OOo checker integration */ private SingleProofreadingError createOOoError(final RuleMatch myMatch, final int startIndex) { final SingleProofreadingError aError = new SingleProofreadingError(); aError.nErrorType = com.sun.star.text.TextMarkupType.PROOFREADING; // the API currently has no support for formatting text in comments final String comment = myMatch.getMessage() .replaceAll("<suggestion>", "\"").replaceAll("</suggestion>", "\"") .replaceAll("([\r]*\n)", " "); // convert line ends to spaces aError.aFullComment = comment; // not all rules have short comments if (!StringTools.isEmpty(myMatch.getShortMessage())) { aError.aShortComment = myMatch.getShortMessage(); } else { aError.aShortComment = aError.aFullComment; } aError.aShortComment = shortenComment(aError.aShortComment); aError.aSuggestions = myMatch.getSuggestedReplacements().toArray( new String[myMatch.getSuggestedReplacements().size()]); aError.nErrorStart = myMatch.getFromPos() + startIndex; aError.nErrorLength = myMatch.getToPos() - myMatch.getFromPos(); aError.aRuleIdentifier = myMatch.getRule().getId(); // LibreOffice since version 3.5 supports an URL that provides more information about the error, // older version will simply ignore the property: if (myMatch.getRule().getUrl() != null) { aError.aProperties = new PropertyValue[] { new PropertyValue("FullCommentURL", -1, myMatch.getRule().getUrl().toString(), PropertyState.DIRECT_VALUE) }; } else { aError.aProperties = new PropertyValue[0]; } return aError; } /** * We leave spell checking to OpenOffice/LibreOffice. * @return false */ @Override public final boolean isSpellChecker() { return false; } /** * Runs LT options dialog box. */ public final void runOptionsDialog() { final Language lang = getLanguage(); if (lang == null) { return; } prepareConfig(lang); final ConfigThread configThread = new ConfigThread(lang, config, this); configThread.start(); } /** * @return An array of Locales supported by LT */ @Override public final Locale[] getLocales() { try { int dims = 0; for (final Language element : Language.LANGUAGES) { dims += element.getCountryVariants().length; } final Locale[] aLocales = new Locale[dims]; int cnt = 0; for (final Language element : Language.LANGUAGES) { for (final String variant : element.getCountryVariants()) { aLocales[cnt] = new Locale(element.getShortName(), variant, ""); cnt++; } } return aLocales; } catch (final Throwable t) { showError(t); return new Locale[0]; } } /** * @return true if LT supports the language of a given locale * @param locale The Locale to check */ @Override public final boolean hasLocale(final Locale locale) { try { for (final Language element : Language.LANGUAGES) { if (element.getShortName().equals(locale.Language)) { return true; } } } catch (final Throwable t) { showError(t); } return false; } /** * Add a listener that allow re-checking the document after changing the * options in the configuration dialog box. * * @param eventListener the listener to be added * @return true if listener is non-null and has been added, false otherwise */ @Override public final boolean addLinguServiceEventListener( final XLinguServiceEventListener eventListener) { if (eventListener == null) { return false; } xEventListeners.add(eventListener); return true; } /** * Remove a listener from the event listeners list. * * @param eventListener the listener to be removed * @return true if listener is non-null and has been removed, false otherwise */ @Override public final boolean removeLinguServiceEventListener( final XLinguServiceEventListener eventListener) { if (eventListener == null) { return false; } if (xEventListeners.contains(eventListener)) { xEventListeners.remove(eventListener); return true; } return false; } /** * Inform listener (grammar checking iterator) that options have changed and * the doc should be rechecked. */ public final void resetDocument() { if (!xEventListeners.isEmpty()) { for (final XLinguServiceEventListener xEvLis : xEventListeners) { if (xEvLis != null) { final com.sun.star.linguistic2.LinguServiceEvent xEvent = new com.sun.star.linguistic2.LinguServiceEvent(); xEvent.nEvent = com.sun.star.linguistic2.LinguServiceEventFlags.PROOFREAD_AGAIN; xEvLis.processLinguServiceEvent(xEvent); } } recheck = true; disabledRules = config.getDisabledRuleIds(); if (disabledRules == null) { disabledRules = new HashSet<String>(); } } } @Override public String[] getSupportedServiceNames() { return getServiceNames(); } public static String[] getServiceNames() { return SERVICE_NAMES; } @Override public boolean supportsService(final String sServiceName) { for (final String sName : SERVICE_NAMES) { if (sServiceName.equals(sName)) { return true; } } return false; } @Override public String getImplementationName() { return Main.class.getName(); } public static XSingleComponentFactory __getComponentFactory( final String sImplName) { SingletonFactory xFactory = null; if (sImplName.equals(Main.class.getName())) { xFactory = new SingletonFactory(); } return xFactory; } public static boolean __writeRegistryServiceInfo(final XRegistryKey regKey) { return Factory.writeRegistryServiceInfo(Main.class.getName(), Main .getServiceNames(), regKey); } @Override public void trigger(final String sEvent) { if (!javaVersionOkay()) { return; } try { if ("configure".equals(sEvent)) { runOptionsDialog(); } else if ("about".equals(sEvent)) { final AboutDialogThread aboutThread = new AboutDialogThread(MESSAGES); aboutThread.start(); } else { System.err.println("Sorry, don't know what to do, sEvent = " + sEvent); } } catch (final Throwable e) { showError(e); } } private boolean javaVersionOkay() { final String version = System.getProperty("java.version"); if (version != null && (version.startsWith("1.0") || version.startsWith("1.1") || version.startsWith("1.2") || version.startsWith("1.3") || version .startsWith("1.4") || version.startsWith("1.5"))) { final DialogThread dt = new DialogThread( "Error: LanguageTool requires Java 6.0 or later. Current version: " + version); dt.start(); return false; } try { // do not set look and feel for on Mac OS X as it causes the following error: // soffice[2149:2703] Apple AWT Java VM was loaded on first thread -- can't start AWT. if(!System.getProperty("os.name").contains("OS X")) { for (UIManager.LookAndFeelInfo info : UIManager.getInstalledLookAndFeels()) { if ("Nimbus".equals(info.getName())) { UIManager.setLookAndFeel(info.getClassName()); break; } } } } catch (Exception ignored) { // Well, what can we do... } return true; } static void showError(final Throwable e) { String msg = "An error has occurred in LanguageTool " + JLanguageTool.VERSION + ":\n" + e.toString() + "\nStacktrace:\n"; msg += Tools.getFullStackTrace(e); final String metaInfo = "OS: " + System.getProperty("os.name") + " on " + System.getProperty("os.arch") + ", Java version " + System.getProperty("java.vm.version") + " from " + System.getProperty("java.vm.vendor"); msg += metaInfo; final DialogThread dt = new DialogThread(msg); dt.start(); } private File getHomeDir() { final String homeDir = System.getProperty("user.home"); if (homeDir == null) { @SuppressWarnings({"ThrowableInstanceNeverThrown"}) final RuntimeException ex = new RuntimeException("Could not get home directory"); showError(ex); } return new File(homeDir); } private class AboutDialogThread extends Thread { private final ResourceBundle messages; AboutDialogThread(final ResourceBundle messages) { this.messages = messages; } @Override public void run() { final XModel model = (XModel) UnoRuntime.queryInterface(XModel.class,getXComponent()); final XWindow parentWindow = model.getCurrentController().getFrame().getContainerWindow(); final XWindowPeer parentWindowPeer = (XWindowPeer) UnoRuntime .queryInterface(XWindowPeer.class, parentWindow); final OOoAboutDialog about = new OOoAboutDialog(messages, parentWindowPeer); about.show(); } } /** * Called from grammar/spell checking dialog to ignore a rule (not called when "Ignore" is * selected in the context menu for an error.) */ @Override public void ignoreRule(final String ruleId, final Locale locale) throws IllegalArgumentException { // TODO: config should be locale-dependent disabledRulesUI.add(ruleId); config.setDisabledRuleIds(disabledRulesUI); try { config.saveConfiguration(langTool.getLanguage()); } catch (final Throwable t) { showError(t); } recheck = true; } /** * Called on rechecking the document - resets the ignore status for rules that * was set in the spelling dialog box or in the context menu. * * The rules disabled in the config dialog box are left as intact. */ @Override public void resetIgnoreRules() { config.setDisabledRuleIds(disabledRules); try { config.saveConfiguration(langTool.getLanguage()); } catch (final Throwable t) { showError(t); } recheck = true; } @Override public String getServiceDisplayName(Locale locale) { return "LanguageTool"; } } /** * A simple comparator for sorting errors by their position. */ class ErrorPositionComparator implements Comparator<SingleProofreadingError> { @Override public int compare(final SingleProofreadingError match1, final SingleProofreadingError match2) { if (match1.aSuggestions.length == 0 && match2.aSuggestions.length > 0) { return 1; } if (match2.aSuggestions.length == 0 && match1.aSuggestions.length > 0) { return -1; } final int error1pos = match1.nErrorStart; final int error2pos = match2.nErrorStart; if (error1pos > error2pos) { return 1; } else if (error1pos < error2pos) { return -1; } else { if (match1.aSuggestions.length != 0 && match2.aSuggestions.length != 0 && match1.aSuggestions.length != match2.aSuggestions.length) { return ((Integer) (match1.aSuggestions.length)).compareTo(match2.aSuggestions.length); } } return match1.aRuleIdentifier.compareTo(match2.aRuleIdentifier); } } class DialogThread extends Thread { private final String text; DialogThread(final String text) { this.text = text; } @Override public void run() { JOptionPane.showMessageDialog(null, text); } }