From 9be14f6492db21a6f807936b8fc5c260ccbf0b0d Mon Sep 17 00:00:00 2001 From: Vlad Lipskiy Date: Tue, 21 Jan 2025 19:22:19 +0300 Subject: [PATCH 1/7] Add PDF content stream parsing logic Initially the idea was to just use PdfTokenizer from iText, but it had some problems, like not preserving token positions in the input data, skipping whitespace outright, have hard errors for invalid output, etc. For more example see PdfContentStreamParser docs. This will be used as a reference model for the editor. Ideally we would have the same model in the editor to limit memory consumption, but having a separate one allows us some flexibility, which might be useful when implementing static analysis. --- .../model/contentstream/ParseTreeNode.java | 560 ++++++++++++++ .../contentstream/ParseTreeNodeType.java | 337 +++++++++ .../contentstream/PdfContentStreamParser.java | 713 ++++++++++++++++++ .../model/contentstream/PdfOperators.java | 176 +++++ 4 files changed, 1786 insertions(+) create mode 100644 src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java create mode 100644 src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNodeType.java create mode 100644 src/main/java/com/itextpdf/rups/model/contentstream/PdfContentStreamParser.java create mode 100644 src/main/java/com/itextpdf/rups/model/contentstream/PdfOperators.java diff --git a/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java new file mode 100644 index 00000000..629a569c --- /dev/null +++ b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java @@ -0,0 +1,560 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.model.contentstream; + +import java.nio.CharBuffer; +import java.util.Arrays; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Objects; + +/** + * Node of a parse tree of a PDF content stream. + * + *

+ * Each node is an element of a circular double-linked list, which allows you + * to easily traverse between siblings. To distinguish, where the list ends, + * a special marker node is inserted of a + * {@link ParseTreeNodeType#CHILD_SENTINEL} type. These marker nodes are + * internal and are not accessible to the class users. List manipulation + * is handled by the class itself. + *

+ * + *

+ * Node can have multiple children, which can be traversed via the siblings + * interface. Only the root node and composite type nodes are expected to have + * children, while primitive type node should have just text instead. Each + * node, with the exception of the root node, will have a parent set for + * traversal. + *

+ * + *

+ * Parse tree starts with a root node, which has no siblings, no parent, and + * is of a {@link ParseTreeNodeType#ROOT} type. Its first order children + * should be a tokenized representation of a PDF stream. For the most part the + * tree shouldn't be very tall at this moment, as there are very few composite + * types (string literals, arrays and dictionaries), and those are rarely + * encountered in a content stream in a deeply nested way. + *

+ */ +public final class ParseTreeNode { + /** + * Parent of the node. Should be null for root. + */ + private final ParseTreeNode parent; + /** + * Type of the node. + */ + private final ParseTreeNodeType type; + /** + * Text array, backing the tree node. Expected to be non-null for primitive + * types. Part of the inlined text segment data. + */ + private final char[] textArray; + /** + * Starting offset into the text array. Expected to be a valid value for + * primitive types. Part of the inlined text segment data. + */ + private final int textOffset; + /** + * Text segment length. Expected to be a valid value for primitive types. + * Part of the inlined text segment data. + */ + private final int textCount; + /** + * Circular double-linked list of children. Maintained manually by the + * class. Should point to a sentinel node, with getNext being the first + * element of the list and getPrev being the last element of the list. + */ + private ParseTreeNode children = null; + /** + * Pointer to the previous sibling node in a circular double-linked list. + * For a root node it will be set to {@code this}. + */ + private ParseTreeNode prev = this; + /** + * Pointer to the next sibling node in a circular double-linked list. + * For a root node it will be set to {@code this}. + */ + private ParseTreeNode next = this; + + /** + * Creates a root parse tree node. + */ + public ParseTreeNode() { + this.parent = null; + this.type = ParseTreeNodeType.ROOT; + this.textArray = null; + this.textOffset = 0; + this.textCount = 0; + } + + /** + * Creates a child parse tree node of a composite type. + * + * @param type Type of the node. Should be a composite type. + * @param parent Parent of the node. Should not be null. + */ + private ParseTreeNode(ParseTreeNodeType type, ParseTreeNode parent) { + this(type, null, 0, 0, parent); + } + + /** + * Creates a child parse tree node of a specified type, which is, + * optionally, backed by text. + * + * @param type Type of the node. + * @param textArray Backing text array of the node. Should not be null + * for a primitive type. + * @param textOffset Starting offset into the text array. Should be valid + * for a primitive type. + * @param textCount Text segment length. Should be valid for a primitive + * type. + * @param parent Parent of the node. Should not be null. + */ + private ParseTreeNode(ParseTreeNodeType type, char[] textArray, int textOffset, int textCount, + ParseTreeNode parent) { + Objects.requireNonNull(type); + Objects.requireNonNull(parent); + if (textArray == null && type.isPrimitive()) { + throw new IllegalArgumentException("Primitive type should have text present"); + } + this.parent = parent; + this.type = type; + this.textArray = textArray; + this.textOffset = textOffset; + this.textCount = textCount; + } + + /** + * Returns whether the node is a root node or not. + * + * @return Whether the node is a root node or not. + */ + public boolean isRoot() { + // Only checking the parent pointer, as you should not be able to + // create a non-root node without a parent + return parent == null; + } + + /** + * Returns whether the node is a leaf node. I.e. it is a leaf node, if it + * has no children. Should be false only for root and primitive nodes. + * + * @return Whether the node is a leaf node. + */ + public boolean isLeaf() { + return children == null || (children.getNext() == children); + } + + /** + * Returns whether this is an operator type node with the specified text. + * + * @param operator Operator text. + * + * @return Whether this is an operator type node with the specified text. + */ + public boolean isOperator(char[] operator) { + if (type != ParseTreeNodeType.OPERATOR) { + return false; + } + return Arrays.equals( + operator, 0, operator.length, + textArray, textOffset, textOffset + textCount + ); + } + + /** + * Returns the parent of the node. Will return null for root. + * + * @return The parent of the node. Will return null for root. + */ + public ParseTreeNode getParent() { + return parent; + } + + /** + * Returns the type of the node. + * + * @return The type of the node. + */ + public ParseTreeNodeType getType() { + return type; + } + + /** + * Returns the backing text of a node as a char sequence. Only valid for + * primitive type nodes. + * + * @return The backing text of a node as a char sequence. + */ + public CharSequence getText() { + return CharBuffer.wrap(textArray, textOffset, textCount); + } + + /** + * Returns the backing text array. Only valid for primitive type nodes. + * + * @return The backing text array. + */ + public char[] getTextArray() { + return textArray; + } + + /** + * Returns the starting offset into the text array. Only valid for + * primitive type nodes. + * + * @return The starting offset into the text array. + */ + public int getTextOffset() { + return textOffset; + } + + /** + * Returns the text segment length. Only valid for primitive type nodes. + * + * @return The text segment length. + */ + public int getTextCount() { + return textCount; + } + + /** + * Returns the first child of a node, or null, if it is a leaf. + * + * @return The first child of a node, or null, if it is a leaf. + */ + public ParseTreeNode getFirstChild() { + if (children == null) { + return null; + } + return children.getNext(); + } + + /** + * Returns the last child of a node, or null, if it is a leaf. + * + * @return The last child of a node, or null, if it is a leaf. + */ + public ParseTreeNode getLastChild() { + if (children == null) { + return null; + } + return children.getPrev(); + } + + /** + * Creates a new tree node and adds it as the last child of the node. + * + * @param type Type of the node. + * @param textArray Backing text array of the node. Should not be null + * for a primitive type. + * @param textOffset Starting offset into the text array. Should be valid + * for a primitive type. + * @param textCount Text segment length. Should be valid for a primitive + * type. + * + * @return The newly created child node. + */ + public ParseTreeNode addChild(ParseTreeNodeType type, char[] textArray, int textOffset, int textCount) { + return addChild(new ParseTreeNode(type, textArray, textOffset, textCount, this)); + } + + /** + * Creates a new tree node of a composite type and adds it as the last + * child of the node. + * + * @param type Type of the node. Should be a composite type. + * + * @return The newly created child node. + */ + public ParseTreeNode addChild(ParseTreeNodeType type) { + return addChild(new ParseTreeNode(type, this)); + } + + /** + * Creates a new tree node and adds it as the next sibling of the node. + * + * @param type Type of the node. + * @param textArray Backing text array of the node. Should not be null + * for a primitive type. + * @param textOffset Starting offset into the text array. Should be valid + * for a primitive type. + * @param textCount Text segment length. Should be valid for a primitive + * type. + * + * @return The newly created child node. + */ + public ParseTreeNode addNext(ParseTreeNodeType type, char[] textArray, int textOffset, int textCount) { + final ParseTreeNode node = new ParseTreeNode(type, textArray, textOffset, textCount, getParent()); + linkNext(node); + return node; + } + + /** + * Returns the total length of text in this parse tree. This is calculated + * by summing the text lengths of all underlying primitive nodes. + * + * @return The total length of text in this parse tree. + */ + public int length() { + int result = 0; + final Iterator it = primitiveNodeIterator(); + while (it.hasNext()) { + final ParseTreeNode node = it.next(); + result += node.getTextCount(); + } + return result; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + if (type.isPrimitive()) { + return type + ": " + getText(); + } + return type.toString(); + } + + /** + * Returns the backing text of this parse tree. This is constructed + * by concatenating the text of all underlying primitive nodes. + * + * @return The backing text of this parse tree. + */ + public String getFullText() { + final StringBuilder sb = new StringBuilder(); + final Iterator it = primitiveNodeIterator(); + while (it.hasNext()) { + final ParseTreeNode node = it.next(); + sb.append(node.getTextArray(), node.getTextOffset(), node.getTextCount()); + } + return sb.toString(); + } + + /** + * Returns an iterator, which goes through all the primitive node from left + * to right. I.e. it goes through the text-backed leaves. + * + * @return An iterator, which goes through all the primitive node from left + * to right. + */ + public Iterator primitiveNodeIterator() { + return new PrimitiveNodeIterator(this); + } + + /** + * Returns whether the node has the next sibling. + * + * @return Whether the node has the next sibling. + */ + public boolean hasNext() { + return next != this && !next.type.isMarker(); + } + + /** + * Returns the next sibling child of a node, or null, if it is the last + * one. + * + * @return The next sibling child of a node, or null, if it is the last + * one. + */ + public ParseTreeNode getNext() { + if (!hasNext()) { + return null; + } + return next; + } + + /** + * Returns whether the node has the previous sibling. + * + * @return Whether the node has the previous sibling. + */ + public boolean hasPrev() { + return prev != this && !prev.type.isMarker(); + } + + + /** + * Returns the previous sibling child of a node, or null, if it is the + * first one. + * + * @return The previous sibling child of a node, or null, if it is the + * first one. + */ + public ParseTreeNode getPrev() { + if (!hasPrev()) { + return null; + } + return prev; + } + + /** + * Removes the current node from the tree and returns its next sibling. + * + * @return Node's next sibling. + */ + public ParseTreeNode remove() { + final ParseTreeNode nextNode = getNext(); + unlink(); + return nextNode; + } + + /** + * Links the element to be the previous sibling. + * + * @param elem Element to link. + */ + private void linkPrev(ParseTreeNode elem) { + assert elem != null; + + prev.next = elem; + elem.prev = this.prev; + elem.next = this; + this.prev = elem; + } + + /** + * Links the element to be the next sibling. + * + * @param elem Element to link. + */ + private void linkNext(ParseTreeNode elem) { + assert elem != null; + + next.prev = elem; + elem.prev = this; + elem.next = this.next; + this.next = elem; + } + + /** + * Unlinks the node from the siblings list. + */ + private void unlink() { + prev.next = this.next; + next.prev = this.prev; + this.prev = this; + this.next = this; + } + + /** + * Adds a new child at the end of the children list. + * + * @param child Child node to add. + * + * @return The added child node. + */ + private ParseTreeNode addChild(ParseTreeNode child) { + assert child != null; + + if (child.getType().isMarker()) { + throw new IllegalArgumentException("Marker types are not allowed"); + } + if (type.isPrimitive()) { + throw new IllegalStateException("Primitive nodes cannot have children"); + } + if (children == null) { + children = new ParseTreeNode(ParseTreeNodeType.CHILD_SENTINEL, this); + } + children.linkPrev(child); + return child; + } + + /** + * Iterator implementation, which goes through all primitive nodes in the + * tree from left to right. + */ + private static final class PrimitiveNodeIterator implements Iterator { + private ParseTreeNode nextNode; + + private PrimitiveNodeIterator(ParseTreeNode start) { + nextNode = start; + if (nextNode.isRoot()) { + moveOnce(); + } + while (nextNode != null && !nextNode.type.isPrimitive()) { + moveOnce(); + } + } + + @Override + public boolean hasNext() { + return nextNode != null; + } + + @Override + public ParseTreeNode next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + final ParseTreeNode result = nextNode; + do { + moveOnce(); + } while (nextNode != null && !nextNode.isLeaf()); + return result; + } + + private void moveOnce() { + assert nextNode != null; + if (!nextNode.isLeaf()) { + nextNode = nextNode.getFirstChild(); + return; + } + if (nextNode.hasNext()) { + nextNode = nextNode.getNext(); + return; + } + do { + nextNode = nextNode.getParent(); + } while (nextNode != null && !nextNode.hasNext()); + if (nextNode != null) { + nextNode = nextNode.getNext(); + } + } + } +} diff --git a/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNodeType.java b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNodeType.java new file mode 100644 index 00000000..9e3d391c --- /dev/null +++ b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNodeType.java @@ -0,0 +1,337 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.model.contentstream; + +/** + * Contains content stream parse tree node types. + * + *

+ * Marker type is a type, which does not have anything to do with PDF, but it + * is used internally as markers with a special meaning, like the root of the + * parse tree. + *

+ * + *

+ * Primitive type means, that it is a leaf node and it is defined by its text. + * For example, {@code NUMERIC} is a primitive type, which has no children and + * contains text of a number. + *

+ * + *

+ * Composite type means, that this node does not contain text, but is just a + * container for other primitive nodes. For example, {@code STRING_LITERAL} is + * a composite type, and its children contain string open markers, string + * data and string close markers. + *

+ */ +public enum ParseTreeNodeType { + /** + * Marker type. Root of the parse tree. + */ + ROOT, + /** + * Marker type. A sentinel for a circular linked list of children. + */ + CHILD_SENTINEL, + + /** + * Primitive type. Whitespace between tokens. + */ + WHITESPACE, + + /** + * Primitive type. End-of-line comment marker with its body. Whitespace + * at the end is not included. + */ + COMMENT, + + /** + * Primitive type. Boolean {@code true} and {@code false} objects. + */ + BOOLEAN, + + /** + * Primitive type. Numeric PDF objects. + */ + NUMERIC, + + /** + * Composite type. Literal PDF strings, enclosed in parentheses. + */ + STRING_LITERAL, + /** + * Primitive type. Byte sequence within a literal PDF string, excluding + * left and right parentheses. + */ + STRING_LITERAL_DATA, + /** + * Primitive type. A left parenthesis. + * + *

+ * First child of a {@code STRING_LITERAL} node will be of this type. One + * literal node can have multiple open tokens, as they are parsed + * separately to support parentheses matching. + *

+ * + *

+ * Can also be found outside of a {@code STRING_LITERAL} node as an + * unexpected token. + *

+ */ + STRING_LITERAL_OPEN, + /** + * Primitive type. A right parenthesis. This will be the first child of a + * {@code STRING_LITERAL} node. + * + *

+ * Should be the last child of a {@code STRING_LITERAL} node, if it has + * been finished and closed properly. One literal node can have multiple + * close tokens, as they are parsed separately to support parentheses + * matching. + *

+ * + *

+ * In contrast to {@code STRING_LITERAL_OPEN}, these should only be found + * withing a {@code STRING_LITERAL} node. + *

+ */ + STRING_LITERAL_CLOSE, + + /** + * Composite type. Hexadecimal PDF strings, enclosed in <>. + */ + STRING_HEX, + /** + * Primitive type. Byte sequence within a hexadecimal PDF string, + * excluding < and >. + */ + STRING_HEX_DATA, + /** + * Primitive type. < symbol. + * + *

+ * First child of a {@code STRING_HEX} node will be of this type. Compared + * to literal strings, there can only be one in each string. But they are + * still parsed separately to support begin/end matching. + *

+ * + *

+ * These should only be found withing a {@code STRING_HEX} node. + *

+ */ + STRING_HEX_OPEN, + /** + * Primitive type. > symbol. + * + *

+ * Should be the last child of a {@code STRING_HEX} node, if it has + * been finished and closed properly. Compared to literal strings, there + * can only be one in each string. But they are still parsed separately to + * support begin/end matching. + *

+ * + *

+ * These should only be found withing a {@code STRING_HEX} node. + *

+ */ + STRING_HEX_CLOSE, + + /** + * Primitive type. Name PDF objects. + */ + NAME, + + /** + * Composite type. PDF arrays, enclosed in square brackets. + */ + ARRAY, + /** + * Primitive type. A left square bracket. + * + *

+ * First child of an {@code ARRAY} node will be of this type. Compared to + * literal strings, there can only be one in each array. But they are + * still parsed separately to support begin/end matching. + *

+ * + *

+ * These should only be found withing an {@code ARRAY} node. + *

+ */ + ARRAY_OPEN, + /** + * Primitive type. A right square bracket. + * + *

+ * Should be the last child of an {@code ARRAY} node, if it has + * been finished and closed properly. Compared to literal strings, there + * can only be one in each array. But they are still parsed separately to + * support begin/end matching. + *

+ * + *

+ * These should only be found withing an {@code ARRAY} node. + *

+ */ + ARRAY_CLOSE, + + /** + * Composite type. PDF dictionaries, enclosed in << >>. + */ + DICTIONARY, + /** + * Primitive type. A << token. + * + *

+ * First child of a {@code DICTIONARY} node will be of this type. Compared + * to literal strings, there can only be one in each dictionary. But they + * are still parsed separately to support begin/end matching. + *

+ * + *

+ * These should only be found withing a {@code DICTIONARY} node. + *

+ */ + DICTIONARY_OPEN, + /** + * Primitive type. A >> token. + * + *

+ * Should be the last child of a {@code DICTIONARY} node, if it has + * been finished and closed properly. Compared to literal strings, there + * can only be one in each dictionary. But they are still parsed + * separately to support begin/end matching. + *

+ * + *

+ * These should only be found withing an {@code DICTIONARY} node. + *

+ */ + DICTIONARY_CLOSE, + + /** + * Primitive type. {@code null} objects. + */ + NULL, + + /** + * Primitive type. PDF content stream operator. + */ + OPERATOR, + + /** + * Primitive type. A byte sequence, which should not be rendered as text. + * An example would be a body of an inline image. + */ + BINARY_DATA, + + /** + * Primitive type. A byte sequence, which is unexpected and has not been + * covered by any of the concrete types. + */ + UNKNOWN; + + /** + * Returns whether this is a marker type or not. + * + * @return Whether this is a marker type or not. + */ + public final boolean isMarker() { + switch (this) { + case ROOT: + case CHILD_SENTINEL: + return true; + default: + return false; + } + } + + /** + * Returns whether this is a primitive type or not. + * + * @return Whether this is a primitive type or not. + */ + public final boolean isPrimitive() { + switch (this) { + case WHITESPACE: + case COMMENT: + case BOOLEAN: + case NUMERIC: + case STRING_LITERAL_DATA: + case STRING_LITERAL_OPEN: + case STRING_LITERAL_CLOSE: + case STRING_HEX_DATA: + case STRING_HEX_OPEN: + case STRING_HEX_CLOSE: + case NAME: + case ARRAY_OPEN: + case ARRAY_CLOSE: + case DICTIONARY_OPEN: + case DICTIONARY_CLOSE: + case NULL: + case OPERATOR: + case BINARY_DATA: + case UNKNOWN: + return true; + default: + return false; + } + } + + /** + * Returns whether this is a composite type or not. + * + * @return Whether this is a composite type or not. + */ + public final boolean isComposite() { + switch (this) { + case STRING_LITERAL: + case STRING_HEX: + case ARRAY: + case DICTIONARY: + return true; + default: + return false; + } + } +} diff --git a/src/main/java/com/itextpdf/rups/model/contentstream/PdfContentStreamParser.java b/src/main/java/com/itextpdf/rups/model/contentstream/PdfContentStreamParser.java new file mode 100644 index 00000000..e95a0e5a --- /dev/null +++ b/src/main/java/com/itextpdf/rups/model/contentstream/PdfContentStreamParser.java @@ -0,0 +1,713 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.model.contentstream; + +import java.util.Arrays; +import javax.swing.text.Segment; + +/** + * A parser, which parses a PDF content stream string into a parse tree. + * + *

+ * This code is based on the {@link com.itextpdf.io.source.PdfTokenizer}. + * Ideally we would just use that, but it has some limitations, which make it + * unusable for our tasks. + *

+ * + *
    + *
  1. + * PdfTokenizer works on byte arrays, while we will be getting strings + * instead. Since this would be called often, these conversions and + * allocation will add up. + *
  2. + *
  3. + * Since we are tokenizing for a text editor, we need all the data + * from the text string to be present in the resulting tokens. + * Unfortunately, PdfTokenizer skips whitespace and it is not present + * in the output. + *
  4. + *
  5. + * We need to know, where the tokens are in the original string, but + * PdfTokenizer does not store that information in the result. You can + * kind of get that information via the cursor position methods, but + * because of the two issues above, it might not be as easy. + *
  6. + *
  7. + * On invalid input PdfTokenizer throws an exception and you cannot + * parse the text further. But in our case we will have intermediate + * invalid text, so we should not throw in such cases, but have error + * type tokens instead. In such cases this parse will just create + * {@link ParseTreeNodeType#UNKNOWN} tokens. + *
  8. + *
+ * + *

+ * Currently there are not a lot of composite types in the parse tree, so the + * resulting representation is pretty low-level. This might get improved in + * the future to simplify static analysis. + *

+ * + *

+ * It is somewhat assumed, that input text is in a Latin-1 encoding (as in no + * char exceeds U+00FF), so it might produce ambiguous results for non-Latin-1 + * characters. + *

+ */ +public final class PdfContentStreamParser { + /** + * "false" string as a char array. + */ + private static final char[] FALSE = {'f', 'a', 'l', 's', 'e'}; + /** + * "true" string as a char array. + */ + private static final char[] TRUE = {'t', 'r', 'u', 'e'}; + /** + * "null" string as a char array. + */ + private static final char[] NULL = {'n', 'u', 'l', 'l'}; + /** + * A length based mapping of PDF content stream operators. + * + *

+ * If {@code L} is the expected length of the operator string, then at + * index {@code L - 1} you will get an array of all the possible + * operators, which has the length of {@code L}. + *

+ * + *

+ * This is done to make the linear search a bit faster. While this can be + * improved, operator matching doesn't seem to be a bottleneck, so this + * will suffice for now. + *

+ */ + private static final char[][][] LENGTH_OPERATOR_MAP = { + { + PdfOperators.w, + PdfOperators.J, + PdfOperators.j, + PdfOperators.M, + PdfOperators.d, + PdfOperators.i, + PdfOperators.q, + PdfOperators.Q, + PdfOperators.m, + PdfOperators.l, + PdfOperators.c, + PdfOperators.v, + PdfOperators.y, + PdfOperators.h, + PdfOperators.S, + PdfOperators.s, + PdfOperators.f, + PdfOperators.F, + PdfOperators.B, + PdfOperators.b, + PdfOperators.n, + PdfOperators.W, + PdfOperators.SINGLE_QUOTE, + PdfOperators.DOUBLE_QUOTE, + PdfOperators.G, + PdfOperators.g, + PdfOperators.K, + PdfOperators.k, + }, + { + PdfOperators.ri, + PdfOperators.gs, + PdfOperators.cm, + PdfOperators.re, + PdfOperators.f_STAR, + PdfOperators.B_STAR, + PdfOperators.b_STAR, + PdfOperators.W_STAR, + PdfOperators.BT, + PdfOperators.ET, + PdfOperators.Tc, + PdfOperators.Tw, + PdfOperators.Tz, + PdfOperators.TL, + PdfOperators.Tf, + PdfOperators.Tr, + PdfOperators.Ts, + PdfOperators.Td, + PdfOperators.TD, + PdfOperators.Tm, + PdfOperators.T_STAR, + PdfOperators.Tj, + PdfOperators.TJ, + PdfOperators.d0, + PdfOperators.d1, + PdfOperators.CS, + PdfOperators.cs, + PdfOperators.SC, + PdfOperators.sc, + PdfOperators.RG, + PdfOperators.rg, + PdfOperators.Sh, + PdfOperators.BI, + PdfOperators.ID, + PdfOperators.EI, + PdfOperators.Do, + PdfOperators.MP, + PdfOperators.DP, + PdfOperators.BX, + PdfOperators.EX, + }, + { + PdfOperators.SCN, + PdfOperators.scn, + PdfOperators.BMC, + PdfOperators.BDC, + PdfOperators.EMC, + }, + }; + + /** + * In progress parsing result in a parse tree form. + */ + private ParseTreeNode result; + /** + * Current composite/marker node, that is being appended to. + */ + private ParseTreeNode currentNode; + /** + * Current parentheses balance inside a string literal. This is valid only + * when current node type is {@link ParseTreeNodeType#STRING_LITERAL}. + */ + private int stringLiteralParenthesesBalance; + + /** + * Creates a new PDF content stream parser. + */ + public PdfContentStreamParser() { + reset(); + } + + /** + * Parses the provided PDF content stream string into a parse tree. + * + * @param text PDF content stream string to parse. + * + * @return Resulting parse tree. + */ + public static ParseTreeNode parse(String text) { + final PdfContentStreamParser parser = new PdfContentStreamParser(); + parser.append(text); + return parser.result(); + } + + /** + * Resets the parser into its initial state. + */ + public void reset() { + result = new ParseTreeNode(); + currentNode = result; + stringLiteralParenthesesBalance = 0; + } + + /** + * Appends the string to be processed by the parser. The string is parsed + * immediately during this call. + * + * @param text String to parse. + */ + public void append(String text) { + final char[] textArray = text.toCharArray(); + append(textArray, 0, textArray.length); + } + + /** + * Appends a sequence, which repeats a single character, to be processed + * by the parser. The sequence is parsed immediately during this call. + * + *

+ * This could be useful, if you want to parse only a part of the stream, + * but you know, that it starts in the middle of a string literal with a + * known parentheses balance. In such case you can start parsing with a + * {@code parser.append('(', balance)} call and append the stream part + * after. After that you would just skip the added tokens in the result. + *

+ * + * @param ch Character to repeat in the sequence. + * @param count Amount of times to repeat the character. Should not be + * negative. + */ + public void append(char ch, int count) { + final char[] text = new char[count]; + Arrays.fill(text, ch); + append(text, 0, text.length); + } + + /** + * Appends the character array to be processed by the parser. The + * characters are parsed immediately during this call. + * + * @param text Characters to parse. + */ + public void append(char[] text) { + append(text, 0, text.length); + } + + /** + * Appends the character array slice to be processed by the parser. The + * characters are parsed immediately during this call. + * + * @param text Text slice backing array. + * @param begin Text slice begin index, inclusive. + * @param end Text slice end index, exclusive. + */ + public void append(char[] text, int begin, int end) { + int index = begin; + while (index < end) { + index = appendToken(text, index, end); + } + } + + /** + * Appends the text segment to be processed by the parser. The text is + * parsed immediately during this call. + * + * @param segment Text segment to parse. + */ + public void append(Segment segment) { + append(segment.array, segment.offset, segment.offset + segment.count); + } + + /** + * Returns the parsing result. + * + * @return The parsing result. + */ + public ParseTreeNode result() { + return result; + } + + /* + * append* methods below are all made the same way. The take an input + * slice as an input and if a token is parsed, returned index will be + * incremented forwards. And they are designed in such a way, that they + * shouldn't parse things there are not supposed to. + * + * So to process a slice you would just go through the token types and try + * appending them. If index wasn't moved, then just try a different type. + */ + + /** + * Process a single token from the input text and returns the index, where + * the next token will start. + * + *

+ * With how the method is designed, it will add one primitive token at + * most, so to process the whole string, you need to call this in a loop + * till the return index is outside the string. + *

+ * + * @param text Text slice backing array. + * @param begin Text slice begin index, inclusive. + * @param end Text slice end index, exclusive. + * + * @return Starting index for the next token. + */ + private int appendToken(char[] text, int begin, int end) { + assert begin < end; + + // Special case: we are currently inside a string literal + if (currentNode.getType() == ParseTreeNodeType.STRING_LITERAL) { + return appendStringLiteralContinuation(text, begin, end); + } + + // Special case: we are currently inside a hex string + if (currentNode.getType() == ParseTreeNodeType.STRING_HEX) { + return appendStringHexContinuation(text, begin, end); + } + + /* + * Everything below is the normal parsing case. Append token calls + * should be ordered based on how often you would encounter them in a + * PDF content stream for performance reasons. + */ + + int index = appendWhitespace(text, begin, end); + if (index > begin) { + return index; + } + + index = appendNumeric(text, begin, end); + if (index > begin) { + return index; + } + + index = appendName(text, begin, end); + if (index > begin) { + return index; + } + + index = appendStringLiteralOpen(text, begin); + if (index > begin) { + return index; + } + + // If a hex string or a dictionary is being open + if (text[begin] == '<') { + // Opening a dictionary + if (begin + 1 < end && text[begin + 1] == '<') { + currentNode = currentNode.addChild(ParseTreeNodeType.DICTIONARY); + currentNode.addChild(ParseTreeNodeType.DICTIONARY_OPEN, text, begin, 2); + return begin + 2; + } + // Otherwise opening a hex string + currentNode = currentNode.addChild(ParseTreeNodeType.STRING_HEX); + currentNode.addChild(ParseTreeNodeType.STRING_HEX_OPEN, text, begin, 1); + return begin + 1; + } + + /* + * Hex string terminator is handled within appendStringHexContinuation. + * Here we just handle dictionary terminators and rogues tokens. + */ + if (text[begin] == '>') { + // Closing a dictionary + if (begin + 1 < end && text[begin + 1] == '>') { + currentNode.addChild(ParseTreeNodeType.DICTIONARY_CLOSE, text, begin, 2); + // If this is actually a dictionary terminator, then finishing the dictionary node + if (currentNode.getType() == ParseTreeNodeType.DICTIONARY) { + currentNode = currentNode.getParent(); + } + return begin + 2; + } + // Otherwise a rogue hex string termination token + currentNode.addChild(ParseTreeNodeType.STRING_HEX_CLOSE, text, begin, 1); + return begin + 1; + } + + // If an array is being open + if (text[begin] == '[') { + currentNode = currentNode.addChild(ParseTreeNodeType.ARRAY); + currentNode.addChild(ParseTreeNodeType.ARRAY_OPEN, text, begin, 1); + return begin + 1; + } + + // If an array is being closed + if (text[begin] == ']') { + currentNode.addChild(ParseTreeNodeType.ARRAY_CLOSE, text, begin, 1); + // If this is actually an array terminator, then finishing the array node + if (currentNode.getType() == ParseTreeNodeType.ARRAY) { + currentNode = currentNode.getParent(); + } + return begin + 1; + } + + index = appendBoolean(text, begin, end); + if (index > begin) { + return index; + } + + index = appendNull(text, begin, end); + if (index > begin) { + return index; + } + + index = appendComment(text, begin, end); + if (index > begin) { + return index; + } + + // This will add something, either an operator or an UNKNOWN token + return appendPotentialOperator(text, begin, end); + } + + private int appendStringLiteralContinuation(char[] text, int begin, int end) { + assert begin < end; + assert currentNode.getType() == ParseTreeNodeType.STRING_LITERAL; + + int index = appendStringLiteralData(text, begin, end); + if (index > begin) { + return index; + } + + index = appendStringLiteralClose(text, begin); + if (index > begin) { + return index; + } + + return appendStringLiteralOpen(text, begin); + } + + private int appendStringHexContinuation(char[] text, int begin, int end) { + assert begin < end; + assert currentNode.getType() == ParseTreeNodeType.STRING_HEX; + + int index = appendStringHexData(text, begin, end); + if (index > begin) { + return index; + } + + index = appendStringHexClose(text, begin); + if (index > begin) { + return index; + } + + return appendWhitespace(text, begin, end); + } + + private int appendWhitespace(char[] text, int begin, int end) { + int index = begin; + while (index < end && isWhitespace(text[index])) { + ++index; + } + if (index > begin) { + currentNode.addChild(ParseTreeNodeType.WHITESPACE, text, begin, index - begin); + } + return index; + } + + private int appendComment(char[] text, int begin, int end) { + assert begin < end; + + int index = begin; + if (text[index] != '%') { + return index; + } + + do { + ++index; + } while (index < end && text[index] != '\r' && text[index] != '\n'); + currentNode.addChild(ParseTreeNodeType.COMMENT, text, begin, index - begin); + return index; + } + + private int appendBoolean(char[] text, int begin, int end) { + if (containsAt(FALSE, text, begin, end)) { + currentNode.addChild(ParseTreeNodeType.BOOLEAN, text, begin, FALSE.length); + return begin + FALSE.length; + } + if (containsAt(TRUE, text, begin, end)) { + currentNode.addChild(ParseTreeNodeType.BOOLEAN, text, begin, TRUE.length); + return begin + TRUE.length; + } + return begin; + } + + private int appendNumeric(char[] text, int begin, int end) { + assert begin < end; + + int index = begin; + while (index < end && text[index] == '-') { + ++index; + } + while (index < end && ('0' <= text[index] && text[index] <= '9')) { + ++index; + } + if (index < end && text[index] == '.') { + do { + ++index; + } while (index < end && ('0' <= text[index] && text[index] <= '9')); + } + if (index > begin) { + currentNode.addChild(ParseTreeNodeType.NUMERIC, text, begin, index - begin); + } + return index; + } + + private int appendStringLiteralData(char[] text, int begin, int end) { + int index = begin; + while (index < end && text[index] != '(' && text[index] != ')') { + if (text[index] == '\\') { + index = Math.min(index + 2, end); + } else { + ++index; + } + } + if (index > begin) { + currentNode.addChild(ParseTreeNodeType.STRING_LITERAL_DATA, text, begin, index - begin); + } + return index; + } + + private int appendStringLiteralOpen(char[] text, int index) { + if (text[index] != '(') { + return index; + } + if (stringLiteralParenthesesBalance == 0) { + currentNode = currentNode.addChild(ParseTreeNodeType.STRING_LITERAL); + } + currentNode.addChild(ParseTreeNodeType.STRING_LITERAL_OPEN, text, index, 1); + ++stringLiteralParenthesesBalance; + return index + 1; + } + + private int appendStringLiteralClose(char[] text, int index) { + if (text[index] != ')') { + return index; + } + currentNode.addChild(ParseTreeNodeType.STRING_LITERAL_CLOSE, text, index, 1); + if (stringLiteralParenthesesBalance == 1) { + currentNode = currentNode.getParent(); + } + if (stringLiteralParenthesesBalance > 0) { + --stringLiteralParenthesesBalance; + } + return index + 1; + } + + private int appendStringHexData(char[] text, int begin, int end) { + int index = begin; + while (index < end && text[index] != '>' && !isWhitespace(text[index])) { + ++index; + } + if (index > begin) { + currentNode.addChild(ParseTreeNodeType.STRING_HEX_DATA, text, begin, index - begin); + } + return index; + } + + private int appendStringHexClose(char[] text, int index) { + if (text[index] == '>') { + currentNode.addChild(ParseTreeNodeType.STRING_HEX_CLOSE, text, index, 1); + currentNode = currentNode.getParent(); + return index + 1; + } + return index; + } + + private int appendName(char[] text, int begin, int end) { + assert begin < end; + + int index = begin; + if (text[index] != '/') { + return index; + } + + do { + ++index; + } while (index < end && !isDelimiterWhitespace(text[index])); + currentNode.addChild(ParseTreeNodeType.NAME, text, begin, index - begin); + return index; + } + + private int appendNull(char[] text, int begin, int end) { + if (containsAt(NULL, text, begin, end)) { + currentNode.addChild(ParseTreeNodeType.NULL, text, begin, NULL.length); + return begin + NULL.length; + } + return begin; + } + + private int appendPotentialOperator(char[] text, int begin, int end) { + assert begin < end; + + /* + * At this point it might only be an operator or garbage... Since we + * need to match the biggest operator, we need to find the end of the + * token before matching. + */ + + int index = begin + 1; + while (index < end && !isDelimiterWhitespace(text[index])) { + ++index; + } + + final int length = index - begin; + if (length <= LENGTH_OPERATOR_MAP.length) { + final char[][] operatorMap = LENGTH_OPERATOR_MAP[length - 1]; + for (final char[] operator : operatorMap) { + if (equals(operator, text, begin, index)) { + currentNode.addChild(ParseTreeNodeType.OPERATOR, text, begin, operator.length); + return index; + } + } + } + + currentNode.addChild(ParseTreeNodeType.UNKNOWN, text, begin, length); + return index; + } + + private static boolean isWhitespace(char ch) { + switch (ch) { + case '\0': + case '\t': + case '\n': + case '\f': + case '\r': + case ' ': + return true; + default: + return false; + } + } + + private static boolean isDelimiterWhitespace(char ch) { + switch (ch) { + case '\0': + case '\t': + case '\n': + case '\f': + case '\r': + case ' ': + case '(': + case ')': + case '<': + case '>': + case '[': + case ']': + case '/': + case '%': + return true; + default: + return false; + } + } + + private static boolean containsAt(char[] expected, char[] text, int begin, int end) { + final int toIndex = begin + expected.length; + if (toIndex > end) { + return false; + } + return Arrays.equals(expected, 0, expected.length, text, begin, toIndex); + } + + private static boolean equals(char[] expected, char[] text, int begin, int end) { + return Arrays.equals(expected, 0, expected.length, text, begin, end); + } +} diff --git a/src/main/java/com/itextpdf/rups/model/contentstream/PdfOperators.java b/src/main/java/com/itextpdf/rups/model/contentstream/PdfOperators.java new file mode 100644 index 00000000..1f14feac --- /dev/null +++ b/src/main/java/com/itextpdf/rups/model/contentstream/PdfOperators.java @@ -0,0 +1,176 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.model.contentstream; + +/** + * Static class, which stores all PDF content stream operators as + * {@code char[]}. + */ +@SuppressWarnings({"java:S1845", "java:S2386"}) +public final class PdfOperators { + /* + * General graphics state + */ + public static final char[] w = new char[] {'w'}; + public static final char[] J = new char[] {'J'}; + public static final char[] j = new char[] {'j'}; + public static final char[] M = new char[] {'M'}; + public static final char[] d = new char[] {'d'}; + public static final char[] ri = new char[] {'r', 'i'}; + public static final char[] i = new char[] {'i'}; + public static final char[] gs = new char[] {'g', 's'}; + public static final char[] Q = new char[] {'Q'}; + public static final char[] q = new char[] {'q'}; + /* + * Special graphics state + */ + public static final char[] cm = new char[] {'c', 'm'}; + /* + * Path construction + */ + public static final char[] m = new char[] {'m'}; + public static final char[] l = new char[] {'l'}; + public static final char[] c = new char[] {'c'}; + public static final char[] v = new char[] {'v'}; + public static final char[] y = new char[] {'y'}; + public static final char[] h = new char[] {'h'}; + public static final char[] re = new char[] {'r', 'e'}; + /* + * Path painting + */ + public static final char[] S = new char[] {'S'}; + public static final char[] s = new char[] {'s'}; + public static final char[] F = new char[] {'F'}; + public static final char[] f = new char[] {'f'}; + public static final char[] f_STAR = new char[] {'f', '*'}; + public static final char[] B = new char[] {'B'}; + public static final char[] B_STAR = new char[] {'B', '*'}; + public static final char[] b = new char[] {'b'}; + public static final char[] b_STAR = new char[] {'b', '*'}; + public static final char[] n = new char[] {'n'}; + /* + * Clipping paths + */ + public static final char[] W = new char[] {'W'}; + public static final char[] W_STAR = new char[] {'W', '*'}; + /* + * Text objects + */ + public static final char[] BT = new char[] {'B', 'T'}; + public static final char[] ET = new char[] {'E', 'T'}; + /* + * Text state + */ + public static final char[] Tc = new char[] {'T', 'c'}; + public static final char[] Tw = new char[] {'T', 'w'}; + public static final char[] Tz = new char[] {'T', 'z'}; + public static final char[] TL = new char[] {'T', 'L'}; + public static final char[] Tf = new char[] {'T', 'f'}; + public static final char[] Tr = new char[] {'T', 'r'}; + public static final char[] Ts = new char[] {'T', 's'}; + /* + * Text positioning + */ + public static final char[] Td = new char[] {'T', 'd'}; + public static final char[] TD = new char[] {'T', 'D'}; + public static final char[] Tm = new char[] {'T', 'm'}; + public static final char[] T_STAR = new char[] {'T', '*'}; + /* + * Text showing + */ + public static final char[] Tj = new char[] {'T', 'j'}; + public static final char[] TJ = new char[] {'T', 'J'}; + public static final char[] SINGLE_QUOTE = new char[] {'\''}; + public static final char[] DOUBLE_QUOTE = new char[] {'"'}; + /* + * Type 3 fonts + */ + public static final char[] d0 = new char[] {'d', '0'}; + public static final char[] d1 = new char[] {'d', '1'}; + /* + * Colour + */ + public static final char[] CS = new char[] {'C', 'S'}; + public static final char[] cs = new char[] {'c', 's'}; + public static final char[] SC = new char[] {'S', 'C'}; + public static final char[] sc = new char[] {'s', 'c'}; + public static final char[] SCN = new char[] {'S', 'C', 'N'}; + public static final char[] scn = new char[] {'s', 'c', 'n'}; + public static final char[] G = new char[] {'G'}; + public static final char[] g = new char[] {'g'}; + public static final char[] RG = new char[] {'R', 'G'}; + public static final char[] rg = new char[] {'r', 'g'}; + public static final char[] K = new char[] {'K'}; + public static final char[] k = new char[] {'k'}; + /* + * Shading patterns + */ + public static final char[] Sh = new char[] {'S', 'h'}; + /* + * Inline images + */ + public static final char[] BI = new char[] {'B', 'I'}; + public static final char[] ID = new char[] {'I', 'D'}; + public static final char[] EI = new char[] {'E', 'I'}; + /* + * XObjects + */ + public static final char[] Do = new char[] {'D', 'o'}; + /* + * Marked-content + */ + public static final char[] MP = new char[] {'M', 'P'}; + public static final char[] DP = new char[] {'D', 'P'}; + public static final char[] BMC = new char[] {'B', 'M', 'C'}; + public static final char[] BDC = new char[] {'B', 'D', 'C'}; + public static final char[] EMC = new char[] {'E', 'M', 'C'}; + /* + * Compatibility + */ + public static final char[] BX = new char[] {'B', 'X'}; + public static final char[] EX = new char[] {'E', 'X'}; + + private PdfOperators() { + // Static class + } +} From 962e3020f719db6128e474e516d0955a9730a659 Mon Sep 17 00:00:00 2001 From: Vlad Lipskiy Date: Wed, 22 Jan 2025 23:02:44 +0300 Subject: [PATCH 2/7] Add RSyntaxTextArea for stream content This is one of the first steps of integrating RSyntaxTextArea for PDF content stream editing. Tokenization is based on the parsing logic, which was added in the previous commit. Since PDF content streams are, in a general case, not text, but binary data, some workarounds had to be made, as RSyntaxTextArea was designed to work with text. A custom token type and painter was created, so that we could render arbitrary characters not as text, but as hexadecimal representations of the binary data. Additionally, Latin1Filter was made so that we could somewhat trick RSyntaxTextArea to work with binary data. It replaces any characters, which are not representable in Latin-1 (i.e. U+0100 and beyond) with their UTF-8 representation, but with bytes stored in chars. As a result all chars in the document fit in one byte and the backing character array for the document acts as an inflated byte array. This way we can just decode the text with Latin-1 to get the expected output, which can be put directly into PDF. With how RSyntaxTextArea is structured, quite a lot of code from there has to be copied, as inheritance is not "granular" enough to do what we cant. Since the input stream is no longer processed by iText, what you see in the editor pane is the raw data in the stream itself unmodified. This was one of the goals, as before opening a stream for editing would make it pretty much impossible to save it without altering at least whitespace in some way. As of now, there are some regressions. For example: 1. Images are no longer render in the stream pane. For now, they are displayed as text. 2. Since stream is presented as-is, there is no indentation at the moment. This will be added later as an explicit prettifier option. Code folding, static syntax analysis and RSTAUI dialog integrations will be added later. --- pom.xml | 12 + .../rups/controller/PdfReaderController.java | 8 +- .../java/com/itextpdf/rups/view/Language.java | 1 + .../view/contextmenu/InspectObjectAction.java | 8 +- .../SaveToPdfStreamJTextPaneAction.java | 6 +- .../contextmenu/StreamPanelContextMenu.java | 6 +- .../rups/view/itext/BeepingUndoManager.java | 73 +++ .../rups/view/itext/StreamTextEditorPane.java | 451 ++++++++++++++ .../itext/SyntaxHighlightedStreamPane.java | 371 ------------ .../rups/view/itext/editor/Latin1Filter.java | 180 ++++++ .../rups/view/itext/editor/PdfToken.java | 230 ++++++++ .../rups/view/itext/editor/PdfTokenMaker.java | 329 +++++++++++ .../view/itext/editor/PdfTokenPainter.java | 558 ++++++++++++++++++ .../itext/editor/PdfTokenPainterFactory.java | 60 ++ .../rups/view/itext/editor/PdfTokenTypes.java | 76 +++ .../resources/bundles/rups-lang.properties | 1 + .../bundles/rups-lang_en_US.properties | 1 + .../StreamPanelContextMenuTest.java | 12 +- .../view/itext/editor/Latin1FilterTest.java | 173 ++++++ 19 files changed, 2165 insertions(+), 391 deletions(-) create mode 100644 src/main/java/com/itextpdf/rups/view/itext/BeepingUndoManager.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java delete mode 100644 src/main/java/com/itextpdf/rups/view/itext/SyntaxHighlightedStreamPane.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/Latin1Filter.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/PdfToken.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenMaker.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainter.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainterFactory.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenTypes.java create mode 100644 src/test/java/com/itextpdf/rups/view/itext/editor/Latin1FilterTest.java diff --git a/pom.xml b/pom.xml index b1fcbe08..7dcd7bb4 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,8 @@ 9.1.0 2.18.3 1.5.18 + 3.6.0 + 3.3.1 5.12.0 @@ -134,6 +136,16 @@ flatlaf ${flatlaf.version} + + com.fifesoft + rsyntaxtextarea + ${rsyntaxtextarea.version} + + + com.fifesoft + rstaui + ${rstaui.version} + com.itextpdf pdftest diff --git a/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java b/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java index aab74cc0..61920e6d 100644 --- a/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java +++ b/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java @@ -73,7 +73,7 @@ This file is part of the iText (R) project. import com.itextpdf.rups.view.itext.PdfTree; import com.itextpdf.rups.view.itext.PlainText; import com.itextpdf.rups.view.itext.StructureTree; -import com.itextpdf.rups.view.itext.SyntaxHighlightedStreamPane; +import com.itextpdf.rups.view.itext.StreamTextEditorPane; import com.itextpdf.rups.view.itext.XRefTable; import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; @@ -135,7 +135,7 @@ public class PdfReaderController implements IPdfObjectPanelEventListener, IRupsE /** * A panel that will show a stream. */ - protected SyntaxHighlightedStreamPane streamPane; + protected StreamTextEditorPane streamPane; /** * The factory producing tree nodes. @@ -205,7 +205,7 @@ public PdfReaderController(TreeSelectionListener treeSelectionListener, objectPanel = new PdfObjectPanel(); objectPanel.addEventListener(this); - streamPane = new SyntaxHighlightedStreamPane(this); + streamPane = new StreamTextEditorPane(this); JScrollPane debug = new JScrollPane(DebugView.getInstance().getTextArea()); editorTabs = new JTabbedPane(); editorTabs.addTab(Language.STREAM.getString(), null, streamPane, Language.STREAM.getString()); @@ -261,7 +261,7 @@ public JTabbedPane getEditorTabs() { * * @return a SyntaxHighlightedStreamPane */ - public SyntaxHighlightedStreamPane getStreamPane() { + public StreamTextEditorPane getStreamPane() { return streamPane; } diff --git a/src/main/java/com/itextpdf/rups/view/Language.java b/src/main/java/com/itextpdf/rups/view/Language.java index b0805779..b220404f 100644 --- a/src/main/java/com/itextpdf/rups/view/Language.java +++ b/src/main/java/com/itextpdf/rups/view/Language.java @@ -95,6 +95,7 @@ public enum Language { ERROR_BUILDING_CONTENT_STREAM, ERROR_CANNOT_CHECK_NULL_FOR_INPUT_STREAM, ERROR_CANNOT_FIND_FILE, + ERROR_CHARACTER_ENCODING, ERROR_CLOSING_STREAM, ERROR_COMPARE_DOCUMENT_CREATION, ERROR_COMPARED_DOCUMENT_CLOSED, diff --git a/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java b/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java index 41cdd73a..80e0647d 100644 --- a/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java +++ b/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java @@ -45,7 +45,7 @@ This file is part of the iText (R) project. import com.itextpdf.rups.view.Language; import com.itextpdf.rups.view.icons.FrameIconUtil; import com.itextpdf.rups.view.itext.PdfTree; -import com.itextpdf.rups.view.itext.SyntaxHighlightedStreamPane; +import com.itextpdf.rups.view.itext.StreamTextEditorPane; import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; import javax.swing.AbstractAction; @@ -83,10 +83,10 @@ public void actionPerformed(ActionEvent e) { final PdfObjectTreeNode node = (PdfObjectTreeNode) ((PdfTree) invoker).getSelectionPath().getLastPathComponent(); - final SyntaxHighlightedStreamPane syntaxHighlightedStreamPane = new SyntaxHighlightedStreamPane(null); + final StreamTextEditorPane streamPane = new StreamTextEditorPane(null); - frame.add(syntaxHighlightedStreamPane); - syntaxHighlightedStreamPane.render(node); + frame.add(streamPane); + streamPane.render(node); final Language dialogCancel = Language.DIALOG_CANCEL; frame.getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) diff --git a/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java b/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java index 431af854..5e978a07 100644 --- a/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java +++ b/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java @@ -42,18 +42,18 @@ This file is part of the iText (R) project. */ package com.itextpdf.rups.view.contextmenu; -import com.itextpdf.rups.view.itext.SyntaxHighlightedStreamPane; +import com.itextpdf.rups.view.itext.StreamTextEditorPane; import java.awt.event.ActionEvent; public class SaveToPdfStreamJTextPaneAction extends AbstractRupsAction { - public SaveToPdfStreamJTextPaneAction(String name, SyntaxHighlightedStreamPane invoker) { + public SaveToPdfStreamJTextPaneAction(String name, StreamTextEditorPane invoker) { super(name, invoker); } public void actionPerformed(ActionEvent event) { - final SyntaxHighlightedStreamPane pane = (SyntaxHighlightedStreamPane) invoker; + final StreamTextEditorPane pane = (StreamTextEditorPane) invoker; pane.saveToTarget(); } } diff --git a/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java b/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java index 0b72a941..54e23f71 100644 --- a/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java +++ b/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java @@ -43,13 +43,13 @@ This file is part of the iText (R) project. package com.itextpdf.rups.view.contextmenu; import com.itextpdf.rups.view.Language; -import com.itextpdf.rups.view.itext.SyntaxHighlightedStreamPane; +import com.itextpdf.rups.view.itext.StreamTextEditorPane; import javax.swing.Action; +import javax.swing.JComponent; import javax.swing.JMenuItem; import javax.swing.JPopupMenu; import javax.swing.JSeparator; -import javax.swing.JTextPane; import javax.swing.text.DefaultEditorKit; /** @@ -73,7 +73,7 @@ public final class StreamPanelContextMenu extends JPopupMenu { * @param textPane the text pane * @param controller the controller */ - public StreamPanelContextMenu(final JTextPane textPane, final SyntaxHighlightedStreamPane controller) { + public StreamPanelContextMenu(final JComponent textPane, final StreamTextEditorPane controller) { super(); final JMenuItem copyItem = getJMenuItem( diff --git a/src/main/java/com/itextpdf/rups/view/itext/BeepingUndoManager.java b/src/main/java/com/itextpdf/rups/view/itext/BeepingUndoManager.java new file mode 100644 index 00000000..c42245bd --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/BeepingUndoManager.java @@ -0,0 +1,73 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext; + +import java.awt.Toolkit; +import javax.swing.undo.CannotRedoException; +import javax.swing.undo.CannotUndoException; +import javax.swing.undo.UndoManager; + +/** + * A variation of an {@link UndoManager}, which will issue a beep instead of + * throwing {@link CannotUndoException} and {@link CannotRedoException} + * exceptions. + */ +public final class BeepingUndoManager extends UndoManager { + @Override + public void redo() { + try { + super.redo(); + } catch (CannotRedoException ignored) { + Toolkit.getDefaultToolkit().beep(); + } + } + + @Override + public void undo() { + try { + super.undo(); + } catch (CannotUndoException ignored) { + Toolkit.getDefaultToolkit().beep(); + } + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java new file mode 100644 index 00000000..ea0956e7 --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java @@ -0,0 +1,451 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2024 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext; + +import com.itextpdf.kernel.pdf.PdfDictionary; +import com.itextpdf.kernel.pdf.PdfName; +import com.itextpdf.kernel.pdf.PdfObject; +import com.itextpdf.kernel.pdf.PdfStream; +import com.itextpdf.rups.controller.PdfReaderController; +import com.itextpdf.rups.model.IRupsEventListener; +import com.itextpdf.rups.model.LoggerHelper; +import com.itextpdf.rups.model.ObjectLoader; +import com.itextpdf.rups.model.contentstream.ParseTreeNode; +import com.itextpdf.rups.model.contentstream.ParseTreeNodeType; +import com.itextpdf.rups.model.contentstream.PdfContentStreamParser; +import com.itextpdf.rups.view.Language; +import com.itextpdf.rups.view.contextmenu.ContextMenuMouseListener; +import com.itextpdf.rups.view.contextmenu.StreamPanelContextMenu; +import com.itextpdf.rups.view.itext.editor.Latin1Filter; +import com.itextpdf.rups.view.itext.editor.PdfTokenMaker; +import com.itextpdf.rups.view.itext.editor.PdfTokenPainterFactory; +import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; + +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import javax.swing.JComponent; +import javax.swing.KeyStroke; +import javax.swing.tree.TreeNode; +import org.fife.ui.rsyntaxtextarea.AbstractTokenMakerFactory; +import org.fife.ui.rsyntaxtextarea.DefaultTokenPainterFactory; +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; +import org.fife.ui.rtextarea.ExpandedFoldRenderStrategy; +import org.fife.ui.rtextarea.RTextScrollPane; + +public final class StreamTextEditorPane extends RTextScrollPane implements IRupsEventListener { + /** + * MIME type for a PDF content stream. + */ + private static final String MIME_PDF = "application/pdf"; + /** + * MIME type for plain text. + */ + private static final String MIME_PLAIN_TEXT = "plain/text"; + + /** + * Char buffer with a single LF character. + */ + private static final char[] LF_TEXT = {'\n'}; + /** + * Max text line width after which it will be forcefully split. + */ + private static final int MAX_LINE_LENGTH = 2048; + private static final int MAX_NUMBER_OF_EDITS = 8192; + + private static final Method GET_INPUT_STREAM_METHOD; + + private final StreamPanelContextMenu popupMenu; + private final BeepingUndoManager undoManager; + + //Todo: Remove that field after proper application structure will be implemented. + private final PdfReaderController controller; + private PdfObjectTreeNode target; + private boolean editable = false; + + static { + /* + * Registering PDF content type, so that we could use PDF syntax + * highlighting in RSyntaxTextArea. + */ + final AbstractTokenMakerFactory tokenMakerFactory = + (AbstractTokenMakerFactory) TokenMakerFactory.getDefaultInstance(); + tokenMakerFactory.putMapping(MIME_PDF, PdfTokenMaker.class.getName()); + /* + * There doesn't seem to be a good way to detect, whether you can call + * setData on a PdfStream or not in advance. It cannot be called if a + * PDF stream was created from an InputStream, so we will be testing + * that via the protected method. + */ + try { + GET_INPUT_STREAM_METHOD = PdfStream.class.getDeclaredMethod("getInputStream"); + GET_INPUT_STREAM_METHOD.setAccessible(true); + } catch (NoSuchMethodException | SecurityException e) { + throw new IllegalStateException(e); + } + } + + /** + * Constructs a SyntaxHighlightedStreamPane. + * + * @param controller the pdf reader controller + */ + public StreamTextEditorPane(PdfReaderController controller) { + super(createTextArea()); + this.controller = controller; + // This will make sure, that the arrow for folding code blocks are + // always visible + getGutter().setExpandedFoldRenderStrategy(ExpandedFoldRenderStrategy.ALWAYS); + + popupMenu = new StreamPanelContextMenu(getTextArea(), this); + getTextArea().setComponentPopupMenu(popupMenu); + getTextArea().addMouseListener(new ContextMenuMouseListener(popupMenu, getTextArea())); + + undoManager = new BeepingUndoManager(); + getDocument().addUndoableEditListener(undoManager); + getTextArea().registerKeyboardAction( + e -> undoManager.undo(), + KeyStroke.getKeyStroke(KeyEvent.VK_Z, InputEvent.CTRL_DOWN_MASK), + JComponent.WHEN_FOCUSED + ); + getTextArea().registerKeyboardAction( + e -> undoManager.redo(), + KeyStroke.getKeyStroke(KeyEvent.VK_Y, InputEvent.CTRL_DOWN_MASK), + JComponent.WHEN_FOCUSED + ); + } + + @Override + public RSyntaxTextArea getTextArea() { + return (RSyntaxTextArea) super.getTextArea(); + } + + public RSyntaxDocument getDocument() { + return getDocument(getTextArea()); + } + + /** + * Renders the content stream of a PdfObject or empties the text area. + * + * @param target the node of which the content stream needs to be rendered + */ + public void render(PdfObjectTreeNode target) { + setUndoEnabled(false); + this.target = target; + final PdfStream stream = getTargetStream(); + if (stream == null) { + clearPane(); + return; + } + + // Assuming that this will stop parsing for a moment... + getTextArea().setVisible(false); + String textToSet; + String mimeToSet; + boolean editableToSet; + /* + * TODO: Differentiate between different content. See below. + * + * Images should be rendered as images (this was before the syntax + * highlight changes). Or at least as hex binary data. + * + * Fonts, binary XMP or just random binary data should be displayed + * as hex. + * + * XML data should be edited as XML with proper encoding and saved + * as such. + * + * Only PDF content streams should be altered and parsed in a custom + * way. + */ + try { + if (isFont(stream) || isImage(stream)) { + textToSet = getText(stream, false); + mimeToSet = MIME_PLAIN_TEXT; + editableToSet = false; + } else { + textToSet = prepareContentStreamText(getText(stream, true)); + mimeToSet = MIME_PDF; + editableToSet = true; + } + setTextEditableRoutine(true); + } catch (RuntimeException e) { + LoggerHelper.error(Language.ERROR_UNEXPECTED_EXCEPTION.getString(), e, getClass()); + textToSet = ""; + mimeToSet = MIME_PLAIN_TEXT; + editableToSet = false; + } + setContentType(mimeToSet); + getTextArea().setText(textToSet); + getTextArea().setCaretPosition(0); + setTextEditableRoutine(editableToSet); + setUndoEnabled(true); + getTextArea().setVisible(true); + + repaint(); + } + + public void saveToTarget() { + /* + * FIXME: With indirect objects with multiple references, this will + * change the tree only in one of them. + * FIXME: This doesn't change Length... + */ + if (controller != null && ((PdfDictionary) target.getPdfObject()).containsKey(PdfName.Filter)) { + controller.deleteTreeNodeDictChild(target, PdfName.Filter); + } + /* + * In the current state, stream node could contain ASN1. data, which + * is parsed and added as tree nodes. After editing, it won't be valid, + * so we must remove them. + */ + if (controller != null) { + int i = 0; + while (i < target.getChildCount()) { + final TreeNode child = target.getChildAt(i); + if (child instanceof PdfObjectTreeNode) { + ++i; + } else { + controller.deleteTreeChild(target, i); + // Will assume it being just a shift... + } + } + } + final byte[] streamData = getTextArea().getText().getBytes(StandardCharsets.ISO_8859_1); + getTargetStream().setData(streamData); + if (controller != null) { + controller.selectNode(target); + } + } + + public void setEditable(boolean editable) { + this.editable = editable; + setTextEditableRoutine(editable); + } + + @Override + public void handleCloseDocument() { + clearPane(); + setEditable(false); + } + + @Override + public void handleOpenDocument(ObjectLoader loader) { + clearPane(); + setEditable(loader.getFile().isOpenedAsOwner()); + } + + private void setTextEditableRoutine(boolean editable) { + // If pane is read-only or in a temp read-only state + if (!this.editable || !editable) { + getTextArea().setEditable(false); + popupMenu.setSaveToStreamEnabled(false); + return; + } + + getTextArea().setEditable(true); + final PdfStream targetStream = getTargetStream(); + if (targetStream != null) { + popupMenu.setSaveToStreamEnabled(isStreamEditable(targetStream)); + } else { + popupMenu.setSaveToStreamEnabled(false); + } + } + + private PdfStream getTargetStream() { + if (target == null) { + return null; + } + final PdfObject obj = target.getPdfObject(); + if (obj instanceof PdfStream) { + return (PdfStream) obj; + } + return null; + } + + private void clearPane() { + target = null; + setUndoEnabled(false); + getTextArea().setText(""); + setTextEditableRoutine(false); + } + + private void setContentType(String mime) { + setContentType(getTextArea(), mime); + } + + private void setUndoEnabled(boolean enabled) { + if (enabled) { + undoManager.setLimit(MAX_NUMBER_OF_EDITS); + } else { + undoManager.discardAllEdits(); + undoManager.setLimit(0); + } + } + + /** + * Modifies the PDF content stream text to make it suitable for usage in + * a code editor. + * + *

+ * At the moment this just splits lines after operators, if lines are too + * long. If the are long lines in the code editor, is is noticeably + * laggier. + *

+ * + * @param originalText PDF content stream text to modify. + * + * @return Modified PDF content stream text. + */ + private static String prepareContentStreamText(String originalText) { + boolean hasOnlyShortLines = true; + int startIndex = 0; + while (startIndex < originalText.length()) { + int lineFeedIndex = originalText.indexOf('\n', startIndex); + if (lineFeedIndex == -1) { + lineFeedIndex = originalText.length(); + } + final int length = lineFeedIndex - startIndex; + if (length > MAX_LINE_LENGTH) { + hasOnlyShortLines = false; + break; + } + startIndex = lineFeedIndex + 1; + } + if (hasOnlyShortLines) { + return originalText; + } + + /* + * TODO: Make this logic smarter. + * + * At the moment if lines are too big, we just replace all whitespace + * after an operator with LF. This is not ideal and destructive. This + * was prompted by a document, where lines were denoted with just CR + * and the text area does not treat them as end-of-line indicators. + */ + final ParseTreeNode tree = PdfContentStreamParser.parse(originalText); + ParseTreeNode child = tree.getFirstChild(); + while (child != null) { + if (child.getType() == ParseTreeNodeType.OPERATOR) { + ParseTreeNode next = child.getNext(); + while (next != null && next.getType() == ParseTreeNodeType.WHITESPACE) { + next = next.remove(); + } + child = child.addNext(ParseTreeNodeType.WHITESPACE, LF_TEXT, 0, LF_TEXT.length); + } + child = child.getNext(); + } + return tree.getFullText(); + } + + private static RSyntaxTextArea createTextArea() { + final RSyntaxTextArea textArea = new RSyntaxTextArea(); + /* + * First we will set up our custom painter with our Latin-1 filter + * "hack". The way it works is that with the filter applied, any + * character greater than U+00FF will be replaced with a UTF-8 byte + * representation. As in the internal char array can actually be + * interpreted as a byte array, which wastes twice as much space... + * + * To make it easier to work with possible binary content of a PDF + * stream we will use a custom token painter. It will paint non-ASCII + * character as their hex-codes instead of their Latin-1 mapped + * glyphs. + * + * Both the filter and painter should be replaced with default, when + * we display a non-content stream. For example, for XML-based + * metadata we should just use the regular XML editor available. But + * by default we will just assume a PDF content stream. + */ + setContentType(textArea, MIME_PDF); + // This will allow to fold code blocks (like BT/ET blocks) + textArea.setCodeFoldingEnabled(true); + // This will automatically add tabulations, when you enter a new line + // after a "q" operator, for example + textArea.setAutoIndentEnabled(true); + // This will mark identical names and operators, when cursor is on + // them after a short delay + textArea.setMarkOccurrences(true); + textArea.setMarkOccurrencesDelay(500); + return textArea; + } + + private static void setContentType(RSyntaxTextArea textArea, String mime) { + if (MIME_PDF.equals(mime)) { + getDocument(textArea).setDocumentFilter(new Latin1Filter()); + textArea.setTokenPainterFactory(new PdfTokenPainterFactory()); + } else { + getDocument(textArea).setDocumentFilter(null); + textArea.setTokenPainterFactory(new DefaultTokenPainterFactory()); + } + textArea.setSyntaxEditingStyle(mime); + } + + private static String getText(PdfStream stream, boolean decoded) { + return new String(stream.getBytes(decoded), StandardCharsets.ISO_8859_1); + } + + private static RSyntaxDocument getDocument(RSyntaxTextArea textArea) { + return (RSyntaxDocument) textArea.getDocument(); + } + + private static boolean isImage(PdfStream stream) { + return PdfName.Image.equals(stream.getAsName(PdfName.Subtype)); + } + + private static boolean isFont(PdfStream stream) { + return stream.get(PdfName.Length1) != null; + } + + private static boolean isStreamEditable(PdfStream stream) { + try { + return (GET_INPUT_STREAM_METHOD.invoke(stream) == null); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/SyntaxHighlightedStreamPane.java b/src/main/java/com/itextpdf/rups/view/itext/SyntaxHighlightedStreamPane.java deleted file mode 100644 index 282bb480..00000000 --- a/src/main/java/com/itextpdf/rups/view/itext/SyntaxHighlightedStreamPane.java +++ /dev/null @@ -1,371 +0,0 @@ -/* - This file is part of the iText (R) project. - Copyright (c) 1998-2025 Apryse Group NV - Authors: Apryse Software. - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License version 3 - as published by the Free Software Foundation with the addition of the - following permission added to Section 15 as permitted in Section 7(a): - FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY - APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT - OF THIRD PARTY RIGHTS - - This program 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 Affero General Public License for more details. - You should have received a copy of the GNU Affero General Public License - along with this program; if not, see http://www.gnu.org/licenses or write to - the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, - Boston, MA, 02110-1301 USA, or download the license from the following URL: - http://itextpdf.com/terms-of-use/ - - The interactive user interfaces in modified source and object code versions - of this program must display Appropriate Legal Notices, as required under - Section 5 of the GNU Affero General Public License. - - In accordance with Section 7(b) of the GNU Affero General Public License, - a covered work must retain the producer line in every PDF that is created - or manipulated using iText. - - You can be released from the requirements of the license by purchasing - a commercial license. Buying such a license is mandatory as soon as you - develop commercial activities involving the iText software without - disclosing the source code of your own applications. - These activities include: offering paid services to customers as an ASP, - serving PDFs on the fly in a web application, shipping iText with a closed - source product. - - For more information, please contact iText Software Corp. at this - address: sales@itextpdf.com - */ -package com.itextpdf.rups.view.itext; - -import com.itextpdf.kernel.exceptions.PdfException; -import com.itextpdf.kernel.pdf.PdfDictionary; -import com.itextpdf.kernel.pdf.PdfName; -import com.itextpdf.kernel.pdf.PdfStream; -import com.itextpdf.kernel.pdf.xobject.PdfImageXObject; -import com.itextpdf.rups.controller.PdfReaderController; -import com.itextpdf.rups.model.LoggerHelper; -import com.itextpdf.rups.model.ObjectLoader; -import com.itextpdf.rups.model.IRupsEventListener; -import com.itextpdf.rups.view.Language; -import com.itextpdf.rups.view.contextmenu.ContextMenuMouseListener; -import com.itextpdf.rups.view.contextmenu.SaveImageAction; -import com.itextpdf.rups.view.contextmenu.StreamPanelContextMenu; -import com.itextpdf.rups.view.itext.contentstream.ContentStreamWriter; -import com.itextpdf.rups.view.itext.contentstream.StyledSyntaxDocument; -import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; - -import java.awt.Toolkit; -import java.awt.event.ActionEvent; -import java.awt.event.InputEvent; -import java.awt.event.KeyEvent; -import java.awt.event.MouseEvent; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import javax.swing.AbstractAction; -import javax.swing.ImageIcon; -import javax.swing.JComponent; -import javax.swing.JScrollPane; -import javax.swing.JTextPane; -import javax.swing.KeyStroke; -import javax.swing.ToolTipManager; -import javax.swing.text.BadLocationException; -import javax.swing.text.SimpleAttributeSet; -import javax.swing.text.Style; -import javax.swing.text.StyleConstants; -import javax.swing.text.StyledDocument; -import javax.swing.tree.TreeNode; -import javax.swing.undo.CannotRedoException; -import javax.swing.undo.CannotUndoException; -import javax.swing.undo.UndoManager; - -public final class SyntaxHighlightedStreamPane extends JScrollPane implements IRupsEventListener { - - private static final int MAX_NUMBER_OF_EDITS = 8192; - - private static Method pdfStreamGetInputStreamMethod; - - /** - * The text pane with the content stream. - */ - private final JSyntaxPane text; - - private final StreamPanelContextMenu popupMenu; - - private PdfObjectTreeNode target; - - private final UndoManager manager; - - //Todo: Remove that field after proper application structure will be implemented. - private final PdfReaderController controller; - - private boolean editable = false; - - static { - try { - pdfStreamGetInputStreamMethod = PdfStream.class.getDeclaredMethod("getInputStream"); - pdfStreamGetInputStreamMethod.setAccessible(true); - } catch (NoSuchMethodException | SecurityException any) { - pdfStreamGetInputStreamMethod = null; - LoggerHelper.error(Language.ERROR_REFLECTION_PDF_STREAM.getString(), any, PdfReaderController.class); - } - } - - /** - * Constructs a SyntaxHighlightedStreamPane. - * - * @param controller the pdf reader controller - */ - public SyntaxHighlightedStreamPane(PdfReaderController controller) { - this.text = new JSyntaxPane(); - ToolTipManager.sharedInstance().registerComponent(text); - setViewportView(text); - this.controller = controller; - - popupMenu = new StreamPanelContextMenu(text, this); - text.setComponentPopupMenu(popupMenu); - text.addMouseListener(new ContextMenuMouseListener(popupMenu, text)); - - manager = new UndoManager(); - manager.setLimit(MAX_NUMBER_OF_EDITS); - text.getDocument().addUndoableEditListener(manager); - text.registerKeyboardAction(new UndoAction(manager), - KeyStroke.getKeyStroke(KeyEvent.VK_Z, InputEvent.CTRL_DOWN_MASK), JComponent.WHEN_FOCUSED); - text.registerKeyboardAction(new RedoAction(manager), - KeyStroke.getKeyStroke(KeyEvent.VK_Y, InputEvent.CTRL_DOWN_MASK), JComponent.WHEN_FOCUSED); - } - - /** - * Renders the content stream of a PdfObject or empties the text area. - * - * @param target the node of which the content stream needs to be rendered - */ - public void render(PdfObjectTreeNode target) { - manager.discardAllEdits(); - manager.setLimit(0); - this.target = target; - if (!(target.getPdfObject() instanceof PdfStream)) { - clearPane(); - return; - } - final PdfStream stream = (PdfStream) target.getPdfObject(); - text.setText(""); - //Check if stream is image - if (PdfName.Image.equals(stream.getAsName(PdfName.Subtype))) { - try { - //Convert byte array back to Image - if (!stream.get(PdfName.Width, false).isNumber() && !stream.get(PdfName.Height, false).isNumber()) { - return; - } - PdfImageXObject pimg = new PdfImageXObject(stream); - BufferedImage img = pimg.getBufferedImage(); - if (img == null) { - text.setText(Language.ERROR_LOADING_IMAGE.getString()); - } else { - //Show image in textpane - StyledDocument doc = (StyledDocument) text.getDocument(); - Style style = doc.addStyle("Image", null); - StyleConstants.setIcon(style, new ImageIcon(img)); - - try { - doc.insertString(doc.getLength(), Language.IGNORED_TEXT.getString(), style); - doc.insertString(doc.getLength(), "\n", SimpleAttributeSet.EMPTY); - text.insertComponent(SaveImageAction.createSaveImageButton(img)); - } catch (BadLocationException e) { - LoggerHelper.error(Language.ERROR_UNEXPECTED_EXCEPTION.getString(), e, getClass()); - } - } - } catch (IOException e) { - LoggerHelper.error(Language.ERROR_UNEXPECTED_EXCEPTION.getString(), e, getClass()); - } - setTextEditableRoutine(false); - } else if (stream.get(PdfName.Length1) != null) { - try { - setTextEditableRoutine(true); - byte[] bytes = stream.getBytes(false); - // This is binary content, so encoding doesn't really matter - text.setText(new String(bytes, StandardCharsets.ISO_8859_1)); - text.setCaretPosition(0); - } catch (com.itextpdf.io.exceptions.IOException e) { - text.setText(""); - setTextEditableRoutine(false); - } - } else { - renderGenericContentStream(stream); - } - text.repaint(); - manager.setLimit(MAX_NUMBER_OF_EDITS); - repaint(); - } - - public void saveToTarget() { - /* - * FIXME: With indirect objects with multiple references, this will - * change the tree only in one of them. - * FIXME: This doesn't change Length... - */ - manager.discardAllEdits(); - manager.setLimit(0); - if (controller != null && ((PdfDictionary) target.getPdfObject()).containsKey(PdfName.Filter)) { - controller.deleteTreeNodeDictChild(target, PdfName.Filter); - } - /* - * In the current state, stream node could contain ASN1. data, which - * is parsed and added as tree nodes. After editing, it won't be valid, - * so we must remove them. - */ - if (controller != null) { - int i = 0; - while (i < target.getChildCount()) { - final TreeNode child = target.getChildAt(i); - if (child instanceof PdfObjectTreeNode) { - ++i; - } else { - controller.deleteTreeChild(target, i); - // Will assume it being just a shift... - } - } - } - final int sizeEst = text.getText().length(); - final ByteArrayOutputStream baos = new ByteArrayOutputStream(sizeEst); - try { - new ContentStreamWriter(baos).write(text.getDocument()); - } catch (IOException e) { - LoggerHelper.error(Language.ERROR_UNEXPECTED_EXCEPTION.getString(), e, getClass()); - } - ((PdfStream) target.getPdfObject()).setData(baos.toByteArray()); - if (controller != null) { - controller.selectNode(target); - } - manager.setLimit(MAX_NUMBER_OF_EDITS); - } - - public void setEditable(boolean editable) { - this.editable = editable; - setTextEditableRoutine(editable); - } - - @Override - public void handleCloseDocument() { - clearPane(); - setEditable(false); - } - - @Override - public void handleOpenDocument(ObjectLoader loader) { - clearPane(); - setEditable(loader.getFile().isOpenedAsOwner()); - } - - private void setTextEditableRoutine(boolean editable) { - if (!this.editable) { - text.setEditable(false); - popupMenu.setSaveToStreamEnabled(false); - return; - } - - text.setEditable(editable); - if ((pdfStreamGetInputStreamMethod != null) && editable && (target != null) && - (target.getPdfObject() instanceof PdfStream)) { - try { - popupMenu.setSaveToStreamEnabled(pdfStreamGetInputStreamMethod.invoke(target.getPdfObject()) == null); - return; - } catch (Exception any) { - LoggerHelper.error(Language.ERROR_CANNOT_CHECK_NULL_FOR_INPUT_STREAM.getString(), any, getClass()); - } - } - popupMenu.setSaveToStreamEnabled(false); - } - - private void clearPane() { - target = null; - manager.discardAllEdits(); - manager.setLimit(0); - text.setText(""); - setTextEditableRoutine(false); - } - - private void renderGenericContentStream(PdfStream stream) { - final StyledSyntaxDocument doc = (StyledSyntaxDocument) text.getDocument(); - setTextEditableRoutine(true); - - byte[] bb = null; - try { - bb = stream.getBytes(); - doc.processContentStream(bb); - } catch (PdfException | com.itextpdf.io.exceptions.IOException e) { - LoggerHelper.warn(Language.ERROR_PARSING_PDF_STREAM.getString(), e, getClass()); - if (bb != null) { - text.setText(new String(bb, StandardCharsets.ISO_8859_1)); - } - } - text.setCaretPosition(0); // set the caret at the start so the panel will show the first line - } - - private static final class JSyntaxPane extends JTextPane { - - JSyntaxPane() { - super(new StyledSyntaxDocument()); - } - - StyledSyntaxDocument getStyledSyntaxDocument() { - // can't just override getDocument() because the superclass - // constructor relies on it - return (StyledSyntaxDocument) super.getDocument(); - } - - @Override - public String getToolTipText(MouseEvent ev) { - final String toolTip = getStyledSyntaxDocument() - .getToolTipAt(viewToModel2D(ev.getPoint())); - return toolTip == null ? super.getToolTipText(ev) : toolTip; - } - - @Override - public boolean getScrollableTracksViewportWidth() { - // Disable line wrapping by ensuring the text pane is never resized smaller than its preferred width - return getParent().getSize().width > getUI().getPreferredSize(this).width; - } - } - -} - - -class UndoAction extends AbstractAction { - private final UndoManager manager; - - public UndoAction(UndoManager manager) { - this.manager = manager; - } - - public void actionPerformed(ActionEvent evt) { - try { - manager.undo(); - } catch (CannotUndoException e) { - Toolkit.getDefaultToolkit().beep(); - } - } -} - -class RedoAction extends AbstractAction { - private final UndoManager manager; - - public RedoAction(UndoManager manager) { - this.manager = manager; - } - - public void actionPerformed(ActionEvent evt) { - try { - manager.redo(); - } catch (CannotRedoException e) { - Toolkit.getDefaultToolkit().beep(); - } - } -} diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/Latin1Filter.java b/src/main/java/com/itextpdf/rups/view/itext/editor/Latin1Filter.java new file mode 100644 index 00000000..719325cd --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/Latin1Filter.java @@ -0,0 +1,180 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2024 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import com.itextpdf.kernel.exceptions.PdfException; +import com.itextpdf.rups.view.Language; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; +import java.nio.charset.StandardCharsets; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DocumentFilter; + +/** + * A document filter, which retains Latin-1 characters as-is, and for all + * others returns a "byte equivalent" UTF-8 encoding of characters. + * + *

+ * This is, pretty much, a hack to allow working with a PDF byte stream as a + * char stream with trivial conversions. At all points in time the document + * characters will have codepoints in a 0-255 range and can be freely encoded + * as Latin-1. + *

+ * + *

+ * Under this filter, if you type, for example, "į" (U+012F), you would get + * "į" instead (U+00C4 U+00AF, which is the UTF-8 encoding of the symbol, + * where each byte is padded to two bytes). + *

+ */ +public final class Latin1Filter extends DocumentFilter { + /** + * Pre-allocated output buffer for the UTF-8 character encoder. + */ + private final ByteBuffer utf8CharBuffer = ByteBuffer.allocate(4); + + @Override + public void insertString(FilterBypass fb, int offset, String string, AttributeSet attr) + throws BadLocationException { + super.insertString(fb, offset, generateSubstitute(string), attr); + } + + @Override + public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) + throws BadLocationException { + super.replace(fb, offset, length, generateSubstitute(text), attrs); + } + + private String generateSubstitute(String original) { + /* + * If text is encodable in Latin-1, just return the string as-is. + * This is a very common case, as the majority of PDF content streams + * contains just ASCII text, so a separate branch at the start should + * be worth it to avoid any allocations. + */ + int index = getNonLatin1Index(original); + if (index >= original.length()) { + return original; + } + /* + * Otherwise we build a substitute string, where non-Latin-1 chars + * are replaced with UTF-8 "bytes". We will assume there is only + * one inconvenient character for pre-allocation (thus +3). + */ + final CharsetEncoder utf8Encoder = StandardCharsets.UTF_8.newEncoder(); + final StringBuilder substitute = initStringBuilder(original, index); + while (index < original.length()) { + /* + * Encoding 1 non-Latin-1 code point first. + */ + final char ch = original.charAt(index); + utf8CharBuffer.clear(); + int end = index + 1; + if (Character.isHighSurrogate(ch) && end < original.length()) { + ++end; + } + final CharBuffer encoderInput = CharBuffer.wrap(original, index, end); + final CoderResult result = utf8Encoder.encode(encoderInput, utf8CharBuffer, true); + if (!result.isUnderflow()) { + throwException(result); + } + for (int j = 0; j < utf8CharBuffer.position(); ++j) { + substitute.append((char) (utf8CharBuffer.get(j) & 0xFF)); + } + /* + * At the end append the possible remaining Latin-1 part. + */ + index = getNonLatin1Index(original, end); + substitute.append(original, end, index); + } + return substitute.toString(); + } + + private static void throwException(CoderResult cr) { + try { + cr.throwException(); + } catch (CharacterCodingException e) { + throw new PdfException(Language.ERROR_CHARACTER_ENCODING.getString(), e); + } + } + + private static int getNonLatin1Index(CharSequence cs) { + return getNonLatin1Index(cs, 0); + } + + private static int getNonLatin1Index(CharSequence cs, int start) { + int index = start; + while (index < cs.length() && isLatin1(cs.charAt(index))) { + ++index; + } + return index; + } + + private static boolean isLatin1(char c) { + return c <= '\u00FF'; + } + + private static StringBuilder initStringBuilder(String str, int nonLatin1Index) { + int capacity = nonLatin1Index; + final int suffixLength = str.length() - nonLatin1Index; + /* + * For small enough strings just assume the worst case scenario and + * allocate 4 "bytes" for each char in suffix. Otherwise, just do + * something more conservative like 1.25 "bytes" per char. + */ + if (suffixLength <= 1024) { + capacity += (4 * suffixLength); + } else { + capacity += (5 * suffixLength / 4); + } + final StringBuilder sb = new StringBuilder(capacity); + // Immediately add the Latin-1 part + sb.append(str, 0, nonLatin1Index); + return sb; + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfToken.java b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfToken.java new file mode 100644 index 00000000..9c498c27 --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfToken.java @@ -0,0 +1,230 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import java.awt.Rectangle; +import java.lang.reflect.Method; +import javax.swing.text.Segment; +import javax.swing.text.TabExpander; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.Token; +import org.fife.ui.rsyntaxtextarea.TokenImpl; +import org.fife.ui.rsyntaxtextarea.TokenPainter; + +/** + * {@link Token} implementation, which respect the painter of the text area, + * when calculating lengths and offsets. + * + *

+ * Overridden code is heavily inspired by the original implementation + * in {@link TokenImpl}. + *

+ */ +public final class PdfToken extends TokenImpl { + /* + * For some reason caret positioning logic in RSyntaxTextArea does not + * take the painter into the account. It calls methods within the Token + * interface, which try to calculate the text width on its own. + * + * This works fine in the default configuration, when both painter and + * TokenImpl has the same logic to calculate text width (i.e. just + * rendering text as-is). But since we want to show non-ASCII symbols + * differently, this no longer works. + * + * Ideally, we should just reuse methods in the Painter to calculate + * widths of what will be drawn. And this would work, but for some reason + * RSyntaxTextArea#getTokenPainter is declared package-private and cannot + * be accessed by a custom implementation. + * + * So we have a nasty reflection here for the time being to get access to + * that painter instead of hardcoding our own here. + */ + private static final Method GET_TOKEN_PAINTER_METHOD; + + static { + try { + GET_TOKEN_PAINTER_METHOD = RSyntaxTextArea.class.getDeclaredMethod("getTokenPainter"); + GET_TOKEN_PAINTER_METHOD.setAccessible(true); + } catch (NoSuchMethodException | SecurityException e) { + throw new IllegalStateException(e); + } + } + + public PdfToken() { + } + + public PdfToken(Segment line, int beg, int end, int startOffset, int type, int languageIndex) { + super(line, beg, end, startOffset, type, languageIndex); + } + + public PdfToken(char[] line, int beg, int end, int startOffset, int type, int languageIndex) { + super(line, beg, end, startOffset, type, languageIndex); + } + + public PdfToken(Token t2) { + super(t2); + } + + @Override + public int getListOffset(RSyntaxTextArea textArea, TabExpander e, float x0, float x) { + int offset = getOffset(); + + // If the coordinate in question is before this line's start, quit. + if (x0 >= x) { + return offset; + } + + final TokenPainter painter = getTokenPainter(textArea); + Token token = this; + float startX = x0; + float avgWidthPerChar = 0; + while (token != null && token.isPaintable()) { + final float endX = painter.nextX(token, token.length(), startX, textArea, e); + // Found the token for the offset + if (x < endX) { + avgWidthPerChar = (endX - startX) / token.length(); + break; + } + startX = endX; + offset += token.length(); + token = token.getNextToken(); + } + + // If we didn't find anything, return the end position of the text. + if (token == null || !token.isPaintable()) { + return offset; + } + + // Search for the char offset now + final int hint = (int) ((x - startX) / avgWidthPerChar); + final int charCount = getCharCountBeforeX(textArea, e, painter, token, startX, x, hint); + offset += charCount; + + // Checking if closer to next char + if (charCount < token.length()) { + final float prevX = painter.nextX(token, charCount, startX, textArea, e); + final float nextX = painter.nextX(token, charCount + 1, startX, textArea, e); + if ((x - prevX) > (nextX - x)) { + ++offset; + } + } + + return offset; + } + + @Override + public int getOffsetBeforeX(RSyntaxTextArea textArea, TabExpander e, float startX, float endBeforeX) { + final int textLength = length(); + // Same as in TokenImpl, 1 length token always fit to avoid inf loop + if (textLength <= 1) { + return getOffset(); + } + final TokenPainter painter = getTokenPainter(textArea); + final int charCount = getCharCountBeforeX(textArea, e, painter, this, startX, endBeforeX, 2); + return getOffset() + charCount - 1; + } + + @Override + public float getWidthUpTo(int numChars, RSyntaxTextArea textArea, TabExpander e, float x0) { + final TokenPainter painter = getTokenPainter(textArea); + return painter.nextX(this, numChars, x0, textArea, e) - x0; + } + + @Override + public Rectangle listOffsetToView(RSyntaxTextArea textArea, TabExpander e, int pos, int x0, Rectangle rect) { + final TokenPainter painter = getTokenPainter(textArea); + Token token = this; + float startX = x0; + while (token != null && token.isPaintable()) { + if (token.containsPosition(pos)) { + final int charOffset = pos - token.getOffset(); + final float endX = painter.nextX(token, charOffset + 1, startX, textArea, e); + if (charOffset > 0) { + startX = painter.nextX(token, charOffset, startX, textArea, e); + } + rect.x = (int) startX; + rect.width = (int) (endX - startX); + return rect; + } + startX = painter.nextX(token, token.length(), startX, textArea, e); + token = token.getNextToken(); + } + + // If we didn't find anything, we're at the end of the line. Return + // a width of 1 (so selection highlights don't extend way past line's + // text). A ConfigurableCaret will know to paint itself with a larger + // width. + rect.x = (int) startX; + rect.width = 1; + return rect; + } + + private static int getCharCountBeforeX(RSyntaxTextArea textArea, TabExpander e, TokenPainter painter, + Token token, float startX, float endBeforeX, int hint) { + final float width = endBeforeX - startX; + int left = 0; + int right = token.length(); + int current = Math.max(1, Math.min(hint, token.length())); + while (left < right) { + final float x = painter.nextX(token, current, startX, textArea, e); + final float avgWidthPerChar = (x - startX) / current; + final int expectedCharCount = (int) (width / avgWidthPerChar); + if (x <= endBeforeX) { + left = current; + current = Math.min(expectedCharCount + 1, right); + } else { + right = current - 1; + current = Math.max(expectedCharCount, left); + } + } + return left; + } + + private static TokenPainter getTokenPainter(RSyntaxTextArea host) { + try { + return (TokenPainter) GET_TOKEN_PAINTER_METHOD.invoke(host); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenMaker.java b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenMaker.java new file mode 100644 index 00000000..4ab3705c --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenMaker.java @@ -0,0 +1,329 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2024 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import com.itextpdf.rups.model.contentstream.ParseTreeNode; +import com.itextpdf.rups.model.contentstream.ParseTreeNodeType; +import com.itextpdf.rups.model.contentstream.PdfContentStreamParser; +import com.itextpdf.rups.model.contentstream.PdfOperators; + +import java.util.Iterator; +import javax.swing.text.Segment; +import org.fife.ui.rsyntaxtextarea.Token; +import org.fife.ui.rsyntaxtextarea.TokenMakerBase; + +/** + * RSyntaxTextArea token maker, which handles PDF content streams. + * + *

+ * This class really wants to just implement TokenMaker, as {@code firstToken}, + * {@code currentToken}, {@code previousToken} and {@code tokenFactory} from + * {@link org.fife.ui.rsyntaxtextarea.TokenMakerBase} are of no use here. But + * just implementing the interface would force us to copy a lot of code from + * the library, and, for some reason {@code DefaultOccurrenceMarker} is marked + * as package-private, so we would need to reimplement that as well. + *

+ * + *

+ * So, at the moment, these fields from TokenMakerBase should be ignored. For + * token manipulation, {@code firstPdfToken} and {@code lastPdfToken} should + * be used instead. + *

+ * + *

+ * This class is expected to be used with a text area, which has a + * {@link Latin1Filter} on the underlying document. This is used as a way to + * represent a byte stream as a string. + *

+ */ +public final class PdfTokenMaker extends TokenMakerBase { + /** + * Special internal token type marker to signify, that previous line ended + * within a hexadecimal string, which was yet to be closed. + */ + private static final int MULTI_LINE_STRING_HEX = Integer.MIN_VALUE; + + /** + * Content stream parser used for token parsing. + */ + private final PdfContentStreamParser pdfContentStreamParser = new PdfContentStreamParser(); + + /** + * First token in the output token list. Should be used instead of + * {@code firstToken}. + */ + private PdfToken firstPdfToken = null; + /** + * Last token in the output token list. Should be used instead of + * {@code lastToken}. + */ + private PdfToken lastPdfToken = null; + + @Override + public void addNullToken() { + final PdfToken token = new PdfToken(); + token.setLanguageIndex(getLanguageIndex()); + addToken(token); + } + + @Override + public void addToken(char[] array, int start, int end, int tokenType, int startOffset, boolean hyperlink) { + final PdfToken token = new PdfToken(array, start, end, startOffset, tokenType, getLanguageIndex()); + token.setHyperlink(hyperlink); + addToken(token); + } + + @Override + public boolean getMarkOccurrencesOfTokenType(int type) { + switch (type) { + case PdfTokenTypes.NAME: + case PdfTokenTypes.OPERATOR: + return true; + default: + return false; + } + } + + @Override + public String[] getLineCommentStartAndEnd(int languageIndex) { + return new String[] { "%", null }; + } + + @Override + public boolean getShouldIndentNextLineAfter(Token token) { + if (token == null) { + return false; + } + // TODO: Re-implement automatic indentation + return false; + } + + @Override + protected void resetTokenList() { + firstPdfToken = null; + lastPdfToken = null; + super.resetTokenList(); + } + + @Override + public Token getTokenList(Segment text, int startTokenType, int startOffset) { + resetTokenList(); + pdfContentStreamParser.reset(); + + /* + * This is some special handling for multi-line strings. + * + * For cases, when previous line contained a part of a hex string, but + * it wasn't close, we will get a specific negative value to detect + * that. This means, that current line will continue the hex string + * body. + * + * For cases of literal string we also need to know parentheses + * balance. So any other negative value will indicate that. And the + * negation of that number will give the current parentheses balance. + * Current string will continue inside the composite literal type. + */ + int leafsToSkip = 0; + if (startTokenType == MULTI_LINE_STRING_HEX) { + leafsToSkip = 1; + pdfContentStreamParser.append('<', leafsToSkip); + } else if (startTokenType < 0) { + leafsToSkip = -startTokenType; + pdfContentStreamParser.append('(', leafsToSkip); + } + + pdfContentStreamParser.append(text); + + final Iterator it = pdfContentStreamParser.result().primitiveNodeIterator(); + ParseTreeNode node = null; + // Skipping artificially added tokens + for (int i = 0; i < leafsToSkip; ++i) { + node = it.next(); + } + while (it.hasNext()) { + node = it.next(); + addToken(text, node, startOffset); + } + + handleMultiline(text, node, startOffset); + return firstPdfToken; + } + + /** + * Appends a PdfToken to the output token list. + * + * @param token Token to append. + */ + private void addToken(PdfToken token) { + if (firstPdfToken == null) { + firstPdfToken = token; + } else { + lastPdfToken.setNextToken(token); + } + lastPdfToken = token; + } + + /** + * Handles adding internal tokens to preserve information on non-closed + * strings. + * + * @param text The text from which to get tokens. + * @param lastLeaf Last primitive node, which was parsed from + * {@code text}. + * @param startOffset The offset into the document at which {@code text} + * starts. + */ + private void handleMultiline(Segment text, ParseTreeNode lastLeaf, int startOffset) { + if (lastLeaf == null || lastLeaf.isRoot()) { + addNullToken(); + return; + } + + // Hex strings cannot be nested, so we only need to check, that last + // non-leaf element was a non-closed hex string + if (lastLeaf.getType() != ParseTreeNodeType.STRING_HEX_CLOSE + && lastLeaf.getParent().getType() == ParseTreeNodeType.STRING_HEX) { + addInternalToken(text, MULTI_LINE_STRING_HEX, startOffset); + return; + } + + // Literal strings preserve parentheses balance, so we need to keep that + if (lastLeaf.getParent().getType() == ParseTreeNodeType.STRING_LITERAL) { + ParseTreeNode walker = lastLeaf; + int balance = 0; + while (walker != null) { + if (walker.getType() == ParseTreeNodeType.STRING_LITERAL_OPEN) { + ++balance; + } else if (walker.getType() == ParseTreeNodeType.STRING_LITERAL_CLOSE) { + --balance; + } + walker = walker.getPrev(); + } + if (balance > 0) { + addInternalToken(text, -balance, startOffset); + return; + } + } + + // Otherwise everything is fine, just add a null token + addNullToken(); + } + + /** + * Appends an internal token to the output token list. + * + *

+ * These are token, which are used to signify non-closed strings. + *

+ * + * @param text {@code Segment} to get text from. + * @param type The token's type. + * @param startOffset The offset in the document at which this token occurs. + */ + private void addInternalToken(Segment text, int type, int startOffset) { + final int index = text.offset + text.count - 1; + addToken(text.array, index, index, type, startOffset); + } + + /** + * Appends a token to the output token list, based on the PDF content + * stream parse node. + * + * @param text {@code Segment} to get text from. + * @param primitiveNode PDF content stream parse node to create token from. + * @param startOffset The offset in the document at which this token occurs. + */ + private void addToken(Segment text, ParseTreeNode primitiveNode, int startOffset) { + final char[] array = primitiveNode.getTextArray(); + final int start = primitiveNode.getTextOffset(); + final int end = start + primitiveNode.getTextCount() - 1; + addToken(array, start, end, getTokenType(primitiveNode), startOffset + (start - text.offset)); + } + + /** + * Maps PDF content stream parse node type to a TokenType value. + * + * @param leafNode Node to map the type of. + * + * @return TokenType value for the node. + */ + private static int getTokenType(ParseTreeNode leafNode) { + switch (leafNode.getType()) { + case WHITESPACE: + return PdfTokenTypes.WHITESPACE; + case COMMENT: + return PdfTokenTypes.COMMENT; + case BOOLEAN: + return PdfTokenTypes.BOOLEAN; + case NUMERIC: + return PdfTokenTypes.NUMERIC; + case STRING_LITERAL_DATA: + case STRING_HEX_DATA: + return PdfTokenTypes.STRING_DATA; + case NAME: + return PdfTokenTypes.NAME; + case NULL: + return PdfTokenTypes.NULL; + case OPERATOR: + if (leafNode.isOperator(PdfOperators.Do)) { + return PdfTokenTypes.FUNCTION; + } else { + return PdfTokenTypes.OPERATOR; + } + case BINARY_DATA: + return PdfTokenTypes.BINARY_DATA; + case STRING_LITERAL_OPEN: + case STRING_LITERAL_CLOSE: + case STRING_HEX_OPEN: + case STRING_HEX_CLOSE: + case ARRAY_OPEN: + case ARRAY_CLOSE: + case DICTIONARY_OPEN: + case DICTIONARY_CLOSE: + return PdfTokenTypes.SEPARATOR; + default: + // Other types are not primitive... Should not get them here + return PdfTokenTypes.ERROR; + } + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainter.java b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainter.java new file mode 100644 index 00000000..a0c0fc38 --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainter.java @@ -0,0 +1,558 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.util.Arrays; +import javax.swing.text.TabExpander; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.Token; +import org.fife.ui.rsyntaxtextarea.TokenPainter; +import org.fife.ui.rsyntaxtextarea.TokenTypes; + +/** + * Special {@link TokenPainter} implementation for working with + * {@link PdfTokenMaker}. + * + *

+ * As of now, base logic of the this painter is the same as + * {@link org.fife.ui.rsyntaxtextarea.DefaultTokenPainter} with most of the + * code copied from there, but with the following changes: + *

+ * + *
    + *
  1. + * Tokens of the {@link PdfTokenTypes#BINARY_DATA} are not painted as text, + * but as boxes with hex codes of the lower byte of each character. + *
  2. + *
  3. + * For all other tokens, only HT, LF and visible ASCII characters are + * painted as characters. Other character are painted as if they belong + * to a {@link PdfTokenTypes#BINARY_DATA} token. + *
  4. + *
+ * + *

+ * This painter should be used together with the {@link Latin1Filter}, as it + * expects the input character arrays to, essentially, be a byte array, where + * each byte is padded to two bytes. + *

+ */ +public final class PdfTokenPainter implements TokenPainter { + /** + * Character used to pad the hex representation of "binary characters" + * horizontally. + * + *

+ * Currently this is THIN SPACE U+2009. + *

+ */ + private static final char PAD_CHAR = '\u2009'; + /** + * Border stroke used to highlight "binary characters". It is drawn over + * the whole sequence, not individual characters. + */ + private static final BasicStroke BINARY_BORDER_STROKE = new BasicStroke( + 1F, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER + ); + /** + * Array for mapping 4-bit integers to their hex display character. + */ + private static final char[] NIBBLE_TO_HEX_MAP = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', + }; + /** + * Array for mapping a single byte character code to whether it should be + * rendered as binary data or not. + */ + private static final boolean[] IS_BINARY_MAP = { + true, true, true, true, true, true, true, true, true, false, + false, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, true, true, true, true, + true, true, true, true, true, true, + }; + + /** + * Cache for {@link FontMetrics} data from {@link RSyntaxTextArea}. + * + *

+ * This cache is pretty important, as this code is performance-sensitive + * and {@link RSyntaxTextArea#getFontMetricsForTokenType(int)} calls are + * heavy enough to light up in the profiler. + *

+ */ + private final FontMetricsCache fontMetricsCache = new FontMetricsCache(); + /** + * Buffer used to group characters, which could be rendered the same way + * (binary or text) in one go. + * + *

+ * This is just a pre-allocated buffer object for performance. + *

+ */ + private final FlushBuffer flushBuffer = new FlushBuffer(null, 0, 0, FlushType.TEXT); + /** + * Temporary buffer used to store display text representation of binary + * data. + * + *

+ * This is just a pre-allocated buffer for performance. + *

+ */ + private final RawBuffer binaryDisplayBuffer = new RawBuffer(new char[0]); + + /** + * Creates a new token painter. + * + * @param textArea Text area, for which token painter will be used for. + */ + public PdfTokenPainter(RSyntaxTextArea textArea) { + // This is pretty important to synchronize our font cache with + // possible style changes + textArea.addPropertyChangeListener( + RSyntaxTextArea.SYNTAX_SCHEME_PROPERTY, + fontMetricsCache + ); + } + + @Override + public float nextX(Token token, int charCount, float x, RSyntaxTextArea host, TabExpander e) { + final FontMetrics fontMetrics = fontMetricsCache.get(host, token); + final char[] text = token.getTextArray(); + final int textBegin = token.getTextOffset(); + final int textEnd = textBegin + charCount; + flushBuffer.reset(text, textBegin); + float nextX = x; + for (int textIndex = textBegin; textIndex < textEnd; ++textIndex) { + final char ch = text[textIndex]; + + if (isBinaryData(token, ch)) { + if (flushBuffer.type != FlushType.BINARY) { + nextX += charsWidth(fontMetrics, getFlushChars()); + flushBuffer.moveOffset(textIndex, FlushType.BINARY); + } + ++flushBuffer.length; + } else if (ch != '\t') { + if (flushBuffer.type != FlushType.TEXT) { + nextX += charsWidth(fontMetrics, getFlushChars()); + flushBuffer.moveOffset(textIndex, FlushType.TEXT); + } + ++flushBuffer.length; + } else { + nextX += charsWidth(fontMetrics, getFlushChars()); + nextX = e.nextTabStop(nextX, 0); + flushBuffer.moveOffset(textIndex + 1, FlushType.TEXT); + } + } + nextX += charsWidth(fontMetrics, getFlushChars()); + + return nextX; + } + + @Override + public float paint(Token token, Graphics2D g, float x, float y, RSyntaxTextArea host, TabExpander e) { + return paintImpl(token, g, x, y, host, e, 0, false, false); + } + + @Override + public float paint(Token token, Graphics2D g, float x, float y, RSyntaxTextArea host, TabExpander e, + float clipStart) { + return paintImpl(token, g, x, y, host, e, clipStart, false, false); + } + + @Override + public float paint(Token token, Graphics2D g, float x, float y, RSyntaxTextArea host, TabExpander e, + float clipStart, boolean paintBG) { + return paintImpl(token, g, x, y, host, e, clipStart, !paintBG, false); + } + + @Override + public float paintSelected(Token token, Graphics2D g, float x, float y, RSyntaxTextArea host, TabExpander e, + boolean useSTC) { + return paintImpl(token, g, x, y, host, e, 0, true, useSTC); + } + + @Override + public float paintSelected(Token token, Graphics2D g, float x, float y, RSyntaxTextArea host, TabExpander e, + float clipStart, boolean useSTC) { + return paintImpl(token, g, x, y, host, e, clipStart, true, useSTC); + } + + private float paintImpl(Token token, Graphics2D g, float x, float y, + RSyntaxTextArea host, TabExpander e, float clipStart, + boolean selected, boolean useSTC) { + g.setFont(host.getFontForToken(token)); + final FontMetrics fm = fontMetricsCache.get(host, token); + final Color bg = getBackgroundColor(token, host, selected); + final Color fg = getForegroundColor(token, host, useSTC); + final char[] text = token.getTextArray(); + final int textBegin = token.getTextOffset(); + final int textEnd = textBegin + token.length(); + flushBuffer.reset(text, textBegin); + float nextX = x; + for (int textIndex = textBegin; textIndex < textEnd; ++textIndex) { + final char ch = text[textIndex]; + + if (isBinaryData(token, ch)) { + if (flushBuffer.type != FlushType.BINARY) { + nextX = drawFlushChars(g, fm, fg, bg, nextX, y, clipStart); + flushBuffer.moveOffset(textIndex, FlushType.BINARY); + } + ++flushBuffer.length; + } else if (ch != '\t') { + if (flushBuffer.type != FlushType.TEXT) { + nextX = drawFlushChars(g, fm, fg, bg, nextX, y, clipStart); + flushBuffer.moveOffset(textIndex, FlushType.TEXT); + } + ++flushBuffer.length; + } else { + final float currX = drawFlushChars(g, fm, fg, bg, nextX, y, clipStart); + nextX = e.nextTabStop(currX, 0); + if (nextX >= clipStart) { + drawBackground(g, fm, bg, currX, y, (int) (nextX - currX)); + } + flushBuffer.moveOffset(textIndex + 1, FlushType.TEXT); + } + } + nextX = drawFlushChars(g, fm, fg, bg, nextX, y, clipStart); + + // Underline + if (nextX >= clipStart && host.getUnderlineForToken(token)) { + final int underlineY = (int) y + 1; + g.drawLine((int) x, underlineY, (int) nextX, underlineY); + } + + // Ignoring PaintTabLines from DefaultTokenPainter for now + + return nextX; + } + + /** + * Returns the character sequence to paint for the current flush buffer + * state. + * + * @return The character sequence to paint for the current flush buffer + * state. + */ + private RawBuffer getFlushChars() { + switch (flushBuffer.type) { + default: + case TEXT: + return flushBuffer; + case BINARY: + return prepareBinaryDataDisplay(flushBuffer); + } + } + /** + * Renders characters currently stored in the flush buffer. + * + * @param g The graphics context in which to draw. + * @param fm Font metrics of the font used. Should already be set in the + * context. + * @param fg Foreground color to use. + * @param bg Background color to use. + * @param x The x-coordinate at which to draw. + * @param y The y-coordinate at which to draw. + * @param clipStart Whether to start clipping, or {@code 0} to clip nothing. + * + * @return The x-coordinate representing the end of the painted text. + */ + private float drawFlushChars(Graphics2D g, FontMetrics fm, Color fg, Color bg, float x, float y, float clipStart) { + final RawBuffer chars = getFlushChars(); + final int width = charsWidth(fm, chars); + if (width <= 0) { + return x; + } + + final float nextX = x + width; + if (nextX >= clipStart) { + drawBackground(g, fm, bg, x, y, width); + + // Box (to signify binary block) + g.setColor(fg); + if (flushBuffer.type == FlushType.BINARY) { + g.setStroke(BINARY_BORDER_STROKE); + g.drawRect( + (int) (x), + (int) (y - fm.getAscent() + BINARY_BORDER_STROKE.getLineWidth()), + width, + (int) (fm.getHeight() - 2 * BINARY_BORDER_STROKE.getLineWidth()) + ); + } + + // Text + g.drawChars(chars.text, chars.offset, chars.length, (int) x, (int) y); + } + + return nextX; + } + + /** + * Prepare the binary data for painting based on input bytes. At the end + * {@code binaryDisplayBuffer} will be filled with display characters. + * + * @param bytes Bytes to display. + * + * @return Filled {@code binaryDisplayBuffer}. + */ + private RawBuffer prepareBinaryDataDisplay(RawBuffer bytes) { + int bytesIndex = bytes.offset; + // 2 pad chars + 2 hex digits + final int displaySize = 4 * bytes.length; + if (binaryDisplayBuffer.text.length < displaySize) { + binaryDisplayBuffer.text = new char[displaySize]; + } + for (int i = 0; i < displaySize; i += 4) { + final int ch = bytes.text[bytesIndex]; + assert 0 < ch && ch <= 0xFF; + binaryDisplayBuffer.text[i] = PAD_CHAR; + binaryDisplayBuffer.text[i + 1] = NIBBLE_TO_HEX_MAP[(ch >>> 4) & 0xF]; + binaryDisplayBuffer.text[i + 2] = NIBBLE_TO_HEX_MAP[ch & 0xF]; + binaryDisplayBuffer.text[i + 3] = PAD_CHAR; + bytesIndex++; + } + binaryDisplayBuffer.length = displaySize; + return binaryDisplayBuffer; + } + + /** + * Returns foreground color for token. + * + * @param token The token to render. + * @param host The text area this token is in. + * @param useSTC Whether to use the text area's "selected text color." + * + * @return Foreground color for the token. + */ + private static Color getForegroundColor(Token token, RSyntaxTextArea host, boolean useSTC) { + if (useSTC) { + return host.getSelectedTextColor(); + } + return host.getForegroundForToken(token); + } + + /** + * Returns background color for token. + * + * @param token The token to render. + * @param host The text area this token is in. + * @param selected Whether token is selected. + * + * @return Background color for the token. + */ + private static Color getBackgroundColor(Token token, RSyntaxTextArea host, boolean selected) { + if (selected) { + return null; + } + return host.getBackgroundForToken(token); + } + + /** + * Returns whether the character should be painted as binary data. + * + * @param token Token to which the character belongs. + * @param ch Character to be painted. + * + * @return Whether the character should be painted as binary data. + */ + private static boolean isBinaryData(Token token, char ch) { + // With how the text area is set up with a "byte" filter, this should + // always be true + assert ch < IS_BINARY_MAP.length; + return (token.getType() == PdfTokenTypes.BINARY_DATA) || IS_BINARY_MAP[ch]; + } + + private static int charsWidth(FontMetrics fontMetrics, RawBuffer chars) { + return fontMetrics.charsWidth(chars.text, chars.offset, chars.length); + } + + private static void drawBackground(Graphics2D g, FontMetrics fm, Color bg, float x, float y, int width) { + if (bg == null) { + return; + } + g.setColor(bg); + g.fillRect((int) x, (int) y - fm.getAscent(), width, fm.getHeight()); + } + + /** + * Type selection for flush buffers. + */ + private enum FlushType { + TEXT, + BINARY, + } + + /** + * The most basic version of {@link java.nio.CharBuffer}. + * + *

+ * This is used for performance reasons, as it allows us to limit call + * counts and index checks. + *

+ */ + private static class RawBuffer { + protected char[] text; + protected int offset; + protected int length; + + protected RawBuffer(char[] text) { + this.text = text; + this.offset = 0; + this.length = text.length; + } + + protected RawBuffer(char[] text, int offset, int length) { + this.text = text; + this.offset = offset; + this.length = length; + } + } + + /** + * Raw buffer, which tracks, which type of data is stored in it. + */ + private static final class FlushBuffer extends RawBuffer { + private FlushType type; + + private FlushBuffer(char[] text, int offset, int length, FlushType type) { + super(text, offset, length); + this.type = type; + } + + private void moveOffset(int offset, FlushType type) { + this.offset = offset; + this.length = 0; + this.type = type; + } + + private void reset(char[] text, int offset) { + this.text = text; + this.offset = offset; + this.length = 0; + this.type = FlushType.TEXT; + } + } + + /** + * Cache for {@link RSyntaxTextArea#getFontMetricsForTokenType(int)} + * results. + * + *

+ * This was made because for some reason that method light up pretty hard + * during profiling, so caching its value improves performance. + *

+ */ + private static final class FontMetricsCache implements PropertyChangeListener { + /** + * Text area, for which cache currently stores result. + */ + private RSyntaxTextArea currentHost = null; + /** + * Call result storage. + */ + private final FontMetrics[] cache = new FontMetrics[TokenTypes.DEFAULT_NUM_TOKEN_TYPES]; + + @Override + public void propertyChange(PropertyChangeEvent evt) { + // If style changed, we need to reset the cache, otherwise the + // results might be wrong + reset(); + } + + /** + * Calls {@code host.getFontMetricsForToken(token)} through the cache. + * + * @param host Text area to call the method on. + * @param token Token to use as the argument. + * + * @return Result of the call. + */ + private FontMetrics get(RSyntaxTextArea host, Token token) { + final int type = token.getType(); + if (host != currentHost) { + reset(); + currentHost = host; + } + FontMetrics result = cache[type]; + if (result == null) { + result = host.getFontMetricsForTokenType(type); + cache[type] = result; + } + return result; + } + + /** + * Resets cache to its initial empty state. + */ + private void reset() { + Arrays.fill(cache, null); + } + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainterFactory.java b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainterFactory.java new file mode 100644 index 00000000..125fd08c --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainterFactory.java @@ -0,0 +1,60 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.TokenPainter; +import org.fife.ui.rsyntaxtextarea.TokenPainterFactory; + +/** + * Returns the {@link PdfTokenPainter} to use for a text area. + */ +public final class PdfTokenPainterFactory implements TokenPainterFactory { + /** + * {@inheritDoc} + */ + @Override + public TokenPainter getTokenPainter(RSyntaxTextArea textArea) { + return new PdfTokenPainter(textArea); + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenTypes.java b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenTypes.java new file mode 100644 index 00000000..e56a2325 --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenTypes.java @@ -0,0 +1,76 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import org.fife.ui.rsyntaxtextarea.TokenTypes; + +/** + * Static class, which matches PDF-relevant token types to what is present in + * {@link TokenTypes} for RSyntaxTextArea. + * + *

+ * Ideally would be to make our own custom token types and add support for + * them in RSyntaxTextArea. But it would be much more work, than just mapping + * our types to existing ones. Especially, since only the SEPARATOR type is + * special, it doesn't really matter, what the underlying type is. This way + * we could reuse existing styles. + *

+ */ +public final class PdfTokenTypes { + public static final int WHITESPACE = TokenTypes.WHITESPACE; + public static final int COMMENT = TokenTypes.COMMENT_EOL; + public static final int BOOLEAN = TokenTypes.LITERAL_BOOLEAN; + public static final int NUMERIC = TokenTypes.LITERAL_NUMBER_FLOAT; + public static final int STRING_DATA = TokenTypes.LITERAL_STRING_DOUBLE_QUOTE; + public static final int NAME = TokenTypes.DATA_TYPE; + public static final int NULL = TokenTypes.LITERAL_BACKQUOTE; + public static final int OPERATOR = TokenTypes.OPERATOR; + public static final int FUNCTION = TokenTypes.FUNCTION; + public static final int SEPARATOR = TokenTypes.SEPARATOR; + public static final int BINARY_DATA = TokenTypes.PREPROCESSOR; + public static final int ERROR = TokenTypes.ERROR_CHAR; + + private PdfTokenTypes() { + // Static class + } +} diff --git a/src/main/resources/bundles/rups-lang.properties b/src/main/resources/bundles/rups-lang.properties index 8bc89445..265b8c98 100644 --- a/src/main/resources/bundles/rups-lang.properties +++ b/src/main/resources/bundles/rups-lang.properties @@ -45,6 +45,7 @@ ERROR=Error ERROR_BUILDING_CONTENT_STREAM=Error building content stream representation. ERROR_CANNOT_CHECK_NULL_FOR_INPUT_STREAM=Cannot check for null inputStream from PdfStream. ERROR_CANNOT_FIND_FILE=Can't find file: %s +ERROR_CHARACTER_ENCODING=Character encoding error. ERROR_CLOSING_STREAM=Can't close stream. ERROR_COMPARE_DOCUMENT_CREATION=Can't open document for comparison ERROR_COMPARED_DOCUMENT_CLOSED=Compared document is closed. diff --git a/src/main/resources/bundles/rups-lang_en_US.properties b/src/main/resources/bundles/rups-lang_en_US.properties index 29427581..def65a48 100644 --- a/src/main/resources/bundles/rups-lang_en_US.properties +++ b/src/main/resources/bundles/rups-lang_en_US.properties @@ -41,6 +41,7 @@ ERROR=Error ERROR_BUILDING_CONTENT_STREAM=Error building content stream representation. ERROR_CANNOT_CHECK_NULL_FOR_INPUT_STREAM=Cannot check for null inputStream from PdfStream. ERROR_CANNOT_FIND_FILE=Can't find file: %s +ERROR_CHARACTER_ENCODING=Character encoding error. ERROR_CLOSING_STREAM=Can't close stream. ERROR_COMPARE_DOCUMENT_CREATION=Can't open document for comparison ERROR_COMPARED_DOCUMENT_CLOSED=Compared document is closed. diff --git a/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java b/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java index 9ade7dca..7fda2927 100644 --- a/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java +++ b/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java @@ -43,7 +43,7 @@ This file is part of the iText (R) project. package com.itextpdf.rups.view.contextmenu; import com.itextpdf.rups.view.Language; -import com.itextpdf.rups.view.itext.SyntaxHighlightedStreamPane; +import com.itextpdf.rups.view.itext.StreamTextEditorPane; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -60,7 +60,7 @@ public class StreamPanelContextMenuTest { @Test public void jMenuLengthTest() { JPopupMenu popupMenu = - new StreamPanelContextMenu(new JTextPane(), new SyntaxHighlightedStreamPane(null)); + new StreamPanelContextMenu(new JTextPane(), new StreamTextEditorPane(null)); MenuElement[] subElements = popupMenu.getSubElements(); Assertions.assertEquals(4, subElements.length); @@ -69,7 +69,7 @@ public void jMenuLengthTest() { @Test public void jMenuSubItemTypeTest() { JPopupMenu popupMenu = - new StreamPanelContextMenu(new JTextPane(), new SyntaxHighlightedStreamPane(null)); + new StreamPanelContextMenu(new JTextPane(), new StreamTextEditorPane(null)); MenuElement[] subElements = popupMenu.getSubElements(); @@ -81,7 +81,7 @@ public void jMenuSubItemTypeTest() { @Test public void assignedActionsTest() { JPopupMenu popupMenu = - new StreamPanelContextMenu(new JTextPane(), new SyntaxHighlightedStreamPane(null)); + new StreamPanelContextMenu(new JTextPane(), new StreamTextEditorPane(null)); MenuElement[] subElements = popupMenu.getSubElements(); @@ -97,7 +97,7 @@ public void assignedActionsTest() { @Test public void saveToStreamDisabledTest() { StreamPanelContextMenu popupMenu = - new StreamPanelContextMenu(new JTextPane(), new SyntaxHighlightedStreamPane(null)); + new StreamPanelContextMenu(new JTextPane(), new StreamTextEditorPane(null)); popupMenu.setSaveToStreamEnabled(false); MenuElement[] subElements = popupMenu.getSubElements(); @@ -114,7 +114,7 @@ public void saveToStreamDisabledTest() { @Test public void saveToStreamReEnabledTest() { StreamPanelContextMenu popupMenu = - new StreamPanelContextMenu(new JTextPane(), new SyntaxHighlightedStreamPane(null)); + new StreamPanelContextMenu(new JTextPane(), new StreamTextEditorPane(null)); popupMenu.setSaveToStreamEnabled(false); popupMenu.setSaveToStreamEnabled(true); diff --git a/src/test/java/com/itextpdf/rups/view/itext/editor/Latin1FilterTest.java b/src/test/java/com/itextpdf/rups/view/itext/editor/Latin1FilterTest.java new file mode 100644 index 00000000..c651c663 --- /dev/null +++ b/src/test/java/com/itextpdf/rups/view/itext/editor/Latin1FilterTest.java @@ -0,0 +1,173 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import com.itextpdf.kernel.exceptions.PdfException; + +import java.util.stream.IntStream; +import java.util.stream.Stream; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.Document; +import javax.swing.text.DocumentFilter.FilterBypass; +import javax.swing.text.SimpleAttributeSet; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@Tag("UnitTest") +class Latin1FilterTest { + static Stream stringReplacementMap() { + return Stream.of( + // ASCII should remain as-is + sameString(collectString(IntStream.range(0, 0x80))), + // Latin-1 Supplement should also remain as-is + sameString(collectString(IntStream.range(0x80, 0x100))), + // And both of their combinations + sameString(collectString(IntStream.range(0, 0x100))), + // Symbols outside that should get replaced + Arguments.of("AĠ₪B", "A\u00C4\u00A0\u00E2\u0082\u00AAB"), + // Should also work properly for symbols outside BMP + Arguments.of("A\uD808\uDC00B", "A\u00F0\u0092\u0080\u0080B"), + // Invalid string (i.e. random surrogates) should throw + Arguments.of("A\uD808", null), + Arguments.of("A\uDC00", null), + Arguments.of("A\uD808B", null), + Arguments.of("A\uDC00B", null) + ); + } + + @ParameterizedTest(name = "{index}") + @MethodSource("stringReplacementMap") + void insertString(String inputString, String outputString) throws BadLocationException { + final int offset = 23; + final AttributeSet attr = new SimpleAttributeSet(); + final FilterBypass fb = new MockFilterBypass() { + @Override + public void insertString(int actualOffset, String actualString, AttributeSet actualAttr) { + Assertions.assertEquals(offset, actualOffset); + if (outputString.equals(inputString)) { + Assertions.assertSame(outputString, actualString); + } else { + Assertions.assertEquals(outputString, actualString); + } + Assertions.assertSame(attr, actualAttr); + } + }; + final Latin1Filter filter = new Latin1Filter(); + if (outputString == null) { + Assertions.assertThrows( + PdfException.class, + () -> filter.insertString(fb, offset, inputString, attr) + ); + } else { + filter.insertString(fb, offset, inputString, attr); + } + } + + @ParameterizedTest(name = "{index}") + @MethodSource("stringReplacementMap") + void replace(String inputString, String outputString) throws BadLocationException { + final int offset = 23; + final int length = 42; + final AttributeSet attr = new SimpleAttributeSet(); + final FilterBypass fb = new MockFilterBypass() { + @Override + public void replace(int actualOffset, int actualLength, String actualString, AttributeSet actualAttr) { + Assertions.assertEquals(offset, actualOffset); + Assertions.assertEquals(length, actualLength); + if (outputString.equals(inputString)) { + Assertions.assertSame(outputString, actualString); + } else { + Assertions.assertEquals(outputString, actualString); + } + Assertions.assertSame(attr, actualAttr); + } + }; + final Latin1Filter filter = new Latin1Filter(); + if (outputString == null) { + Assertions.assertThrows( + PdfException.class, + () -> filter.replace(fb, offset, length, inputString, attr) + ); + } else { + filter.replace(fb, offset, length, inputString, attr); + } + } + + private static Arguments sameString(String s) { + return Arguments.of(s, s); + } + + private static String collectString(IntStream is) { + return is.collect( + StringBuilder::new, + StringBuilder::appendCodePoint, + StringBuilder::append + ).toString(); + } + + private static class MockFilterBypass extends FilterBypass { + @Override + public Document getDocument() { + throw new AssertionError("Unexpected getDocument call"); + } + + @Override + public void remove(int actualOffset, int actualLength) { + throw new AssertionError("Unexpected remove call"); + } + + @Override + public void insertString(int actualOffset, String actualString, AttributeSet actualAttr) { + throw new AssertionError("Unexpected insertString call"); + } + + @Override + public void replace(int actualOffset, int actualLength, String actualString, AttributeSet actualAttr) { + throw new AssertionError("Unexpected replace call"); + } + } +} \ No newline at end of file From 0ec2a40f49690a2903d6f7a504ea0f892f00001d Mon Sep 17 00:00:00 2001 From: Vlad Lipskiy Date: Thu, 23 Jan 2025 00:14:42 +0300 Subject: [PATCH 3/7] Add basic fold parser for PDF content streams Now in the editor you should be able to freely fold BT->ET blocks and BMC/BDC->EMC sequences. --- .../model/contentstream/ParseTreeNode.java | 17 +- .../rups/view/itext/StreamTextEditorPane.java | 3 + .../rups/view/itext/editor/PdfFoldParser.java | 224 ++++++++++++++++++ 3 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/PdfFoldParser.java diff --git a/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java index 629a569c..dc8baef3 100644 --- a/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java +++ b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java @@ -187,6 +187,18 @@ public boolean isLeaf() { return children == null || (children.getNext() == children); } + /** + * Returns whether text of this node matches the specified text. This + * operation is valid only for primitive nodes. + * + * @param text Expected text. + * + * @return Whether text of this node matches the specified text. + */ + public boolean is(char[] text) { + return Arrays.equals(text, 0, text.length, textArray, textOffset, textOffset + textCount); + } + /** * Returns whether this is an operator type node with the specified text. * @@ -198,10 +210,7 @@ public boolean isOperator(char[] operator) { if (type != ParseTreeNodeType.OPERATOR) { return false; } - return Arrays.equals( - operator, 0, operator.length, - textArray, textOffset, textOffset + textCount - ); + return is(operator); } /** diff --git a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java index ea0956e7..2ca76532 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java +++ b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java @@ -57,6 +57,7 @@ This file is part of the iText (R) project. import com.itextpdf.rups.view.contextmenu.ContextMenuMouseListener; import com.itextpdf.rups.view.contextmenu.StreamPanelContextMenu; import com.itextpdf.rups.view.itext.editor.Latin1Filter; +import com.itextpdf.rups.view.itext.editor.PdfFoldParser; import com.itextpdf.rups.view.itext.editor.PdfTokenMaker; import com.itextpdf.rups.view.itext.editor.PdfTokenPainterFactory; import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; @@ -73,6 +74,7 @@ This file is part of the iText (R) project. import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; +import org.fife.ui.rsyntaxtextarea.folding.FoldParserManager; import org.fife.ui.rtextarea.ExpandedFoldRenderStrategy; import org.fife.ui.rtextarea.RTextScrollPane; @@ -114,6 +116,7 @@ public final class StreamTextEditorPane extends RTextScrollPane implements IRups final AbstractTokenMakerFactory tokenMakerFactory = (AbstractTokenMakerFactory) TokenMakerFactory.getDefaultInstance(); tokenMakerFactory.putMapping(MIME_PDF, PdfTokenMaker.class.getName()); + FoldParserManager.get().addFoldParserMapping(MIME_PDF, new PdfFoldParser()); /* * There doesn't seem to be a good way to detect, whether you can call * setData on a PdfStream or not in advance. It cannot be called if a diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfFoldParser.java b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfFoldParser.java new file mode 100644 index 00000000..385f800a --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfFoldParser.java @@ -0,0 +1,224 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2024 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import com.itextpdf.rups.model.LoggerHelper; +import com.itextpdf.rups.model.contentstream.ParseTreeNode; +import com.itextpdf.rups.model.contentstream.ParseTreeNodeType; +import com.itextpdf.rups.model.contentstream.PdfContentStreamParser; +import com.itextpdf.rups.model.contentstream.PdfOperators; +import com.itextpdf.rups.view.Language; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import javax.swing.text.BadLocationException; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.folding.Fold; +import org.fife.ui.rsyntaxtextarea.folding.FoldParser; +import org.fife.ui.rsyntaxtextarea.folding.FoldType; + +/** + * Fold parser for handling PDF content streams. + */ +public final class PdfFoldParser implements FoldParser { + /** + * Default size to use for the marker stack. + */ + private static final int DEFAULT_MARKER_STACK_SIZE = 8; + /** + * Marker for a marked content sequence fold. + */ + private static final Object MARKED_CONTENT = new Object(); + /** + * Marked for a text object block fold. + */ + private static final Object TEXT_OBJECT = new Object(); + + /** + * Pre-allocated content stream parser. + */ + private final PdfContentStreamParser parser = new PdfContentStreamParser(); + + @Override + public List getFolds(RSyntaxTextArea textArea) { + try { + return getFoldsInternal(textArea); + } catch (BadLocationException e) { + LoggerHelper.error(Language.ERROR_UNEXPECTED_EXCEPTION.getString(), e, getClass()); + return Collections.emptyList(); + } + } + + private List getFoldsInternal(RSyntaxTextArea textArea) + throws BadLocationException { + /* + * TODO: We shouldn't constantly re-parse this... + * + * We should have the parse tree stored next to the text area and only + * update it, when text area is changed. + */ + final ParseTreeNode root = parseText(textArea); + final State state = new State(); + final Iterator it = root.primitiveNodeIterator(); + while (it.hasNext()) { + final ParseTreeNode node = it.next(); + if (node.getType() != ParseTreeNodeType.OPERATOR) { + continue; + } + if (node.is(PdfOperators.BT)) { + // Text object block start + startNewFold(state, textArea, TEXT_OBJECT, node); + } else if (inTextObject(state) && node.is(PdfOperators.ET)) { + // Text object block end + endCurrentFold(state, node); + } else if (node.is(PdfOperators.BMC) || node.is(PdfOperators.BDC)) { + // Marked content sequence start + startNewFold(state, textArea, MARKED_CONTENT, node); + } else if (inMarkedContent(state) && node.is(PdfOperators.EMC)) { + // Marked content sequence end + endCurrentFold(state, node); + } + // TODO: Add more blocks (like less stable q/Q) + } + return state.folds; + } + + private ParseTreeNode parseText(RSyntaxTextArea textArea) { + parser.reset(); + parser.append(textArea.getText()); + return parser.result(); + } + + /** + * Starts a new child fold of the specified type. + * + * @param state Current folding algorithm state. + * @param textArea The text area whose contents should be analyzed. + * @param marker Type marker. + * @param node Node where fold starts. + */ + private static void startNewFold(State state, RSyntaxTextArea textArea, Object marker, ParseTreeNode node) + throws BadLocationException { + if (state.currentFold != null) { + state.currentFold = state.currentFold.createChild(FoldType.CODE, node.getTextOffset()); + } else { + state.currentFold = new Fold(FoldType.CODE, textArea, node.getTextOffset()); + state.folds.add(state.currentFold); + } + state.markers.push(marker); + } + + /** + * Ends the current fold and sets current fold to parent. If current fold + * spans only one line, it will be deleted. + * + * @param state Current folding algorithm state. + * @param node Node where fold ends. + */ + private static void endCurrentFold(State state, ParseTreeNode node) + throws BadLocationException { + if (state.currentFold == null) { + return; + } + state.currentFold.setEndOffset(node.getTextOffset()); + // If it is on a single line, we skip it + if (state.currentFold.isOnSingleLine()) { + removeCurrentFold(state); + } else { + state.currentFold = state.currentFold.getParent(); + state.markers.pop(); + } + } + + /** + * Removes the current fold and set its parent as the new current fold. + * + * @param state Current folding algorithm state. + */ + private static void removeCurrentFold(State state) { + if (state.currentFold == null) { + return; + } + final Fold parent = state.currentFold.getParent(); + if (!state.currentFold.removeFromParent()) { + state.folds.remove(state.folds.size() - 1); + } + state.currentFold = parent; + state.markers.pop(); + } + + /** + * Returns whether we are inside a marked content sequence fold. + * + * @param state Current folding algorithm state. + * + * @return Whether we are inside a marked content sequence fold. + */ + private static boolean inMarkedContent(State state) { + return MARKED_CONTENT == state.markers.peek(); + } + + /** + * Returns whether we are inside a text object block fold. + * + * @param state Current folding algorithm state. + * + * @return Whether we are inside a text object block fold. + */ + private static boolean inTextObject(State state) { + return TEXT_OBJECT == state.markers.peek(); + } + + /** + * Folding algorithm state. + */ + private static final class State { + private final List folds = new ArrayList<>(); + private final Deque markers = new ArrayDeque<>(DEFAULT_MARKER_STACK_SIZE); + private Fold currentFold = null; + } +} From b3d0f31050a08500ec0c268567bd0de382b87123 Mon Sep 17 00:00:00 2001 From: Vlad Lipskiy Date: Thu, 23 Jan 2025 01:51:28 +0300 Subject: [PATCH 4/7] Add basic static analysis for PDF content streams This is pretty basic as a proof of concept. It can currently show the following issues: * Array/Dictionary/String object was not closed. * Unnecessary whitespace at the end of lines. * Unexpected tokens. * Operand count and type for path construction operators. --- .../model/contentstream/ParseTreeNode.java | 44 ++ .../java/com/itextpdf/rups/view/Language.java | 15 + .../rups/view/itext/StreamTextEditorPane.java | 35 +- .../rups/view/itext/editor/PdfParser.java | 440 ++++++++++++++++++ .../resources/bundles/rups-lang.properties | 14 + .../bundles/rups-lang_en_US.properties | 13 + 6 files changed, 543 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/PdfParser.java diff --git a/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java index dc8baef3..0606242f 100644 --- a/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java +++ b/src/main/java/com/itextpdf/rups/model/contentstream/ParseTreeNode.java @@ -269,6 +269,50 @@ public int getTextCount() { return textCount; } + /** + * Returns the start offset for the node. If this is a primitive node, + * then it is equivalent to calling {@link #getTextOffset()}. But if it is + * a composite node, it returns the text offset of the leftmost + * primitive descendant. + * + * @return The start offset for the node. + */ + public int getStartOffset() { + if (textArray != null) { + return textOffset; + } + ParseTreeNode child = getFirstChild(); + while (child != null) { + if (child.textArray != null) { + return child.textOffset; + } + child = child.getFirstChild(); + } + return 0; + } + + /** + * Returns the end offset for the node. If this is a primitive node, then + * it is equivalent to summing {@link #getTextOffset()} and + * {@link #getTextCount()}. But if it is a composite node, it returns the + * end offset of the leftmost primitive descendant. + * + * @return The start offset for the node. + */ + public int getEndOffset() { + if (textArray != null) { + return textOffset + textCount; + } + ParseTreeNode child = getLastChild(); + while (child != null) { + if (child.textArray != null) { + return child.textOffset + child.textCount; + } + child = child.getLastChild(); + } + return 0; + } + /** * Returns the first child of a node, or null, if it is a leaf. * diff --git a/src/main/java/com/itextpdf/rups/view/Language.java b/src/main/java/com/itextpdf/rups/view/Language.java index b220404f..e01daaa9 100644 --- a/src/main/java/com/itextpdf/rups/view/Language.java +++ b/src/main/java/com/itextpdf/rups/view/Language.java @@ -197,6 +197,21 @@ public enum Language { PAGE_NUMBER, PAGES, PAGES_TABLE_OBJECT, + + PARSER_NOT_CLOSED_ARRAY, + PARSER_NOT_CLOSED_DICTIONARY, + PARSER_NOT_CLOSED_STRING_HEX, + PARSER_NOT_CLOSED_STRING_LITERAL, + PARSER_OPERAND_TYPES_C, + PARSER_OPERAND_TYPES_H, + PARSER_OPERAND_TYPES_L, + PARSER_OPERAND_TYPES_M, + PARSER_OPERAND_TYPES_RE, + PARSER_OPERAND_TYPES_V, + PARSER_OPERAND_TYPES_Y, + PARSER_UNEXPECTED_TOKEN, + PARSER_WASTEFUL_WHITESPACE, + PDF_READING, PDF_OBJECT_TREE, PLAINTEXT, diff --git a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java index 2ca76532..b2110535 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java +++ b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java @@ -58,6 +58,7 @@ This file is part of the iText (R) project. import com.itextpdf.rups.view.contextmenu.StreamPanelContextMenu; import com.itextpdf.rups.view.itext.editor.Latin1Filter; import com.itextpdf.rups.view.itext.editor.PdfFoldParser; +import com.itextpdf.rups.view.itext.editor.PdfParser; import com.itextpdf.rups.view.itext.editor.PdfTokenMaker; import com.itextpdf.rups.view.itext.editor.PdfTokenPainterFactory; import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; @@ -73,6 +74,7 @@ This file is part of the iText (R) project. import org.fife.ui.rsyntaxtextarea.DefaultTokenPainterFactory; import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.SyntaxConstants; import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; import org.fife.ui.rsyntaxtextarea.folding.FoldParserManager; import org.fife.ui.rtextarea.ExpandedFoldRenderStrategy; @@ -82,11 +84,7 @@ public final class StreamTextEditorPane extends RTextScrollPane implements IRups /** * MIME type for a PDF content stream. */ - private static final String MIME_PDF = "application/pdf"; - /** - * MIME type for plain text. - */ - private static final String MIME_PLAIN_TEXT = "plain/text"; + private static final String SYNTAX_STYLE_PDF = "application/pdf"; /** * Char buffer with a single LF character. @@ -115,8 +113,8 @@ public final class StreamTextEditorPane extends RTextScrollPane implements IRups */ final AbstractTokenMakerFactory tokenMakerFactory = (AbstractTokenMakerFactory) TokenMakerFactory.getDefaultInstance(); - tokenMakerFactory.putMapping(MIME_PDF, PdfTokenMaker.class.getName()); - FoldParserManager.get().addFoldParserMapping(MIME_PDF, new PdfFoldParser()); + tokenMakerFactory.putMapping(SYNTAX_STYLE_PDF, PdfTokenMaker.class.getName()); + FoldParserManager.get().addFoldParserMapping(SYNTAX_STYLE_PDF, new PdfFoldParser()); /* * There doesn't seem to be a good way to detect, whether you can call * setData on a PdfStream or not in advance. It cannot be called if a @@ -187,7 +185,7 @@ public void render(PdfObjectTreeNode target) { // Assuming that this will stop parsing for a moment... getTextArea().setVisible(false); String textToSet; - String mimeToSet; + String styleToSet; boolean editableToSet; /* * TODO: Differentiate between different content. See below. @@ -207,21 +205,21 @@ public void render(PdfObjectTreeNode target) { try { if (isFont(stream) || isImage(stream)) { textToSet = getText(stream, false); - mimeToSet = MIME_PLAIN_TEXT; + styleToSet = SyntaxConstants.SYNTAX_STYLE_NONE; editableToSet = false; } else { textToSet = prepareContentStreamText(getText(stream, true)); - mimeToSet = MIME_PDF; + styleToSet = SYNTAX_STYLE_PDF; editableToSet = true; } setTextEditableRoutine(true); } catch (RuntimeException e) { LoggerHelper.error(Language.ERROR_UNEXPECTED_EXCEPTION.getString(), e, getClass()); textToSet = ""; - mimeToSet = MIME_PLAIN_TEXT; + styleToSet = SyntaxConstants.SYNTAX_STYLE_NONE; editableToSet = false; } - setContentType(mimeToSet); + setContentType(styleToSet); getTextArea().setText(textToSet); getTextArea().setCaretPosition(0); setTextEditableRoutine(editableToSet); @@ -316,8 +314,8 @@ private void clearPane() { setTextEditableRoutine(false); } - private void setContentType(String mime) { - setContentType(getTextArea(), mime); + private void setContentType(String style) { + setContentType(getTextArea(), style); } private void setUndoEnabled(boolean enabled) { @@ -404,7 +402,8 @@ private static RSyntaxTextArea createTextArea() { * metadata we should just use the regular XML editor available. But * by default we will just assume a PDF content stream. */ - setContentType(textArea, MIME_PDF); + setContentType(textArea, SYNTAX_STYLE_PDF); + textArea.addParser(new PdfParser()); // This will allow to fold code blocks (like BT/ET blocks) textArea.setCodeFoldingEnabled(true); // This will automatically add tabulations, when you enter a new line @@ -417,15 +416,15 @@ private static RSyntaxTextArea createTextArea() { return textArea; } - private static void setContentType(RSyntaxTextArea textArea, String mime) { - if (MIME_PDF.equals(mime)) { + private static void setContentType(RSyntaxTextArea textArea, String style) { + if (SYNTAX_STYLE_PDF.equals(style)) { getDocument(textArea).setDocumentFilter(new Latin1Filter()); textArea.setTokenPainterFactory(new PdfTokenPainterFactory()); } else { getDocument(textArea).setDocumentFilter(null); textArea.setTokenPainterFactory(new DefaultTokenPainterFactory()); } - textArea.setSyntaxEditingStyle(mime); + textArea.setSyntaxEditingStyle(style); } private static String getText(PdfStream stream, boolean decoded) { diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfParser.java b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfParser.java new file mode 100644 index 00000000..02703f9e --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/PdfParser.java @@ -0,0 +1,440 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2024 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import com.itextpdf.rups.model.LoggerHelper; +import com.itextpdf.rups.model.contentstream.ParseTreeNode; +import com.itextpdf.rups.model.contentstream.ParseTreeNodeType; +import com.itextpdf.rups.model.contentstream.PdfContentStreamParser; +import com.itextpdf.rups.model.contentstream.PdfOperators; +import com.itextpdf.rups.view.Language; + +import javax.swing.text.BadLocationException; +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.parser.AbstractParser; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParseResult; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParserNotice; +import org.fife.ui.rsyntaxtextarea.parser.ParseResult; +import org.fife.ui.rsyntaxtextarea.parser.ParserNotice; +import org.fife.ui.rsyntaxtextarea.parser.ParserNotice.Level; + +/** + * Basic static analyzer for PDF content streams. + */ +public final class PdfParser extends AbstractParser { + /** + * MIME type for a PDF content stream. + */ + private static final String SYNTAX_STYLE_PDF = "application/pdf"; + + /** + * Document, that is being currently processed. + */ + private RSyntaxDocument currentDoc = null; + /** + * Current parse result. + */ + private final DefaultParseResult currentResult = new DefaultParseResult(this); + /** + * Pre-allocated content stream parser. + */ + private final PdfContentStreamParser parser = new PdfContentStreamParser(); + + @Override + public ParseResult parse(RSyntaxDocument doc, String style) { + clearResult(doc); + // We only handle PDF + if (!SYNTAX_STYLE_PDF.equals(style)) { + return currentResult; + } + + currentDoc = doc; + try { + handleFullPdf(); + } catch (BadLocationException e) { + currentResult.setError(e); + LoggerHelper.error(Language.ERROR_UNEXPECTED_EXCEPTION.getString(), e, getClass()); + } + currentDoc = null; + return currentResult; + } + + private void handleFullPdf() throws BadLocationException { + /* + * TODO: We shouldn't constantly re-parse this... + * + * We should have the parse tree stored next to the text area and only + * update it, when text area is changed. + */ + final ParseTreeNode root = parseText(); + handleNode(root); + } + + private void handleNode(ParseTreeNode node) { + processNotClosed(node); + processOperandTypes(node); + processWastefulWhitespace(node); + if (node.getType() == ParseTreeNodeType.UNKNOWN) { + addErrorNotice(node, Language.PARSER_UNEXPECTED_TOKEN); + } + + // Now handle children + ParseTreeNode child = node.getFirstChild(); + while (child != null) { + handleNode(child); + child = child.getNext(); + } + } + + /** + * Handle PARSER_NOT_CLOSED_* errors. + * + * @param node Current node. + */ + private void processNotClosed(ParseTreeNode node) { + if (processNotClosedArray(node)) { + return; + } + if (processNotClosedDictionary(node)) { + return; + } + if (processNotClosedStringHex(node)) { + return; + } + processNotClosedStringLiteral(node); + } + + /** + * Handle PARSER_NOT_CLOSED_ARRAY error. + * + * @param node Current node. + * + * @return True, if node is an array. + */ + private boolean processNotClosedArray(ParseTreeNode node) { + if (node.getType() != ParseTreeNodeType.ARRAY) { + return false; + } + if (node.getLastChild().getType() != ParseTreeNodeType.ARRAY_CLOSE) { + addErrorNotice(node, Language.PARSER_NOT_CLOSED_ARRAY); + } + return true; + } + + /** + * Handle PARSER_NOT_CLOSED_DICTIONARY error. + * + * @param node Current node. + * + * @return True, if node is a dictionary. + */ + private boolean processNotClosedDictionary(ParseTreeNode node) { + if (node.getType() != ParseTreeNodeType.DICTIONARY) { + return false; + } + if (node.getLastChild().getType() != ParseTreeNodeType.DICTIONARY_CLOSE) { + addErrorNotice(node, Language.PARSER_NOT_CLOSED_DICTIONARY); + } + return true; + } + + /** + * Handle PARSER_NOT_CLOSED_STRING_HEX error. + * + * @param node Current node. + * + * @return True, if node is a hexadecimal string. + */ + private boolean processNotClosedStringHex(ParseTreeNode node) { + if (node.getType() != ParseTreeNodeType.STRING_HEX) { + return false; + } + if (node.getLastChild().getType() != ParseTreeNodeType.STRING_HEX_CLOSE) { + addErrorNotice(node, Language.PARSER_NOT_CLOSED_STRING_HEX); + } + return true; + } + + /** + * Handle PARSER_NOT_CLOSED_STRING_LITERAL error. + * + * @param node Current node. + * + * @return True, if node is a literal string. + */ + private boolean processNotClosedStringLiteral(ParseTreeNode node) { + if (node.getType() != ParseTreeNodeType.STRING_LITERAL) { + return false; + } + // If string literal doesn't end with a close, balance is broken already + if (node.getLastChild().getType() != ParseTreeNodeType.STRING_LITERAL_CLOSE) { + addErrorNotice(node, Language.PARSER_NOT_CLOSED_STRING_LITERAL); + return true; + } + // Calculating parentheses balance + ParseTreeNode walker = node.getFirstChild(); + int balance = 0; + while (walker != null) { + while (walker != null) { + if (walker.getType() == ParseTreeNodeType.STRING_LITERAL_OPEN) { + ++balance; + } else if (walker.getType() == ParseTreeNodeType.STRING_LITERAL_CLOSE) { + --balance; + } + walker = walker.getNext(); + } + } + if (balance != 0) { + addErrorNotice(node, Language.PARSER_NOT_CLOSED_STRING_LITERAL); + } + return true; + } + + /** + * Handle PARSER_OPERAND_TYPES_* errors. + * + * @param node Current node. + */ + private void processOperandTypes(ParseTreeNode node) { + // Quick exit, if not an operand + if (node.getType() != ParseTreeNodeType.OPERATOR) { + return; + } + if (node.is(PdfOperators.c)) { + processNumericOperands(node, 6, Language.PARSER_OPERAND_TYPES_C); + } else if (node.is(PdfOperators.h)) { + processNumericOperands(node, 0, Language.PARSER_OPERAND_TYPES_H); + } else if (node.is(PdfOperators.l)) { + processNumericOperands(node, 2, Language.PARSER_OPERAND_TYPES_L); + } else if (node.is(PdfOperators.m)) { + processNumericOperands(node, 2, Language.PARSER_OPERAND_TYPES_M); + } else if (node.is(PdfOperators.re)) { + processNumericOperands(node, 4, Language.PARSER_OPERAND_TYPES_RE); + } else if (node.is(PdfOperators.v)) { + processNumericOperands(node, 4, Language.PARSER_OPERAND_TYPES_V); + } else if (node.is(PdfOperators.y)) { + processNumericOperands(node, 4, Language.PARSER_OPERAND_TYPES_Y); + } + } + + /** + * Common processing for "Operator X expects Y numeric operands" + * + * @param node Node of the operator. + * @param expectedCount Expected numeric operand count. + * @param errorMessage Message to add on error. + */ + private void processNumericOperands(ParseTreeNode node, int expectedCount, Language errorMessage) { + int operandCount = 0; + int numericCount = 0; + ParseTreeNode firstRelevantNode = node; + ParseTreeNode walker = node; + while (walker.hasPrev()) { + walker = walker.getPrev(); + final ParseTreeNodeType type = walker.getType(); + // Skipping these, as they don't matter + if (type == ParseTreeNodeType.WHITESPACE || type == ParseTreeNodeType.COMMENT) { + continue; + } + // At this point no operands left + if (type == ParseTreeNodeType.OPERATOR || type == ParseTreeNodeType.UNKNOWN) { + break; + } + ++operandCount; + firstRelevantNode = walker; + if (walker.getType() == ParseTreeNodeType.NUMERIC) { + ++numericCount; + } + } + if (expectedCount != operandCount || expectedCount != numericCount) { + addErrorNotice(firstRelevantNode, node, errorMessage); + } + } + + /** + * Handle PARSER_WASTEFUL_WHITESPACE error. + * + * @param node Current node. + */ + private void processWastefulWhitespace(ParseTreeNode node) { + if (node.getType() != ParseTreeNodeType.WHITESPACE) { + return; + } + final char[] text = node.getTextArray(); + final int begin = node.getTextOffset(); + final int end = begin + node.getTextCount(); + int index = node.getTextOffset(); + while (index < end && text[index] != '\n') { + ++index; + } + if (index < end && index != begin) { + addNotice(begin, index, Level.INFO, Language.PARSER_WASTEFUL_WHITESPACE); + } + } + + private ParseTreeNode parseText() throws BadLocationException { + parser.reset(); + parser.append(currentDoc.getText(0, currentDoc.getLength())); + return parser.result(); + } + + private void clearResult(RSyntaxDocument doc) { + currentResult.clearNotices(); + currentResult.setParsedLines(0, getLineCount(doc) - 1); + } + + /** + * Adds a parser notice. + * + * @param startOffset Start index for the notice. + * @param endOffset End index for the notice. + * @param level Level of the notice. + * @param message Localized notice message. + */ + private void addNotice(int startOffset, int endOffset, ParserNotice.Level level, Language message) { + final int length = endOffset - startOffset; + final int lineIndex = getLineIndex(currentDoc, startOffset); + final DefaultParserNotice notice = + new DefaultParserNotice(this, message.getString(), lineIndex, startOffset, length); + notice.setLevel(level); + currentResult.addNotice(notice); + } + + /** + * Adds a parser notice. + * + * @param firstNode First node, included in the notice. + * @param lastNode Last node, included in the notice. + * @param level Level of the notice. + * @param message Localized notice message. + */ + private void addNotice(ParseTreeNode firstNode, ParseTreeNode lastNode, + ParserNotice.Level level, Language message) { + addNotice(firstNode.getStartOffset(), lastNode.getEndOffset(), level, message); + } + + /** + * Adds a parser error notice. + * + * @param firstNode First node, included in the notice. + * @param lastNode Last node, included in the notice. + * @param message Localized notice message. + */ + private void addErrorNotice(ParseTreeNode firstNode, ParseTreeNode lastNode, Language message) { + addNotice(firstNode, lastNode, Level.ERROR, message); + } + + /** + * Adds a parser error notice. + * + * @param node Node, included in the notice. + * @param message Localized notice message. + */ + private void addErrorNotice(ParseTreeNode node, Language message) { + addNotice(node, node, Level.ERROR, message); + } + + /** + * Adds a parser warning notice. + * + * @param firstNode First node, included in the notice. + * @param lastNode Last node, included in the notice. + * @param message Localized notice message. + */ + private void addWarningNotice(ParseTreeNode firstNode, ParseTreeNode lastNode, Language message) { + addNotice(firstNode, lastNode, Level.WARNING, message); + } + + /** + * Adds a parser warning notice. + * + * @param node Node, included in the notice. + * @param message Localized notice message. + */ + private void addWarningNotice(ParseTreeNode node, Language message) { + addNotice(node, node, Level.WARNING, message); + } + /** + * Adds a parser info notice. + * + * @param firstNode First node, included in the notice. + * @param lastNode Last node, included in the notice. + * @param message Localized notice message. + */ + private void addInfoNotice(ParseTreeNode firstNode, ParseTreeNode lastNode, Language message) { + addNotice(firstNode, lastNode, Level.INFO, message); + } + + /** + * Adds a parser info notice. + * + * @param node Node, included in the notice. + * @param message Localized notice message. + */ + private void addInfoNotice(ParseTreeNode node, Language message) { + addNotice(node, node, Level.INFO, message); + } + + /** + * Returns the line count in the document. + * + * @param doc Document to get line count for. + * + * @return The line count. + */ + private static int getLineCount(RSyntaxDocument doc) { + return doc.getDefaultRootElement().getElementCount(); + } + + /** + * Returns the line index for a specific offset. + * + * @param doc Document to search for the offset in. + * @param offset Offset to find the line for. + * + * @return The line index. + */ + private static int getLineIndex(RSyntaxDocument doc, int offset) { + return doc.getDefaultRootElement().getElementIndex(offset); + } +} diff --git a/src/main/resources/bundles/rups-lang.properties b/src/main/resources/bundles/rups-lang.properties index 265b8c98..65f12c1c 100644 --- a/src/main/resources/bundles/rups-lang.properties +++ b/src/main/resources/bundles/rups-lang.properties @@ -162,6 +162,20 @@ PAGE_NUMBER=Page %d PAGES=Pages PAGES_TABLE_OBJECT=Object %d +PARSER_NOT_CLOSED_ARRAY=Array object is not closed +PARSER_NOT_CLOSED_DICTIONARY=Dictionary object is not closed +PARSER_NOT_CLOSED_STRING_HEX=Hexadecimal string object is not closed +PARSER_NOT_CLOSED_STRING_LITERAL=Literal string object is not closed +PARSER_OPERAND_TYPES_C=Operator 'c' expects 6 numeric operands +PARSER_OPERAND_TYPES_H=Operator 'h' expects no operands +PARSER_OPERAND_TYPES_L=Operator 'l' expects 2 numeric operands +PARSER_OPERAND_TYPES_M=Operator 'm' expects 2 numeric operands +PARSER_OPERAND_TYPES_RE=Operator 're' expects 4 numeric operands +PARSER_OPERAND_TYPES_V=Operator 'v' expects 4 numeric operands +PARSER_OPERAND_TYPES_Y=Operator 'y' expects 4 numeric operands +PARSER_UNEXPECTED_TOKEN=Unexpected token +PARSER_WASTEFUL_WHITESPACE=Whitespace before LF can be safely removed + PDF_READING=Reading PDF document... PDF_OBJECT_TREE=PDF Object Tree (%s) diff --git a/src/main/resources/bundles/rups-lang_en_US.properties b/src/main/resources/bundles/rups-lang_en_US.properties index def65a48..33c4a052 100644 --- a/src/main/resources/bundles/rups-lang_en_US.properties +++ b/src/main/resources/bundles/rups-lang_en_US.properties @@ -151,6 +151,19 @@ PAGE_NUMBER=Page %d PAGES=Pages PAGES_TABLE_OBJECT=Object %d +PARSER_NOT_CLOSED_ARRAY=Array object is not closed +PARSER_NOT_CLOSED_DICTIONARY=Dictionary object is not closed +PARSER_NOT_CLOSED_STRING_HEX=Hexadecimal string object is not closed +PARSER_NOT_CLOSED_STRING_LITERAL=Literal string object is not closed +PARSER_OPERAND_TYPES_C=Operator 'c' expects 6 numeric operands +PARSER_OPERAND_TYPES_H=Operator 'h' expects no operands +PARSER_OPERAND_TYPES_L=Operator 'l' expects 2 numeric operands +PARSER_OPERAND_TYPES_M=Operator 'm' expects 2 numeric operands +PARSER_OPERAND_TYPES_RE=Operator 're' expects 4 numeric operands +PARSER_OPERAND_TYPES_V=Operator 'v' expects 4 numeric operands +PARSER_OPERAND_TYPES_Y=Operator 'y' expects 4 numeric operands +PARSER_WASTEFUL_WHITESPACE=Whitespace before LF can be safely removed + PDF_READING=Reading PDF document... PDF_OBJECT_TREE=PDF Object Tree (%s) From 9f659be2fc03c3c73298b3f16ea7793527c4c4f4 Mon Sep 17 00:00:00 2001 From: Vlad Lipskiy Date: Fri, 21 Mar 2025 19:56:17 +0300 Subject: [PATCH 5/7] Fix stream editor caret not appearing in read-only mode Default caret never becomes visible, if the text area is not editable, which is very odd... --- .../rups/view/itext/StreamTextEditorPane.java | 7 ++ .../itext/editor/CustomConfigurableCaret.java | 75 +++++++++++++++++++ .../editor/CustomConfigurableCaretTest.java | 70 +++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/main/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaret.java create mode 100644 src/test/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaretTest.java diff --git a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java index b2110535..f1f643de 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java +++ b/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java @@ -56,6 +56,7 @@ This file is part of the iText (R) project. import com.itextpdf.rups.view.Language; import com.itextpdf.rups.view.contextmenu.ContextMenuMouseListener; import com.itextpdf.rups.view.contextmenu.StreamPanelContextMenu; +import com.itextpdf.rups.view.itext.editor.CustomConfigurableCaret; import com.itextpdf.rups.view.itext.editor.Latin1Filter; import com.itextpdf.rups.view.itext.editor.PdfFoldParser; import com.itextpdf.rups.view.itext.editor.PdfParser; @@ -403,6 +404,12 @@ private static RSyntaxTextArea createTextArea() { * by default we will just assume a PDF content stream. */ setContentType(textArea, SYNTAX_STYLE_PDF); + /* + * Pretty important to install our custom caret. The default one is + * invisible, when the text area is not visible, which is very odd and + * inconvenient. The custom one fixes that. + */ + textArea.setCaret(new CustomConfigurableCaret()); textArea.addParser(new PdfParser()); // This will allow to fold code blocks (like BT/ET blocks) textArea.setCodeFoldingEnabled(true); diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaret.java b/src/main/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaret.java new file mode 100644 index 00000000..5d202587 --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaret.java @@ -0,0 +1,75 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import java.awt.event.FocusEvent; +import org.fife.ui.rtextarea.ConfigurableCaret; + +/** + * Our custom {@link ConfigurableCaret}, which remains visible, if the text + * area is not editable. + */ +public final class CustomConfigurableCaret extends ConfigurableCaret { + private static final int DEFAULT_BLINK_RATE = 500; + + public CustomConfigurableCaret() { + /* + * The situation is a bit odd. Usually a caret is created via the UI + * class, and then the blink rate is set manually in that class after + * creation based on some component properties. + * + * But what it also means is that if you replace the caret in a text + * area afterward, it will not blink, even though it is the default + * behavior. So for simplicity we will set it here. + */ + setBlinkRate(DEFAULT_BLINK_RATE); + } + + @Override + public void focusGained(FocusEvent e) { + super.focusGained(e); + if (getComponent().isEnabled()) { + setVisible(true); + } + } +} diff --git a/src/test/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaretTest.java b/src/test/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaretTest.java new file mode 100644 index 00000000..d86b6e15 --- /dev/null +++ b/src/test/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaretTest.java @@ -0,0 +1,70 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.editor; + +import java.awt.event.FocusEvent; +import org.fife.ui.rtextarea.ConfigurableCaret; +import org.fife.ui.rtextarea.RTextArea; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("UnitTest") +class CustomConfigurableCaretTest { + @Test + void focusGained() { + final ConfigurableCaret caret = new CustomConfigurableCaret(); + final RTextArea textArea = new RTextArea(); + textArea.setCaret(caret); + Assertions.assertFalse(caret.isVisible()); + + // Making sure visibility changes for read-only text areas + textArea.setEditable(false); + textArea.setEnabled(false); + caret.focusGained(new FocusEvent(textArea, FocusEvent.FOCUS_GAINED)); + Assertions.assertFalse(caret.isVisible()); + textArea.setEnabled(true); + caret.focusGained(new FocusEvent(textArea, FocusEvent.FOCUS_GAINED)); + Assertions.assertTrue(caret.isVisible()); + } +} From 6a19b3c9c3056865b2ec6660beb08f5e0c006bf0 Mon Sep 17 00:00:00 2001 From: Vlad Lipskiy Date: Wed, 26 Mar 2025 17:24:17 +0300 Subject: [PATCH 6/7] Fix locale usage in RSyntaxTextArea Our code uses the locale explicitly for i18n. But RSyntaxTextArea uses the default locale everywhere for its controls and dialogs. To combat that we will just change the default locale at the start of the app. --- src/main/java/com/itextpdf/rups/Rups.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/itextpdf/rups/Rups.java b/src/main/java/com/itextpdf/rups/Rups.java index 0cdc56ee..ab8290ec 100644 --- a/src/main/java/com/itextpdf/rups/Rups.java +++ b/src/main/java/com/itextpdf/rups/Rups.java @@ -62,6 +62,7 @@ This file is part of the iText (R) project. import java.io.IOException; import java.io.InputStream; import java.util.List; +import java.util.Locale; import java.util.Properties; import javax.swing.JFrame; import javax.swing.SwingUtilities; @@ -94,6 +95,13 @@ public static void showBriefMessage(String message) { */ public static void startNewApplication(final List files) { SwingUtilities.invokeLater(() -> { + /* + * While we get the locale explicitly for our localized strings, + * some of the 3rd party Swings components use the default locale + * for string (ex. RSyntaxTextArea). So we need to change the + * default locale for everything to look consistent. + */ + Locale.setDefault(RupsConfiguration.INSTANCE.getUserLocale()); setLookandFeel(); final IRupsController rupsController = initApplication(new JFrame()); setOpenFileHandler( From 0e77dc046921b23e841260391a3cbbad91a61fe4 Mon Sep 17 00:00:00 2001 From: Vlad Lipskiy Date: Wed, 26 Mar 2025 17:32:53 +0300 Subject: [PATCH 7/7] Improve PDF stream pane * Everything related to the stream pane was moved to a separate package. * Added SYNTAX_STYLE_BINARY handling for the text editor. In this mode it uses the same painter (so that non-ASCII character are displayed in hex), but there is no fancy processing or coloration. Ideally we would have a separate hex editor pane for that... * Temporarily removed our custom menu dialog in the text editor. It was replacing the existing one in RSyntaxTextArea. Instead, we should add additional entries to the existing menu. TBD. * Removed our custom undo manager. RSyntaxManager was using its own, so it caused more issues, than it solved. * Added image stream view back in a basic form. For now, it just shows the image instead of the text editor, but there is no manipulation controls yet. * Additional refactoring. --- .../rups/controller/PdfReaderController.java | 16 +- .../itextpdf/rups/model/PdfStreamUtil.java | 94 ++++++++++ .../view/contextmenu/InspectObjectAction.java | 2 +- .../SaveToPdfStreamJTextPaneAction.java | 2 +- .../contextmenu/StreamPanelContextMenu.java | 2 +- .../view/itext/stream/StreamImagePane.java | 73 ++++++++ .../rups/view/itext/stream/StreamPane.java | 135 +++++++++++++++ .../{ => stream}/StreamTextEditorPane.java | 162 ++++-------------- .../AbstractPainterAwareTokenMaker.java | 119 +++++++++++++ .../editor/BinaryTokenMaker.java} | 35 ++-- .../editor/CustomConfigurableCaret.java | 2 +- .../{ => stream}/editor/Latin1Filter.java | 4 +- .../editor/PainterAwareToken.java} | 12 +- .../{ => stream}/editor/PdfFoldParser.java | 4 +- .../itext/{ => stream}/editor/PdfParser.java | 4 +- .../stream/editor/PdfSyntaxTextArea.java | 145 ++++++++++++++++ .../{ => stream}/editor/PdfTokenMaker.java | 70 +------- .../{ => stream}/editor/PdfTokenPainter.java | 4 +- .../editor/PdfTokenPainterFactory.java | 2 +- .../{ => stream}/editor/PdfTokenTypes.java | 2 +- .../itext/treenodes/PdfObjectTreeNode.java | 14 ++ .../StreamPanelContextMenuTest.java | 2 +- .../editor/CustomConfigurableCaretTest.java | 2 +- .../{ => stream}/editor/Latin1FilterTest.java | 2 +- 24 files changed, 652 insertions(+), 257 deletions(-) create mode 100644 src/main/java/com/itextpdf/rups/model/PdfStreamUtil.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/stream/StreamImagePane.java create mode 100644 src/main/java/com/itextpdf/rups/view/itext/stream/StreamPane.java rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/StreamTextEditorPane.java (67%) create mode 100644 src/main/java/com/itextpdf/rups/view/itext/stream/editor/AbstractPainterAwareTokenMaker.java rename src/main/java/com/itextpdf/rups/view/itext/{BeepingUndoManager.java => stream/editor/BinaryTokenMaker.java} (74%) rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/CustomConfigurableCaret.java (98%) rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/Latin1Filter.java (98%) rename src/main/java/com/itextpdf/rups/view/itext/{editor/PdfToken.java => stream/editor/PainterAwareToken.java} (95%) rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/PdfFoldParser.java (98%) rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/PdfParser.java (99%) create mode 100644 src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfSyntaxTextArea.java rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/PdfTokenMaker.java (82%) rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/PdfTokenPainter.java (99%) rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/PdfTokenPainterFactory.java (98%) rename src/main/java/com/itextpdf/rups/view/itext/{ => stream}/editor/PdfTokenTypes.java (98%) rename src/test/java/com/itextpdf/rups/view/itext/{ => stream}/editor/CustomConfigurableCaretTest.java (98%) rename src/test/java/com/itextpdf/rups/view/itext/{ => stream}/editor/Latin1FilterTest.java (99%) diff --git a/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java b/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java index 61920e6d..8428d7c3 100644 --- a/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java +++ b/src/main/java/com/itextpdf/rups/controller/PdfReaderController.java @@ -72,8 +72,8 @@ This file is part of the iText (R) project. import com.itextpdf.rups.view.itext.PdfObjectPanel; import com.itextpdf.rups.view.itext.PdfTree; import com.itextpdf.rups.view.itext.PlainText; +import com.itextpdf.rups.view.itext.stream.StreamPane; import com.itextpdf.rups.view.itext.StructureTree; -import com.itextpdf.rups.view.itext.StreamTextEditorPane; import com.itextpdf.rups.view.itext.XRefTable; import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; @@ -135,7 +135,7 @@ public class PdfReaderController implements IPdfObjectPanelEventListener, IRupsE /** * A panel that will show a stream. */ - protected StreamTextEditorPane streamPane; + protected StreamPane streamPane; /** * The factory producing tree nodes. @@ -205,7 +205,7 @@ public PdfReaderController(TreeSelectionListener treeSelectionListener, objectPanel = new PdfObjectPanel(); objectPanel.addEventListener(this); - streamPane = new StreamTextEditorPane(this); + streamPane = new StreamPane(this); JScrollPane debug = new JScrollPane(DebugView.getInstance().getTextArea()); editorTabs = new JTabbedPane(); editorTabs.addTab(Language.STREAM.getString(), null, streamPane, Language.STREAM.getString()); @@ -255,16 +255,6 @@ public JTabbedPane getEditorTabs() { return editorTabs; } - /** - * Getter for the object that holds the TextPane - * with the content stream of a PdfStream object. - * - * @return a SyntaxHighlightedStreamPane - */ - public StreamTextEditorPane getStreamPane() { - return streamPane; - } - public PdfSyntaxParser getParser() { return parser; } diff --git a/src/main/java/com/itextpdf/rups/model/PdfStreamUtil.java b/src/main/java/com/itextpdf/rups/model/PdfStreamUtil.java new file mode 100644 index 00000000..0b54b0b6 --- /dev/null +++ b/src/main/java/com/itextpdf/rups/model/PdfStreamUtil.java @@ -0,0 +1,94 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.model; + +import com.itextpdf.kernel.pdf.PdfName; +import com.itextpdf.kernel.pdf.PdfStream; +import com.itextpdf.kernel.pdf.xobject.PdfImageXObject; +import com.itextpdf.rups.view.Language; + +import java.awt.image.BufferedImage; +import java.io.IOException; + +/** + * Static utility class for getting information on PDF streams. + */ +public final class PdfStreamUtil { + private PdfStreamUtil() { + // static class + } + + public static BufferedImage getAsImage(PdfStream stream) { + if (!isImage(stream)) { + return null; + } + final PdfImageXObject xObject = new PdfImageXObject(stream); + try { + return xObject.getBufferedImage(); + } catch (IOException e) { + LoggerHelper.warn(Language.ERROR_PARSING_IMAGE.getString(), e, PdfStreamUtil.class); + return null; + } + } + + public static boolean isImage(PdfStream stream) { + /* + * We will consider stream being an image, if it has /Width and + * /Height number fields present and /Subtype is /Image. + * + * This could skip thumbnail images, as those do not require the + * /Subtype field being there. + */ + return PdfName.Image.equals(stream.getAsName(PdfName.Subtype)) + && (stream.getAsNumber(PdfName.Width) != null) + && (stream.getAsNumber(PdfName.Height) != null); + } + + public static boolean isFont(PdfStream stream) { + /* + * For now just checking, that there is a /Length1 field present. It + * is required for Type 1 and TrueType fonts. + */ + return stream.containsKey(PdfName.Length1); + } +} diff --git a/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java b/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java index 80e0647d..b58dd194 100644 --- a/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java +++ b/src/main/java/com/itextpdf/rups/view/contextmenu/InspectObjectAction.java @@ -45,7 +45,7 @@ This file is part of the iText (R) project. import com.itextpdf.rups.view.Language; import com.itextpdf.rups.view.icons.FrameIconUtil; import com.itextpdf.rups.view.itext.PdfTree; -import com.itextpdf.rups.view.itext.StreamTextEditorPane; +import com.itextpdf.rups.view.itext.stream.StreamTextEditorPane; import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; import javax.swing.AbstractAction; diff --git a/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java b/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java index 5e978a07..2ae6b6ea 100644 --- a/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java +++ b/src/main/java/com/itextpdf/rups/view/contextmenu/SaveToPdfStreamJTextPaneAction.java @@ -42,7 +42,7 @@ This file is part of the iText (R) project. */ package com.itextpdf.rups.view.contextmenu; -import com.itextpdf.rups.view.itext.StreamTextEditorPane; +import com.itextpdf.rups.view.itext.stream.StreamTextEditorPane; import java.awt.event.ActionEvent; diff --git a/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java b/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java index 54e23f71..6320c635 100644 --- a/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java +++ b/src/main/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenu.java @@ -43,7 +43,7 @@ This file is part of the iText (R) project. package com.itextpdf.rups.view.contextmenu; import com.itextpdf.rups.view.Language; -import com.itextpdf.rups.view.itext.StreamTextEditorPane; +import com.itextpdf.rups.view.itext.stream.StreamTextEditorPane; import javax.swing.Action; import javax.swing.JComponent; diff --git a/src/main/java/com/itextpdf/rups/view/itext/stream/StreamImagePane.java b/src/main/java/com/itextpdf/rups/view/itext/stream/StreamImagePane.java new file mode 100644 index 00000000..ae150f13 --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/StreamImagePane.java @@ -0,0 +1,73 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.stream; + +import java.awt.Image; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JScrollPane; +import javax.swing.SwingConstants; + +/** + * Simple pane, which shows an image that can be interacted with via a context + * menu. + */ +public final class StreamImagePane extends JScrollPane { + private final JLabel label; + + public StreamImagePane() { + this.label = new JLabel(); + this.label.setVerticalAlignment(SwingConstants.TOP); + this.label.setHorizontalAlignment(SwingConstants.LEFT); + setViewportView(this.label); + } + + public void setImage(Image image) { + Icon icon = null; + if (image != null) { + icon = new ImageIcon(image); + } + label.setIcon(icon); + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/stream/StreamPane.java b/src/main/java/com/itextpdf/rups/view/itext/stream/StreamPane.java new file mode 100644 index 00000000..2fbdacdc --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/StreamPane.java @@ -0,0 +1,135 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.stream; + +import com.itextpdf.rups.controller.PdfReaderController; +import com.itextpdf.rups.model.IRupsEventListener; +import com.itextpdf.rups.model.ObjectLoader; +import com.itextpdf.rups.model.PdfStreamUtil; +import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; + +import java.awt.CardLayout; +import java.awt.image.BufferedImage; +import javax.swing.JComponent; +import javax.swing.JPanel; + +/** + * Pane for showing PDF stream content. + * + *

+ * For images the pane shows the image itself, with the relevant image options. + *

+ * + *

+ * For everything else (at the moment of writing) a syntax editor is used. + *

+ */ +public final class StreamPane extends JPanel implements IRupsEventListener { + private final StreamTextEditorPane textEditorPane; + private final StreamImagePane imagePane; + private final JPanel emptyPane; + + public StreamPane(PdfReaderController controller) { + this.textEditorPane = new StreamTextEditorPane(controller); + this.textEditorPane.setVisible(false); + this.imagePane = new StreamImagePane(); + this.imagePane.setVisible(false); + this.emptyPane = new JPanel(); + this.emptyPane.setVisible(true); + + setLayout(new CardLayout()); + add(this.textEditorPane); + add(this.imagePane); + add(this.emptyPane); + } + + public void render(PdfObjectTreeNode target) { + if (target == null || !target.isStream()) { + showPane(emptyPane); + return; + } + final BufferedImage image = PdfStreamUtil.getAsImage(target.getAsStream()); + if (image != null) { + imagePane.setImage(image); + showPane(imagePane); + return; + } + textEditorPane.render(target); + showPane(textEditorPane); + } + + @Override + public void handleCloseDocument() { + showPane(emptyPane); + textEditorPane.handleCloseDocument(); + } + + @Override + public void handleOpenDocument(ObjectLoader loader) { + showPane(emptyPane); + textEditorPane.handleOpenDocument(loader); + } + + private void showPane(JComponent pane) { + assert pane != null; + + showImagePane(imagePane == pane); + showTextEditorPane(textEditorPane == pane); + emptyPane.setVisible(emptyPane == pane); + validate(); + } + + private void showImagePane(boolean flag) { + imagePane.setVisible(flag); + if (!flag) { + imagePane.setImage(null); + } + } + + private void showTextEditorPane(boolean flag) { + textEditorPane.setVisible(flag); + if (!flag) { + textEditorPane.render(null); + } + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java b/src/main/java/com/itextpdf/rups/view/itext/stream/StreamTextEditorPane.java similarity index 67% rename from src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/StreamTextEditorPane.java index f1f643de..b9186fc4 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/StreamTextEditorPane.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/StreamTextEditorPane.java @@ -1,6 +1,6 @@ /* This file is part of the iText (R) project. - Copyright (c) 1998-2024 Apryse Group NV + Copyright (c) 1998-2025 Apryse Group NV Authors: Apryse Software. This program is free software; you can redistribute it and/or modify @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext; +package com.itextpdf.rups.view.itext.stream; import com.itextpdf.kernel.pdf.PdfDictionary; import com.itextpdf.kernel.pdf.PdfName; @@ -54,39 +54,21 @@ This file is part of the iText (R) project. import com.itextpdf.rups.model.contentstream.ParseTreeNodeType; import com.itextpdf.rups.model.contentstream.PdfContentStreamParser; import com.itextpdf.rups.view.Language; -import com.itextpdf.rups.view.contextmenu.ContextMenuMouseListener; import com.itextpdf.rups.view.contextmenu.StreamPanelContextMenu; -import com.itextpdf.rups.view.itext.editor.CustomConfigurableCaret; -import com.itextpdf.rups.view.itext.editor.Latin1Filter; -import com.itextpdf.rups.view.itext.editor.PdfFoldParser; -import com.itextpdf.rups.view.itext.editor.PdfParser; -import com.itextpdf.rups.view.itext.editor.PdfTokenMaker; -import com.itextpdf.rups.view.itext.editor.PdfTokenPainterFactory; +import com.itextpdf.rups.view.itext.stream.editor.PdfSyntaxTextArea; import com.itextpdf.rups.view.itext.treenodes.PdfObjectTreeNode; -import java.awt.event.InputEvent; -import java.awt.event.KeyEvent; +import java.awt.BorderLayout; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; -import javax.swing.JComponent; -import javax.swing.KeyStroke; +import javax.swing.JPanel; import javax.swing.tree.TreeNode; -import org.fife.ui.rsyntaxtextarea.AbstractTokenMakerFactory; -import org.fife.ui.rsyntaxtextarea.DefaultTokenPainterFactory; -import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; -import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.ErrorStrip; import org.fife.ui.rsyntaxtextarea.SyntaxConstants; -import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; -import org.fife.ui.rsyntaxtextarea.folding.FoldParserManager; import org.fife.ui.rtextarea.ExpandedFoldRenderStrategy; import org.fife.ui.rtextarea.RTextScrollPane; -public final class StreamTextEditorPane extends RTextScrollPane implements IRupsEventListener { - /** - * MIME type for a PDF content stream. - */ - private static final String SYNTAX_STYLE_PDF = "application/pdf"; - +public final class StreamTextEditorPane extends JPanel implements IRupsEventListener { /** * Char buffer with a single LF character. */ @@ -95,12 +77,11 @@ public final class StreamTextEditorPane extends RTextScrollPane implements IRups * Max text line width after which it will be forcefully split. */ private static final int MAX_LINE_LENGTH = 2048; - private static final int MAX_NUMBER_OF_EDITS = 8192; private static final Method GET_INPUT_STREAM_METHOD; + private final RTextScrollPane textScrollPane; private final StreamPanelContextMenu popupMenu; - private final BeepingUndoManager undoManager; //Todo: Remove that field after proper application structure will be implemented. private final PdfReaderController controller; @@ -108,14 +89,6 @@ public final class StreamTextEditorPane extends RTextScrollPane implements IRups private boolean editable = false; static { - /* - * Registering PDF content type, so that we could use PDF syntax - * highlighting in RSyntaxTextArea. - */ - final AbstractTokenMakerFactory tokenMakerFactory = - (AbstractTokenMakerFactory) TokenMakerFactory.getDefaultInstance(); - tokenMakerFactory.putMapping(SYNTAX_STYLE_PDF, PdfTokenMaker.class.getName()); - FoldParserManager.get().addFoldParserMapping(SYNTAX_STYLE_PDF, new PdfFoldParser()); /* * There doesn't seem to be a good way to detect, whether you can call * setData on a PdfStream or not in advance. It cannot be called if a @@ -136,37 +109,29 @@ public final class StreamTextEditorPane extends RTextScrollPane implements IRups * @param controller the pdf reader controller */ public StreamTextEditorPane(PdfReaderController controller) { - super(createTextArea()); + super(new BorderLayout()); this.controller = controller; + + final PdfSyntaxTextArea textArea = new PdfSyntaxTextArea(); + this.textScrollPane = new RTextScrollPane(textArea); // This will make sure, that the arrow for folding code blocks are // always visible - getGutter().setExpandedFoldRenderStrategy(ExpandedFoldRenderStrategy.ALWAYS); - - popupMenu = new StreamPanelContextMenu(getTextArea(), this); - getTextArea().setComponentPopupMenu(popupMenu); - getTextArea().addMouseListener(new ContextMenuMouseListener(popupMenu, getTextArea())); - - undoManager = new BeepingUndoManager(); - getDocument().addUndoableEditListener(undoManager); - getTextArea().registerKeyboardAction( - e -> undoManager.undo(), - KeyStroke.getKeyStroke(KeyEvent.VK_Z, InputEvent.CTRL_DOWN_MASK), - JComponent.WHEN_FOCUSED - ); - getTextArea().registerKeyboardAction( - e -> undoManager.redo(), - KeyStroke.getKeyStroke(KeyEvent.VK_Y, InputEvent.CTRL_DOWN_MASK), - JComponent.WHEN_FOCUSED + this.textScrollPane.getGutter().setExpandedFoldRenderStrategy( + ExpandedFoldRenderStrategy.ALWAYS ); - } + add(this.textScrollPane); - @Override - public RSyntaxTextArea getTextArea() { - return (RSyntaxTextArea) super.getTextArea(); + final ErrorStrip errorStrip = new ErrorStrip(textArea); + add(errorStrip, BorderLayout.LINE_END); + + popupMenu = new StreamPanelContextMenu(getTextArea(), this); + // TODO: Augment existing menu with our own options +// getTextArea().setComponentPopupMenu(popupMenu); +// getTextArea().addMouseListener(new ContextMenuMouseListener(popupMenu, getTextArea())); } - public RSyntaxDocument getDocument() { - return getDocument(getTextArea()); + public PdfSyntaxTextArea getTextArea() { + return (PdfSyntaxTextArea) textScrollPane.getTextArea(); } /** @@ -175,7 +140,7 @@ public RSyntaxDocument getDocument() { * @param target the node of which the content stream needs to be rendered */ public void render(PdfObjectTreeNode target) { - setUndoEnabled(false); + getTextArea().discardAllEdits(); this.target = target; final PdfStream stream = getTargetStream(); if (stream == null) { @@ -206,11 +171,11 @@ public void render(PdfObjectTreeNode target) { try { if (isFont(stream) || isImage(stream)) { textToSet = getText(stream, false); - styleToSet = SyntaxConstants.SYNTAX_STYLE_NONE; + styleToSet = PdfSyntaxTextArea.SYNTAX_STYLE_BINARY; editableToSet = false; } else { textToSet = prepareContentStreamText(getText(stream, true)); - styleToSet = SYNTAX_STYLE_PDF; + styleToSet = PdfSyntaxTextArea.SYNTAX_STYLE_PDF; editableToSet = true; } setTextEditableRoutine(true); @@ -220,11 +185,11 @@ public void render(PdfObjectTreeNode target) { styleToSet = SyntaxConstants.SYNTAX_STYLE_NONE; editableToSet = false; } - setContentType(styleToSet); + getTextArea().setSyntaxEditingStyle(styleToSet); getTextArea().setText(textToSet); getTextArea().setCaretPosition(0); + getTextArea().discardAllEdits(); setTextEditableRoutine(editableToSet); - setUndoEnabled(true); getTextArea().setVisible(true); repaint(); @@ -310,24 +275,11 @@ private PdfStream getTargetStream() { private void clearPane() { target = null; - setUndoEnabled(false); getTextArea().setText(""); + getTextArea().discardAllEdits(); setTextEditableRoutine(false); } - private void setContentType(String style) { - setContentType(getTextArea(), style); - } - - private void setUndoEnabled(boolean enabled) { - if (enabled) { - undoManager.setLimit(MAX_NUMBER_OF_EDITS); - } else { - undoManager.discardAllEdits(); - undoManager.setLimit(0); - } - } - /** * Modifies the PDF content stream text to make it suitable for usage in * a code editor. @@ -384,64 +336,10 @@ private static String prepareContentStreamText(String originalText) { return tree.getFullText(); } - private static RSyntaxTextArea createTextArea() { - final RSyntaxTextArea textArea = new RSyntaxTextArea(); - /* - * First we will set up our custom painter with our Latin-1 filter - * "hack". The way it works is that with the filter applied, any - * character greater than U+00FF will be replaced with a UTF-8 byte - * representation. As in the internal char array can actually be - * interpreted as a byte array, which wastes twice as much space... - * - * To make it easier to work with possible binary content of a PDF - * stream we will use a custom token painter. It will paint non-ASCII - * character as their hex-codes instead of their Latin-1 mapped - * glyphs. - * - * Both the filter and painter should be replaced with default, when - * we display a non-content stream. For example, for XML-based - * metadata we should just use the regular XML editor available. But - * by default we will just assume a PDF content stream. - */ - setContentType(textArea, SYNTAX_STYLE_PDF); - /* - * Pretty important to install our custom caret. The default one is - * invisible, when the text area is not visible, which is very odd and - * inconvenient. The custom one fixes that. - */ - textArea.setCaret(new CustomConfigurableCaret()); - textArea.addParser(new PdfParser()); - // This will allow to fold code blocks (like BT/ET blocks) - textArea.setCodeFoldingEnabled(true); - // This will automatically add tabulations, when you enter a new line - // after a "q" operator, for example - textArea.setAutoIndentEnabled(true); - // This will mark identical names and operators, when cursor is on - // them after a short delay - textArea.setMarkOccurrences(true); - textArea.setMarkOccurrencesDelay(500); - return textArea; - } - - private static void setContentType(RSyntaxTextArea textArea, String style) { - if (SYNTAX_STYLE_PDF.equals(style)) { - getDocument(textArea).setDocumentFilter(new Latin1Filter()); - textArea.setTokenPainterFactory(new PdfTokenPainterFactory()); - } else { - getDocument(textArea).setDocumentFilter(null); - textArea.setTokenPainterFactory(new DefaultTokenPainterFactory()); - } - textArea.setSyntaxEditingStyle(style); - } - private static String getText(PdfStream stream, boolean decoded) { return new String(stream.getBytes(decoded), StandardCharsets.ISO_8859_1); } - private static RSyntaxDocument getDocument(RSyntaxTextArea textArea) { - return (RSyntaxDocument) textArea.getDocument(); - } - private static boolean isImage(PdfStream stream) { return PdfName.Image.equals(stream.getAsName(PdfName.Subtype)); } diff --git a/src/main/java/com/itextpdf/rups/view/itext/stream/editor/AbstractPainterAwareTokenMaker.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/AbstractPainterAwareTokenMaker.java new file mode 100644 index 00000000..7cdde3df --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/AbstractPainterAwareTokenMaker.java @@ -0,0 +1,119 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.stream.editor; + +import org.fife.ui.rsyntaxtextarea.TokenMakerBase; + +/** + * Base class for our custom token makers. + * + *

+ * This class really wants to just implement TokenMaker, as {@code firstToken}, + * {@code currentToken}, {@code previousToken} and {@code tokenFactory} from + * {@link TokenMakerBase} are of no use here. But just implementing the + * interface would force us to copy a lot of code from + * the library, and, for some reason {@code DefaultOccurrenceMarker} is marked + * as package-private, so we would need to reimplement that as well. + *

+ * + *

+ * So, at the moment, these fields from TokenMakerBase should be ignored. For + * token manipulation, {@code firstRupsToken} and {@code lastRupsToken} should + * be used instead. + *

+ * + *

+ * This class is expected to be used with a text area, which has a + * {@link Latin1Filter} on the underlying document. This is used as a way to + * represent a byte stream as a string. + *

+ */ +public abstract class AbstractPainterAwareTokenMaker extends TokenMakerBase { + /** + * First token in the output token list. Should be used instead of + * {@code firstToken}. + */ + protected PainterAwareToken firstRupsToken = null; + /** + * Last token in the output token list. Should be used instead of + * {@code lastToken}. + */ + protected PainterAwareToken lastRupsToken = null; + + @Override + public void addNullToken() { + final PainterAwareToken token = new PainterAwareToken(); + token.setLanguageIndex(getLanguageIndex()); + addToken(token); + } + + @Override + public void addToken(char[] array, int start, int end, int tokenType, int startOffset, boolean hyperlink) { + final PainterAwareToken token = new PainterAwareToken( + array, start, end, startOffset, tokenType, getLanguageIndex() + ); + token.setHyperlink(hyperlink); + addToken(token); + } + + @Override + protected void resetTokenList() { + firstRupsToken = null; + lastRupsToken = null; + super.resetTokenList(); + } + + /** + * Appends a PdfToken to the output token list. + * + * @param token Token to append. + */ + protected void addToken(PainterAwareToken token) { + if (firstRupsToken == null) { + firstRupsToken = token; + } else { + lastRupsToken.setNextToken(token); + } + lastRupsToken = token; + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/BeepingUndoManager.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/BinaryTokenMaker.java similarity index 74% rename from src/main/java/com/itextpdf/rups/view/itext/BeepingUndoManager.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/BinaryTokenMaker.java index c42245bd..152a3dd8 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/BeepingUndoManager.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/BinaryTokenMaker.java @@ -40,34 +40,23 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext; +package com.itextpdf.rups.view.itext.stream.editor; -import java.awt.Toolkit; -import javax.swing.undo.CannotRedoException; -import javax.swing.undo.CannotUndoException; -import javax.swing.undo.UndoManager; +import javax.swing.text.Segment; +import org.fife.ui.rsyntaxtextarea.Token; +import org.fife.ui.rsyntaxtextarea.TokenTypes; -/** - * A variation of an {@link UndoManager}, which will issue a beep instead of - * throwing {@link CannotUndoException} and {@link CannotRedoException} - * exceptions. - */ -public final class BeepingUndoManager extends UndoManager { +public final class BinaryTokenMaker extends AbstractPainterAwareTokenMaker { @Override - public void redo() { - try { - super.redo(); - } catch (CannotRedoException ignored) { - Toolkit.getDefaultToolkit().beep(); - } + public boolean getMarkOccurrencesOfTokenType(int type) { + return false; } @Override - public void undo() { - try { - super.undo(); - } catch (CannotUndoException ignored) { - Toolkit.getDefaultToolkit().beep(); - } + public Token getTokenList(Segment text, int initialTokenType, int startOffset) { + resetTokenList(); + addToken(text, text.offset, text.offset + text.count, TokenTypes.IDENTIFIER, startOffset); + addNullToken(); + return firstRupsToken; } } diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaret.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/CustomConfigurableCaret.java similarity index 98% rename from src/main/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaret.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/CustomConfigurableCaret.java index 5d202587..a8fe891a 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaret.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/CustomConfigurableCaret.java @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import java.awt.event.FocusEvent; import org.fife.ui.rtextarea.ConfigurableCaret; diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/Latin1Filter.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/Latin1Filter.java similarity index 98% rename from src/main/java/com/itextpdf/rups/view/itext/editor/Latin1Filter.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/Latin1Filter.java index 719325cd..555cf827 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/Latin1Filter.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/Latin1Filter.java @@ -1,6 +1,6 @@ /* This file is part of the iText (R) project. - Copyright (c) 1998-2024 Apryse Group NV + Copyright (c) 1998-2025 Apryse Group NV Authors: Apryse Software. This program is free software; you can redistribute it and/or modify @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import com.itextpdf.kernel.exceptions.PdfException; import com.itextpdf.rups.view.Language; diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfToken.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PainterAwareToken.java similarity index 95% rename from src/main/java/com/itextpdf/rups/view/itext/editor/PdfToken.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/PainterAwareToken.java index 9c498c27..d723039d 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfToken.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PainterAwareToken.java @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import java.awt.Rectangle; import java.lang.reflect.Method; @@ -60,7 +60,7 @@ This file is part of the iText (R) project. * in {@link TokenImpl}. *

*/ -public final class PdfToken extends TokenImpl { +public final class PainterAwareToken extends TokenImpl { /* * For some reason caret positioning logic in RSyntaxTextArea does not * take the painter into the account. It calls methods within the Token @@ -90,18 +90,18 @@ public final class PdfToken extends TokenImpl { } } - public PdfToken() { + public PainterAwareToken() { } - public PdfToken(Segment line, int beg, int end, int startOffset, int type, int languageIndex) { + public PainterAwareToken(Segment line, int beg, int end, int startOffset, int type, int languageIndex) { super(line, beg, end, startOffset, type, languageIndex); } - public PdfToken(char[] line, int beg, int end, int startOffset, int type, int languageIndex) { + public PainterAwareToken(char[] line, int beg, int end, int startOffset, int type, int languageIndex) { super(line, beg, end, startOffset, type, languageIndex); } - public PdfToken(Token t2) { + public PainterAwareToken(Token t2) { super(t2); } diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfFoldParser.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfFoldParser.java similarity index 98% rename from src/main/java/com/itextpdf/rups/view/itext/editor/PdfFoldParser.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfFoldParser.java index 385f800a..7cc36379 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfFoldParser.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfFoldParser.java @@ -1,6 +1,6 @@ /* This file is part of the iText (R) project. - Copyright (c) 1998-2024 Apryse Group NV + Copyright (c) 1998-2025 Apryse Group NV Authors: Apryse Software. This program is free software; you can redistribute it and/or modify @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import com.itextpdf.rups.model.LoggerHelper; import com.itextpdf.rups.model.contentstream.ParseTreeNode; diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfParser.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfParser.java similarity index 99% rename from src/main/java/com/itextpdf/rups/view/itext/editor/PdfParser.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfParser.java index 02703f9e..f5acdfb7 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfParser.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfParser.java @@ -1,6 +1,6 @@ /* This file is part of the iText (R) project. - Copyright (c) 1998-2024 Apryse Group NV + Copyright (c) 1998-2025 Apryse Group NV Authors: Apryse Software. This program is free software; you can redistribute it and/or modify @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import com.itextpdf.rups.model.LoggerHelper; import com.itextpdf.rups.model.contentstream.ParseTreeNode; diff --git a/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfSyntaxTextArea.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfSyntaxTextArea.java new file mode 100644 index 00000000..3373978e --- /dev/null +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfSyntaxTextArea.java @@ -0,0 +1,145 @@ +/* + This file is part of the iText (R) project. + Copyright (c) 1998-2025 Apryse Group NV + Authors: Apryse Software. + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License version 3 + as published by the Free Software Foundation with the addition of the + following permission added to Section 15 as permitted in Section 7(a): + FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY + APRYSE GROUP. APRYSE GROUP DISCLAIMS THE WARRANTY OF NON INFRINGEMENT + OF THIRD PARTY RIGHTS + + This program 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 Affero General Public License for more details. + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses or write to + the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, + Boston, MA, 02110-1301 USA, or download the license from the following URL: + http://itextpdf.com/terms-of-use/ + + The interactive user interfaces in modified source and object code versions + of this program must display Appropriate Legal Notices, as required under + Section 5 of the GNU Affero General Public License. + + In accordance with Section 7(b) of the GNU Affero General Public License, + a covered work must retain the producer line in every PDF that is created + or manipulated using iText. + + You can be released from the requirements of the license by purchasing + a commercial license. Buying such a license is mandatory as soon as you + develop commercial activities involving the iText software without + disclosing the source code of your own applications. + These activities include: offering paid services to customers as an ASP, + serving PDFs on the fly in a web application, shipping iText with a closed + source product. + + For more information, please contact iText Software Corp. at this + address: sales@itextpdf.com + */ +package com.itextpdf.rups.view.itext.stream.editor; + +import org.fife.ui.rsyntaxtextarea.AbstractTokenMakerFactory; +import org.fife.ui.rsyntaxtextarea.DefaultTokenPainterFactory; +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.TokenMakerFactory; +import org.fife.ui.rsyntaxtextarea.folding.FoldParserManager; + +/** + * Our custom RSyntaxTextArea. + */ +public final class PdfSyntaxTextArea extends RSyntaxTextArea { + /** + * MIME type for generic binary data. + */ + public static final String SYNTAX_STYLE_BINARY = "application/octet-stream"; + /** + * MIME type for a PDF content stream. + */ + public static final String SYNTAX_STYLE_PDF = "application/pdf"; + + private static final int DEFAULT_MARK_OCCURRENCES_DELAY = 500; + + static { + /* + * Registering PDF content type, so that we could use PDF syntax + * highlighting in RSyntaxTextArea. + */ + final AbstractTokenMakerFactory tokenMakerFactory = + (AbstractTokenMakerFactory) TokenMakerFactory.getDefaultInstance(); + tokenMakerFactory.putMapping(SYNTAX_STYLE_PDF, PdfTokenMaker.class.getName()); + tokenMakerFactory.putMapping(SYNTAX_STYLE_BINARY, BinaryTokenMaker.class.getName()); + FoldParserManager.get().addFoldParserMapping(SYNTAX_STYLE_PDF, new PdfFoldParser()); + } + + public PdfSyntaxTextArea() { + setCustomDefaults(); + } + + @Override + public RSyntaxDocument getDocument() { + return (RSyntaxDocument) super.getDocument(); + } + + @Override + public void setSyntaxEditingStyle(String styleKey) { + /* + * For PDF streams we will set up our custom painter with our Latin-1 + * filter "hack". The way it works is that with the filter applied, + * any character greater than U+00FF will be replaced with a UTF-8 + * byte representation. As in the internal char array can actually be + * interpreted as a byte array, which wastes twice as much space... + * + * To make it easier to work with possible binary content of a PDF + * stream we will use a custom token painter. It will paint non-ASCII + * character as their hex-codes instead of their Latin-1 mapped + * glyphs. + * + * We will use the same scheme for generic binary data, so that you + * could set/get it into the text area without it getting broken + * during Unicode conversions. + * + * Both the filter and painter should be replaced with default, when + * we display a non-binary stream. For example, for XML-based metadata + * we should just use the regular XML editor available. + */ + if (SYNTAX_STYLE_PDF.equals(styleKey) || SYNTAX_STYLE_BINARY.equals(styleKey)) { + getDocument().setDocumentFilter(new Latin1Filter()); + setTokenPainterFactory(new PdfTokenPainterFactory()); + } else { + getDocument().setDocumentFilter(null); + setTokenPainterFactory(new DefaultTokenPainterFactory()); + } + super.setSyntaxEditingStyle(styleKey); + } + + private void setCustomDefaults() { + /* + * We will change some default, while we are here anyway. + * + * By default, we will just assume generic binary data. + */ + setSyntaxEditingStyle(PdfSyntaxTextArea.SYNTAX_STYLE_BINARY); + /* + * Pretty important to install our custom caret. The default one is + * invisible, when the text area is not visible, which is very odd and + * inconvenient. The custom one fixes that. + */ + setCaret(new CustomConfigurableCaret()); + // This parser will only work, when PDF style is enabled + addParser(new PdfParser()); + // This will allow to fold code blocks (like BT/ET blocks) + setCodeFoldingEnabled(true); + // This will automatically add tabulations, when you enter a new line + // after a "q" operator, for example + setAutoIndentEnabled(true); + // This will mark identical names and operators, when cursor is on + // them after a short delay + setMarkOccurrences(true); + setMarkOccurrencesDelay(DEFAULT_MARK_OCCURRENCES_DELAY); + } +} diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenMaker.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenMaker.java similarity index 82% rename from src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenMaker.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenMaker.java index 4ab3705c..cb10826b 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenMaker.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenMaker.java @@ -1,6 +1,6 @@ /* This file is part of the iText (R) project. - Copyright (c) 1998-2024 Apryse Group NV + Copyright (c) 1998-2025 Apryse Group NV Authors: Apryse Software. This program is free software; you can redistribute it and/or modify @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import com.itextpdf.rups.model.contentstream.ParseTreeNode; import com.itextpdf.rups.model.contentstream.ParseTreeNodeType; @@ -50,33 +50,17 @@ This file is part of the iText (R) project. import java.util.Iterator; import javax.swing.text.Segment; import org.fife.ui.rsyntaxtextarea.Token; -import org.fife.ui.rsyntaxtextarea.TokenMakerBase; /** * RSyntaxTextArea token maker, which handles PDF content streams. * *

- * This class really wants to just implement TokenMaker, as {@code firstToken}, - * {@code currentToken}, {@code previousToken} and {@code tokenFactory} from - * {@link org.fife.ui.rsyntaxtextarea.TokenMakerBase} are of no use here. But - * just implementing the interface would force us to copy a lot of code from - * the library, and, for some reason {@code DefaultOccurrenceMarker} is marked - * as package-private, so we would need to reimplement that as well. - *

- * - *

- * So, at the moment, these fields from TokenMakerBase should be ignored. For - * token manipulation, {@code firstPdfToken} and {@code lastPdfToken} should - * be used instead. - *

- * - *

* This class is expected to be used with a text area, which has a * {@link Latin1Filter} on the underlying document. This is used as a way to * represent a byte stream as a string. *

*/ -public final class PdfTokenMaker extends TokenMakerBase { +public final class PdfTokenMaker extends AbstractPainterAwareTokenMaker { /** * Special internal token type marker to signify, that previous line ended * within a hexadecimal string, which was yet to be closed. @@ -88,31 +72,6 @@ public final class PdfTokenMaker extends TokenMakerBase { */ private final PdfContentStreamParser pdfContentStreamParser = new PdfContentStreamParser(); - /** - * First token in the output token list. Should be used instead of - * {@code firstToken}. - */ - private PdfToken firstPdfToken = null; - /** - * Last token in the output token list. Should be used instead of - * {@code lastToken}. - */ - private PdfToken lastPdfToken = null; - - @Override - public void addNullToken() { - final PdfToken token = new PdfToken(); - token.setLanguageIndex(getLanguageIndex()); - addToken(token); - } - - @Override - public void addToken(char[] array, int start, int end, int tokenType, int startOffset, boolean hyperlink) { - final PdfToken token = new PdfToken(array, start, end, startOffset, tokenType, getLanguageIndex()); - token.setHyperlink(hyperlink); - addToken(token); - } - @Override public boolean getMarkOccurrencesOfTokenType(int type) { switch (type) { @@ -138,13 +97,6 @@ public boolean getShouldIndentNextLineAfter(Token token) { return false; } - @Override - protected void resetTokenList() { - firstPdfToken = null; - lastPdfToken = null; - super.resetTokenList(); - } - @Override public Token getTokenList(Segment text, int startTokenType, int startOffset) { resetTokenList(); @@ -186,21 +138,7 @@ public Token getTokenList(Segment text, int startTokenType, int startOffset) { } handleMultiline(text, node, startOffset); - return firstPdfToken; - } - - /** - * Appends a PdfToken to the output token list. - * - * @param token Token to append. - */ - private void addToken(PdfToken token) { - if (firstPdfToken == null) { - firstPdfToken = token; - } else { - lastPdfToken.setNextToken(token); - } - lastPdfToken = token; + return firstRupsToken; } /** diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainter.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenPainter.java similarity index 99% rename from src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainter.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenPainter.java index a0c0fc38..0fb5993d 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainter.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenPainter.java @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import java.awt.BasicStroke; import java.awt.Color; @@ -57,7 +57,7 @@ This file is part of the iText (R) project. /** * Special {@link TokenPainter} implementation for working with - * {@link PdfTokenMaker}. + * {@link AbstractPainterAwareTokenMaker}. * *

* As of now, base logic of the this painter is the same as diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainterFactory.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenPainterFactory.java similarity index 98% rename from src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainterFactory.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenPainterFactory.java index 125fd08c..18ff172d 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenPainterFactory.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenPainterFactory.java @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; import org.fife.ui.rsyntaxtextarea.TokenPainter; diff --git a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenTypes.java b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenTypes.java similarity index 98% rename from src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenTypes.java rename to src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenTypes.java index e56a2325..ec5e5628 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/editor/PdfTokenTypes.java +++ b/src/main/java/com/itextpdf/rups/view/itext/stream/editor/PdfTokenTypes.java @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import org.fife.ui.rsyntaxtextarea.TokenTypes; diff --git a/src/main/java/com/itextpdf/rups/view/itext/treenodes/PdfObjectTreeNode.java b/src/main/java/com/itextpdf/rups/view/itext/treenodes/PdfObjectTreeNode.java index 4ecd9360..7bd394e0 100644 --- a/src/main/java/com/itextpdf/rups/view/itext/treenodes/PdfObjectTreeNode.java +++ b/src/main/java/com/itextpdf/rups/view/itext/treenodes/PdfObjectTreeNode.java @@ -46,6 +46,7 @@ This file is part of the iText (R) project. import com.itextpdf.kernel.pdf.PdfIndirectReference; import com.itextpdf.kernel.pdf.PdfName; import com.itextpdf.kernel.pdf.PdfObject; +import com.itextpdf.kernel.pdf.PdfStream; import com.itextpdf.kernel.pdf.PdfString; import com.itextpdf.rups.model.LoggerHelper; import com.itextpdf.rups.view.Language; @@ -222,6 +223,19 @@ public int getNumber() { return number; } + /** + * Returns the object associated with this tree node as a PdfStream. If the + * value isn't a PdfStream, null is returned. + * + * @return PdfStream associated with this tree node + */ + public PdfStream getAsStream() { + if (object.isStream()) { + return (PdfStream) object; + } + return null; + } + /** * Tells you if the node contains an indirect reference. * diff --git a/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java b/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java index 7fda2927..0e64ab2e 100644 --- a/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java +++ b/src/test/java/com/itextpdf/rups/view/contextmenu/StreamPanelContextMenuTest.java @@ -43,7 +43,7 @@ This file is part of the iText (R) project. package com.itextpdf.rups.view.contextmenu; import com.itextpdf.rups.view.Language; -import com.itextpdf.rups.view.itext.StreamTextEditorPane; +import com.itextpdf.rups.view.itext.stream.StreamTextEditorPane; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaretTest.java b/src/test/java/com/itextpdf/rups/view/itext/stream/editor/CustomConfigurableCaretTest.java similarity index 98% rename from src/test/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaretTest.java rename to src/test/java/com/itextpdf/rups/view/itext/stream/editor/CustomConfigurableCaretTest.java index d86b6e15..442777dc 100644 --- a/src/test/java/com/itextpdf/rups/view/itext/editor/CustomConfigurableCaretTest.java +++ b/src/test/java/com/itextpdf/rups/view/itext/stream/editor/CustomConfigurableCaretTest.java @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import java.awt.event.FocusEvent; import org.fife.ui.rtextarea.ConfigurableCaret; diff --git a/src/test/java/com/itextpdf/rups/view/itext/editor/Latin1FilterTest.java b/src/test/java/com/itextpdf/rups/view/itext/stream/editor/Latin1FilterTest.java similarity index 99% rename from src/test/java/com/itextpdf/rups/view/itext/editor/Latin1FilterTest.java rename to src/test/java/com/itextpdf/rups/view/itext/stream/editor/Latin1FilterTest.java index c651c663..ecdeec57 100644 --- a/src/test/java/com/itextpdf/rups/view/itext/editor/Latin1FilterTest.java +++ b/src/test/java/com/itextpdf/rups/view/itext/stream/editor/Latin1FilterTest.java @@ -40,7 +40,7 @@ This file is part of the iText (R) project. For more information, please contact iText Software Corp. at this address: sales@itextpdf.com */ -package com.itextpdf.rups.view.itext.editor; +package com.itextpdf.rups.view.itext.stream.editor; import com.itextpdf.kernel.exceptions.PdfException;