# HG changeset patch # User David Barts # Date 1580428911 28800 # Node ID 0c6c18a733b7330397d71bef2c26bfe4f6f0c5c4 # Parent 0e88c6bed11eed8907748b7f839c8db8a67d5318 Compiles, new menu still a mess. diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/Coerce.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/clipman/Coerce.kt Thu Jan 30 16:01:51 2020 -0800 @@ -0,0 +1,137 @@ +/* + * Font coercion. Would be easier if we didn't have to rummage through + * a doc tree and could use SAX-style callbacks instead. Would run faster + * if we didn't have a doc tree. Would use less memory if we didn't have + * a doc tree. But all HTML parsers these days force a doc tree on you. + * Sigh. + */ +package name.blackcap.clipman + +import org.jsoup.Jsoup +import org.jsoup.nodes.* +import java.util.Formatter +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Coerce fonts in HTML document. + * @param uncoerced String HTML document in + * @param pFamily String proportionally-spaced font family + * @param pSize Float proportionally-spaced font size + * @param mFamily String monospaced font family + * @param mSize Float monospaced font size + * @return HTML document with coerced fonts + */ +fun coerceHTML(uncoerced: String, pFamily: String, pSize: Float, mFamily: String, mSize: Float): String { + val doc = Jsoup.parse(uncoerced) + + /* apply standard scrubbing */ + val head = _scrub(doc) + + /* remove all stylesheets (and their content) and references to same */ + doc.select("link[rel=stylesheet],style").remove() + + /* add a style sheet of our own */ + head?.appendElement("style")?.appendChild(DataNode(Formatter().run { + format("%nbody { font-family: \"%s\"; font-size: %.2f; }%n", + pFamily, pSize) + format("code, kbd, pre, samp, tt { font-family: \"%s\"; font-size: %.2f; }%n", + mFamily, mSize) + toString() + })) + + /* remove all styling tags, but keep their content */ + doc.select("basefont,big,div,font,small,span").forEach { discardTag -> + discardTag.getChildrenAsArray().forEach { + discardTag.before(it) + } + discardTag.remove() + } + + /* remove all styling attributes */ + val hitList = arrayOf("bgcolor", "class", "color", "style") + val selector = hitList.joinToString(prefix = "[", separator = "],[", postfix = "]") + doc.select(selector).forEach { + it.removeAll(*hitList) + } + + /* remove body styling attributes */ + doc.selectFirst(":root>body")?.removeAll("background", "text", "link", "alink", "vlink") + + /* clean up horizontal rules */ + doc.select("hr").forEach { + it.removeAll("shade", "noshade") + } + + /* that's all! */ + return _output(doc) +} + +/** + * "Scrub" an HTML document (make it Java and clipboard-friendly) + * @param unscrubbed String HTML document in + * @return scrubbed HTML document + */ +fun scrub(unscrubbed: String): String { + val doc = Jsoup.parse(unscrubbed) + _scrub(doc) + return _output(doc) +} + +private fun _scrub(doc: Document): Element? { + /* remove any doctype or XML declarations */ + doc.getChildrenAsArray().forEach { + if (it is DocumentType || it is XmlDeclaration) { + it.remove() + } + } + + /* remove all non-HTML-4.01 attributes from */ + doc.attributes().map { it.key } .forEach { + if (it !in setOf("lang", "dir")) { + doc.removeAttr(it) + } + } + + /* remove any conflicting charset declarations */ + doc.select("meta[http-equiv=content-type],meta[charset]").remove() + + /* add a charset declaration of our own */ + val head = doc.selectFirst(":root>head") + if (head == null) { + LOGGER.log(Level.SEVERE, "no head found!") + return null + } + head.prependElement("meta").run { + attr("http-equiv", "Content-Type") + attr("content", "text/html; charset=" + CHARSET_NAME) + } + return head +} + +private fun _output(doc: Document): String { + doc.outputSettings() + .charset(CHARSET_NAME) + .syntax(Document.OutputSettings.Syntax.xml); + return doc.outerHtml() +} + +/** + * Turns node list to array. Needed to avoid ConcurrentModificationException. + * Ah, the joys of the doc tree. + * @return Array + */ +fun Node.getChildrenAsArray(): Array = childNodes().run { + Array(size) { get(it) } +} + +/** + * Remove all specified attributes from the element. + */ +fun Element.removeAll(vararg hitList: String) { + hitList.forEach { + if (hasAttr(it)) { + removeAttr(it) + } + } +} diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/CoerceDialog.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/clipman/CoerceDialog.kt Thu Jan 30 16:01:51 2020 -0800 @@ -0,0 +1,221 @@ +/* + * The dialog that controls font corecion. + */ +package name.blackcap.clipman + +import java.awt.Color +import java.awt.Font +import java.awt.GraphicsEnvironment +import java.awt.Toolkit +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import java.util.logging.Level +import java.util.logging.Logger +import javax.swing.* +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener + +class CoerceDialog: JDialog(frame.v), ActionListener { + private val FONTS = + GraphicsEnvironment.getLocalGraphicsEnvironment().availableFontFamilyNames.copyOf().apply { + sort() + } + private val SIZES = + arrayOf(9.0f, 10.0f, 11.0f, 12.0f, 13.0f, 14.0f, 16.0f, 18.0f, + 24.0f, 36.0f, 48.0f, 64.0f, 72.0f, 96.0f, 144.0f, 288.0f) + private val DSIZEI = 6 /* SIZES[6] = 16 */ + + /* the proportional font family */ + private val _pFamily = JComboBox(FONTS).apply { + selectedIndex = getFontIndex(Font.SERIF) + } + val pFamily: String + get() { + return _pFamily.selectedItem as String + } + + /* the proportional font size */ + private val _pSize = JComboBox(SIZES).also { + it.selectedIndex = DSIZEI + it.setEditable(true) + it.actionCommand = "Size" + it.addActionListener(this) + } + val pSize: Float + get() { + return _pSize.selectedItem as Float + } + + /* the monospaced font family */ + private val _mFamily = JComboBox(FONTS).apply { + selectedIndex = getFontIndex(Font.MONOSPACED) + } + val mFamily: String + get() { + return _mFamily.selectedItem as String + } + + /* the monospaced font size */ + private val _mSize = JComboBox(SIZES).also { + it.selectedIndex = DSIZEI + it.setEditable(true) + it.actionCommand = "Size" + it.addActionListener(this) + } + val mSize: Float + get() { + return _mSize.selectedItem as Float + } + + /* standard spacing between elements (10 pixels ≅ 1/7") and half that */ + private val BW = 5 + private val BW2 = 10 + private val VSPACE = Box.createVerticalStrut(BW) + private val VSPACE2 = Box.createVerticalStrut(BW2) + private val HSPACE2 = Box.createHorizontalStrut(BW2) + + /* buttons */ + private val _coerce = JButton("Coerce").also { + it.actionCommand = "Coerce" + it.addActionListener(this) + } + + private val _cancel = JButton("Cancel").also { + it.actionCommand = "Cancel" + it.addActionListener(this) + } + + /* initializer */ + init { + title = "Coerce Fonts" + contentPane.apply { + add(Box(BoxLayout.Y_AXIS).apply { + add(VSPACE2) + add(leftLabel("Coerce proportionally-spaced text to:")) + add(VSPACE) + add(Box(BoxLayout.X_AXIS).apply { + add(HSPACE2) + add(Box.createGlue()) + add(Box(BoxLayout.Y_AXIS).apply { + add(leftLabel("Family:")) + add(_pFamily) + }) + add(Box.createGlue()) + add(Box(BoxLayout.Y_AXIS).apply { + add(leftLabel("Size:")) + add(_pSize) + }) + add(Box.createGlue()) + add(HSPACE2) + }) + add(VSPACE2) + add(JSeparator()) + add(VSPACE2) + add(leftLabel("Coerce monospaced text to:")) + add(VSPACE) + add(Box(BoxLayout.X_AXIS).apply { + add(HSPACE2) + add(Box.createGlue()) + add(Box(BoxLayout.Y_AXIS).apply { + add(leftLabel("Family:")) + add(_mFamily) + }) + add(Box.createGlue()) + add(Box(BoxLayout.Y_AXIS).apply { + add(leftLabel("Size:")) + add(_mSize) + }) + add(Box.createGlue()) + add(HSPACE2) + }) + add(VSPACE2) + add(JSeparator()) + add(VSPACE2) + add(Box(BoxLayout.X_AXIS).apply { + add(Box.createGlue()) + add(_cancel) + add(Box.createGlue()) + add(_coerce) + add(Box.createGlue()) + }) + add(VSPACE2) + }) + } + rootPane.setDefaultButton(_coerce) + pack() + } + + private fun leftLabel(text: String) = JLabel(text).apply { + horizontalAlignment = JLabel.LEFT + alignmentX = JLabel.LEFT_ALIGNMENT + } + + override fun actionPerformed(e: ActionEvent) { + when (e.actionCommand) { + "Size" -> { + val source = e.source as? JComboBox + if (source != null && (source.selectedItem as Float) < 1.0f) { + Toolkit.getDefaultToolkit().beep() + source.selectedIndex = DSIZEI + } + } + "Coerce" -> { + setVisible(false) + coerce() + } + "Cancel" -> setVisible(false) + } + } + + private fun coerce() { + val selected = queue.v.getSelected() + if (selected == null) { + JOptionPane.showMessageDialog(frame.v, + "No item selected.", + "Error", + JOptionPane.ERROR_MESSAGE) + } else { + val (plain, html) = when (selected.contents) { + is PasteboardItem.Plain -> + Pair(selected.contents.plain, null) + is PasteboardItem.HTML -> + Pair(selected.contents.plain, selected.contents.html) + is PasteboardItem.RTF -> + Pair(selected.contents.plain, selected.contents.html) + } + if (html == null) { + JOptionPane.showMessageDialog(frame.v, + "Only styled texts may be coerced.", + "Error", + JOptionPane.ERROR_MESSAGE) + } else { + PasteboardItem.write( + PasteboardItem.HTML( + plain, + coerceHTML(html, normalizeFont(pFamily), pSize, + normalizeFont(mFamily), mSize))) + } + } + } + + private fun getFontIndex(font: String): Int { + val found = FONTS.indexOf(font) + if (found < 0) { + LOGGER.log(Level.WARNING, "font '${font}' not found") + return 0 + } + return found + } + + private fun normalizeFont(font: String): String { + val lcFont = font.toLowerCase() + return when (lcFont) { + in setOf("monospace", "serif", "sans-serif") -> lcFont + "monospaced" -> "monospace" + "sansserif" -> "sans-serif" + else -> font + } + } +} + +val coerceDialog = CoerceDialog() diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/Main.kt --- a/src/name/blackcap/clipman/Main.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/Main.kt Thu Jan 30 16:01:51 2020 -0800 @@ -72,7 +72,7 @@ is PasteboardItem.RTF -> Pair(contents.plain, contents.html) } val piv = if (html == null) { - PasteboardItemView("Plain text", ClipText().apply { + PasteboardItemView("Plain text", ClipText(contents).apply { contentType = "text/plain" text = plain font = Font(Font.MONOSPACED, Font.PLAIN, MONO_SIZE) @@ -84,7 +84,7 @@ style.addStyleSheet(defaultStyleSheet) styleSheet = style } - PasteboardItemView("Styled text", ClipText().apply { + PasteboardItemView("Styled text", ClipText(contents).apply { editorKit = hek text = dhtml resize() @@ -134,7 +134,12 @@ queue.v.deselectAll() source.selected = true source.validate() - SelectionRequired.enable() + anyRequired.enable() + source.basedOn.let { + if (it is PasteboardItem.HTML || it is PasteboardItem.RTF) { + styledRequired.enable() + } + } } override fun mousePressed(e: MouseEvent) { diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/Menus.kt --- a/src/name/blackcap/clipman/Menus.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/Menus.kt Thu Jan 30 16:01:51 2020 -0800 @@ -18,7 +18,7 @@ when (e.actionCommand) { "File.Quit" -> System.exit(0) "Edit.Clone" -> onlyIfSelected { PasteboardItem.write(it.contents) } - "Edit.Coerce" -> onlyIfSelected { doCoerceFonts(it) } + "Edit.Coerce" -> onlyIfSelected { coerceDialog.setVisible(true) } "Edit.Find" -> searchDialog.setVisible(true) "Edit.FindAgain" -> searchDialog.find() else -> throw RuntimeException("unexpected actionCommand!") @@ -31,20 +31,57 @@ JOptionPane.showMessageDialog(frame.v, "No item selected.", "Error", - JOptionPane.ERROR_MESSAGE); - } else { + JOptionPane.ERROR_MESSAGE) + } else { block(selected) } } - private fun doCoerceFonts(item: QueueItem) { - /* not finished */ + private fun clone(contents: PasteboardItem) { + val (plain, html) = when(contents) { + is PasteboardItem.Plain -> Pair(contents.plain, null) + is PasteboardItem.HTML -> Pair(contents.plain, contents.html) + is PasteboardItem.RTF -> Pair(contents.plain, contents.html) + } + if (html == null) { + PasteboardItem.write(PasteboardItem.Plain(plain)) + } else { + PasteboardItem.write(PasteboardItem.HTML(plain, scrub(html))) + } } } val menuItemListener = MenuItemListener() /** + * Track menu items that require something to be selected in order + * to work, and allow them to be enabled and disabled en masse. + */ +class SelectionRequired { + private val cache = LinkedList() + + fun add(item: JMenuItem): JMenuItem { + cache.add(item) + return item + } + + fun enable() { + cache.forEach { + it.setEnabled(true) + } + } + + fun disable() { + cache.forEach { + it.setEnabled(false) + } + } +} + +val anyRequired = SelectionRequired() +val styledRequired = SelectionRequired() + +/** * Our menu bar. What we display depends somewhat on the system type, as * the Mac gives us a gratuitous menu bar entry for handling some stuff. */ @@ -60,13 +97,13 @@ }) } add(JMenu("Edit").apply { - add(SelectionRequired.add(JMenuItem("Clone").apply { + add(anyRequired.add(JMenuItem("Clone").apply { setEnabled(false) actionCommand = "Edit.Clone" addActionListener(menuItemListener) makeShortcut(KeyEvent.VK_C) })) - add(SelectionRequired.add(JMenuItem("Coerce…").apply { + add(styledRequired.add(JMenuItem("Coerce…").apply { setEnabled(false) actionCommand = "Edit.Coerce" addActionListener(menuItemListener) @@ -112,11 +149,11 @@ */ class MyPopupMenu: JPopupMenu() { init { - add(SelectionRequired.add(JMenuItem("Clone").apply { + add(anyRequired.add(JMenuItem("Clone").apply { actionCommand = "Edit.Clone" addActionListener(menuItemListener) })) - add(SelectionRequired.add(JMenuItem("Coerce…").apply { + add(styledRequired.add(JMenuItem("Coerce…").apply { actionCommand = "Edit.Coerce" addActionListener(menuItemListener) })) @@ -124,28 +161,3 @@ } val popupMenu = MyPopupMenu() - -/** - * Track menu items that require something to be selected in order - * to work, and allow them to be enabled and disabled en masse. - */ -object SelectionRequired { - private val cache = LinkedList() - - fun add(item: JMenuItem): JMenuItem { - cache.add(item) - return item - } - - fun enable() { - cache.forEach { - it.setEnabled(true) - } - } - - fun disable() { - cache.forEach { - it.setEnabled(false) - } - } -} \ No newline at end of file diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/Misc.kt --- a/src/name/blackcap/clipman/Misc.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/Misc.kt Thu Jan 30 16:01:51 2020 -0800 @@ -9,6 +9,12 @@ import javax.swing.text.JTextComponent /** + * Name of the character set (encoding) we preferentially use for many + * things. + */ +val CHARSET_NAME = "UTF-8" + +/** * Allows a val to have lateinit functionality. It is an error to attempt * to retrieve this object's value unless it has been set, and it is an * error to attempt to set the value of an already-set object. diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/Pasteboard.kt --- a/src/name/blackcap/clipman/Pasteboard.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/Pasteboard.kt Thu Jan 30 16:01:51 2020 -0800 @@ -18,9 +18,6 @@ import java.util.logging.Logger import kotlin.collections.HashMap -/* Constants, etc. */ -val CHARSET_NAME = "UTF-8" - /** * Represents an item of data in the clipboard and how to read and * write it. diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/PasteboardQueue.kt --- a/src/name/blackcap/clipman/PasteboardQueue.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/PasteboardQueue.kt Thu Jan 30 16:01:51 2020 -0800 @@ -110,6 +110,7 @@ pos = search(needle, text, pos) if (pos >= 0) { si.highlighter.addHighlight(pos, pos+needle.length, painter) + si.scrollRectToVisible(si.getBounds(null)) break } norigin = Offset(norigin.inQueue + incr, start) @@ -150,7 +151,8 @@ var extra = queue.removeFirst().view inSwingThread { if (extra.searchable.selected) { - SelectionRequired.disable() + anyRequired.disable() + styledRequired.disable() } parent.remove(extra.contents) } diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/PasteboardView.kt --- a/src/name/blackcap/clipman/PasteboardView.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/PasteboardView.kt Thu Jan 30 16:01:51 2020 -0800 @@ -18,7 +18,7 @@ /** * What we use to display the text that is or was in the clipboard. */ -class ClipText: JTextPane() { +class ClipText(val basedOn: PasteboardItem): JTextPane() { private val normalBorder = BorderFactory.createCompoundBorder( BorderFactory.createLineBorder(Color.GRAY, INNER_BORDER), BorderFactory.createEmptyBorder(MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER, MARGIN_BORDER)) diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/RtfToHtml.kt --- a/src/name/blackcap/clipman/RtfToHtml.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/RtfToHtml.kt Thu Jan 30 16:01:51 2020 -0800 @@ -13,7 +13,7 @@ import java.io.OutputStream import java.io.UnsupportedEncodingException -private val RTF_CHARSET_NAME = "UTF-8" +private val RTF_CHARSET_NAME = CHARSET_NAME private val LANG = "en_US." + RTF_CHARSET_NAME private val UNRTF = System.getenv("UNRTF") diff -r 0e88c6bed11e -r 0c6c18a733b7 src/name/blackcap/clipman/SearchDialog.kt --- a/src/name/blackcap/clipman/SearchDialog.kt Wed Jan 29 21:56:12 2020 -0800 +++ b/src/name/blackcap/clipman/SearchDialog.kt Thu Jan 30 16:01:51 2020 -0800 @@ -8,7 +8,6 @@ import java.awt.event.ActionEvent import java.awt.event.ActionListener import javax.swing.* -import javax.swing.border.* import javax.swing.event.DocumentEvent import javax.swing.event.DocumentListener @@ -58,9 +57,9 @@ throw RuntimeException("impossible button state!") } - /* standard spacing between elements (18 pixels = 1/4") and half that */ - private val BW = 9 - private val BW2 = 2 * BW + /* standard spacing between elements (10 pixels ≅ 1/7") and half that */ + private val BW = 5 + private val BW2 = 10 /* where to begin searching from. unlike the other properties, this one is read/write. null means to start from the beginning on