diff --git a/client/src/main/java/com/mirth/connect/client/ui/components/MirthPlainDocument.java b/client/src/main/java/com/mirth/connect/client/ui/components/MirthPlainDocument.java new file mode 100644 index 0000000000..f2f12a4eda --- /dev/null +++ b/client/src/main/java/com/mirth/connect/client/ui/components/MirthPlainDocument.java @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Tony Germano + +package com.mirth.connect.client.ui.components; + +import java.util.Arrays; + +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.GapContent; +import javax.swing.text.PlainDocument; +import javax.swing.text.Segment; + +/** + * A {@link PlainDocument} that also treats a lone carriage return as a line + * break, so CR-delimited content such as HL7 is shown one segment per line. + * A CR immediately followed by LF is still a single line break. The text + * itself is stored unchanged. The default tab size is 4. + */ +public class MirthPlainDocument extends PlainDocument { + + /** + * Constructs a plain text document backed by a gap buffer that recognizes + * carriage returns as line breaks. + */ + public MirthPlainDocument() { + super(new LineBreakContent()); + putProperty(tabSizeAttribute, 4); + } + + @Override + protected void insertUpdate(DefaultDocumentEvent chng, AttributeSet attr) { + /* + * PlainDocument builds the line map from a single read of the inserted + * range, splitting after each LF. Presenting lone CRs as LFs for that + * read gives them the same treatment while producing one element + * change for the whole insert. + */ + LineBreakContent content = (LineBreakContent) getContent(); + content.translatingThread = Thread.currentThread(); + try { + super.insertUpdate(chng, attr); + } finally { + content.translatingThread = null; + } + } + + /** + * Gap buffer content that can report lone CRs as LFs for a single read. + */ + private static class LineBreakContent extends GapContent { + + private static final long serialVersionUID = 1L; + + /** + * When set, the next read on this thread reports lone CRs as LFs. + * Every other read (getText, painting, tokenizing, search, saving) + * must see the real text, so the translation is limited to the read + * PlainDocument makes to find line breaks. It is tied to the inserting + * thread because AbstractDocument.getText does not take the document + * lock, so another thread may read while an insert is in progress. It + * is cleared on first use because AbstractDocument.insertUpdate reads + * the inserted text again afterwards for its bidi and multibyte + * checks, and those should see the real characters too. + */ + private transient Thread translatingThread; + + @Override + public void getChars(int where, int len, Segment chars) throws BadLocationException { + super.getChars(where, len, chars); + if (translatingThread != Thread.currentThread()) { + return; + } + translatingThread = null; + + /* + * The segment may point into the gap buffer, so translate a copy. + * Pointing the segment at the copy is allowed here: GapContent + * does the same when the range spans the gap. + */ + char[] copy = null; + int end = chars.offset + chars.count; + for (int i = chars.offset; i < end; i++) { + if (chars.array[i] == '\r' && (i + 1 == end || chars.array[i + 1] != '\n')) { + if (copy == null) { + copy = Arrays.copyOfRange(chars.array, chars.offset, end); + } + copy[i - chars.offset] = '\n'; + } + } + if (copy != null) { + chars.array = copy; + chars.offset = 0; + } + } + } +} diff --git a/client/src/main/java/org/syntax/jedit/MirthPlainDocument.java b/client/src/main/java/org/syntax/jedit/MirthPlainDocument.java deleted file mode 100644 index 255622a737..0000000000 --- a/client/src/main/java/org/syntax/jedit/MirthPlainDocument.java +++ /dev/null @@ -1,342 +0,0 @@ -/* - * @(#)PlainDocument.java 1.43 03/12/19 - * - * Copyright 2004 Sun Microsystems, Inc. All rights reserved. - * SUN PROPRIETARY/CONFIDENTIAL. Use is subject to license terms. - */ -package org.syntax.jedit; - -import java.util.Vector; - -import javax.swing.text.AbstractDocument; -import javax.swing.text.AttributeSet; -import javax.swing.text.BadLocationException; -import javax.swing.text.Document; -import javax.swing.text.Element; -import javax.swing.text.GapContent; -import javax.swing.text.Segment; -import javax.swing.text.StyleConstants; - -/** - * A plain document that maintains no character attributes. The default element - * structure for this document is a map of the lines in the text. The Element - * returned by getDefaultRootElement is a map of the lines, and each child - * element represents a line. This model does not maintain any character level - * attributes, but each line can be tagged with an arbitrary set of attributes. - * Line to offset, and offset to line translations can be quickly performed - * using the default root element. The structure information of the - * DocumentEvent's fired by edits will indicate the line structure changes. - *

- * The default content storage management is performed by a gapped buffer - * implementation (GapContent). It supports editing reasonably large documents - * with good efficiency when the edits are contiguous or clustered, as is - * typical. - *

- * Warning: Serialized objects of this class will not be - * compatible with future Swing releases. The current serialization support is - * appropriate for short term storage or RMI between applications running the - * same version of Swing. As of 1.4, support for long term storage of all - * JavaBeansTM has been added to the - * java.beans package. Please see {@link java.beans.XMLEncoder}. - * - * @author Timothy Prinzing - * @version 1.43 12/19/03 - * @see Document - * @see AbstractDocument - */ -public class MirthPlainDocument extends AbstractDocument { - - /** - * Name of the attribute that specifies the tab size for tabs contained in - * the content. The type for the value is Integer. - */ - public static final String tabSizeAttribute = "tabSize"; - - /** - * Name of the attribute that specifies the maximum length of a line, if - * there is a maximum length. The type for the value is Integer. - */ - public static final String lineLimitAttribute = "lineLimit"; - - /** - * Constructs a plain text document. A default model using - * GapContent is constructed and set. - */ - public MirthPlainDocument() { - this(new GapContent()); - } - - /** - * Constructs a plain text document. A default root element is created, and - * the tab size set to 4. - * - * @param c - * the container for the content - */ - public MirthPlainDocument(Content c) { - super(c); - putProperty(tabSizeAttribute, 4); - defaultRoot = createDefaultRoot(); - } - - /** - * Inserts some content into the document. Inserting content causes a write - * lock to be held while the actual changes are taking place, followed by - * notification to the observers on the thread that grabbed the write lock. - *

- * This method is thread safe, although most Swing methods are not. Please - * see Threads - * and Swing for more information. - * - * @param offs - * the starting offset >= 0 - * @param str - * the string to insert; does nothing with null/empty strings - * @param a - * the attributes for the inserted content - * @exception BadLocationException - * the given insert position is not a valid position within - * the document - * @see Document#insertString - */ - public void insertString(int offs, String str, AttributeSet a) throws BadLocationException { - // fields don't want to have multiple lines. We may provide a - // field-specific - // model in the future in which case the filtering logic here will no - // longer - // be needed. - Object filterNewlines = getProperty("filterNewlines"); - if ((filterNewlines instanceof Boolean) && filterNewlines.equals(Boolean.TRUE)) { - if ((str != null) && (str.indexOf('\n') >= 0)) { - StringBuffer filtered = new StringBuffer(str); - int n = filtered.length(); - for (int i = 0; i < n; i++) { - if (filtered.charAt(i) == '\n') { - filtered.setCharAt(i, ' '); - } - } - str = filtered.toString(); - } - } - super.insertString(offs, str, a); - } - - /** - * Gets the default root element for the document model. - * - * @return the root - * @see Document#getDefaultRootElement - */ - public Element getDefaultRootElement() { - return defaultRoot; - } - - /** - * Creates the root element to be used to represent the default document - * structure. - * - * @return the element base - */ - protected AbstractElement createDefaultRoot() { - BranchElement map = (BranchElement) createBranchElement(null, null); - Element line = createLeafElement(map, null, 0, 1); - Element[] lines = new Element[1]; - lines[0] = line; - map.replace(0, 0, lines); - return map; - } - - /** - * Get the paragraph element containing the given position. Since this - * document only models lines, it returns the line instead. - */ - public Element getParagraphElement(int pos) { - Element lineMap = getDefaultRootElement(); - return lineMap.getElement(lineMap.getElementIndex(pos)); - } - - /** - * Updates document structure as a result of text insertion. This will - * happen within a write lock. Since this document simply maps out lines, we - * refresh the line map. - * - * @param chng - * the change event describing the dit - * @param attr - * the set of attributes for the inserted text - */ - protected void insertUpdate(DefaultDocumentEvent chng, AttributeSet attr) { - removed.removeAllElements(); - added.removeAllElements(); - BranchElement lineMap = (BranchElement) getDefaultRootElement(); - int offset = chng.getOffset(); - int length = chng.getLength(); - if (offset > 0) { - offset -= 1; - length += 1; - } - int index = lineMap.getElementIndex(offset); - Element rmCandidate = lineMap.getElement(index); - int rmOffs0 = rmCandidate.getStartOffset(); - int rmOffs1 = rmCandidate.getEndOffset(); - int lastOffset = rmOffs0; - try { - if (s == null) { - s = new Segment(); - } - getContent().getChars(offset, length, s); - boolean hasBreaks = false; - for (int i = 0; i < length; i++) { - char c = s.array[s.offset + i]; - //handle both newline and carriage return - if (c == '\r') { - char nextChar; - if (s.array.length > s.offset + i + 1 && i + 1 < length && (nextChar = s.array[s.offset + i + 1]) == '\n'){ - //check for \r\n - int breakOffset = offset + i + 2; - added.addElement(createLeafElement(lineMap, null, lastOffset, breakOffset)); - lastOffset = breakOffset; - hasBreaks = true; - i++; - }else{ - int breakOffset = offset + i + 1; - added.addElement(createLeafElement(lineMap, null, lastOffset, breakOffset)); - lastOffset = breakOffset; - hasBreaks = true; - } - - }else if (c == '\n'){ - int breakOffset = offset + i + 1; - added.addElement(createLeafElement(lineMap, null, lastOffset, breakOffset)); - lastOffset = breakOffset; - hasBreaks = true; - } - } - if (hasBreaks) { - int rmCount = 1; - removed.addElement(rmCandidate); - if ((offset + length == rmOffs1) && (lastOffset != rmOffs1) && ((index + 1) < lineMap.getElementCount())) { - rmCount += 1; - Element e = lineMap.getElement(index + 1); - removed.addElement(e); - rmOffs1 = e.getEndOffset(); - } - if (lastOffset < rmOffs1) { - added.addElement(createLeafElement(lineMap, null, lastOffset, rmOffs1)); - } - - Element[] aelems = new Element[added.size()]; - added.copyInto(aelems); - Element[] relems = new Element[removed.size()]; - removed.copyInto(relems); - ElementEdit ee = new ElementEdit(lineMap, index, relems, aelems); - chng.addEdit(ee); - lineMap.replace(index, relems.length, aelems); - } - if (isComposedTextAttributeDefined(attr)) { - insertComposedTextUpdate(chng, attr); - } - } catch (BadLocationException e) { - throw new Error("Internal error: " + e.toString()); - } - super.insertUpdate(chng, attr); - } - - static boolean isComposedTextAttributeDefined(AttributeSet as) { - return ((as != null) && (as.isDefined(StyleConstants.ComposedTextAttribute))); - } - - /** - * Updates any document structure as a result of text removal. This will - * happen within a write lock. Since the structure represents a line map, - * this just checks to see if the removal spans lines. If it does, the two - * lines outside of the removal area are joined together. - * - * @param chng - * the change event describing the edit - */ - protected void removeUpdate(DefaultDocumentEvent chng) { - removed.removeAllElements(); - BranchElement map = (BranchElement) getDefaultRootElement(); - int offset = chng.getOffset(); - int length = chng.getLength(); - int line0 = map.getElementIndex(offset); - int line1 = map.getElementIndex(offset + length); - if (line0 != line1) { - // a line was removed - for (int i = line0; i <= line1; i++) { - removed.addElement(map.getElement(i)); - } - int p0 = map.getElement(line0).getStartOffset(); - int p1 = map.getElement(line1).getEndOffset(); - Element[] aelems = new Element[1]; - aelems[0] = createLeafElement(map, null, p0, p1); - Element[] relems = new Element[removed.size()]; - removed.copyInto(relems); - ElementEdit ee = new ElementEdit(map, line0, relems, aelems); - chng.addEdit(ee); - map.replace(line0, relems.length, aelems); - } else { - // Check for the composed text element - Element line = map.getElement(line0); - if (!line.isLeaf()) { - Element leaf = line.getElement(line.getElementIndex(offset)); - if (isComposedTextElement(leaf)) { - Element[] aelem = new Element[1]; - aelem[0] = createLeafElement(map, null, line.getStartOffset(), line.getEndOffset()); - Element[] relem = new Element[1]; - relem[0] = line; - ElementEdit ee = new ElementEdit(map, line0, relem, aelem); - chng.addEdit(ee); - map.replace(line0, 1, aelem); - } - } - } - - super.removeUpdate(chng); - } - - private boolean isComposedTextElement(Element elem) { - AttributeSet as = elem.getAttributes(); - return isComposedTextAttributeDefined(as); - } - - // - // Inserts the composed text of an input method. The line element - // where the composed text is inserted into becomes an branch element - // which contains leaf elements of the composed text and the text - // backing store. - // - private void insertComposedTextUpdate(DefaultDocumentEvent chng, AttributeSet attr) { - added.removeAllElements(); - BranchElement lineMap = (BranchElement) getDefaultRootElement(); - int offset = chng.getOffset(); - int length = chng.getLength(); - int index = lineMap.getElementIndex(offset); - Element elem = lineMap.getElement(index); - int elemStart = elem.getStartOffset(); - int elemEnd = elem.getEndOffset(); - BranchElement[] abelem = new BranchElement[1]; - abelem[0] = (BranchElement) createBranchElement(lineMap, null); - Element[] relem = new Element[1]; - relem[0] = elem; - if (elemStart != offset) - added.addElement(createLeafElement(abelem[0], null, elemStart, offset)); - added.addElement(createLeafElement(abelem[0], attr, offset, offset + length)); - if (elemEnd != offset + length) - added.addElement(createLeafElement(abelem[0], null, offset + length, elemEnd)); - Element[] alelem = new Element[added.size()]; - added.copyInto(alelem); - ElementEdit ee = new ElementEdit(lineMap, index, relem, abelem); - chng.addEdit(ee); - - abelem[0].replace(0, 0, alelem); - lineMap.replace(index, 1, abelem); - } - - private AbstractElement defaultRoot; - private Vector added = new Vector(); // Vector - private Vector removed = new Vector(); // Vector - private transient Segment s; -} diff --git a/client/src/main/java/org/syntax/jedit/SyntaxDocument.java b/client/src/main/java/org/syntax/jedit/SyntaxDocument.java index 4dd3d016fd..86afbfd7e3 100644 --- a/client/src/main/java/org/syntax/jedit/SyntaxDocument.java +++ b/client/src/main/java/org/syntax/jedit/SyntaxDocument.java @@ -17,6 +17,8 @@ import org.syntax.jedit.tokenmarker.TokenMarker; +import com.mirth.connect.client.ui.components.MirthPlainDocument; + /** * A document implementation that can be tokenized by the syntax highlighting * system. diff --git a/client/src/test/java/com/mirth/connect/client/ui/components/MirthPlainDocumentTest.java b/client/src/test/java/com/mirth/connect/client/ui/components/MirthPlainDocumentTest.java new file mode 100644 index 0000000000..b7fca87392 --- /dev/null +++ b/client/src/test/java/com/mirth/connect/client/ui/components/MirthPlainDocumentTest.java @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Tony Germano + +package com.mirth.connect.client.ui.components; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.text.AttributedString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.Element; +import javax.swing.text.PlainDocument; +import javax.swing.text.SimpleAttributeSet; +import javax.swing.text.StyleConstants; +import javax.swing.undo.UndoManager; + +import org.junit.Test; + +public class MirthPlainDocumentTest { + + @Test + public void testTabSize() { + assertEquals(4, new MirthPlainDocument().getProperty(PlainDocument.tabSizeAttribute)); + } + + @Test + public void testLoneCarriageReturn() throws Exception { + assertLines(docWith("a\rb"), "a\r", "b"); + } + + @Test + public void testLineFeed() throws Exception { + assertLines(docWith("a\nb"), "a\n", "b"); + } + + @Test + public void testCrLf() throws Exception { + assertLines(docWith("a\r\nb"), "a\r\n", "b"); + } + + @Test + public void testLfCr() throws Exception { + assertLines(docWith("a\n\rb"), "a\n", "\r", "b"); + } + + @Test + public void testMixed() throws Exception { + assertLines(docWith("\r\r\n"), "\r", "\r\n", ""); + } + + @Test + public void testTextIsUnchanged() throws Exception { + MirthPlainDocument doc = docWith("a\rb\r\nc\n"); + assertEquals("a\rb\r\nc\n", doc.getText(0, doc.getLength())); + } + + @Test + public void testHl7() throws Exception { + MirthPlainDocument doc = docWith("MSH|^~\\&|A\rPID|1\rPV1|1\r"); + assertLines(doc, "MSH|^~\\&|A\r", "PID|1\r", "PV1|1\r", ""); + } + + @Test + public void testLineFeedAfterCarriageReturnJoins() throws Exception { + MirthPlainDocument doc = docWith("a\rb"); + DocumentEvent.ElementChange change = captureInsert(doc, 2, "\n"); + assertLines(doc, "a\r\n", "b"); + assertEquals(1, change.getChildrenRemoved().length); + assertEquals(1, change.getChildrenAdded().length); + } + + @Test + public void testInsertBetweenCrLfSplits() throws Exception { + MirthPlainDocument doc = docWith("a\r\nb"); + DocumentEvent.ElementChange change = captureInsert(doc, 2, "x"); + assertLines(doc, "a\r", "x\n", "b"); + assertEquals(0, change.getIndex()); + assertEquals(1, change.getChildrenRemoved().length); + assertEquals(2, change.getChildrenAdded().length); + } + + @Test + public void testInsertMultipleLinesSingleChange() throws Exception { + MirthPlainDocument doc = docWith("ab"); + DocumentEvent.ElementChange change = captureInsert(doc, 1, "1\r2\r\n3\n4"); + assertLines(doc, "a1\r", "2\r\n", "3\n", "4b"); + assertEquals(0, change.getIndex()); + assertEquals(1, change.getChildrenRemoved().length); + assertEquals(4, change.getChildrenAdded().length); + } + + @Test + public void testRemoveAcrossLines() throws Exception { + MirthPlainDocument doc = docWith("a\rb\rc"); + doc.remove(1, 2); + assertLines(doc, "a\r", "c"); + } + + @Test + public void testUndoRedo() throws Exception { + MirthPlainDocument doc = new MirthPlainDocument(); + UndoManager undo = new UndoManager(); + doc.addUndoableEditListener(undo); + doc.insertString(0, "a\rb", null); + undo.undo(); + assertLines(doc, ""); + undo.redo(); + assertLines(doc, "a\r", "b"); + } + + @Test + public void testRightToLeftLines() throws Exception { + // Hebrew, Latin, Arabic + String text = "שלום\rhello\r\nمرحبا"; + MirthPlainDocument doc = docWith(text); + assertLines(doc, "שלום\r", "hello\r\n", "مرحبا"); + assertEquals(text, doc.getText(0, doc.getLength())); + assertEquals(1, bidiLevel(doc, 0)); + assertEquals(0, bidiLevel(doc, 5)); + assertEquals(1, bidiLevel(doc, 12)); + } + + @Test + public void testMixedDirectionLines() throws Exception { + MirthPlainDocument doc = docWith("abc אבג def\rאב 123\r"); + assertLines(doc, "abc אבג def\r", "אב 123\r", ""); + // Left-to-right line with an embedded Hebrew word + assertEquals(0, bidiLevel(doc, 0)); + assertEquals(1, bidiLevel(doc, 4)); + assertEquals(0, bidiLevel(doc, 9)); + // Right-to-left line with embedded digits + assertEquals(1, bidiLevel(doc, 12)); + assertEquals(2, bidiLevel(doc, 15)); + } + + @Test + public void testInsertRightToLeftIntoLine() throws Exception { + MirthPlainDocument doc = docWith("abc\rdef"); + doc.insertString(4, "אב", null); + assertLines(doc, "abc\r", "אבdef"); + assertEquals(0, bidiLevel(doc, 0)); + assertEquals(1, bidiLevel(doc, 4)); + assertEquals(2, bidiLevel(doc, 6)); + } + + @Test + public void testBidiControlCharacters() throws Exception { + // RLE ... PDF, RLI ... PDI, RLM, LRM + String text = "‫abc‬\r⁧xyz⁩\r\n‏א‎q"; + MirthPlainDocument doc = docWith(text); + assertLines(doc, "‫abc‬\r", "⁧xyz⁩\r\n", "‏א‎q"); + assertEquals(text, doc.getText(0, doc.getLength())); + } + + @Test + public void testCjkAndSupplementaryCharacters() throws Exception { + // Chinese, Japanese, Korean, a CJK Extension B ideograph and an emoji (surrogate pairs) + String text = "中文\r日本語\r\n한국어\n𠀀\r😀"; + MirthPlainDocument doc = docWith(text); + assertLines(doc, "中文\r", "日本語\r\n", "한국어\n", "𠀀\r", "😀"); + assertEquals(text, doc.getText(0, doc.getLength())); + } + + @Test + public void testComposedText() throws Exception { + MirthPlainDocument doc = docWith("a\rbc\rd"); + SimpleAttributeSet composed = new SimpleAttributeSet(); + composed.addAttribute(StyleConstants.ComposedTextAttribute, new AttributedString("日本")); + + // Input method text in progress becomes its own element within the line + doc.insertString(3, "日本", composed); + assertLines(doc, "a\r", "b日本c\r", "d"); + Element line = doc.getDefaultRootElement().getElement(1); + assertFalse(line.isLeaf()); + assertEquals(3, line.getElementCount()); + + // Committing removes the composed text and restores a plain line + doc.remove(3, 2); + assertLines(doc, "a\r", "bc\r", "d"); + assertTrue(doc.getDefaultRootElement().getElement(1).isLeaf()); + } + + private static int bidiLevel(MirthPlainDocument doc, int offset) { + Element bidiRoot = doc.getBidiRootElement(); + Element run = bidiRoot.getElement(bidiRoot.getElementIndex(offset)); + return StyleConstants.getBidiLevel(run.getAttributes()); + } + + private static MirthPlainDocument docWith(String text) throws BadLocationException { + MirthPlainDocument doc = new MirthPlainDocument(); + doc.insertString(0, text, null); + return doc; + } + + private static DocumentEvent.ElementChange captureInsert(MirthPlainDocument doc, int offset, String text) throws BadLocationException { + List changes = new ArrayList<>(); + DocumentListener listener = new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + changes.add(e.getChange(doc.getDefaultRootElement())); + } + + @Override + public void removeUpdate(DocumentEvent e) {} + + @Override + public void changedUpdate(DocumentEvent e) {} + }; + doc.addDocumentListener(listener); + doc.insertString(offset, text, null); + doc.removeDocumentListener(listener); + assertEquals(1, changes.size()); + assertNotNull(changes.get(0)); + return changes.get(0); + } + + private static void assertLines(MirthPlainDocument doc, String... expected) throws BadLocationException { + Element root = doc.getDefaultRootElement(); + List lines = new ArrayList<>(); + for (int i = 0; i < root.getElementCount(); i++) { + Element line = root.getElement(i); + int end = Math.min(line.getEndOffset(), doc.getLength()); + lines.add(doc.getText(line.getStartOffset(), end - line.getStartOffset())); + } + assertEquals(Arrays.asList(expected), lines); + } +}