diff --git a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/Bias.java b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/Bias.java new file mode 100644 index 000000000..719bd0616 --- /dev/null +++ b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/Bias.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.core.pobjects.graphics.text; + +/** + * Caret bias used to disambiguate an offset that sits on a boundary shared by two + * positions (a line wrap, or the seam between two glyphs). Mirrors the intent of + * {@code javax.swing.text.Position.Bias}. + * + * @see Caret + * @since 7.5 + */ +public enum Bias { + /** + * The caret associates with the content that follows the offset. + */ + FORWARD, + /** + * The caret associates with the content that precedes the offset. + */ + BACKWARD +} diff --git a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/BreakType.java b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/BreakType.java new file mode 100644 index 000000000..2f60ac602 --- /dev/null +++ b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/BreakType.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.core.pobjects.graphics.text; + +/** + * Granularity of caret-boundary navigation used by {@link TextSequence#nextBoundary}. + * + * @since 7.5 + */ +public enum BreakType { + /** A single glyph/character step. */ + GLYPH, + /** A word boundary. */ + WORD, + /** A line boundary. */ + LINE +} diff --git a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/Caret.java b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/Caret.java new file mode 100644 index 000000000..07892916c --- /dev/null +++ b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/Caret.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.core.pobjects.graphics.text; + +/** + * An immutable page-local caret: a character {@code offset} into a {@link TextSequence} + * plus a {@link Bias} used to disambiguate boundary positions. The owning viewer is + * responsible for pairing this with a page index for document-level selection. + * + * @see TextSequence + * @since 7.5 + */ +public final class Caret { + + private final int offset; + private final Bias bias; + + public Caret(int offset, Bias bias) { + this.offset = offset; + this.bias = bias; + } + + public int getOffset() { + return offset; + } + + public Bias getBias() { + return bias; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Caret)) return false; + Caret caret = (Caret) o; + return offset == caret.offset && bias == caret.bias; + } + + @Override + public int hashCode() { + return 31 * offset + (bias == null ? 0 : bias.hashCode()); + } + + @Override + public String toString() { + return "Caret[" + offset + ", " + bias + ']'; + } +} diff --git a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/OffsetRange.java b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/OffsetRange.java new file mode 100644 index 000000000..118324d94 --- /dev/null +++ b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/OffsetRange.java @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.core.pobjects.graphics.text; + +/** + * An immutable, normalized half-open character-offset range {@code [start, end)} into a + * {@link TextSequence}. {@code start} is always {@code <= end}. + * + * @see TextSequence + * @since 7.5 + */ +public final class OffsetRange { + + private final int start; + private final int end; + + private OffsetRange(int start, int end) { + this.start = start; + this.end = end; + } + + /** + * Creates a normalized range from two offsets in either order. + * + * @param a one endpoint + * @param b the other endpoint + * @return range with {@code start = min(a,b)}, {@code end = max(a,b)} + */ + public static OffsetRange of(int a, int b) { + return a <= b ? new OffsetRange(a, b) : new OffsetRange(b, a); + } + + public int getStart() { + return start; + } + + public int getEnd() { + return end; + } + + public int length() { + return end - start; + } + + public boolean isEmpty() { + return end == start; + } + + public boolean contains(int offset) { + return offset >= start && offset < end; + } + + public OffsetRange union(OffsetRange other) { + return new OffsetRange(Math.min(start, other.start), Math.max(end, other.end)); + } + + /** + * Returns this range clamped so both endpoints fall within {@code [0, max]}. + * + * @param max upper bound (typically the sequence length) + * @return clamped range + */ + public OffsetRange clamp(int max) { + int s = Math.max(0, Math.min(start, max)); + int e = Math.max(0, Math.min(end, max)); + return of(s, e); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof OffsetRange)) return false; + OffsetRange that = (OffsetRange) o; + return start == that.start && end == that.end; + } + + @Override + public int hashCode() { + return 31 * start + end; + } + + @Override + public String toString() { + return "OffsetRange[" + start + ", " + end + ")"; + } +} diff --git a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/PageText.java b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/PageText.java index 5a6528fad..663258bca 100644 --- a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/PageText.java +++ b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/PageText.java @@ -46,14 +46,39 @@ public class PageText implements TextSelect { private static final boolean checkForDuplicates; - private static final boolean preserveColumns; + + /** Page reading-order strategy applied to the sorted line list. */ + private enum ReadingOrder {PLOT, YSORT, XYCUT} + + private static final ReadingOrder readingOrder; static { checkForDuplicates = Defs.booleanProperty( "org.icepdf.core.views.page.text.trim.duplicates", false); - preserveColumns = Defs.booleanProperty( + // readingOrder supersedes the older boolean preserveColumns; when readingOrder is not set + // we derive the mode from preserveColumns so existing configurations behave unchanged. + // plot = preserveColumns=true (default): keep content-stream plot order + // ysort = preserveColumns=false : global top-to-bottom y-sort + // xycut = geometry-driven column/band ordering + boolean preserveColumns = Defs.booleanProperty( "org.icepdf.core.views.page.text.preserveColumns", true); + String mode = Defs.sysProperty("org.icepdf.core.views.page.text.readingOrder"); + if (mode == null) { + readingOrder = preserveColumns ? ReadingOrder.PLOT : ReadingOrder.YSORT; + } else { + switch (mode.trim().toLowerCase()) { + case "ysort": + readingOrder = ReadingOrder.YSORT; + break; + case "xycut": + readingOrder = ReadingOrder.XYCUT; + break; + default: + readingOrder = ReadingOrder.PLOT; + break; + } + } } // pointer to current line during document parse, no other use. @@ -63,6 +88,9 @@ public class PageText implements TextSelect { private final ArrayList pageLines; private ArrayList sortedPageLines; + // reading-order view over sortedPageLines; lazily built, invalidated on re-sort. + private TextSequence textSequence; + private AffineTransform previousTextTransform; private AffineTransform previousXObjectTransform; @@ -139,6 +167,21 @@ public ArrayList getPageLines() { return sortedPageLines; } + /** + * Gets the reading-order {@link TextSequence} for this page, a flattened view over the + * sorted page lines that maps between page-space points, character offsets, and the + * underlying glyph/word/line structure. The value is cached and rebuilt whenever the + * page re-sorts (see {@link #sortAndFormatText}). + * + * @return reading-order sequence for this page's visible text. + */ + public TextSequence getTextSequence() { + if (textSequence == null) { + textSequence = new TextSequence(this); + } + return textSequence; + } + /** * Gets all visible lines, checking the page text for any text that is * in an optional content group and that that group is flagged as visible. @@ -567,9 +610,21 @@ public void sortAndFormatText() { } } - // sort the lines - if (sortedPageLines.size() > 0 && !preserveColumns) { - sortedPageLines.sort(new LinePositionComparator()); + // order the lines according to the configured reading-order strategy. PLOT keeps the + // content-stream order the slicing produced; YSORT is a global top-to-bottom sort; XYCUT + // applies geometry-driven column/band ordering (see XYCutReadingOrder). + if (sortedPageLines.size() > 0) { + switch (readingOrder) { + case YSORT: + sortedPageLines.sort(new LinePositionComparator()); + break; + case XYCUT: + sortedPageLines = XYCutReadingOrder.order(sortedPageLines); + break; + case PLOT: + default: + break; + } } // Round out the word bounds @@ -594,6 +649,8 @@ public void sortAndFormatText() { // assign back the sorted lines. this.sortedPageLines = sortedPageLines; + // invalidate the reading-order view so it rebuilds from the new sort. + this.textSequence = null; } diff --git a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/TextSequence.java b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/TextSequence.java new file mode 100644 index 000000000..b36434a5d --- /dev/null +++ b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/TextSequence.java @@ -0,0 +1,664 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.core.pobjects.graphics.text; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.List; + +/** + * A reading-order view over a {@link PageText}. The page's sorted lines + * ({@link PageText#getPageLines()}) are flattened into a single canonical character + * sequence: every glyph's unicode in order, with a synthetic {@code '\n'} between lines. + * All offsets exposed by this class are character offsets into that canonical string + * ({@code 0 .. length()}). + *
+ * This is the primitive that maps between three spaces the viewer cares about: + *
    + *
  • a page-space point and a caret offset ({@link #caretAt}),
  • + *
  • a caret offset and the underlying glyph/word/line ({@link #glyphAt}, + * {@link #wordRange}, {@link #lineRange}),
  • + *
  • a range of offsets and the geometry that highlights it ({@link #rectsFor}).
  • + *
+ * All geometry is in page space, the same space as {@link GlyphText#getBounds()}. + *
+ * Instances are immutable once built and are cached by {@link PageText}; the cache is + * invalidated whenever the page re-sorts (optional-content visibility change, re-parse). + * + * @see PageText#getTextSequence() + * @since 7.5 + */ +public final class TextSequence { + + // reading-order glyphs and their parallel offset / structure arrays + private final GlyphText[] glyphs; + private final int[] glyphCharStart; // char offset of glyph start + private final int[] glyphCharEnd; // char offset of glyph end (exclusive) + private final int[] glyphLine; // sorted-line index of glyph + private final int[] glyphWord; // word index of glyph + + // per sorted line + private final LineText[] lines; + private final int[] lineStart; // char offset of line start + private final int[] lineEnd; // char offset of line end (before the newline) + private final int[] lineFirstGlyph; // first glyph index on line + private final int[] lineLastGlyph; // last glyph index on line + + // per word + private final WordText[] words; + private final int[] wordStart; + private final int[] wordEnd; + private final int[] wordLine; // sorted-line index of each word + private final IdentityHashMap wordIndex; + + private final String canonical; + private final IdentityHashMap glyphIndex; + + // lazily built normalized search corpus: runs of whitespace collapsed to a single space, + // with a map back to canonical offsets. Enables searching over a stable, reading-order string. + private String searchText; + private int[] searchToCanonical; + + // lazily built diacritic-folded search corpus (whitespace collapsed + accents removed), with a + // map back to canonical offsets. Enables accent-insensitive matching (e.g. "ataudes" finds + // "ataúdes"). + private String foldedSearchText; + private int[] foldedToCanonical; + + TextSequence(PageText pageText) { + List pageLines = pageText.getPageLines(); + + List gl = new ArrayList<>(256); + List gcs = new ArrayList<>(256), gce = new ArrayList<>(256); + List gLine = new ArrayList<>(256), gWord = new ArrayList<>(256); + List ll = new ArrayList<>(64); + List ls = new ArrayList<>(64), le = new ArrayList<>(64); + List lFirst = new ArrayList<>(64), lLast = new ArrayList<>(64); + List wl = new ArrayList<>(128); + List ws = new ArrayList<>(128), we = new ArrayList<>(128), wLine = new ArrayList<>(128); + + StringBuilder sb = new StringBuilder(1024); + int lineIdx = 0; + for (LineText line : pageLines) { + if (line.getWords() == null || line.getWords().isEmpty()) continue; + int lineFirst = gl.size(); + boolean lineHasGlyph = false; + ll.add(line); + ls.add(sb.length()); + for (WordText word : line.getWords()) { + if (word.getGlyphs() == null || word.getGlyphs().isEmpty()) continue; + int wordIdx = wl.size(); + wl.add(word); + wLine.add(lineIdx); + ws.add(sb.length()); + for (GlyphText glyph : word.getGlyphs()) { + String u = glyph.getUnicode() == null ? "" : glyph.getUnicode(); + gl.add(glyph); + gcs.add(sb.length()); + sb.append(u); + gce.add(sb.length()); + gLine.add(lineIdx); + gWord.add(wordIdx); + lineHasGlyph = true; + } + we.add(sb.length()); + } + if (!lineHasGlyph) { + // line held only empty words; roll it back so we don't emit a stray newline. + ll.remove(ll.size() - 1); + ls.remove(ls.size() - 1); + continue; + } + lFirst.add(lineFirst); + lLast.add(gl.size() - 1); + le.add(sb.length()); + sb.append('\n'); // synthetic line break between lines + lineIdx++; + } + // drop trailing newline so the last line has no dangling offset + if (sb.length() > 0 && sb.charAt(sb.length() - 1) == '\n') sb.setLength(sb.length() - 1); + + canonical = sb.toString(); + glyphs = gl.toArray(new GlyphText[0]); + glyphCharStart = toIntArray(gcs); + glyphCharEnd = toIntArray(gce); + glyphLine = toIntArray(gLine); + glyphWord = toIntArray(gWord); + lines = ll.toArray(new LineText[0]); + lineStart = toIntArray(ls); + lineEnd = toIntArray(le); + lineFirstGlyph = toIntArray(lFirst); + lineLastGlyph = toIntArray(lLast); + words = wl.toArray(new WordText[0]); + wordStart = toIntArray(ws); + wordEnd = toIntArray(we); + wordLine = toIntArray(wLine); + + glyphIndex = new IdentityHashMap<>(glyphs.length * 2); + for (int i = 0; i < glyphs.length; i++) glyphIndex.put(glyphs[i], i); + wordIndex = new IdentityHashMap<>(words.length * 2); + for (int i = 0; i < words.length; i++) wordIndex.put(words[i], i); + } + + // ------------------------------------------------------------------ + // size / text + // ------------------------------------------------------------------ + + public int length() { + return canonical.length(); + } + + public boolean isEmpty() { + return glyphs.length == 0; + } + + public int lineCount() { + return lines.length; + } + + public int glyphCount() { + return glyphs.length; + } + + public CharSequence text() { + return canonical; + } + + public String text(int start, int end) { + int lo = Math.max(0, Math.min(start, end)); + int hi = Math.min(length(), Math.max(start, end)); + return lo >= hi ? "" : canonical.substring(lo, hi); + } + + public String text(OffsetRange range) { + return range == null ? "" : text(range.getStart(), range.getEnd()); + } + + public OffsetRange fullRange() { + return OffsetRange.of(0, length()); + } + + // ------------------------------------------------------------------ + // search corpus + // ------------------------------------------------------------------ + + /** + * A normalized, reading-order corpus for searching: every run of whitespace (spaces, tabs, + * newlines, non-breaking spaces) is collapsed to a single space. Matches found in this string + * map back to canonical offsets via {@link #searchToCanonicalRange}, and from there to glyphs, + * words and highlight rectangles. Lazily built and cached. + * + * @return the normalized search corpus. + */ + public String searchText() { + if (searchText == null) buildSearchText(); + return searchText; + } + + /** + * Maps a half-open range in {@link #searchText()} back to the corresponding canonical + * {@link OffsetRange}. + * + * @param searchStart inclusive start offset in the search corpus + * @param searchEnd exclusive end offset in the search corpus + * @return canonical offset range + */ + public OffsetRange searchToCanonicalRange(int searchStart, int searchEnd) { + if (searchText == null) buildSearchText(); + int max = searchText.length(); + int s = Math.max(0, Math.min(searchStart, max)); + int e = Math.max(0, Math.min(searchEnd, max)); + return OffsetRange.of(searchToCanonical[s], searchToCanonical[e]); + } + + /** + * A diacritic-folded, whitespace-collapsed search corpus: like {@link #searchText()} but with + * accents removed (Unicode NFD decomposition with combining marks stripped) so that matching is + * accent-insensitive. Map back to canonical offsets via {@link #foldedToCanonicalRange}. + * + * @return the folded search corpus. + */ + public String foldedSearchText() { + if (foldedSearchText == null) buildFoldedSearchText(); + return foldedSearchText; + } + + /** + * Maps a half-open range in {@link #foldedSearchText()} back to the corresponding canonical + * {@link OffsetRange}. + * + * @param foldedStart inclusive start offset in the folded corpus + * @param foldedEnd exclusive end offset in the folded corpus + * @return canonical offset range + */ + public OffsetRange foldedToCanonicalRange(int foldedStart, int foldedEnd) { + if (foldedSearchText == null) buildFoldedSearchText(); + int max = foldedSearchText.length(); + int s = Math.max(0, Math.min(foldedStart, max)); + int e = Math.max(0, Math.min(foldedEnd, max)); + return OffsetRange.of(foldedToCanonical[s], foldedToCanonical[e]); + } + + /** + * Folds a string for accent-insensitive comparison: Unicode NFD decomposition with combining + * marks removed. ASCII is returned unchanged. + * + * @param text text to fold + * @return the diacritic-folded text + */ + public static String foldDiacritics(String text) { + boolean ascii = true; + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) > 0x7F) { + ascii = false; + break; + } + } + if (ascii) return text; + return Normalizer.normalize(text, Normalizer.Form.NFD).replaceAll("\\p{M}+", ""); + } + + private void buildFoldedSearchText() { + StringBuilder sb = new StringBuilder(canonical.length()); + List map = new ArrayList<>(canonical.length() + 1); + boolean previousWhitespace = false; + for (int i = 0; i < canonical.length(); i++) { + char c = canonical.charAt(i); + boolean whitespace = c == 160 || Character.isWhitespace(c); + if (whitespace) { + if (!previousWhitespace) { + map.add(i); + sb.append(' '); + previousWhitespace = true; + } + } else { + String folded = foldDiacritics(String.valueOf(c)); + for (int k = 0; k < folded.length(); k++) { + map.add(i); + sb.append(folded.charAt(k)); + } + previousWhitespace = false; + } + } + map.add(canonical.length()); + foldedSearchText = sb.toString(); + foldedToCanonical = toIntArray(map); + } + + private void buildSearchText() { + StringBuilder sb = new StringBuilder(canonical.length()); + int[] map = new int[canonical.length() + 1]; + boolean previousWhitespace = false; + for (int i = 0; i < canonical.length(); i++) { + char c = canonical.charAt(i); + boolean whitespace = c == 160 || Character.isWhitespace(c); + if (whitespace) { + if (!previousWhitespace) { + map[sb.length()] = i; + sb.append(' '); + previousWhitespace = true; + } + } else { + map[sb.length()] = i; + sb.append(c); + previousWhitespace = false; + } + } + map[sb.length()] = canonical.length(); + searchText = sb.toString(); + searchToCanonical = Arrays.copyOf(map, sb.length() + 1); + } + + // ------------------------------------------------------------------ + // hit testing (page space) + // ------------------------------------------------------------------ + + /** + * Maps a page-space point to the nearest caret. Total: for any point (including + * margins and off-page) a valid caret in {@code [0, length()]} is returned. + * + * @param pagePoint point in page space + * @return nearest caret + */ + public Caret caretAt(Point2D pagePoint) { + if (glyphs.length == 0) return new Caret(0, Bias.FORWARD); + double px = pagePoint.getX(), py = pagePoint.getY(); + + // 1. direct hit: the glyph whose bounds contain the point (closest centre if several overlap). + // This is essential on multi-column or rotated (vertical) layouts where many lines share a + // y-band; a y-only line lookup would grab the wrong line and select the wrong word. + int hit = -1; + double hitDist = Double.MAX_VALUE; + for (int i = 0; i < glyphs.length; i++) { + Rectangle2D.Double b = glyphs[i].getBounds(); + if (px >= b.getMinX() && px <= b.getMaxX() && py >= b.getMinY() && py <= b.getMaxY()) { + double dx = px - b.getCenterX(), dy = py - b.getCenterY(); + double d = dx * dx + dy * dy; + if (d < hitDist) { + hitDist = d; + hit = i; + } + } + } + if (hit >= 0) { + Rectangle2D.Double b = glyphs[hit].getBounds(); + return px >= b.getCenterX() + ? new Caret(glyphCharEnd[hit], Bias.BACKWARD) + : new Caret(glyphCharStart[hit], Bias.FORWARD); + } + + // 2. no glyph under the point: pick the nearest line in 2D, then position within it by x. + return caretInLine(nearestLine(pagePoint), px); + } + + /** + * Positions a caret within a specific line by x, clamping to the line's start/end. Unlike + * {@link #caretAt(Point2D)} this never re-picks the line: vertical navigation onto a shorter + * line must land on that line even when {@code px} overshoots its right edge (otherwise + * a free 2D line search snaps back to the longer neighbour and the caret can't advance). + */ + private Caret caretInLine(int line, double px) { + int first = lineFirstGlyph[line], last = lineLastGlyph[line]; + Rectangle2D.Double fb = glyphs[first].getBounds(); + Rectangle2D.Double lb = glyphs[last].getBounds(); + if (px <= fb.getMinX()) return new Caret(glyphCharStart[first], Bias.FORWARD); + if (px >= lb.getMaxX()) return new Caret(glyphCharEnd[last], Bias.BACKWARD); + for (int i = first; i <= last; i++) { + Rectangle2D.Double b = glyphs[i].getBounds(); + if (px >= b.getMinX() && px <= b.getMaxX()) { + return px >= b.getCenterX() + ? new Caret(glyphCharEnd[i], Bias.BACKWARD) + : new Caret(glyphCharStart[i], Bias.FORWARD); + } + } + // in a gap between glyphs - snap to the nearer boundary + for (int i = first; i < last; i++) { + double gapMid = (glyphs[i].getBounds().getMaxX() + glyphs[i + 1].getBounds().getMinX()) / 2; + if (px < gapMid) return new Caret(glyphCharEnd[i], Bias.BACKWARD); + } + return new Caret(glyphCharEnd[last], Bias.BACKWARD); + } + + /** + * @param pagePoint point in page space + * @return true only when the point falls within a glyph's bounding box (drives the + * text-selection cursor icon). + */ + public boolean hitsText(Point2D pagePoint) { + for (GlyphText glyph : glyphs) { + if (glyph.getBounds().contains(pagePoint)) return true; + } + return false; + } + + /** Index of the line whose bounding box is nearest the point in 2D (0 if the point is inside). */ + private int nearestLine(Point2D p) { + int best = 0; + double bestDist = Double.MAX_VALUE; + double px = p.getX(), py = p.getY(); + for (int i = 0; i < lines.length; i++) { + Rectangle2D.Double b = lines[i].getBounds(); + double dx = Math.max(Math.max(b.getMinX() - px, px - b.getMaxX()), 0); + double dy = Math.max(Math.max(b.getMinY() - py, py - b.getMaxY()), 0); + double d = dx * dx + dy * dy; + if (d < bestDist) { + bestDist = d; + best = i; + } + } + return best; + } + + // ------------------------------------------------------------------ + // offset <-> content + // ------------------------------------------------------------------ + + /** + * @param offset char offset + * @return the glyph whose span covers {@code offset} (downstream), or {@code null} + * when the offset falls on a line break or past the end. + */ + public GlyphText glyphAt(int offset) { + int i = firstGlyphEndingAfter(offset); + if (i < glyphs.length && glyphCharStart[i] <= offset) return glyphs[i]; + return null; + } + + /** + * @param glyph glyph to locate + * @return the glyph's start char offset, or {@code -1} if not part of this sequence. + */ + public int offsetOf(GlyphText glyph) { + Integer i = glyphIndex.get(glyph); + return i == null ? -1 : glyphCharStart[i]; + } + + /** + * @param offset char offset + * @return the sorted-line index that contains {@code offset}. + */ + public int lineIndexOf(int offset) { + if (lines.length == 0) return -1; + int lo = 0, hi = lines.length - 1, ans = 0; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + if (lineStart[mid] <= offset) { + ans = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return ans; + } + + // ------------------------------------------------------------------ + // geometry (page space) + // ------------------------------------------------------------------ + + public List rectsFor(int start, int end) { + int lo = Math.max(0, Math.min(start, end)); + int hi = Math.min(length(), Math.max(start, end)); + List out = new ArrayList<>(); + if (lo >= hi) return out; + int curLine = -1; + Rectangle2D.Double acc = null; + for (int i = firstGlyphEndingAfter(lo); i < glyphs.length && glyphCharStart[i] < hi; i++) { + if (glyphLine[i] != curLine) { + if (acc != null) out.add(acc); + acc = (Rectangle2D.Double) glyphs[i].getBounds().clone(); + curLine = glyphLine[i]; + } else { + acc.add(glyphs[i].getBounds()); + } + } + if (acc != null) out.add(acc); + return out; + } + + public List rectsFor(OffsetRange range) { + return range == null ? new ArrayList<>() : rectsFor(range.getStart(), range.getEnd()); + } + + /** + * A thin (zero-width) rectangle at the caret position for painting a caret bar. + * + * @param caret caret to locate + * @return caret bar rectangle in page space + */ + public Rectangle2D.Double caretRect(Caret caret) { + int off = caret.getOffset(); + GlyphText g = glyphAt(off); + if (g != null && glyphCharStart[glyphIndex.get(g)] == off) { + Rectangle2D.Double b = g.getBounds(); + return new Rectangle2D.Double(b.getMinX(), b.getMinY(), 0, b.getHeight()); + } + // caret at the trailing edge of the previous glyph (line/word/text end) + int i = firstGlyphEndingAfter(off) - 1; + if (i < 0) i = 0; + if (i >= glyphs.length) i = glyphs.length - 1; + if (glyphs.length == 0) return new Rectangle2D.Double(); + Rectangle2D.Double b = glyphs[i].getBounds(); + return new Rectangle2D.Double(b.getMaxX(), b.getMinY(), 0, b.getHeight()); + } + + // ------------------------------------------------------------------ + // boundary snapping + // ------------------------------------------------------------------ + + public OffsetRange wordRange(int offset) { + if (words.length == 0) return OffsetRange.of(0, 0); + int gi = glyphIndexNear(offset); + int wi = glyphWord[gi]; + return OffsetRange.of(wordStart[wi], wordEnd[wi]); + } + + public OffsetRange lineRange(int offset) { + if (lines.length == 0) return OffsetRange.of(0, 0); + int li = lineIndexOf(offset); + return OffsetRange.of(lineStart[li], lineEnd[li]); + } + + public int nextBoundary(int offset, BreakType type, boolean forward) { + switch (type) { + case WORD: + OffsetRange w = wordRange(offset); + if (forward) return offset < w.getEnd() ? w.getEnd() : clampOffset(offset + 1); + return offset > w.getStart() ? w.getStart() : clampOffset(offset - 1); + case LINE: + OffsetRange l = lineRange(offset); + return forward ? l.getEnd() : l.getStart(); + case GLYPH: + default: + return clampOffset(forward ? offset + 1 : offset - 1); + } + } + + // ------------------------------------------------------------------ + // vertical navigation + // ------------------------------------------------------------------ + + public Caret caretAbove(Caret caret, double goalX) { + int li = lineIndexOf(caret.getOffset()); + if (li <= 0) return null; + return new Caret(caretInLine(li - 1, goalX).getOffset(), caret.getBias()); + } + + public Caret caretBelow(Caret caret, double goalX) { + int li = lineIndexOf(caret.getOffset()); + if (li < 0 || li >= lines.length - 1) return null; + return new Caret(caretInLine(li + 1, goalX).getOffset(), caret.getBias()); + } + + /** + * Caret at the given line index nearest {@code goalX}; used to land the caret when vertical + * navigation crosses onto this page. + * + * @param lineIndex sorted-line index (clamped into range) + * @param goalX preferred page-space x column + * @return caret on that line + */ + public Caret caretAtLine(int lineIndex, double goalX) { + if (lines.length == 0) return new Caret(0, Bias.FORWARD); + int li = Math.max(0, Math.min(lineIndex, lines.length - 1)); + return caretInLine(li, goalX); + } + + // ------------------------------------------------------------------ + // range -> content (used by the viewer write-through bridge) + // ------------------------------------------------------------------ + + public List glyphsIn(OffsetRange range) { + List out = new ArrayList<>(); + if (range == null || range.isEmpty()) return out; + int lo = range.getStart(), hi = range.getEnd(); + for (int i = firstGlyphEndingAfter(lo); i < glyphs.length && glyphCharStart[i] < hi; i++) { + out.add(glyphs[i]); + } + return out; + } + + public List wordsIn(OffsetRange range) { + List out = new ArrayList<>(); + if (range == null || range.isEmpty()) return out; + int lo = range.getStart(), hi = range.getEnd(); + int lastWord = -1; + for (int i = firstGlyphEndingAfter(lo); i < glyphs.length && glyphCharStart[i] < hi; i++) { + if (glyphWord[i] != lastWord) { + out.add(words[glyphWord[i]]); + lastWord = glyphWord[i]; + } + } + return out; + } + + /** + * @param wordText a word in this sequence + * @return the sorted line that contains the word, or {@code null} if not part of this sequence. + */ + public LineText lineOf(WordText wordText) { + Integer wi = wordIndex.get(wordText); + return wi == null ? null : lines[wordLine[wi]]; + } + + /** + * @param wordText a word in this sequence + * @return the word's offset range, or {@code null} if it is not part of this sequence. + */ + public OffsetRange rangeOf(WordText wordText) { + for (int i = 0; i < words.length; i++) { + if (words[i] == wordText) return OffsetRange.of(wordStart[i], wordEnd[i]); + } + return null; + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + /** First glyph index whose end offset is strictly greater than {@code offset}. */ + private int firstGlyphEndingAfter(int offset) { + int lo = 0, hi = glyphs.length; + while (lo < hi) { + int mid = (lo + hi) >>> 1; + if (glyphCharEnd[mid] <= offset) lo = mid + 1; + else hi = mid; + } + return lo; + } + + /** Nearest glyph index to {@code offset} (covering, else the one just before/after). */ + private int glyphIndexNear(int offset) { + int i = firstGlyphEndingAfter(offset); + if (i >= glyphs.length) return glyphs.length - 1; + if (glyphCharStart[i] <= offset) return i; // offset is inside glyph i + return i; // offset is before glyph i -> use i + } + + private int clampOffset(int offset) { + return Math.max(0, Math.min(length(), offset)); + } + + private static int[] toIntArray(List list) { + int[] a = new int[list.size()]; + for (int i = 0; i < a.length; i++) a[i] = list.get(i); + return a; + } +} diff --git a/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/XYCutReadingOrder.java b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/XYCutReadingOrder.java new file mode 100644 index 000000000..c91d19635 --- /dev/null +++ b/core/core-awt/src/main/java/org/icepdf/core/pobjects/graphics/text/XYCutReadingOrder.java @@ -0,0 +1,225 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.icepdf.core.pobjects.graphics.text; + +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Geometry-driven page reading order via recursive XY-cut (projection profiling). + *

+ * Operates on the already-sliced {@link LineText} list produced by + * {@code PageText.sortAndFormatText()} and returns the same lines re-ordered. At each region it + * prefers a vertical cut (a whitespace gutter between columns) so a column is read + * top-to-bottom before moving right; failing that a horizontal cut (a band break); and if + * neither is significant the region is a leaf and is sorted plain top-to-bottom. + *

+ * Coordinate note: line bounds are in PDF space where larger {@code y} is higher on the page, so + * "top first" means descending {@code y}. + *

+ * Design and rationale: {@code READING-ORDER-XYCUT-PLAN.md}. This is opt-in + * ({@code org.icepdf.core.views.page.text.readingOrder=xycut}); it does not change the default. + * + * @since 7.5 + */ +public final class XYCutReadingOrder { + + /** Column gutter must exceed this many median line-heights to split columns. */ + private static final double COL_GUTTER_MIN = 1.5; + /** Band gap must exceed this many median line-heights to split rows. */ + private static final double ROW_GAP_MIN = 1.5; + /** A column side must overlap the other in y by at least this fraction to be a genuine column. */ + private static final double COL_Y_OVERLAP_MIN = 0.25; + /** Refuse a vertical cut that would leave a side with fewer than this many lines. */ + private static final int MIN_COLUMN_LINES = 2; + /** Recursion guard against pathological input. */ + private static final int MAX_DEPTH = 48; + + private XYCutReadingOrder() { + } + + /** Reading-order comparator for a leaf region: top-to-bottom (y desc), then left-to-right. */ + private static final Comparator TOP_LEFT = + (a, b) -> { + int c = Double.compare(b.getBounds().getY(), a.getBounds().getY()); + return c != 0 ? c : Double.compare(a.getBounds().getX(), b.getBounds().getX()); + }; + + /** + * Returns {@code lines} re-ordered into reading order. The input list is not modified. + */ + public static ArrayList order(List lines) { + ArrayList out = new ArrayList<>(lines.size()); + order(new ArrayList<>(lines), 0, out); + return out; + } + + private static void order(List region, int depth, List out) { + int n = region.size(); + if (n <= 1 || depth > MAX_DEPTH) { + emitLeaf(region, out); + return; + } + double scale = medianHeight(region); + + // 1. columns first: a vertical gutter, provided both sides are genuinely side-by-side. + Gap gutter = widestGap(region, true); + if (gutter != null && gutter.width > COL_GUTTER_MIN * scale) { + List left = new ArrayList<>(); + List right = new ArrayList<>(); + for (LineText line : region) { + if (line.getBounds().getCenterX() < gutter.mid) left.add(line); + else right.add(line); + } + if (left.size() >= MIN_COLUMN_LINES && right.size() >= MIN_COLUMN_LINES + && yOverlapFraction(left, right) >= COL_Y_OVERLAP_MIN) { + order(left, depth + 1, out); // left-to-right + order(right, depth + 1, out); + return; + } + } + + // 2. otherwise a horizontal band break (harmless within a single column: order is preserved). + Gap band = widestGap(region, false); + if (band != null && band.width > ROW_GAP_MIN * scale) { + List top = new ArrayList<>(); + List bottom = new ArrayList<>(); + for (LineText line : region) { + if (line.getBounds().getCenterY() > band.mid) top.add(line); // larger y = higher + else bottom.add(line); + } + if (!top.isEmpty() && !bottom.isEmpty()) { + order(top, depth + 1, out); // top-to-bottom + order(bottom, depth + 1, out); + return; + } + } + + // 3. leaf. + emitLeaf(region, out); + } + + /** + * Emits a leaf region. A skinny, tall block (a vertical glyph stack such as rotated marginal + * text) is left in its incoming order rather than forced top-to-bottom: we do not know its true + * flow direction, and re-sorting a bottom-to-top vertical run would reverse it. Fixing vertical + * text order is an explicit non-goal (see plan); preserving plot order is the no-regression choice. + * Everything else is a normal horizontal block sorted top-to-bottom, left-to-right. + */ + private static void emitLeaf(List region, List out) { + if (isVerticalStack(region)) { + out.addAll(region); + } else { + region.sort(TOP_LEFT); + out.addAll(region); + } + } + + /** + * True for a rotated glyph-stack (e.g. a vertical marginal label): three or more lines, the block + * taller than it is wide, and each line about as wide as it is tall — i.e. every "line" is a + * single glyph rather than a run of text. Real horizontal text, even in a narrow column, has lines + * far wider than tall, so it fails this test and is sorted top-to-bottom normally. + */ + private static boolean isVerticalStack(List region) { + if (region.size() < 3) return false; + double minX = Double.MAX_VALUE, maxX = -Double.MAX_VALUE; + double minY = Double.MAX_VALUE, maxY = -Double.MAX_VALUE; + double[] widths = new double[region.size()]; + int i = 0; + for (LineText l : region) { + Rectangle2D.Double b = l.getBounds(); + widths[i++] = b.getWidth(); + minX = Math.min(minX, b.getMinX()); + maxX = Math.max(maxX, b.getMaxX()); + minY = Math.min(minY, b.getMinY()); + maxY = Math.max(maxY, b.getMaxY()); + } + java.util.Arrays.sort(widths); + double medianLineWidth = widths[widths.length / 2]; + boolean tallerThanWide = (maxY - minY) > (maxX - minX); + boolean glyphWideLines = medianLineWidth < medianHeight(region) * 3; + return tallerThanWide && glyphWideLines; + } + + /** + * Widest zero-coverage gap along one axis, computed by projecting each line box onto that axis, + * merging overlapping intervals, and taking the largest interior gap. + * + * @param horizontalAxis true for the x-axis (column gutter), false for the y-axis (band break) + * @return the widest gap, or null if the projection has no interior gap + */ + private static Gap widestGap(List region, boolean horizontalAxis) { + double[][] iv = new double[region.size()][2]; + for (int i = 0; i < region.size(); i++) { + Rectangle2D.Double b = region.get(i).getBounds(); + iv[i][0] = horizontalAxis ? b.getMinX() : b.getMinY(); + iv[i][1] = horizontalAxis ? b.getMaxX() : b.getMaxY(); + } + java.util.Arrays.sort(iv, Comparator.comparingDouble(a -> a[0])); + double bestLo = 0, bestHi = 0, best = 0; + double coverEnd = iv[0][1]; + for (int i = 1; i < iv.length; i++) { + if (iv[i][0] > coverEnd) { + double w = iv[i][0] - coverEnd; + if (w > best) { + best = w; + bestLo = coverEnd; + bestHi = iv[i][0]; + } + } + coverEnd = Math.max(coverEnd, iv[i][1]); + } + return best > 0 ? new Gap(bestLo, bestHi, best) : null; + } + + /** + * Fraction of the smaller y-extent over which the two column candidates overlap. Stacked blocks + * (one above the other) overlap little and are rejected as columns; true side-by-side columns + * overlap heavily. + */ + private static double yOverlapFraction(List a, List b) { + double aMin = Double.MAX_VALUE, aMax = -Double.MAX_VALUE; + double bMin = Double.MAX_VALUE, bMax = -Double.MAX_VALUE; + for (LineText l : a) { + aMin = Math.min(aMin, l.getBounds().getMinY()); + aMax = Math.max(aMax, l.getBounds().getMaxY()); + } + for (LineText l : b) { + bMin = Math.min(bMin, l.getBounds().getMinY()); + bMax = Math.max(bMax, l.getBounds().getMaxY()); + } + double overlap = Math.min(aMax, bMax) - Math.max(aMin, bMin); + if (overlap <= 0) return 0; + double smaller = Math.min(aMax - aMin, bMax - bMin); + return smaller <= 0 ? 0 : overlap / smaller; + } + + private static double medianHeight(List region) { + double[] h = new double[region.size()]; + for (int i = 0; i < region.size(); i++) h[i] = region.get(i).getBounds().getHeight(); + java.util.Arrays.sort(h); + double m = h[h.length / 2]; + return m > 0 ? m : 1; // guard against zero-height degenerate lines + } + + /** A zero-coverage interval [lo, hi] of the given width along one axis. */ + private static final class Gap { + final double mid; + final double width; + + Gap(double lo, double hi, double width) { + this.mid = (lo + hi) / 2; + this.width = width; + } + } +} diff --git a/core/core-awt/src/main/java/org/icepdf/core/search/SearchTerm.java b/core/core-awt/src/main/java/org/icepdf/core/search/SearchTerm.java index b52f6047e..5ced887ba 100644 --- a/core/core-awt/src/main/java/org/icepdf/core/search/SearchTerm.java +++ b/core/core-awt/src/main/java/org/icepdf/core/search/SearchTerm.java @@ -44,6 +44,8 @@ public class SearchTerm { private final boolean wholeWord; // allow for regex compare private final boolean regex; + // accent-insensitive (Unicode-normalized) matching for literal terms; opt-in, default off. + private boolean foldDiacritics; private Pattern searchPattern; /** @@ -74,10 +76,14 @@ public SearchTerm(String term, ArrayList terms, } /** - * Gets individual strings that make up the search term, + * Gets the individual word/space/punctuation tokens that make up the search term. * - * @return list of strings that contain searchable words. + * @return list of tokens that make up the search term. + * @deprecated Since 7.5 the search matcher operates on the whole term ({@link #getTerm()}) + * against a reading-order text corpus and no longer uses these tokens. Retained for API + * compatibility; scheduled for removal in a future major release. */ + @Deprecated public ArrayList getTerms() { return terms; } @@ -113,6 +119,25 @@ public boolean isRegex() { return regex; } + /** + * Whether literal matching should be accent-insensitive (Unicode-normalized). Opt-in; has no + * effect on regex terms. + * + * @return true if diacritics should be folded when matching. + */ + public boolean isFoldDiacritics() { + return foldDiacritics; + } + + /** + * Enables accent-insensitive (Unicode-normalized) matching for this literal term. + * + * @param foldDiacritics true to fold diacritics when matching. + */ + public void setFoldDiacritics(boolean foldDiacritics) { + this.foldDiacritics = foldDiacritics; + } + public Pattern getRegexPattern() { if (searchPattern != null) return searchPattern; if (regex && term != null && !term.isEmpty()) { diff --git a/examples/search/component/src/main/java/org/icepdf/examples/search/SearchController.java b/examples/search/component/src/main/java/org/icepdf/examples/search/SearchController.java index c8f16c861..88dd54acf 100644 --- a/examples/search/component/src/main/java/org/icepdf/examples/search/SearchController.java +++ b/examples/search/component/src/main/java/org/icepdf/examples/search/SearchController.java @@ -17,6 +17,7 @@ import org.icepdf.core.pobjects.Document; import org.icepdf.core.pobjects.graphics.text.WordText; import org.icepdf.core.search.DocumentSearchController; +import org.icepdf.core.search.SearchTerm; import org.icepdf.ri.common.SwingController; import org.icepdf.ri.common.SwingViewBuilder; import org.icepdf.ri.util.FontPropertiesManager; @@ -81,9 +82,11 @@ public static void main(String[] args) { // get the search controller DocumentSearchController searchController = controller.getDocumentSearchController(); - // add a specified search terms. + // add the specified search terms. for (String term : terms) { - searchController.addSearchTerm(term, false, false); + SearchTerm searchTerm = searchController.addSearchTerm(term, false, false); + // enable accent-insensitive (Unicode-normalized) matching so, e.g., "cafe" finds "café". + searchTerm.setFoldDiacritics(true); } // search the pages in the document or a subset Document document = controller.getDocument(); diff --git a/examples/search/headless/src/main/java/org/icepdf/examples/search/SearchControllerHeadless.java b/examples/search/headless/src/main/java/org/icepdf/examples/search/SearchControllerHeadless.java index 52374a5b6..598c75366 100644 --- a/examples/search/headless/src/main/java/org/icepdf/examples/search/SearchControllerHeadless.java +++ b/examples/search/headless/src/main/java/org/icepdf/examples/search/SearchControllerHeadless.java @@ -18,6 +18,7 @@ import org.icepdf.core.pobjects.PDimension; import org.icepdf.core.pobjects.Page; import org.icepdf.core.search.DocumentSearchController; +import org.icepdf.core.search.SearchTerm; import org.icepdf.core.util.GraphicsRenderingHints; import org.icepdf.ri.common.search.DocumentSearchControllerImpl; @@ -52,10 +53,12 @@ public static void main(String[] args) { // get the search controller DocumentSearchController searchController = new DocumentSearchControllerImpl(document); - // add a specified search terms. - searchController.addSearchTerm("PDF", true, false); - searchController.addSearchTerm("Part", true, false); - searchController.addSearchTerm("Contents", true, false); + // add the specified search terms. Enabling diacritic folding makes matching + // accent-insensitive (Unicode-normalized), so e.g. "resume" would also find "résumé". + for (String term : new String[]{"PDF", "Part", "Contents"}) { + SearchTerm searchTerm = searchController.addSearchTerm(term, true, false); + searchTerm.setFoldDiacritics(true); + } // Paint each pages content to an image and write the image to file for (int i = 0; i < 5; i++) { diff --git a/viewer/viewer-awt/build.gradle b/viewer/viewer-awt/build.gradle index 6033c6e05..6f3541606 100644 --- a/viewer/viewer-awt/build.gradle +++ b/viewer/viewer-awt/build.gradle @@ -67,6 +67,13 @@ test { testLogging { events "passed", "skipped", "failed" } + // forward opt-in characterization/golden flags to the test JVM + // e.g. ./gradlew ... -Dupdate.reading.order.golden=true + ['update.reading.order.golden', 'update.reading.order.xycut.golden'].each { k -> + if (System.getProperty(k) != null) { + systemProperty(k, System.getProperty(k)) + } + } } jar { diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/search/DocumentSearchControllerImpl.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/search/DocumentSearchControllerImpl.java index ad22cb7f0..9f22ca2ec 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/search/DocumentSearchControllerImpl.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/search/DocumentSearchControllerImpl.java @@ -19,7 +19,9 @@ import org.icepdf.core.pobjects.annotations.MarkupAnnotation; import org.icepdf.core.pobjects.annotations.TextWidgetAnnotation; import org.icepdf.core.pobjects.graphics.text.LineText; +import org.icepdf.core.pobjects.graphics.text.OffsetRange; import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.TextSequence; import org.icepdf.core.pobjects.graphics.text.WordText; import org.icepdf.core.search.DestinationResult; import org.icepdf.core.search.DocumentSearchController; @@ -27,6 +29,8 @@ import org.icepdf.core.search.SearchTerm; import org.icepdf.core.util.Library; import org.icepdf.ri.common.SwingController; +import org.icepdf.ri.common.tools.TextSelectionSupport; +import org.icepdf.ri.common.views.DocumentViewModel; import org.icepdf.ri.common.utility.search.SearchHitComponent; import org.icepdf.ri.common.utility.search.SearchHitComponentFactory; import org.icepdf.ri.common.utility.search.SearchHitComponentFactoryImpl; @@ -175,155 +179,40 @@ public int searchHighlightPage(int pageIndex) { * list is returned. */ public List searchHighlightPage(int pageIndex, int wordPadding) { - switch (searchMode) { - case WORD: - return searchHighlightWordPage(pageIndex, wordPadding); - case PAGE: - return searchHighlightWholePage(pageIndex); - default: - return Collections.emptyList(); - } - } - - private List searchHighlightWordPage(int pageIndex, int wordPadding) { - // search hit list - List searchHits = new ArrayList<>(); - - // get our page text reference - PageText pageText = getPageText(pageIndex); - - // some pages just don't have any text. + final List searchHits = new ArrayList<>(); + final PageText pageText = getPageText(pageIndex); if (pageText == null) { return searchHits; } - - // get search terms from model and search for each occurrence. - List terms = searchModel.getSearchTerms(); - // we need to do the search for each term. - SearchTerm term; - for (SearchTerm searchTerm : terms) { - term = searchTerm; - - // found word index to keep track of when we have found a hit - int searchPhraseHitCount = 0; - int searchPhraseFoundCount = term.getTerms().size(); - - // start iteration over words. - List pageLines = pageText.getPageLines(); - if (pageLines != null) { - for (LineText pageLine : pageLines) { - List lineWords = pageLine.getWords(); - // compare words against search terms. - String wordString; - WordText word; - for (int i = 0, max = lineWords.size(); i < max; i++) { - word = lineWords.get(i); - - // apply case sensitivity rule. - wordString = term.isCaseSensitive() ? word.toString() : - word.toString().toLowerCase(); - - // skip if it's white space, so we don't worry about it in the search - if (wordString.length() == 1) { - char c = wordString.charAt(0); - if (WordText.isWhiteSpace(c)) { - continue; - } - } - - // word matches, we have to match full word hits - if (term.isWholeWord()) { - final List termList = term.getTerms(); - if (termList != null && termList.size() > searchPhraseHitCount) { - final String hit = termList.get(searchPhraseHitCount); - if (wordString.equals(hit)) { - // add word to potentials - searchPhraseHitCount++; - } - // reset the counters. - else { - searchPhraseHitCount = 0; - } - } else { - searchPhraseHitCount = 0; - } - } else if (term.isRegex()) { - Pattern pattern = term.getRegexPattern(); - Matcher matcher = pattern.matcher(wordString); - if (matcher.find()) { - // add word to potentials - if (!word.isWhiteSpace()) { - searchPhraseHitCount++; - } - } - } - // otherwise we look for an index of hits - else { - // found a potential hit, depends on the length - // of searchPhrase. - final List termList = term.getTerms(); - if (termList != null && termList.size() > searchPhraseHitCount) { - final String hit = term.getTerms().get(searchPhraseHitCount); - if (hit != null && wordString.contains(hit)) { - // add word to potentials - searchPhraseHitCount++; - } - else { - // reset the counters. - searchPhraseHitCount = 0; - } - } else { - searchPhraseHitCount = 0; - } - } - // check if we have found what we're looking for - if (searchPhraseHitCount > 0 && searchPhraseHitCount == searchPhraseFoundCount) { - LineText lineText = new LineText(); - int lineWordsSize = lineWords.size(); - List hitWords = lineText.getWords(); - // add pre padding - int spaces = searchPhraseHitCount - 1; - spaces = Math.max(spaces, 0); - int start = i - searchPhraseHitCount - spaces - wordPadding + 1; - start = Math.max(start, 0); - int end = i - searchPhraseHitCount - spaces; - end = Math.max(end, 0); - // add post padding indexes. - int start2 = i + 1; - start2 = Math.min(start2, lineWordsSize); - int end2 = start2 + wordPadding; - end2 = Math.min(end2, lineWordsSize); - - for (int p = start; p < end; p++) { - hitWords.add(lineWords.get(p)); - } - - // highlight the found words. - WordText wordHit; - for (int w = (end == 0) ? 0 : end + 1; w < start2; w++) { - wordHit = lineWords.get(w); - wordHit.setHighlighted(true); - wordHit.setHasHighlight(true); - wordHit.setHighlightColor(term.getHighlightColor()); - hitWords.add(wordHit); - addComponent(pageIndex, wordHit.getText(), wordHit.getBounds()); - } - - for (int p = start2; p < end2; p++) { - hitWords.add(lineWords.get(p)); - } - - // add the hits to our list. - searchHits.add(lineText); - searchPhraseHitCount = 0; - } - - } - } + // One corpus-based matcher for both modes: literal terms match a whitespace-collapsed + // reading-order corpus (searchText), regex terms match the canonical text; whole-word wraps + // the term in \b boundaries. Matches map back to canonical offsets and then to WordTexts, + // which are highlighted in place. The search mode only affects result-fragment context: + // PAGE uses full line context, WORD pads a fixed number of words on each side of the hit. + final TextSequence sequence = pageText.getTextSequence(); + final boolean fullLineContext = searchMode == SearchMode.PAGE; + for (final SearchTerm term : searchModel.getSearchTerms()) { + final boolean regex = term.isRegex(); + final boolean fold = !regex && term.isFoldDiacritics(); + // Regex terms match the raw canonical text. Literal terms match a whitespace-collapsed + // corpus, optionally diacritic-folded (accent-insensitive) when the term opts in. + final String corpus = regex ? sequence.text().toString() + : (fold ? sequence.foldedSearchText() : sequence.searchText()); + final Pattern pattern = compileSearchPattern(term); + if (pattern == null) continue; + final Matcher matcher = pattern.matcher(corpus); + while (matcher.find()) { + if (matcher.end() == matcher.start()) continue; // ignore zero-width matches + final OffsetRange range = regex + ? OffsetRange.of(matcher.start(), matcher.end()) + : (fold ? sequence.foldedToCanonicalRange(matcher.start(), matcher.end()) + : sequence.searchToCanonicalRange(matcher.start(), matcher.end())); + final List hitWords = sequence.wordsIn(range); + if (hitWords.isEmpty()) continue; + searchHits.add(buildHitFragment(sequence, hitWords, term.getHighlightColor(), + pageIndex, matcher.group(), fullLineContext, wordPadding)); } } - - // if we have a hit we'll add it to the model cache if (!searchHits.isEmpty()) { searchModel.addPageSearchHit(pageIndex, pageText, searchHits.size()); if (logger.isLoggable(Level.FINE)) { @@ -333,6 +222,31 @@ private List searchHighlightWordPage(int pageIndex, int wordPadding) { return searchHits; } + /** + * Compiles a term into a matcher pattern: regex terms use their own pattern; literal terms are + * quoted, optionally wrapped in {@code \b} word boundaries for whole-word searches, and made + * case-insensitive / Unicode-aware as configured. + * + * @param term search term + * @return compiled pattern, or null if a regex term has no pattern. + */ + private Pattern compileSearchPattern(SearchTerm term) { + if (term.isRegex()) { + return term.getRegexPattern(); + } + int flags = Pattern.UNICODE_CHARACTER_CLASS; + if (!term.isCaseSensitive()) { + flags |= Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE; + } + // fold the term to match the folded corpus only when the term opts into accent-insensitivity. + String expression = Pattern.quote( + term.isFoldDiacritics() ? TextSequence.foldDiacritics(term.getTerm()) : term.getTerm()); + if (term.isWholeWord()) { + expression = "\\b" + expression + "\\b"; + } + return Pattern.compile(expression, flags); + } + @Override public void setSearchMode(SearchMode searchMode) { this.searchMode = searchMode; @@ -343,140 +257,57 @@ public SearchMode getSearchMode() { return searchMode; } - private List searchHighlightWholePage(final int pageIndex) { - // search hit list - final List searchHits = new ArrayList<>(); - - // get our page text reference - final PageText pageText = getPageText(pageIndex); - - // some pages just don't have any text. - if (pageText == null) { - return searchHits; - } - - //Prepare whole text - final List terms = searchModel.getSearchTerms(); - final Map idxToWordText = new HashMap<>(); - final List wordOffsets = new ArrayList<>(); - final Map wordTextToLineText = new HashMap<>(); - SearchTerm term; - final StringBuilder textBuilder = new StringBuilder(); - for (final LineText line : pageText.getPageLines()) { - String lastWordText = null; - if (line != null && line.getWords() != null) { - for (final WordText word : line.getWords()) { - final String wordText = word.getText(); - //Remove multi-spaces - if (!wordText.equals(" ") || (lastWordText != null && !lastWordText.equals(" "))) { - wordOffsets.add(textBuilder.length()); - idxToWordText.put(wordOffsets.size() - 1, word); - textBuilder.append(wordText); - wordOffsets.add(textBuilder.length()); - idxToWordText.put(wordOffsets.size() - 1, word); - lastWordText = wordText; - wordTextToLineText.put(word, line); - } - } - } - textBuilder.append("\n"); - } - final String text = textBuilder.toString(); - final Map> colorToHits = new HashMap<>(); - for (final SearchTerm searchTerm : terms) { - term = searchTerm; - - String searchString = text; - //If not regex, replace line feed by spaces - if (!term.isRegex()) { - searchString = searchString.replace("\n", " "); - } - //Always use regex to search - final Pattern pattern = term.isRegex() ? term.getRegexPattern() : Pattern.compile(Pattern.quote(term.getTerm()), - term.isCaseSensitive() ? 0 : Pattern.CASE_INSENSITIVE); - final Matcher matcher = pattern.matcher(searchString); - final List hits = new ArrayList<>(); - while (matcher.find()) { - // add word to potentials - final int start = matcher.start(); - final int end = matcher.end(); - final String hitText = text.substring(start, end); - hits.add(new SearchHit(start, end, hitText)); + /** + * Builds a search-result fragment: context words on the first hit word's line before the hit, + * the highlighted hit words, and context words on the last hit word's line after the hit. + * With {@code fullLineContext} the whole line is included on each side; otherwise up to + * {@code wordPadding} words are included on each side. + * + * @param sequence page text sequence + * @param hitWords matched words, in reading order + * @param color highlight color for the term + * @param pageIndex page the hit is on + * @param hitText matched text (used for the search-hit component label) + * @param fullLineContext true to include the rest of the line on each side (PAGE mode) + * @param wordPadding number of context words on each side when not full-line (WORD mode) + * @return a LineText fragment for the results list. + */ + private LineText buildHitFragment(TextSequence sequence, List hitWords, Color color, + int pageIndex, String hitText, boolean fullLineContext, int wordPadding) { + final LineText fragment = new LineText(); + final WordText firstWord = hitWords.get(0); + final WordText lastWord = hitWords.get(hitWords.size() - 1); + + // leading context on the first hit word's line. + final LineText firstLine = sequence.lineOf(firstWord); + if (firstLine != null) { + final List lineWords = firstLine.getWords(); + final int index = lineWords.indexOf(firstWord); + if (index > 0) { + final int from = fullLineContext ? 0 : Math.max(0, index - wordPadding); + for (int i = from; i < index; i++) fragment.getWords().add(lineWords.get(i)); } - final List existingHits = colorToHits.getOrDefault(term.getHighlightColor(), new ArrayList<>()); - existingHits.addAll(hits); - colorToHits.put(term.getHighlightColor(), existingHits); } - // check if we have found what we're looking for - for (final Color color : colorToHits.keySet()) { - for (final SearchHit searchHit : colorToHits.get(color)) { - final int start = searchHit.getStartOffset(); - final int end = searchHit.getEndOffset(); - //Get relevant WordTexts - int startIdx = Collections.binarySearch(wordOffsets, start); - if (startIdx < 0) { - startIdx = -(startIdx + 1); - } else { - //Take word start - if (startIdx + 1 < wordOffsets.size() && wordOffsets.get(startIdx + 1) == start) { - startIdx += 1; - } - } - int endIdx = Collections.binarySearch(wordOffsets, end); - if (endIdx < 0) { - endIdx = Math.min(-(endIdx + 1), idxToWordText.size() - 1); - } - final LineText lineText = new LineText(); - //Add start line context - final WordText firstWord = idxToWordText.get(startIdx); - final LineText firstLineText = wordTextToLineText.get(firstWord); - if (firstLineText != null && firstLineText.getWords() != null) { - for (final WordText wt : firstLineText.getWords()) { - if (wt != firstWord) { - lineText.getWords().add(wt); - } else { - break; - } - } - } - WordText previous = null; - for (int i = startIdx; i <= endIdx; ++i) { - final WordText wt = idxToWordText.get(i); - if (wt != previous) { - wt.setHighlighted(true); - wt.setHasHighlight(true); - wt.setHighlightColor(color); - lineText.getWords().add(wt); - addComponent(pageIndex, searchHit.getText(), wt.getBounds()); - } - previous = wt; - } - //Add end line context - final WordText lastWord = idxToWordText.get(endIdx); - final LineText lastLineText = wordTextToLineText.get(lastWord); - if (lastLineText != null && lastLineText.getWords() != null) { - boolean take = false; - for (final WordText wt : lastLineText.getWords()) { - if (take) { - lineText.getWords().add(wt); - } else if (wt == lastWord) { - take = true; - } - } - } - searchHits.add(lineText); - } + // the hit words themselves, highlighted. + for (final WordText word : hitWords) { + word.setHighlighted(true); + word.setHasHighlight(true); + word.setHighlightColor(color); + fragment.getWords().add(word); + addComponent(pageIndex, hitText, word.getBounds()); } - - - // if we have a hit we'll add it to the model cache - if (!searchHits.isEmpty()) { - searchModel.addPageSearchHit(pageIndex, pageText, searchHits.size()); - if (logger.isLoggable(Level.FINE)) { - logger.fine("Found search hits on page " + pageIndex + " hit count " + searchHits.size()); + // trailing context on the last hit word's line. + final LineText lastLine = sequence.lineOf(lastWord); + if (lastLine != null) { + final List lineWords = lastLine.getWords(); + final int index = lineWords.indexOf(lastWord); + if (index >= 0) { + final int to = fullLineContext ? lineWords.size() + : Math.min(lineWords.size(), index + 1 + wordPadding); + for (int i = index + 1; i < to; i++) fragment.getWords().add(lineWords.get(i)); } } - return searchHits; + return fragment; } /** @@ -731,16 +562,19 @@ public WordText nextSearchHit() { for (int j = searchWordCursor, maxJ = words.size(); j < maxJ; j++) { word = words.get(j); if (word.isHighlighted()) { - // highlight the rest of the words + // highlight the rest of the words in the run + WordText lastHit = word; for (; j < maxJ; j++) { if (!words.get(j).isHighlighted()) { break; } words.get(j).setHighlightCursor(true); + lastHit = words.get(j); } searchModel.setSearchPageCursor(i); searchModel.setSearchLineCursor(k); searchModel.setSearchWordCursor(j); + selectSearchHit(i, pageText, word, lastHit); showWord(i, word); return word; } @@ -776,6 +610,27 @@ public void showWord(int pageIndex, WordText word) { (int) bounds.x, (int) (bounds.y + bounds.height + 100))); } + /** + * Selects the current search hit's word run in the document text selection model, so the found + * text is copyable and stays consistent with mouse/keyboard selection (find-and-select). No-op + * when running headless (no view controller). + * + * @param pageIndex page the hit is on + * @param pageText the hit page's text + * @param firstWord first word of the hit run, in reading order + * @param lastWord last word of the hit run, in reading order + */ + private void selectSearchHit(int pageIndex, PageText pageText, WordText firstWord, WordText lastWord) { + if (viewerController == null || pageText == null) return; + TextSequence sequence = pageText.getTextSequence(); + OffsetRange first = sequence.rangeOf(firstWord); + OffsetRange last = sequence.rangeOf(lastWord); + if (first == null || last == null) return; + DocumentViewModel documentViewModel = viewerController.getDocumentViewController().getDocumentViewModel(); + documentViewModel.setTextSelection(pageIndex, first.getStart(), pageIndex, last.getEnd()); + TextSelectionSupport.applyDocumentSelection(documentViewModel); + } + @Override public WordText previousSearchHit() { if (searchModel.getPageSearchHitsSize() == 0) return null; @@ -807,16 +662,19 @@ public WordText previousSearchHit() { for (int j = searchWordCursor; j >= 0; j--) { word = words.get(j); if (word.isHighlighted()) { - // highlight the rest of the words + // highlight the rest of the words in the run + WordText firstHit = word; for (; j >= 0; j--) { if (!words.get(j).isHighlighted()) { break; } words.get(j).setHighlightCursor(true); + firstHit = words.get(j); } searchModel.setSearchPageCursor(i); searchModel.setSearchLineCursor(k); searchModel.setSearchWordCursor(j); + selectSearchHit(i, pageText, firstHit, word); showWord(i, word); return word; } @@ -867,6 +725,7 @@ public SearchTerm addSearchTerm(String term, boolean caseSensitive, boolean whol } @Override + @SuppressWarnings("deprecation") // still populates SearchTerm.getTerms() for API compatibility public SearchTerm addSearchTerm(String term, boolean caseSensitive, boolean wholeWord, boolean regex, Color highlightColor) { // keep original copy String originalTerm = String.valueOf(term); @@ -875,8 +734,8 @@ public SearchTerm addSearchTerm(String term, boolean caseSensitive, boolean whol if (!caseSensitive) { term = term.toLowerCase(); } - // parse search term out into words, so we can match - // them against WordText + // legacy: populate the (deprecated) per-word token list; the matcher itself uses the whole + // term against the reading-order corpus and ignores these tokens. ArrayList searchPhrase = searchMode == SearchMode.PAGE ? new ArrayList<>(Collections.singletonList(term)) : searchPhraseParser(term); // finally add the search term to the list and return it for management SearchTerm searchTerm = @@ -987,12 +846,15 @@ protected PageText getPageText(int pageIndex) { } /** - * Utility for breaking the pattern up into searchable words. Breaks are - * done on white spaces and punctuation. + * Utility for breaking a phrase into word/space/punctuation tokens. * - * @param phrase pattern to search words for. - * @return list of words that make up phrase, words, spaces, punctuation. + * @param phrase pattern to break into words. + * @return list of tokens that make up the phrase. + * @deprecated Since 7.5 the search matcher no longer tokenizes terms (it matches the whole term + * against a reading-order corpus). Retained only to populate the deprecated + * {@link org.icepdf.core.search.SearchTerm#getTerms()}; scheduled for removal. */ + @Deprecated protected ArrayList searchPhraseParser(String phrase) { // trim white space, not really useful. phrase = phrase.trim(); diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/search/SearchHit.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/search/SearchHit.java deleted file mode 100644 index 6b2f7cbcb..000000000 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/search/SearchHit.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.icepdf.ri.common.search; - -/** - * Represents a search hit (for the whole page search) - */ -public class SearchHit { - private final int startOffset; - private final int endOffset; - private final String text; - - protected SearchHit(final int startOffset, final int endOffset, final String text) { - this.startOffset = startOffset; - this.endOffset = endOffset; - this.text = text.replace("\n", " "); - } - - /** - * @return The starting offset in the page text - */ - public int getStartOffset() { - return startOffset; - } - - /** - * @return The ending offset in the page text - */ - public int getEndOffset() { - return endOffset; - } - - /** - * @return The text found - */ - public String getText() { - return text; - } -} diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/CaretBlink.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/CaretBlink.java new file mode 100644 index 000000000..64ff452e1 --- /dev/null +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/CaretBlink.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.ri.common.tools; + +import org.icepdf.ri.common.views.AbstractPageViewComponent; +import org.icepdf.ri.common.views.DocumentTextSelection; +import org.icepdf.ri.common.views.DocumentViewController; +import org.icepdf.ri.common.views.DocumentViewModel; + +import javax.swing.*; +import java.util.List; + +/** + * Drives the text-selection caret blink. A single {@link javax.swing.Timer} (EDT-safe) toggles the + * caret's visible state and repaints the page that holds the document focus caret; the caret is + * painted by {@code TextSelectionPageHandler.paintTool} only while {@link #isVisible()} is true. + *
+ * The blink is started/stopped by the text-selection view handler as the tool is installed/removed, + * and reset to solid whenever the caret moves so it is immediately visible on interaction. + * + * @since 7.5 + */ +public final class CaretBlink { + + private static final int BLINK_RATE_MS = 500; + + private static Timer timer; + private static boolean visible = true; + private static DocumentViewController documentViewController; + + private CaretBlink() { + } + + /** + * @return true when the caret should currently be painted. + */ + public static boolean isVisible() { + return visible; + } + + /** + * Starts (or restarts) the blink for the given view. + * + * @param controller the active document view controller. + */ + public static void start(DocumentViewController controller) { + documentViewController = controller; + visible = true; + if (timer == null) { + timer = new Timer(BLINK_RATE_MS, e -> tick()); + timer.setRepeats(true); + } + timer.restart(); + } + + /** + * Stops the blink and leaves the caret in a steady (visible) state. + */ + public static void stop() { + if (timer != null) timer.stop(); + visible = true; + repaintFocusPage(); + documentViewController = null; + } + + /** + * Resets the caret to solid and restarts the blink cycle; called when the caret moves so it is + * immediately visible. No-op when the blink is not running. + */ + public static void reset() { + if (timer == null || !timer.isRunning()) return; + visible = true; + timer.restart(); + } + + private static void tick() { + visible = !visible; + repaintFocusPage(); + } + + private static void repaintFocusPage() { + if (documentViewController == null) return; + DocumentViewModel model = documentViewController.getDocumentViewModel(); + if (model == null) return; + DocumentTextSelection selection = model.getTextSelection(); + if (selection.isEmpty()) return; + List pages = model.getPageComponents(); + int focusPage = selection.getFocusPage(); + if (focusPage >= 0 && focusPage < pages.size()) { + pages.get(focusPage).repaint(); + } + } +} diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelection.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelection.java index f2433c413..fe00f73cf 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelection.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelection.java @@ -1,1099 +1,593 @@ -/* - * Copyright 2006-2019 ICEsoft Technologies Canada Corp. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the - * License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an "AS - * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -package org.icepdf.ri.common.tools; - -import org.icepdf.core.pobjects.Page; -import org.icepdf.core.pobjects.graphics.text.GlyphText; -import org.icepdf.core.pobjects.graphics.text.LineText; -import org.icepdf.core.pobjects.graphics.text.PageText; -import org.icepdf.core.pobjects.graphics.text.WordText; -import org.icepdf.core.util.Defs; -import org.icepdf.core.util.PropertyConstants; -import org.icepdf.ri.common.views.AbstractPageViewComponent; -import org.icepdf.ri.common.views.DocumentViewController; -import org.icepdf.ri.common.views.DocumentViewModel; -import org.icepdf.ri.common.views.PageViewComponentImpl; - -import java.awt.*; -import java.awt.geom.*; -import java.util.ArrayList; -import java.util.logging.Level; -import java.util.logging.Logger; - -import static org.icepdf.ri.common.tools.TextSelection.enableMarginExclusion; - -/** - * TextSelection is a utility class that captures most of the work needed to do basic text, word and line selection. - */ -public class TextSelection extends SelectionBoxHandler { - - protected static final Logger logger = - Logger.getLogger(TextSelection.class.getName()); - - public int selectedCount; - - protected Point lastMousePressedLocation; - protected Point lastMouseLocation; - - private GlyphLocation glyphStartLocation; - private GlyphLocation glyphEndLocation; - - private GlyphLocation lastGlyphStartLocation; - private GlyphLocation lastGlyphEndLocation; - - // todo make configurable - protected int topMargin = 75; - protected int bottomMargin = 75; - protected static boolean enableMarginExclusion; - protected static boolean enableMarginExclusionBorder; - protected Rectangle2D topMarginExclusion; - protected Rectangle2D bottomMarginExclusion; - - // Pointer to make sure the GC doesn't collect a page while selection state is present - protected Page pageLock; - - - static { - try { - enableMarginExclusion = Defs.booleanProperty( - "org.icepdf.core.views.page.marginExclusion.enabled", false); - } catch (NumberFormatException e) { - logger.warning("Error reading margin exclusion enabled property."); - } - try { - enableMarginExclusionBorder = Defs.booleanProperty( - "org.icepdf.core.views.page.marginExclusionBorder.enabled", false); - } catch (NumberFormatException e) { - logger.warning("Error reading margin exclusion boarder enabled property."); - } - } - - // first page that was selected - private boolean isFirst; - - public TextSelection(DocumentViewController documentViewController, AbstractPageViewComponent pageViewComponent) { - super(documentViewController, pageViewComponent); - } - - @Override - protected void checkAndApplyPreferences() { - - } - - /** - * Handles double and triple left mouse clicks to select a word or line of text respectively. - * - * @param clickCount number of mouse clicks to interpret for line or word selection. - * @param clickPoint point that mouse was clicked. - * @param pageViewComponent parent page view component - */ - public void wordLineSelection(int clickCount, Point clickPoint, AbstractPageViewComponent pageViewComponent) { - // double click we select the whole line. - try { - if (clickCount == 3) { - Page currentPage = pageViewComponent.getPage(); - // handle text selection mouse coordinates - Point mouseLocation = (Point) clickPoint.clone(); - lineSelectHandler(currentPage, mouseLocation); - } - // single click we select word that was clicked. - else if (clickCount == 2) { - Page currentPage = pageViewComponent.getPage(); - // handle text selection mouse coordinates - Point mouseLocation = (Point) clickPoint.clone(); - wordSelectHandler(currentPage, mouseLocation); - } - if (pageViewComponent != null) { - pageViewComponent.requestFocus(); - } - } catch (InterruptedException e) { - logger.fine("Text selection page access interrupted"); - } - } - - /** - * Selection started so we want to record the position and update the selection rectangle. - * - * @param startPoint starting selection position. - * @param isFirst start of selection if true - * @param pageViewComponent parent page component - */ - public void selectionStart(Point startPoint, AbstractPageViewComponent pageViewComponent, boolean isFirst) { - try { - Page currentPage = pageViewComponent.getPage(); - this.isFirst = isFirst; - if (currentPage != null) { - // get page text - PageText pageText = currentPage.getViewText(); - - // create exclusion boxes - calculateTextSelectionExclusion(); - - ArrayList pageLines = pageText.getPageLines(); - Point2D.Float dragStartLocation = convertToPageSpace(startPoint); - glyphStartLocation = GlyphLocation.findGlyphLocation(pageLines, dragStartLocation, true, true, null, - topMarginExclusion, bottomMarginExclusion); - glyphEndLocation = null; - } - - // text selection box. - currentRect = new Rectangle(startPoint.x, startPoint.y, 0, 0); - updateDrawableRect(pageViewComponent.getWidth(), pageViewComponent.getHeight()); - pageViewComponent.repaint(); - } catch (InterruptedException e) { - logger.fine("Text selection page access interrupted"); - } - } - - /** - * Selection ended so we want to stop record the position and update the selection. - * - * @param pageViewComponent page component view - * @param endPoint end point of drag - */ - public void selectionEnd(Point endPoint, AbstractPageViewComponent pageViewComponent) { - - try { - // write out selected text. - if (pageViewComponent != null && logger.isLoggable(Level.FINE)) { - Page currentPage = pageViewComponent.getPage(); - // handle text selection mouse coordinates - if (currentPage.getViewText() != null) { - logger.fine(currentPage.getViewText().getSelected().toString()); - } - } - if (selectedCount > 0) { - // add the page to the page as it is marked for selection - documentViewController.getDocumentViewModel().addSelectedPageText(pageViewComponent); - documentViewController.firePropertyChange( - PropertyConstants.TEXT_SELECTED, - null, null); - } - // clear the rectangle - clearRectangle(pageViewComponent); - - pageViewComponent.repaint(); - } catch (InterruptedException e) { - logger.fine("Text selection page access interrupted"); - } - } - - public void clearSelection() { - - // release the page lock so the Reference API can take care of collecting the page post selection. - pageLock = null; - - lastGlyphStartLocation = null; - lastGlyphEndLocation = null; - - glyphStartLocation = null; - glyphEndLocation = null; - - selectedCount = 0; - } - - public void clearSelectionState() { - java.util.List pages = documentViewController.getDocumentViewModel().getPageComponents(); - for (AbstractPageViewComponent page : pages) { - ((PageViewComponentImpl) page).getTextSelectionPageHandler().clearSelection(); - } - } - - public void selection(Point dragPoint, AbstractPageViewComponent pageViewComponent, - boolean isDown, boolean isMovingRight) { - try { - if (pageViewComponent != null) { - - // acquire a page lock. - pageLock = pageViewComponent.getPage(); - - boolean isLocalDown; - if (lastMouseLocation != null) { - // double check we're actually moving down - isLocalDown = lastMouseLocation.y <= dragPoint.y; - } else { - isLocalDown = isDown; - } - multiLineSelectHandler(pageViewComponent, dragPoint, isDown, isLocalDown, isMovingRight); - - lastMouseLocation = dragPoint; - } - } catch (InterruptedException e) { - logger.fine("Text selection page access interrupted"); - } - } - - public boolean selectionTextSelectIcon(Point mouseLocation, AbstractPageViewComponent pageViewComponent) { - boolean foundSelectableText = false; - try { - Page currentPage = pageViewComponent.getPage(); - if (currentPage != null) { - // get page text - PageText pageText = currentPage.getViewText(); - if (pageText != null) { - // create exclusion boxes - calculateTextSelectionExclusion(); - - ArrayList pageLines = pageText.getPageLines(); - if (pageLines != null) { - Point2D.Float pageMouseLocation = convertToPageSpace(mouseLocation); - for (LineText pageLine : pageLines) { - // check for containment, if so break into words. - if (pageLine.getBounds().contains(pageMouseLocation) - && ((topMarginExclusion == null || bottomMarginExclusion == null) - || (!topMarginExclusion.contains(pageMouseLocation) - && !bottomMarginExclusion.contains(pageMouseLocation)))) { - foundSelectableText = true; - documentViewController.setViewCursor( - DocumentViewController.CURSOR_TEXT_SELECTION); - break; - } - } - if (!foundSelectableText) { - documentViewController.setViewCursor( - DocumentViewController.CURSOR_SELECT); - } - } - } - } - } catch (InterruptedException e) { - logger.fine("Text selection page access interrupted"); - } - return foundSelectableText; - } - - protected void calculateTextSelectionExclusion() { - if (enableMarginExclusion) { - Rectangle2D mediaBox = pageViewComponent.getPage().getCropBox(); - topMarginExclusion = new Rectangle2D.Float( - (int) mediaBox.getX(), - (int) mediaBox.getY() - topMargin, - (int) mediaBox.getWidth(), topMargin); - bottomMarginExclusion = new Rectangle2D.Float( - (int) mediaBox.getX(), - (int) (mediaBox.getY() - mediaBox.getHeight()), - (int) mediaBox.getWidth(), bottomMargin); - } - } - - /** - * Paints any text that is selected in the page wrapped by a pageViewComponent. - * - * @param g graphics context to paint to. - * @param pageViewComponent page view component to paint selected to on. - * @param documentViewModel document model contains view properties such as zoom and rotation. - * @throws InterruptedException thread interrupted. - */ - public static void paintSelectedText(Graphics g, - AbstractPageViewComponent pageViewComponent, - DocumentViewModel documentViewModel) throws InterruptedException { - // ready outline paint - Graphics2D gg = (Graphics2D) g; - AffineTransform prePaintTransform = gg.getTransform(); - Color oldColor = gg.getColor(); - Stroke oldStroke = gg.getStroke(); -// gg.setComposite(BlendComposite.getInstance(BlendComposite.BlendingMode.MULTIPLY, 1.0f)); - gg.setComposite(AlphaComposite.getInstance( - AlphaComposite.SRC_OVER, - Page.SELECTION_ALPHA)); - gg.setColor(Page.selectionColor); - gg.setStroke(new BasicStroke(1.0f)); - - Page currentPage = pageViewComponent.getPage(); - if (currentPage != null) { - PageText pageText = currentPage.getViewText(); - if (pageText != null) { - // get page transformation - AffineTransform pageTransform = currentPage.getPageTransform( - documentViewModel.getPageBoundary(), - documentViewModel.getViewRotation(), - documentViewModel.getViewZoom()); - // paint the sprites - GeneralPath textPath; - ArrayList visiblePageLines = pageText.getPageLines(); - if (visiblePageLines != null) { - for (LineText lineText : visiblePageLines) { - for (WordText wordText : lineText.getWords()) { - // paint whole word - if (wordText.isSelected() || wordText.isHighlighted()) { - textPath = new GeneralPath(wordText.getBounds()); - textPath.transform(pageTransform); - // paint highlight over any selected - if (wordText.isSelected()) { - gg.setColor(Page.selectionColor); - gg.fill(textPath); - } - if (wordText.isHighlighted()) { - if (wordText.isHighlightCursor()) { - gg.setColor(Page.highlightCursorColor); - } else { - gg.setColor(wordText.getHighlightColor()); - } - gg.fill(textPath); - } - } - // check children - else { - for (GlyphText glyph : wordText.getGlyphs()) { - if (glyph.isSelected()) { - textPath = new GeneralPath(glyph.getBounds()); - textPath.transform(pageTransform); - gg.setColor(Page.selectionColor); - gg.fill(textPath); - } - } - } - } - } - } - } - } -// gg.setComposite(BlendComposite.getInstance(BlendComposite.BlendingMode.NORMAL, 1.0f)); - gg.setComposite(AlphaComposite.getInstance( - AlphaComposite.SRC_OVER, - 1.0f)); - // restore graphics state to where we left it. - gg.setTransform(prePaintTransform); - gg.setStroke(oldStroke); - gg.setColor(oldColor); - - // paint words for bounds test. -// paintTextBounds(g); - - } - - /** - * Utility for painting text bounds. - * - * @param g graphics context to paint to. - * @throws InterruptedException thread interrupted. - */ - protected void paintTextBounds(Graphics g) throws InterruptedException { - Page currentPage = pageViewComponent.getPage(); - // get page transformation - AffineTransform pageTransform = getPageTransform(); - Graphics2D gg = (Graphics2D) g; - Color oldColor = g.getColor(); - g.setColor(Color.red); - - PageText pageText = currentPage.getViewText(); - if (pageText != null) { - ArrayList pageLines = pageText.getPageLines(); - if (pageLines != null) { - for (LineText lineText : pageLines) { - - for (WordText wordText : lineText.getWords()) { - for (GlyphText glyph : wordText.getGlyphs()) { - g.setColor(Color.black); - GeneralPath glyphSpritePath = - new GeneralPath(glyph.getBounds()); - glyphSpritePath.transform(pageTransform); - gg.draw(glyphSpritePath); - } - - // if (!wordText.isWhiteSpace()) { - // g.setColor(Color.blue); - // GeneralPath glyphSpritePath = - // new GeneralPath(wordText.getBounds()); - // glyphSpritePath.transform(pageTransform); - // gg.draw(glyphSpritePath); - // } - } - g.setColor(Color.red); - GeneralPath glyphSpritePath = - new GeneralPath(lineText.getBounds()); - glyphSpritePath.transform(pageTransform); - gg.draw(glyphSpritePath); - } - } - } - g.setColor(oldColor); - } - - /** - * Entry point for multiline text selection. Contains logic for moving from once page to the next which boils - * down to defining a start position when a new page is entered. - * - * @param pageViewComponent page view that is being acted. - * @param mouseLocation current mouse location already normalized to page space. . - * @param isDown general selection trent is down, if false it's up. - * @param isMovingRight general selection trent is right, if false it's left. - * @param isLocalDown local movement is down. - * @throws InterruptedException thread interrupted. - */ - protected void multiLineSelectHandler(AbstractPageViewComponent pageViewComponent, Point mouseLocation, - boolean isDown, boolean isLocalDown, boolean isMovingRight) throws InterruptedException { - Page currentPage = pageViewComponent.getPage(); - selectedCount = 0; - - if (currentPage != null) { - // get page text - PageText pageText = currentPage.getViewText(); - if (pageText != null) { - - // clear the currently selected state, ignore highlighted. - pageText.clearSelected(); - - ArrayList pageLines = pageText.getPageLines(); - - // create exclusion boxes - calculateTextSelectionExclusion(); - - // normalize the mouse coordinates to page space - Point2D.Float draggingMouseLocation = convertToPageSpace(mouseLocation); - - // dragging mouse into a page or from white space, neither glyphStart or glyphEnd will be initialized. - if (glyphStartLocation == null) { - glyphStartLocation = GlyphLocation.findFirstGlyphLocation(pageLines, draggingMouseLocation, isDown, isLocalDown, - lastGlyphEndLocation, topMarginExclusion, bottomMarginExclusion); - // if we're close to something mark the isFirst=false. - if (glyphStartLocation != null) { - glyphEndLocation = new GlyphLocation(glyphStartLocation); - isFirst = false; - } - } else { - // should already have start but no end. - glyphEndLocation = GlyphLocation.findGlyphLocation(pageLines, draggingMouseLocation, isDown, isLocalDown, - lastGlyphEndLocation, topMarginExclusion, bottomMarginExclusion); - } - - // normal page selection, fill in the the highlight between start and end. - // todo configurable system property to switch to rightToLeft. - boolean leftToRight = true; - if (glyphStartLocation != null && glyphEndLocation != null) { - selectedCount = GlyphLocation.highLightGlyphs(pageLines, glyphStartLocation, glyphEndLocation, leftToRight, - isDown, isLocalDown, isMovingRight, topMarginExclusion, bottomMarginExclusion); - lastGlyphStartLocation = glyphStartLocation; - lastGlyphEndLocation = glyphEndLocation; - } - // check if last draw are still around and draw them. - else if (lastGlyphStartLocation != null && lastGlyphEndLocation != null) { - selectedCount = GlyphLocation.highLightGlyphs(pageLines, lastGlyphStartLocation, lastGlyphEndLocation, leftToRight, - isDown, isLocalDown, isMovingRight, topMarginExclusion, bottomMarginExclusion); - } - } - pageViewComponent.repaint(); - } - } - - /** - * Utility for selecting multiple lines via rectangle like tool. The - * selection works based on the intersection of the rectangle and glyph - * bounding box. - * This method should only be called from within a locked page content - * - * @param currentPage page to looking for text intersection on. - * @param mouseLocation location of mouse. - * @throws InterruptedException thread interrupted. - */ - protected void wordSelectHandler(Page currentPage, Point mouseLocation) throws InterruptedException { - - if (currentPage != null) { - // get page text - PageText pageText = currentPage.getViewText(); - if (pageText != null) { - - // clear the currently selected state, ignore highlighted. - pageText.clearSelected(); - - // get page transform, same for all calculations - DocumentViewModel documentViewModel = documentViewController.getDocumentViewModel(); - Point2D.Float pageMouseLocation = convertToPageSpace(mouseLocation); - ArrayList pageLines = pageText.getPageLines(); - if (pageLines != null) { - for (LineText pageLine : pageLines) { - // check for containment, if so break into words. - if (pageLine.getBounds().contains(pageMouseLocation)) { - pageLine.setHasSelected(true); - java.util.List lineWords = pageLine.getWords(); - for (WordText word : lineWords) { - if (word.getBounds().contains(pageMouseLocation)) { - word.selectAll(); - // let the ri know we have selected text. - documentViewModel.addSelectedPageText(pageViewComponent); - documentViewController.firePropertyChange( - PropertyConstants.TEXT_SELECTED, - null, null); - pageViewComponent.repaint(); - break; - } - } - } - } - } - } - } - } - - /** - * Utility for selecting a LineText which is usually a sentence in the - * document. This is usually triggered by a triple click of the mouse - * - * @param currentPage page to select - * @param mouseLocation location of mouse - * @throws InterruptedException thread interrupted. - */ - protected void lineSelectHandler(Page currentPage, Point mouseLocation) throws InterruptedException { - if (currentPage != null) { - // get page text - PageText pageText = currentPage.getViewText(); - if (pageText != null) { - - // clear the currently selected state, ignore highlighted. - pageText.clearSelected(); - - // get page transform, same for all calculations - DocumentViewModel documentViewModel = documentViewController.getDocumentViewModel(); - - Point2D.Float pageMouseLocation = convertToPageSpace(mouseLocation); - ArrayList pageLines = pageText.getPageLines(); - if (pageLines != null) { - for (LineText pageLine : pageLines) { - // check for containment, if so break into words. - if (pageLine.getBounds().contains(pageMouseLocation)) { - pageLine.selectAll(); - - // let the ri know we have selected text. - documentViewModel.addSelectedPageText(pageViewComponent); - documentViewController.firePropertyChange( - PropertyConstants.TEXT_SELECTED, - null, null); - - pageViewComponent.repaint(); - break; - } - } - } - } - } - } - - @Override - public void setSelectionRectangle(Point cursorLocation, Rectangle selection) { - } - - /** - * Sets the top margin used to define an exclusion zone for text selection. For this value - * to be applied the system property -Dorg.icepdf.core.views.page.marginExclusion.enabled=true - * must be set. - * - * @param topMargin top margin height in pixels. - */ - public void setTopMargin(int topMargin) { - this.topMargin = topMargin; - } - - /** - * Sets the bottom margin used to define an exclusion zone for text selection. For this value - * to be applied the system property -Dorg.icepdf.core.views.page.marginExclusion.enabled=true - * must be set. - * - * @param bottomMargin bottom margin height in pixels. - */ - public void setBottomMargin(int bottomMargin) { - this.bottomMargin = bottomMargin; - } - - public static GeneralPath convertTextShapesToBounds(ArrayList textShapes) { - if (textShapes != null && !textShapes.isEmpty()) { - - // bound of the selected text - Rectangle2D shapeBounds; - double padding; - // padding out the bound, so we get a better hits when looking for redacted text. Since the redaction - // box is derived from the glyph bounds we can get rounding errors when a contains call is made and - // a glyph will be just slightly outside the redaction bounds and contains will return false - Area area = new Area(); - for (Shape bounds : textShapes) { - shapeBounds = bounds.getBounds2D(); - padding = shapeBounds.getHeight() * 0.025; - shapeBounds.setRect( - shapeBounds.getX() - padding, - shapeBounds.getY() - padding, - shapeBounds.getWidth() + (padding * 2), - shapeBounds.getHeight() + (padding * 2)); - // area is important here as we want a union of the shapes, not multiple separate paths. - area.add(new Area(shapeBounds)); - } - GeneralPath textPath = new GeneralPath(); - textPath.append(area, false); - - - return textPath; - } - return null; - } - - /** - * Convert the shapes that make up the annotation to page space so that - * they will scale correctly at different zooms. - * - * @param bounds bounds to convert to page space - * @param path path - * @return transformed bBox. - */ - protected Rectangle convertToPageSpace(ArrayList bounds, - GeneralPath path) { - Page currentPage = pageViewComponent.getPage(); - DocumentViewModel documentViewModel = documentViewController.getDocumentViewModel(); - AffineTransform at = currentPage.getToPageSpaceTransform( - documentViewModel.getPageBoundary(), - documentViewModel.getViewRotation(), - documentViewModel.getViewZoom()); - // convert the two points as well as the bbox. - Rectangle tBbox = at.createTransformedShape(path).getBounds(); - // convert the points - Shape bound; - for (int i = 0; i < bounds.size(); i++) { - bound = bounds.get(i); - bound = at.createTransformedShape(bound); - bounds.set(i, bound); - } - - path.transform(at); - - return tBbox; - } -} - -class GlyphLocation { - - private final int line; - private final int word; - private final int glyph; - - public GlyphLocation(int line, int word, int glyph) { - this.line = line; - this.word = word; - this.glyph = glyph; - } - - public GlyphLocation(GlyphLocation glyphLocation) { - this.line = glyphLocation.line; - this.word = glyphLocation.word; - this.glyph = glyphLocation.glyph; - } - - @Override - public String toString() { - return "GlyphLocation{" + - "line=" + line + - ", word=" + word + - ", glyph=" + glyph + - '}'; - } - - public static WordText getWord(ArrayList pageLines, GlyphLocation location) { - return pageLines.get(location.line).getWords().get(location.word); - } - - public static GlyphText getGlyph(ArrayList pageLines, GlyphLocation location) { - return pageLines.get(location.line).getWords().get(location.word).getGlyphs().get(location.glyph); - } - - public static GlyphLocation multiPageSelectGlyphLocation(ArrayList pageLines, - Point2D.Float mouseLocation, - boolean isDown, boolean leftToRight, - Shape topMarginExclusion, Shape bottomMarginExclusion) { - if (pageLines == null) return null; - // find first glyph of first line - if (isDown && leftToRight) { - for (int i = 0, max = pageLines.size(); i < max; i++) { - if (isLineTextIncluded(pageLines.get(i), topMarginExclusion, bottomMarginExclusion) && - findMouseContainedWithInLine(mouseLocation, pageLines.get(i))) { - return new GlyphLocation(i, 0, 0); - } - } - } - // first line and last glyph - else if (isDown) { - for (int i = 0, max = pageLines.size(); i < max; i++) { - if (isLineTextIncluded(pageLines.get(i), topMarginExclusion, bottomMarginExclusion) && - findMouseContainedWithInLine(mouseLocation, pageLines.get(i))) { - int lastWordIndex = pageLines.get(i).getWords().size() - 1; - WordText lastWord = pageLines.get(i).getWords().get(lastWordIndex); - return new GlyphLocation(i, lastWordIndex, lastWord.getGlyphs().size() - 1); - } - } - } - // going up is always right to left. - else { - for (int i = pageLines.size() - 1; i >= 0; i--) { - if (isLineTextIncluded(pageLines.get(i), topMarginExclusion, bottomMarginExclusion) - && findMouseContainedWithInLine(mouseLocation, pageLines.get(i))) { - int lastWordIndex = pageLines.get(i).getWords().size() - 1; - WordText lastWord = pageLines.get(i).getWords().get(lastWordIndex); - return new GlyphLocation(i, lastWordIndex, lastWord.getGlyphs().size() - 1); - } - } - } - return null; - } - - public static boolean isLineTextIncluded(LineText lineText, Shape topMarginExclusion, Shape bottomMarginExclusion) { - return !enableMarginExclusion || - !(topMarginExclusion.contains(lineText.getBounds()) || - bottomMarginExclusion.contains(lineText.getBounds())); - } - - public static GlyphLocation findGlyphLocation(ArrayList pageLines, Point2D.Float cursorLocation, - boolean isDown, boolean isLocalDown, GlyphLocation lastGlyphEndLocation, - Shape topMarginExclusion, Shape bottomMarginExclusion) { - if (pageLines != null) { - // check for a direct intersection. - GlyphLocation glyphLocation = - findGlyphIntersection(pageLines, cursorLocation, topMarginExclusion, bottomMarginExclusion); - if (glyphLocation != null) return glyphLocation; - - // check mouse location against y-coordinate of a line and grab the last line - // this is buggy if the lines aren't sorted via !org.icepdf.core.views.page.text.preserveColumns. - if (isLocalDown) { - int lastGlyphEndLine = 0; - if (lastGlyphEndLocation != null) { - lastGlyphEndLine = lastGlyphEndLocation.line; - } - // get the next line last word. - int lineIndex = lastGlyphEndLine; - for (int lineMax = pageLines.size(); lineIndex < lineMax - 1; lineIndex++) { - double y1 = pageLines.get(lineIndex).getBounds().y; - double y2 = pageLines.get(lineIndex + 1).getBounds().y; - if (cursorLocation.y < y1 && cursorLocation.y >= y2) { - LineText lineText = pageLines.get(lineIndex + 1); - if (isLineTextIncluded(lineText, topMarginExclusion, bottomMarginExclusion)) { - return new GlyphLocation(lineIndex + 1, 0, 0); - } - } - } - // fill in the last lastGlyphEndLocation and fill the line. - if (lastGlyphEndLocation != null) { - for (int i = lastGlyphEndLine; i < pageLines.size(); i++) { - LineText lineText = pageLines.get(i); - double lineTextLocation = lineText.getBounds().y; - if (cursorLocation.y < lineTextLocation) { - if (isLineTextIncluded(lineText, topMarginExclusion, bottomMarginExclusion)) { - // return last line - if (i == pageLines.size() - 1) { - java.util.List words = lineText.getWords(); - return new GlyphLocation(i, words.size() - 1, - words.get(words.size() - 1).getGlyphs().size() - 1); - } - } - } else { - lineText = pageLines.get(i); - java.util.List words = lineText.getWords(); - if (isLineTextIncluded(lineText, topMarginExclusion, bottomMarginExclusion)) { - return new GlyphLocation(i, words.size() - 1, - words.get(words.size() - 1).getGlyphs().size() - 1); - } - } - } - - } - } - // selection moving up a page. - else { - int lastGlyphEndLine = 0; - if (lastGlyphEndLocation != null) { - lastGlyphEndLine = lastGlyphEndLocation.line; - } - // find left most world. - for (; lastGlyphEndLine > 0; lastGlyphEndLine--) { - double y1 = pageLines.get(lastGlyphEndLine).getBounds().y; - double y2 = pageLines.get(lastGlyphEndLine - 1).getBounds().y; - if (cursorLocation.y > y1 && cursorLocation.y < y2) { - LineText lineText = pageLines.get(lastGlyphEndLine - 1); - if (isLineTextIncluded(lineText, topMarginExclusion, bottomMarginExclusion)) { - return new GlyphLocation(lastGlyphEndLine - 1, 0, 0); - } - } - } - // else fill the line - if (lastGlyphEndLocation != null) { - for (int i = lastGlyphEndLine; i >= 0; i--) { - LineText lineText = pageLines.get(i); - double lineTextLocation = lineText.getBounds().y; - if (cursorLocation.y > lineTextLocation) { - if (isLineTextIncluded(lineText, topMarginExclusion, bottomMarginExclusion)) { - return new GlyphLocation(i, 0, 0); - } - } - } - } - } - } - return null; - } - - public static GlyphLocation findGlyphIntersection(ArrayList pageLines, Point2D.Float cursorLocation, - Shape topMarginExclusion, Shape bottomMarginExclusion) { - LineText pageLine; - // check for a direct intersection. - for (int lineIndex = 0, lineMax = pageLines.size(); lineIndex < lineMax; lineIndex++) { - pageLine = pageLines.get(lineIndex); - if (pageLine.intersects(cursorLocation) && isLineTextIncluded(pageLine, topMarginExclusion, bottomMarginExclusion)) { - java.util.List lineWords = pageLines.get(lineIndex).getWords(); - WordText currentWord; - for (int wordIndex = 0, wordMax = lineWords.size(); wordIndex < wordMax; wordIndex++) { - currentWord = lineWords.get(wordIndex); - if (currentWord.intersects(cursorLocation)) { - ArrayList glyphs = currentWord.getGlyphs(); - for (int glyphIndex = 0, glyphMax = glyphs.size(); glyphIndex < glyphMax; glyphIndex++) { - GlyphText currentGlyph = glyphs.get(glyphIndex); - if (currentGlyph.intersects(cursorLocation)) { - return new GlyphLocation(lineIndex, wordIndex, glyphIndex); - } - } - } - } - } - } - return null; - } - - public static GlyphLocation findFirstGlyphLocation(ArrayList pageLines, Point2D.Float cursorLocation, - boolean isDown, boolean isLocalDown, GlyphLocation lastGlyphEndLocation, - Shape topMarginExclusion, Shape bottomMarginExclusion) { - if (pageLines != null) { - // check mouse location against y-coordinate of a line and depending on direction pick - // first or last world a line. - if (isDown) { - // find first word of first y line not in exclusion - int lineIndex = 0; - for (int lineMax = pageLines.size() - 1; lineIndex < lineMax; lineIndex++) { - LineText lineText = pageLines.get(lineIndex); - if (isLineTextIncluded(lineText, topMarginExclusion, bottomMarginExclusion)) { - return new GlyphLocation(lineIndex, 0, 0); - } - } - } - // going up so check against y bottom up and then pick last work. - else { - // find left most world. - int lineIndex = pageLines.size() - 1; - for (; lineIndex > 0; lineIndex--) { - LineText lineText = pageLines.get(lineIndex); - if (isLineTextIncluded(lineText, topMarginExclusion, bottomMarginExclusion)) { - java.util.List words = lineText.getWords(); - return new GlyphLocation(lineIndex, words.size() - 1, - words.get(words.size() - 1).getGlyphs().size() - 1); - } - } - } - } - return null; - } - - public static boolean findMouseContainedWithInLine(Point2D.Float mouseLocation, LineText pageLines) { - return pageLines.getBounds().contains(mouseLocation); - } - - public static int highLightGlyphs(ArrayList pageLines, GlyphLocation start, GlyphLocation end, - boolean leftToRight, boolean isDown, boolean isLocalDown, boolean isRight, - Shape topMarginExclusion, Shape bottomMarginExclusion) { - if (pageLines == null) return 0; - int selectedCount = fillFirstLine(pageLines.get(start.line), start, end, isDown, isRight, leftToRight); - // fill middle, if any - selectedCount += fillMiddleLines(pageLines, start, end, topMarginExclusion, bottomMarginExclusion); - // fill last line, last line if any - selectedCount += fillLastLine(pageLines.get(end.line), start, end, isDown, isRight, leftToRight); - return selectedCount; - } - - - public static int fillFirstLine(LineText pageLine, GlyphLocation start, GlyphLocation end, - boolean isDown, boolean isRight, boolean isLTR) { - pageLine.setHasHighlight(true); - java.util.List lineWords = pageLine.getWords(); - int selectedCount = 0; - // last half of the first word - selectedCount += fillFirstWord(lineWords, start, end, isRight, isDown); - // first half of the last word - selectedCount += fillLastWord(lineWords, start, end, isRight, isDown); - - if (start.line == end.line) { - if (isRight && end.word > start.word) { - // fill left to right - for (int wordIndex = start.word + 1; wordIndex <= end.word - 1; wordIndex++) { - lineWords.get(wordIndex).selectAll(); - selectedCount++; - } - } else { - // fill right to left - for (int wordIndex = start.word - 1; wordIndex >= end.word + 1; wordIndex--) { - lineWords.get(wordIndex).selectAll(); - selectedCount++; - } - } - } else if ((isRight && isDown) || (!isRight && isDown)) { - // fill right to end of line - for (int wordIndex = start.word + 1; wordIndex < lineWords.size(); wordIndex++) { - lineWords.get(wordIndex).selectAll(); - selectedCount++; - } - } else {// if ((isRight && !isDown) || (!isRight && !isDown)){ - // fill left to start of line - for (int wordIndex = start.word - 1; wordIndex >= 0; wordIndex--) { - lineWords.get(wordIndex).selectAll(); - selectedCount++; - } - } - return selectedCount; - } - - public static int fillFirstWord(java.util.List words, GlyphLocation start, GlyphLocation end, - boolean isRight, boolean isDown) { - int selectedCount = 0; - if (end != null && start.line == end.line) { - if (start.word == end.word) { - if (isRight) { - // same word so we move to select start->end. - WordText word = words.get(start.word); - word.setHasSelected(true); - for (int glyphIndex = start.glyph; glyphIndex <= end.glyph; glyphIndex++) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - - } else { - WordText word = words.get(start.word); - word.setHasSelected(true); - for (int glyphIndex = start.glyph; glyphIndex >= end.glyph; glyphIndex--) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - } - } else { - if (isRight && end.word > start.word) { - WordText word = words.get(start.word); - word.setHasSelected(true); - for (int glyphIndex = start.glyph; glyphIndex < word.getGlyphs().size(); glyphIndex++) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - } else { - WordText word = words.get(start.word); - word.setHasSelected(true); - for (int glyphIndex = start.glyph; glyphIndex >= 0; glyphIndex--) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - } - } - } else if ((isRight && isDown) || (!isRight && isDown)) { - WordText word = words.get(start.word); - word.setHasSelected(true); - for (int glyphIndex = start.glyph; glyphIndex < word.getGlyphs().size(); glyphIndex++) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - } else {//if ((isRight && !isDown) || (!isRight && !isDown)) { - WordText word = words.get(start.word); - word.setHasSelected(true); - for (int glyphIndex = start.glyph; glyphIndex >= 0; glyphIndex--) { - word.getGlyphs().get(glyphIndex).setSelected(true); - } - } - return selectedCount; - - } - - public static int fillLastWord(java.util.List words, GlyphLocation start, GlyphLocation end, - boolean isRight, boolean isDown) { - int selectedCount = 0; - if (isRight && end.word > start.word) { - // same word, so we move to select start->end. - if (start.line == end.line) { - WordText word = words.get(end.word); - word.setHasSelected(true); - for (int glyphIndex = 0; glyphIndex <= end.glyph; glyphIndex++) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - } - } else { - // same word so we move to select start->end. - if (start.word == end.word && start.line == end.line) { - WordText word = words.get(end.word); - word.setHasSelected(true); - for (int glyphIndex = start.glyph; glyphIndex >= end.glyph; glyphIndex--) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - } - // at least two words so we can do the last half of start and first half of end. - else if (start.line == end.line) { - WordText word = words.get(end.word); - word.setHasSelected(true); - for (int glyphIndex = word.getGlyphs().size() - 1; glyphIndex >= end.glyph; glyphIndex--) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - } - } - return selectedCount; - } - - public static int fillLastLine(LineText pageLine, GlyphLocation start, GlyphLocation end, - boolean isDown, boolean isRight, boolean isLTR) { - java.util.List lineWords = pageLine.getWords(); - int selectedCount = 0; - if (start.line != end.line) { - pageLine.setHasHighlight(true); - if (isDown) { - WordText word = lineWords.get(end.word); - word.setHasSelected(true); - for (int glyphIndex = 0; glyphIndex <= end.glyph; glyphIndex++) { - word.getGlyphs().get(glyphIndex).setSelected(true); - selectedCount++; - } - for (int wordIndex = 0; wordIndex < end.word; wordIndex++) { - lineWords.get(wordIndex).selectAll(); - selectedCount++; - } - } else { - selectedCount += fillFirstWord(lineWords, end, null, isRight, true); - // - for (int wordIndex = end.word + 1; wordIndex < lineWords.size(); wordIndex++) { - lineWords.get(wordIndex).selectAll(); - selectedCount++; - } - } - } - return selectedCount; - } - - public static int fillMiddleLines(ArrayList pageLines, GlyphLocation start, GlyphLocation end, - Shape topMarginExclusion, Shape bottomMarginExclusion) { - GlyphLocation startLocal = new GlyphLocation(start); - GlyphLocation endLocal = new GlyphLocation(end); - if (startLocal.line > endLocal.line) { - GlyphLocation tmp = startLocal; - startLocal = end; - endLocal = tmp; - } - int selectedCount = 0; - for (int lineIndex = startLocal.line + 1; lineIndex < endLocal.line; lineIndex++) { - if (isLineTextIncluded(pageLines.get(lineIndex), topMarginExclusion, bottomMarginExclusion)) { - pageLines.get(lineIndex).selectAll(); - selectedCount++; - } - } - return selectedCount; - } - - - -} +/* + * Copyright 2006-2019 ICEsoft Technologies Canada Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.ri.common.tools; + +import org.icepdf.core.pobjects.Page; +import org.icepdf.core.pobjects.graphics.text.Bias; +import org.icepdf.core.pobjects.graphics.text.BreakType; +import org.icepdf.core.pobjects.graphics.text.Caret; +import org.icepdf.core.pobjects.graphics.text.LineText; +import org.icepdf.core.pobjects.graphics.text.OffsetRange; +import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.TextSequence; +import org.icepdf.core.pobjects.graphics.text.WordText; +import org.icepdf.core.util.PropertyConstants; +import org.icepdf.ri.common.views.AbstractPageViewComponent; +import org.icepdf.ri.common.views.DocumentTextSelection; +import org.icepdf.ri.common.views.DocumentViewController; +import org.icepdf.ri.common.views.DocumentViewModel; +import org.icepdf.ri.common.views.PageViewComponentImpl; + +import java.awt.*; +import java.awt.geom.*; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * TextSelection captures the work needed to do basic text, word and line selection. Selection is + * expressed as caret character offsets into a page's {@link TextSequence}: a mouse point maps to a + * caret via {@link TextSequence#caretAt}, and the document-level anchor→focus selection is + * held by the {@link DocumentViewModel} ({@link DocumentTextSelection}). The legacy per-glyph + * selection flags are kept in sync (write-through) so that redaction, highlight and text-edit + * consumers keep working; painting and text extraction derive directly from the offset model. + */ +public class TextSelection extends SelectionBoxHandler { + + protected static final Logger logger = + Logger.getLogger(TextSelection.class.getName()); + + public int selectedCount; + + protected Point lastMousePressedLocation; + protected Point lastMouseLocation; + + // Pointer to make sure the GC doesn't collect a page while selection state is present + protected Page pageLock; + + // sticky page-space column for vertical caret navigation; -1 when not navigating vertically. + protected double goalX = -1; + + public TextSelection(DocumentViewController documentViewController, AbstractPageViewComponent pageViewComponent) { + super(documentViewController, pageViewComponent); + } + + @Override + protected void checkAndApplyPreferences() { + + } + + /** + * Handles double and triple left mouse clicks to select a word or line of text respectively. + * + * @param clickCount number of mouse clicks to interpret for line or word selection. + * @param clickPoint point that mouse was clicked. + * @param pageViewComponent parent page view component + */ + public void wordLineSelection(int clickCount, Point clickPoint, AbstractPageViewComponent pageViewComponent) { + try { + // triple click selects the whole line. + if (clickCount == 3) { + lineSelectHandler(pageViewComponent.getPage(), (Point) clickPoint.clone()); + } + // double click selects the word that was clicked. + else if (clickCount == 2) { + wordSelectHandler(pageViewComponent.getPage(), (Point) clickPoint.clone()); + } + if (pageViewComponent != null) { + pageViewComponent.requestFocusInWindow(); + } + } catch (InterruptedException e) { + logger.fine("Text selection page access interrupted"); + } + } + + /** + * Selection started, records the anchor caret at the start point. + * + * @param startPoint starting selection position. + * @param isFirst start of selection if true + * @param pageViewComponent parent page component + */ + public void selectionStart(Point startPoint, AbstractPageViewComponent pageViewComponent, boolean isFirst) { + try { + Page currentPage = pageViewComponent.getPage(); + if (currentPage != null) { + PageText pageText = currentPage.getViewText(); + int offset = caretOffset(pageText, startPoint); + pageLock = currentPage; + documentViewController.getDocumentViewModel() + .collapseTo(pageViewComponent.getPageIndex(), offset); + selectedCount = 0; + syncSelection(); + // grab keyboard focus so caret navigation keys reach the page rather than + // scrolling the parent viewport. + pageViewComponent.requestFocusInWindow(); + } + pageViewComponent.repaint(); + } catch (InterruptedException e) { + logger.fine("Text selection page access interrupted"); + } + } + + /** + * Selection ended, fires the selection property change if any text was selected. + * + * @param pageViewComponent page component view + * @param endPoint end point of drag + */ + public void selectionEnd(Point endPoint, AbstractPageViewComponent pageViewComponent) { + if (selectedCount > 0) { + documentViewController.getDocumentViewModel().addSelectedPageText(pageViewComponent); + documentViewController.firePropertyChange(PropertyConstants.TEXT_SELECTED, null, null); + } + clearRectangle(pageViewComponent); + pageViewComponent.repaint(); + } + + public void clearSelection() { + // release the page lock so the Reference API can collect the page post selection. + pageLock = null; + selectedCount = 0; + } + + public void clearSelectionState() { + for (AbstractPageViewComponent page : documentViewController.getDocumentViewModel().getPageComponents()) { + ((PageViewComponentImpl) page).getTextSelectionPageHandler().clearSelection(); + } + } + + /** + * Selection drag, extends the focus caret to the drag point on the given page. + * + * @param dragPoint drag location in the page component's coordinates. + * @param pageViewComponent page being dragged over. + * @param isDown unused, retained for call-site compatibility. + * @param isMovingRight unused, retained for call-site compatibility. + */ + public void selection(Point dragPoint, AbstractPageViewComponent pageViewComponent, + boolean isDown, boolean isMovingRight) { + try { + if (pageViewComponent != null) { + Page currentPage = pageViewComponent.getPage(); + if (currentPage != null) { + pageLock = currentPage; + PageText pageText = currentPage.getViewText(); + int offset = caretOffset(pageText, dragPoint); + DocumentViewModel documentViewModel = documentViewController.getDocumentViewModel(); + if (documentViewModel.getTextSelection().isEmpty()) { + documentViewModel.collapseTo(pageViewComponent.getPageIndex(), offset); + } else { + documentViewModel.extendTo(pageViewComponent.getPageIndex(), offset); + } + selectedCount = documentViewModel.getTextSelection().isCollapsed() ? 0 : 1; + syncSelection(); + lastMouseLocation = dragPoint; + } + } + } catch (InterruptedException e) { + logger.fine("Text selection page access interrupted"); + } + } + + public boolean selectionTextSelectIcon(Point mouseLocation, AbstractPageViewComponent pageViewComponent) { + boolean foundSelectableText = false; + try { + Page currentPage = pageViewComponent.getPage(); + if (currentPage != null) { + PageText pageText = currentPage.getViewText(); + if (pageText != null) { + Point2D.Float pageMouseLocation = convertToPageSpace(mouseLocation); + foundSelectableText = pageText.getTextSequence().hitsText(pageMouseLocation); + documentViewController.setViewCursor(foundSelectableText + ? DocumentViewController.CURSOR_TEXT_SELECTION + : DocumentViewController.CURSOR_SELECT); + } + } + } catch (InterruptedException e) { + logger.fine("Text selection page access interrupted"); + } + return foundSelectableText; + } + + /** + * Maps a point in the page component's coordinates to a caret character offset in the page's + * reading-order text sequence. + */ + protected int caretOffset(PageText pageText, Point componentPoint) { + if (pageText == null) return 0; + return pageText.getTextSequence().caretAt(convertToPageSpace(componentPoint)).getOffset(); + } + + /** + * Projects the authoritative selection onto the legacy per-glyph flags and repaints; delegates + * to {@link TextSelectionSupport#applyDocumentSelection} so the mouse and keyboard paths behave + * identically. + */ + protected void syncSelection() { + TextSelectionSupport.applyDocumentSelection(documentViewController.getDocumentViewModel()); + } + + /** + * Paints the page's selection (derived from the document selection offset model) and any search + * highlight (still driven by the per-word highlight flags). + * + * @param g graphics context to paint to. + * @param pageViewComponent page view component to paint selected text on. + * @param documentViewModel document model contains view properties such as zoom and rotation. + */ + public static void paintSelectedText(Graphics g, + AbstractPageViewComponent pageViewComponent, + DocumentViewModel documentViewModel) throws InterruptedException { + Graphics2D gg = (Graphics2D) g; + AffineTransform prePaintTransform = gg.getTransform(); + Color oldColor = gg.getColor(); + Stroke oldStroke = gg.getStroke(); + gg.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Page.SELECTION_ALPHA)); + gg.setStroke(new BasicStroke(1.0f)); + + Page currentPage = pageViewComponent.getPage(); + PageText pageText = TextSelectionSupport.loadedPageText(pageViewComponent); + if (currentPage != null && pageText != null) { + AffineTransform pageTransform = currentPage.getPageTransform( + documentViewModel.getPageBoundary(), + documentViewModel.getViewRotation(), + documentViewModel.getViewZoom()); + TextSequence sequence = pageText.getTextSequence(); + + // selection fill, derived from the document-level offset model (or full page for select-all). + OffsetRange range = documentViewModel.isSelectAll() ? sequence.fullRange() + : TextSelectionSupport.rangeForPage(documentViewModel.getTextSelection(), + pageViewComponent.getPageIndex(), sequence); + if (range != null) { + gg.setColor(Page.selectionColor); + for (Rectangle2D.Double rect : sequence.rectsFor(range)) { + GeneralPath path = new GeneralPath(rect); + path.transform(pageTransform); + gg.fill(path); + } + } + + // search highlight fill, still driven by the per-word highlight flags. + for (LineText lineText : pageText.getPageLines()) { + for (WordText wordText : lineText.getWords()) { + if (wordText.isHighlighted()) { + gg.setColor(wordText.isHighlightCursor() + ? Page.highlightCursorColor : wordText.getHighlightColor()); + GeneralPath path = new GeneralPath(wordText.getBounds()); + path.transform(pageTransform); + gg.fill(path); + } + } + } + } + gg.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f)); + gg.setTransform(prePaintTransform); + gg.setStroke(oldStroke); + gg.setColor(oldColor); + } + + /** + * Utility for painting text bounds (debug). + * + * @param g graphics context to paint to. + * @throws InterruptedException thread interrupted. + */ + protected void paintTextBounds(Graphics g) throws InterruptedException { + Page currentPage = pageViewComponent.getPage(); + AffineTransform pageTransform = getPageTransform(); + Graphics2D gg = (Graphics2D) g; + Color oldColor = g.getColor(); + g.setColor(Color.red); + + PageText pageText = currentPage.getViewText(); + if (pageText != null) { + ArrayList pageLines = pageText.getPageLines(); + if (pageLines != null) { + for (LineText lineText : pageLines) { + for (WordText wordText : lineText.getWords()) { + for (org.icepdf.core.pobjects.graphics.text.GlyphText glyph : wordText.getGlyphs()) { + g.setColor(Color.black); + GeneralPath glyphSpritePath = new GeneralPath(glyph.getBounds()); + glyphSpritePath.transform(pageTransform); + gg.draw(glyphSpritePath); + } + } + g.setColor(Color.red); + GeneralPath glyphSpritePath = new GeneralPath(lineText.getBounds()); + glyphSpritePath.transform(pageTransform); + gg.draw(glyphSpritePath); + } + } + } + g.setColor(oldColor); + } + + /** + * Selects the word under the mouse (double-click). + * + * @param currentPage page to look for text on. + * @param mouseLocation location of mouse in the page component's coordinates. + * @throws InterruptedException thread interrupted. + */ + protected void wordSelectHandler(Page currentPage, Point mouseLocation) throws InterruptedException { + selectRangeAtPoint(currentPage, mouseLocation, true); + } + + /** + * Selects the line under the mouse (triple-click). + * + * @param currentPage page to select on. + * @param mouseLocation location of mouse in the page component's coordinates. + * @throws InterruptedException thread interrupted. + */ + protected void lineSelectHandler(Page currentPage, Point mouseLocation) throws InterruptedException { + selectRangeAtPoint(currentPage, mouseLocation, false); + } + + private void selectRangeAtPoint(Page currentPage, Point mouseLocation, boolean word) throws InterruptedException { + if (currentPage == null) return; + PageText pageText = currentPage.getViewText(); + if (pageText == null) return; + TextSequence sequence = pageText.getTextSequence(); + int offset = caretOffset(pageText, mouseLocation); + OffsetRange range = word ? sequence.wordRange(offset) : sequence.lineRange(offset); + if (range.isEmpty()) return; + int pageIndex = pageViewComponent.getPageIndex(); + pageLock = currentPage; + documentViewController.getDocumentViewModel() + .setTextSelection(pageIndex, range.getStart(), pageIndex, range.getEnd()); + selectedCount = 1; + syncSelection(); + documentViewController.firePropertyChange(PropertyConstants.TEXT_SELECTED, null, null); + } + + // ------------------------------------------------------------------ + // Keyboard caret navigation. All operate on the document-level focus caret; the focus page + // may differ from this handler's own page. extend == true moves the focus only (shift-select). + // ------------------------------------------------------------------ + + public void caretRight(boolean extend) { + horizontalCaret(true, extend); + } + + public void caretLeft(boolean extend) { + horizontalCaret(false, extend); + } + + private void horizontalCaret(boolean forward, boolean extend) { + DocumentViewModel model = documentViewController.getDocumentViewModel(); + DocumentTextSelection selection = model.getTextSelection(); + if (selection.isEmpty()) return; + try { + int page = selection.getFocusPage(), offset = selection.getFocusOffset(); + TextSequence seq = sequenceAt(page); + if (seq == null) return; + int newPage = page, newOffset; + if (forward) { + if (offset < seq.length()) newOffset = seq.nextBoundary(offset, BreakType.GLYPH, true); + else if (page < lastPageIndex()) { + newPage = page + 1; + newOffset = 0; + } else newOffset = offset; + } else { + if (offset > 0) newOffset = seq.nextBoundary(offset, BreakType.GLYPH, false); + else if (page > 0) { + newPage = page - 1; + TextSequence prev = sequenceAt(newPage); + newOffset = prev != null ? prev.length() : 0; + } else newOffset = 0; + } + applyCaret(newPage, newOffset, extend, false); + } catch (InterruptedException e) { + logger.fine("Caret navigation interrupted"); + } + } + + public void caretWordRight(boolean extend) { + wordCaret(true, extend); + } + + public void caretWordLeft(boolean extend) { + wordCaret(false, extend); + } + + private void wordCaret(boolean forward, boolean extend) { + DocumentViewModel model = documentViewController.getDocumentViewModel(); + DocumentTextSelection selection = model.getTextSelection(); + if (selection.isEmpty()) return; + try { + TextSequence seq = sequenceAt(selection.getFocusPage()); + if (seq == null) return; + int offset = selection.getFocusOffset(); + int nb = seq.nextBoundary(offset, BreakType.WORD, forward); + if (nb == offset) { + // at a page edge, roll over one glyph to the neighbouring page. + horizontalCaret(forward, extend); + return; + } + applyCaret(selection.getFocusPage(), nb, extend, false); + } catch (InterruptedException e) { + logger.fine("Caret navigation interrupted"); + } + } + + public void caretLineStart(boolean extend) { + lineEdgeCaret(false, extend); + } + + public void caretLineEnd(boolean extend) { + lineEdgeCaret(true, extend); + } + + private void lineEdgeCaret(boolean end, boolean extend) { + DocumentViewModel model = documentViewController.getDocumentViewModel(); + DocumentTextSelection selection = model.getTextSelection(); + if (selection.isEmpty()) return; + try { + TextSequence seq = sequenceAt(selection.getFocusPage()); + if (seq == null) return; + OffsetRange line = seq.lineRange(selection.getFocusOffset()); + applyCaret(selection.getFocusPage(), end ? line.getEnd() : line.getStart(), extend, false); + } catch (InterruptedException e) { + logger.fine("Caret navigation interrupted"); + } + } + + public void caretDown(boolean extend) { + verticalCaret(true, extend); + } + + public void caretUp(boolean extend) { + verticalCaret(false, extend); + } + + private void verticalCaret(boolean down, boolean extend) { + DocumentViewModel model = documentViewController.getDocumentViewModel(); + DocumentTextSelection selection = model.getTextSelection(); + if (selection.isEmpty()) return; + try { + int page = selection.getFocusPage(), offset = selection.getFocusOffset(); + TextSequence seq = sequenceAt(page); + if (seq == null) return; + if (goalX < 0) goalX = seq.caretRect(new Caret(offset, Bias.FORWARD)).getX(); + Caret adjacent = down + ? seq.caretBelow(new Caret(offset, Bias.FORWARD), goalX) + : seq.caretAbove(new Caret(offset, Bias.FORWARD), goalX); + if (adjacent != null) { + applyCaret(page, adjacent.getOffset(), extend, true); + } else if (down && page < lastPageIndex()) { + TextSequence next = sequenceAt(page + 1); + if (next != null) applyCaret(page + 1, next.caretAtLine(0, goalX).getOffset(), extend, true); + } else if (!down && page > 0) { + TextSequence prev = sequenceAt(page - 1); + if (prev != null) applyCaret(page - 1, prev.caretAtLine(prev.lineCount() - 1, goalX).getOffset(), extend, true); + } + } catch (InterruptedException e) { + logger.fine("Caret navigation interrupted"); + } + } + + private void applyCaret(int newPage, int newOffset, boolean extend, boolean verticalMotion) { + if (!verticalMotion) goalX = -1; + DocumentViewModel model = documentViewController.getDocumentViewModel(); + if (extend) model.extendTo(newPage, newOffset); + else model.collapseTo(newPage, newOffset); + TextSelectionSupport.applyDocumentSelection(model); + focusAndScrollCaret(newPage, newOffset); + } + + private TextSequence sequenceAt(int pageIndex) throws InterruptedException { + java.util.List pages = documentViewController.getDocumentViewModel().getPageComponents(); + if (pageIndex < 0 || pageIndex >= pages.size()) return null; + Page page = pages.get(pageIndex).getPage(); + if (page == null) return null; + PageText pageText = page.getViewText(); + return pageText != null ? pageText.getTextSequence() : null; + } + + private int lastPageIndex() { + return documentViewController.getDocumentViewModel().getPageComponents().size() - 1; + } + + private void focusAndScrollCaret(int pageIndex, int offset) { + DocumentViewModel model = documentViewController.getDocumentViewModel(); + java.util.List pages = model.getPageComponents(); + if (pageIndex < 0 || pageIndex >= pages.size()) return; + AbstractPageViewComponent pageComponent = pages.get(pageIndex); + pageComponent.requestFocusInWindow(); + try { + Page page = pageComponent.getPage(); + if (page == null) return; + PageText pageText = page.getViewText(); + if (pageText == null) return; + AffineTransform transform = page.getPageTransform( + model.getPageBoundary(), model.getViewRotation(), model.getViewZoom()); + Rectangle2D.Double caret = pageText.getTextSequence().caretRect(new Caret(offset, Bias.FORWARD)); + Rectangle bounds = transform.createTransformedShape(caret).getBounds(); + bounds.grow(8, 24); + pageComponent.scrollRectToVisible(bounds); + } catch (InterruptedException e) { + logger.fine("Caret scroll interrupted"); + } + } + + @Override + public void setSelectionRectangle(Point cursorLocation, Rectangle selection) { + } + + public static GeneralPath convertTextShapesToBounds(ArrayList textShapes) { + if (textShapes != null && !textShapes.isEmpty()) { + + // bound of the selected text + Rectangle2D shapeBounds; + double padding; + // padding out the bound, so we get a better hits when looking for redacted text. Since the redaction + // box is derived from the glyph bounds we can get rounding errors when a contains call is made and + // a glyph will be just slightly outside the redaction bounds and contains will return false + Area area = new Area(); + for (Shape bounds : textShapes) { + shapeBounds = bounds.getBounds2D(); + padding = shapeBounds.getHeight() * 0.025; + shapeBounds.setRect( + shapeBounds.getX() - padding, + shapeBounds.getY() - padding, + shapeBounds.getWidth() + (padding * 2), + shapeBounds.getHeight() + (padding * 2)); + // area is important here as we want a union of the shapes, not multiple separate paths. + area.add(new Area(shapeBounds)); + } + GeneralPath textPath = new GeneralPath(); + textPath.append(area, false); + + + return textPath; + } + return null; + } + + /** + * Convert the shapes that make up the annotation to page space so that + * they will scale correctly at different zooms. + * + * @param bounds bounds to convert to page space + * @param path path + * @return transformed bBox. + */ + protected Rectangle convertToPageSpace(ArrayList bounds, + GeneralPath path) { + Page currentPage = pageViewComponent.getPage(); + DocumentViewModel documentViewModel = documentViewController.getDocumentViewModel(); + AffineTransform at = currentPage.getToPageSpaceTransform( + documentViewModel.getPageBoundary(), + documentViewModel.getViewRotation(), + documentViewModel.getViewZoom()); + // convert the two points as well as the bbox. + Rectangle tBbox = at.createTransformedShape(path).getBounds(); + // convert the points + Shape bound; + for (int i = 0; i < bounds.size(); i++) { + bound = bounds.get(i); + bound = at.createTransformedShape(bound); + bounds.set(i, bound); + } + + path.transform(at); + + return tBbox; + } +} diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionPageHandler.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionPageHandler.java index 1ac497f7b..73a4ae5a1 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionPageHandler.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionPageHandler.java @@ -15,12 +15,21 @@ */ package org.icepdf.ri.common.tools; +import org.icepdf.core.pobjects.Page; +import org.icepdf.core.pobjects.graphics.text.Bias; +import org.icepdf.core.pobjects.graphics.text.Caret; +import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.TextSequence; import org.icepdf.ri.common.views.AbstractPageViewComponent; +import org.icepdf.ri.common.views.DocumentTextSelection; import org.icepdf.ri.common.views.DocumentViewController; +import org.icepdf.ri.common.views.DocumentViewModel; import java.awt.*; import java.awt.event.MouseEvent; import java.awt.geom.AffineTransform; +import java.awt.geom.Line2D; +import java.awt.geom.Rectangle2D; /** * Handles Paint and mouse/keyboard logic around text selection and search @@ -72,7 +81,7 @@ public void mouseClicked(MouseEvent e) { */ public void mousePressed(MouseEvent e) { isClearSelection = false; - this.pageViewComponent.requestFocus(); + this.pageViewComponent.requestFocusInWindow(); lastMousePressedLocation = e.getPoint(); selectionStart(e.getPoint(), pageViewComponent, true); @@ -157,14 +166,29 @@ public void uninstallTool() { } public void paintTool(Graphics g) { - if (enableMarginExclusionBorder && topMarginExclusion != null && bottomMarginExclusion != null) { - AffineTransform at = getPageTransform(); - ((Graphics2D)g).transform(at); - g.setColor(Color.RED); - paintSelectionBox(g, topMarginExclusion.getBounds()); - g.setColor(Color.BLUE); - paintSelectionBox(g, bottomMarginExclusion.getBounds()); + // paint the keyboard caret bar when the document focus caret is on this page. + DocumentViewModel model = documentViewController.getDocumentViewModel(); + DocumentTextSelection selection = model.getTextSelection(); + if (selection.isEmpty() || selection.getFocusPage() != pageViewComponent.getPageIndex() + || !CaretBlink.isVisible()) { + return; } + PageText pageText = TextSelectionSupport.loadedPageText(pageViewComponent); + if (pageText == null) return; + Page page = pageViewComponent.getPage(); + TextSequence sequence = pageText.getTextSequence(); + AffineTransform transform = page.getPageTransform( + model.getPageBoundary(), model.getViewRotation(), model.getViewZoom()); + Rectangle2D.Double caret = sequence.caretRect(new Caret(selection.getFocusOffset(), Bias.FORWARD)); + Rectangle2D bounds = transform.createTransformedShape(caret).getBounds2D(); + Graphics2D g2 = (Graphics2D) g; + Color oldColor = g2.getColor(); + Stroke oldStroke = g2.getStroke(); + g2.setColor(Color.BLACK); + g2.setStroke(new BasicStroke(1f)); + g2.draw(new Line2D.Double(bounds.getX(), bounds.getMinY(), bounds.getX(), bounds.getMaxY())); + g2.setColor(oldColor); + g2.setStroke(oldStroke); } diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionSupport.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionSupport.java new file mode 100644 index 000000000..56636ef37 --- /dev/null +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionSupport.java @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.ri.common.tools; + +import org.icepdf.core.pobjects.Document; +import org.icepdf.core.pobjects.Page; +import org.icepdf.core.pobjects.graphics.Shapes; +import org.icepdf.core.pobjects.graphics.text.*; +import org.icepdf.ri.common.views.AbstractPageViewComponent; +import org.icepdf.ri.common.views.DocumentTextSelection; +import org.icepdf.ri.common.views.DocumentViewModel; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Stateless helpers that bridge the document-level {@link DocumentTextSelection} (owned by + * the viewer) and the per-page {@link TextSequence} (owned by core). These are the seam + * described in TEXT-SELECTION-PLAN.md Appendix C. + *
+ * Note: as of Phase 2 Step 1 these are built and unit-tested but not yet wired into + * the live handlers/painting; that happens in Step 2. + * + * @since 7.5 + */ +public final class TextSelectionSupport { + + private static final Logger logger = Logger.getLogger(TextSelectionSupport.class.getName()); + + private TextSelectionSupport() { + } + + /** + * Derives the character range of a document selection that falls on a single page. + * The first page contributes {@code anchor -> end}, interior pages the whole page, and + * the last page {@code start -> focus}. + * + * @param sel document selection + * @param pageIndex page to derive the range for + * @param seq that page's text sequence + * @return the offset range on this page, or {@code null} if the page is outside the + * selection. + */ + public static OffsetRange rangeForPage(DocumentTextSelection sel, int pageIndex, TextSequence seq) { + if (sel == null || sel.isEmpty() || seq == null) return null; + if (pageIndex < sel.startPage() || pageIndex > sel.endPage()) return null; + int lo = (pageIndex == sel.startPage()) ? sel.startOffset() : 0; + int hi = (pageIndex == sel.endPage()) ? sel.endOffset() : seq.length(); + return OffsetRange.of(lo, hi).clamp(seq.length()); + } + + /** + * Reproduces the legacy per-node selection flags from an offset range so that existing + * consumers ({@code getSelectedWordText}, highlight/redaction bounds, {@code getSelected}) + * keep working while selection moves to the offset model (D3 write-through bridge). + * Clears the page's selection first, then marks the covered glyphs/words. + * + * @param pageText page whose node flags should mirror {@code range} + * @param range range to apply, may be null/empty to just clear + */ + public static void applySelectionToFlags(PageText pageText, OffsetRange range) { + if (pageText == null) return; + pageText.clearSelected(); + if (range == null || range.isEmpty()) return; + TextSequence seq = pageText.getTextSequence(); + for (GlyphText glyph : seq.glyphsIn(range)) { + glyph.setSelected(true); + } + for (WordText word : seq.wordsIn(range)) { + word.setHasSelected(true); + OffsetRange wr = seq.rangeOf(word); + // whole word inside the range -> mark the word selected as well (matches selectAll()). + if (wr != null && range.getStart() <= wr.getStart() && range.getEnd() >= wr.getEnd()) { + word.setSelected(true); + } + } + } + + /** + * Projects the authoritative {@link DocumentTextSelection} onto the legacy per-glyph selection + * flags for every loaded page it covers (write-through bridge), clears pages that dropped out, + * and repaints the affected pages. Painting itself derives from the offset model, so unloaded + * pages still render correctly when they come back. Shared by the mouse and keyboard paths. + * + * @param model document view model holding the authoritative selection and page components. + */ + public static void applyDocumentSelection(DocumentViewModel model) { + // the caret just moved; keep it solid and restart the blink cycle. + CaretBlink.reset(); + DocumentTextSelection selection = model.getTextSelection(); + + // clear flags/repaint on pages that were previously selected. + List previous = model.getSelectedPageText(); + List previousCopy = + previous != null ? new ArrayList<>(previous) : new ArrayList<>(); + model.clearSelectedPageText(); + for (AbstractPageViewComponent page : previousCopy) { + PageText pageText = loadedPageText(page); + if (pageText != null) applySelectionToFlags(pageText, null); + page.repaint(); + } + if (selection.isEmpty()) return; + + // apply flags/repaint on pages the selection now covers. + List pages = model.getPageComponents(); + for (int index = selection.startPage(); index <= selection.endPage(); index++) { + if (index < 0 || index >= pages.size()) continue; + AbstractPageViewComponent page = pages.get(index); + PageText pageText = loadedPageText(page); + if (pageText != null) { + OffsetRange range = rangeForPage(selection, index, pageText.getTextSequence()); + applySelectionToFlags(pageText, range); + } + model.addSelectedPageText(page); + page.repaint(); + } + } + + /** + * Non-initializing page text accessor; returns null rather than triggering a parse on the EDT. + * + * @param page page component + * @return the page's already-built text, or null if not loaded. + */ + public static PageText loadedPageText(AbstractPageViewComponent page) { + Page currentPage = page.getPage(); + if (currentPage == null) return null; + Shapes shapes = currentPage.getShapes(); + return shapes != null ? shapes.getPageText() : null; + } + + /** + * Extracts the selected text for a document selection by walking each covered page's + * {@link TextSequence}. Independent of the legacy node flags. + * + * @param sel document selection + * @param document document to read page text from + * @return concatenated selected text, or empty string. + */ + public static String selectedText(DocumentTextSelection sel, Document document) { + if (sel == null || sel.isEmpty() || document == null) return ""; + StringBuilder sb = new StringBuilder(); + try { + for (int p = sel.startPage(); p <= sel.endPage(); p++) { + PageText pageText = document.getPageText(p); + if (pageText == null) continue; + TextSequence seq = pageText.getTextSequence(); + OffsetRange r = rangeForPage(sel, p, seq); + if (r != null) sb.append(seq.text(r)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + if (logger.isLoggable(Level.FINE)) { + logger.fine("Text selection extraction interrupted."); + } + } + return sb.toString(); + } +} diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionViewHandler.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionViewHandler.java index cf94f6c98..f08b7ff3c 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionViewHandler.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/tools/TextSelectionViewHandler.java @@ -65,6 +65,12 @@ public TextSelectionViewHandler(DocumentViewController documentViewController, public void mouseClicked(MouseEvent e) { if (e.getButton() == MouseEvent.BUTTON1 || e.getButton() == MouseEvent.BUTTON3) { + // A single click only places the caret, which mousePressed already handled (it also + // clears any previous selection). Clearing here would wipe that caret; only double and + // triple clicks clear and perform word/line selection. + if (e.getClickCount() < 2) { + return; + } // clear all selected text. documentViewController.clearSelectedText(); clearSelectionState(); @@ -73,7 +79,7 @@ public void mouseClicked(MouseEvent e) { PageViewComponentImpl pageComponent = isOverPageComponent(parentComponent, e); if (pageComponent != null) { - pageComponent.requestFocus(); + pageComponent.requestFocusInWindow(); // click word and line selection MouseEvent modeEvent = SwingUtilities.convertMouseEvent(parentComponent, e, pageComponent); pageComponent.getTextSelectionPageHandler().wordLineSelection( @@ -98,7 +104,7 @@ public void mousePressed(MouseEvent e) { // check if we are over a page PageViewComponentImpl pageComponent = isOverPageComponent(parentComponent, e); if (pageComponent != null) { - pageComponent.requestFocus(); + pageComponent.requestFocusInWindow(); MouseEvent modeEvent = SwingUtilities.convertMouseEvent(parentComponent, e, pageComponent); pageComponent.getTextSelectionPageHandler().selectionStart(modeEvent.getPoint(), pageComponent, true); } @@ -110,7 +116,7 @@ public void mousePressed(MouseEvent e) { if (canExtract && documentViewController.getSelectedText() != null && !documentViewController.getSelectedText().isEmpty()) { PageViewComponentImpl pageComponent = isOverPageComponent(parentComponent, e); - pageComponent.requestFocus(); + pageComponent.requestFocusInWindow(); JPopupMenu contextMenu = buildSelectedTextContextMenu(pageComponent); contextMenu.show(parentComponent, e.getX(), e.getY()); } @@ -120,7 +126,7 @@ else if (textEditingEnabled && (documentViewController.getSelectedText() == null || documentViewController.getSelectedText().isEmpty())) { PageViewComponentImpl pageComponent = isOverPageComponent(parentComponent, e); - pageComponent.requestFocus(); + pageComponent.requestFocusInWindow(); MouseEvent modeEvent = SwingUtilities.convertMouseEvent(parentComponent, e, pageComponent); JPopupMenu contextMenu = buildEditTextContextMenu(pageComponent, modeEvent.getPoint()); contextMenu.show(parentComponent, e.getX(), e.getY()); @@ -268,45 +274,42 @@ public void mouseDragged(MouseEvent e) { if (documentViewController != null && isSelecting) { isDragging = true; - // update the currently parentComponent box + // update the parentComponent box (kept for autoscroll bookkeeping). updateSelectionSize(e.getX(), e.getY(), parentComponent); - DocumentViewModel documentViewModel = documentViewController.getDocumentViewModel(); - // clear previously selected pages - documentViewModel.clearSelectedPageText(); - - // add selection box to child pages - java.util.List pages = - documentViewModel.getPageComponents(); - for (AbstractPageViewComponent page : pages) { - Rectangle tmp = SwingUtilities.convertRectangle( - parentComponent, getRectToDraw(), page); - if (page.getBounds().intersects(tmp)) { - // add the page to the page as it is marked for selection - documentViewModel.addSelectedPageText(page); - - Point modEvent = SwingUtilities.convertPoint(parentComponent, - e.getPoint(), page); - - // set the selected region. - page.setSelectionRectangle(modEvent, tmp); - ((PageViewComponentImpl) page).getTextSelectionPageHandler().setRectToDraw(tmp); - - // pass the selection movement on to the page. - boolean isMovingDown = lastMousePressedLocation.y <= e.getPoint().y; - boolean isMovingRight = lastMousePressedLocation.x <= e.getPoint().x; - - ((PageViewComponentImpl) page).getTextSelectionPageHandler() - .selection(modEvent, page, isMovingDown, isMovingRight); - - } else { - documentViewModel.removeSelectedPageText(page); - page.clearSelectedText(); - page.repaint(); - } + // find the page (or nearest page in the drag direction) under the cursor and extend + // the document caret selection to it; syncSelection() fills the pages in between. + PageViewComponentImpl page = pageUnderOrNearest(e); + if (page != null) { + Point pagePoint = SwingUtilities.convertPoint(parentComponent, e.getPoint(), page); + boolean isMovingDown = lastMousePressedLocation.y <= e.getPoint().y; + boolean isMovingRight = lastMousePressedLocation.x <= e.getPoint().x; + page.getTextSelectionPageHandler().selection(pagePoint, page, isMovingDown, isMovingRight); } } + } + /** + * Returns the page component under the event, or the vertically nearest page when the cursor is + * in a margin/gap so that dragging past a page boundary still extends the selection. + */ + private PageViewComponentImpl pageUnderOrNearest(MouseEvent e) { + PageViewComponentImpl over = isOverPageComponent(parentComponent, e); + if (over != null) return over; + java.util.List pages = + documentViewController.getDocumentViewModel().getPageComponents(); + AbstractPageViewComponent best = null; + double bestDist = Double.MAX_VALUE; + int y = e.getY(); + for (AbstractPageViewComponent page : pages) { + Rectangle b = page.getBounds(); + double d = y < b.y ? b.y - y : (y > b.y + b.height ? y - (b.y + b.height) : 0); + if (d < bestDist) { + bestDist = d; + best = page; + } + } + return (PageViewComponentImpl) best; } public void mouseMoved(MouseEvent e) { @@ -325,11 +328,11 @@ public void paintTool(Graphics g) { } public void installTool() { - + CaretBlink.start(documentViewController); } public void uninstallTool() { - + CaretBlink.stop(); } public void mouseEntered(MouseEvent e) { diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/utility/search/SearchTextTask.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/utility/search/SearchTextTask.java index ba86446b2..aee22c1df 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/utility/search/SearchTextTask.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/utility/search/SearchTextTask.java @@ -23,6 +23,8 @@ import org.icepdf.core.search.DestinationResult; import org.icepdf.core.search.DocumentSearchController; import org.icepdf.core.search.SearchMode; +import org.icepdf.core.search.SearchTerm; +import org.icepdf.core.util.Defs; import org.icepdf.ri.common.views.Controller; import javax.swing.*; @@ -135,7 +137,10 @@ protected Void doInBackground() { searchController.clearAllSearchHighlight(); } searchController.setSearchMode(searchMode); - searchController.addSearchTerm(pattern, caseSensitive, wholeWord, regex); + SearchTerm searchTerm = searchController.addSearchTerm(pattern, caseSensitive, wholeWord, regex); + // interactive search is accent-insensitive by default (Unicode-normalized); can be disabled + // with -Dorg.icepdf.core.search.foldDiacritics=false. + searchTerm.setFoldDiacritics(Defs.booleanProperty("org.icepdf.core.search.foldDiacritics", true)); Document document = controller.getDocument(); // iterate over each page in the document diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentView.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentView.java index fe4253f48..38ba6e1cf 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentView.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentView.java @@ -310,6 +310,15 @@ public void focusLost(FocusEvent e) { } public void mouseClicked(MouseEvent e) { + // With the text-selection tool active a single click places a keyboard caret on the + // page component, which requests focus so caret-navigation keys reach the page. Grabbing + // focus back to the view here would steal those keys (the parent scroll pane then handles + // the arrows). A drag never fires mouseClicked, which is why selection kept focus but a + // bare click did not. Leave focus with the page when the selection tool is active. + if (documentViewModel != null + && documentViewModel.getViewToolMode() == DocumentViewModel.DISPLAY_TOOL_TEXT_SELECTION) { + return; + } requestFocus(); } diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentViewModel.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentViewModel.java index a24159479..c95bc3e3c 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentViewModel.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/AbstractDocumentViewModel.java @@ -55,6 +55,8 @@ public abstract class AbstractDocumentViewModel implements DocumentViewModel { private HashMap selectedPageText; // select all state flag, optimization for painting select all state lazily private boolean selectAll; + // authoritative document-level text selection (anchor->focus offset pair). + private final DocumentTextSelection textSelection = new DocumentTextSelection(); protected List pageComponents; protected HashMap> documentViewAnnotationComponents; // scroll pane used to contain the view @@ -245,6 +247,26 @@ public void clearSelectedPageText() { selectAll = false; } + public DocumentTextSelection getTextSelection() { + return textSelection; + } + + public void collapseTo(int page, int offset) { + textSelection.collapseTo(page, offset); + } + + public void extendTo(int page, int offset) { + textSelection.extendTo(page, offset); + } + + public void setTextSelection(int anchorPage, int anchorOffset, int focusPage, int focusOffset) { + textSelection.set(anchorPage, anchorOffset, focusPage, focusOffset); + } + + public void clearTextSelection() { + textSelection.clear(); + } + /** * Sets the zoom factor of the page visualization. A zoom factor of 1.0f * is equal to 100% or actual size. A zoom factor of 0.5f is equal to 50% diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentTextSelection.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentTextSelection.java new file mode 100644 index 000000000..3850fec06 --- /dev/null +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentTextSelection.java @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an "AS + * IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +package org.icepdf.ri.common.views; + +/** + * The authoritative text selection for a document: a single anchor→focus caret pair + * expressed as document-level {@code (page, offset)} positions. {@code anchor} is the + * fixed end (where a drag or shift-extend started); {@code focus} is the moving end. + * Per-page character ranges are derived from this on demand + * (see {@code TextSelectionSupport.rangeForPage}). + *
+ * The state is intentionally tiny (four ints) so it survives page disposal by the memory + * manager; the offsets re-resolve against a page's {@code TextSequence} when it is + * re-initialized. + * + * @since 7.5 + */ +public final class DocumentTextSelection { + + private int anchorPage; + private int anchorOffset; + private int focusPage; + private int focusOffset; + private boolean empty = true; + + /** + * @return true when there is no selection at all. + */ + public boolean isEmpty() { + return empty; + } + + /** + * @return true when a caret exists but nothing is highlighted (anchor == focus). + */ + public boolean isCollapsed() { + return !empty && anchorPage == focusPage && anchorOffset == focusOffset; + } + + /** + * Places both anchor and focus at a single position (mouse press / click). + * + * @param page page index + * @param offset char offset within that page's sequence + */ + public void collapseTo(int page, int offset) { + anchorPage = focusPage = page; + anchorOffset = focusOffset = offset; + empty = false; + } + + /** + * Moves the focus end only, keeping the anchor fixed (drag / shift-extend). + * + * @param page page index + * @param offset char offset within that page's sequence + */ + public void extendTo(int page, int offset) { + if (empty) { + collapseTo(page, offset); + return; + } + focusPage = page; + focusOffset = offset; + } + + /** + * Sets both ends explicitly (e.g. select-all, word/line click, API). + */ + public void set(int anchorPage, int anchorOffset, int focusPage, int focusOffset) { + this.anchorPage = anchorPage; + this.anchorOffset = anchorOffset; + this.focusPage = focusPage; + this.focusOffset = focusOffset; + empty = false; + } + + /** + * Clears the selection. + */ + public void clear() { + empty = true; + anchorPage = focusPage = 0; + anchorOffset = focusOffset = 0; + } + + public int getAnchorPage() { + return anchorPage; + } + + public int getAnchorOffset() { + return anchorOffset; + } + + public int getFocusPage() { + return focusPage; + } + + public int getFocusOffset() { + return focusOffset; + } + + /** + * @return true when the anchor is at or before the focus in document order. + */ + public boolean isForward() { + return anchorPage < focusPage || (anchorPage == focusPage && anchorOffset <= focusOffset); + } + + public int startPage() { + return isForward() ? anchorPage : focusPage; + } + + public int startOffset() { + return isForward() ? anchorOffset : focusOffset; + } + + public int endPage() { + return isForward() ? focusPage : anchorPage; + } + + public int endOffset() { + return isForward() ? focusOffset : anchorOffset; + } + + @Override + public String toString() { + return "DocumentTextSelection[" + (empty ? "empty" : + "anchor(" + anchorPage + ":" + anchorOffset + ") focus(" + focusPage + ":" + focusOffset + ")") + ']'; + } +} diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewControllerImpl.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewControllerImpl.java index fe779e526..a82d9c27e 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewControllerImpl.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewControllerImpl.java @@ -24,6 +24,7 @@ import org.icepdf.core.util.Library; import org.icepdf.core.util.PropertyConstants; import org.icepdf.ri.common.SwingController; +import org.icepdf.ri.common.tools.TextSelectionSupport; import org.icepdf.ri.common.views.annotations.AbstractAnnotationComponent; import org.icepdf.ri.common.views.annotations.AnnotationState; import org.icepdf.ri.common.views.annotations.PopupAnnotationComponent; @@ -267,6 +268,7 @@ public void clearSelectedText() { ArrayList selectedPages = documentViewModel.getSelectedPageText(); documentViewModel.setSelectAll(false); + documentViewModel.clearTextSelection(); if (selectedPages != null && selectedPages.size() > 0) { for (AbstractPageViewComponent pageComp : selectedPages) { @@ -315,31 +317,18 @@ public String getFlatSelectedText() { public String getSelectedText() { if (documentViewModel == null) return null; + // regular selection by user mouse, keyboard or api derives from the document offset model. + if (!documentViewModel.isSelectAll()) { + return TextSelectionSupport.selectedText( + documentViewModel.getTextSelection(), documentViewModel.getDocument()); + } + // select all text StringBuilder selectedText = new StringBuilder(); try { - // regular page selected by user mouse, keyboard or api - if (!documentViewModel.isSelectAll()) { - ArrayList selectedPages = - documentViewModel.getSelectedPageText(); - if (selectedPages != null && - selectedPages.size() > 0) { - for (AbstractPageViewComponent pageComp : selectedPages) { - if (pageComp != null) { - int pageIndex = pageComp.getPageIndex(); - selectedText.append(document.getPageText(pageIndex).getSelected()); - } - } - } + Document document = documentViewModel.getDocument(); + for (int i = 0; i < document.getNumberOfPages(); i++) { + selectedText.append(document.getPageText(i)); } - // select all text - else { - Document document = documentViewModel.getDocument(); - // iterate over each page in the document - for (int i = 0; i < document.getNumberOfPages(); i++) { - selectedText.append(viewerController.getDocument().getPageText(i)); - } - } - } catch (InterruptedException e) { logger.log(Level.SEVERE, "Page text extraction thread interrupted.", e); } diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewModel.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewModel.java index 216ab3fc1..221d468e3 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewModel.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/DocumentViewModel.java @@ -176,6 +176,45 @@ public interface DocumentViewModel { */ void clearSelectedPageText(); + /** + * Gets the authoritative document-level text selection (a single anchor→focus + * caret pair). Never null; may be {@link DocumentTextSelection#isEmpty() empty}. + * + * @return the document text selection. + */ + DocumentTextSelection getTextSelection(); + + /** + * Places the selection caret at a single position (mouse press / click). + * + * @param page page index + * @param offset char offset within that page's text sequence + */ + void collapseTo(int page, int offset); + + /** + * Moves the selection's focus end while keeping the anchor fixed (drag / shift-extend). + * + * @param page page index + * @param offset char offset within that page's text sequence + */ + void extendTo(int page, int offset); + + /** + * Sets both ends of the selection explicitly. + * + * @param anchorPage anchor page index + * @param anchorOffset anchor char offset + * @param focusPage focus page index + * @param focusOffset focus char offset + */ + void setTextSelection(int anchorPage, int anchorOffset, int focusPage, int focusOffset); + + /** + * Clears the document text selection. + */ + void clearTextSelection(); + /** * Gets the page components associated with this view model. * diff --git a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/PageViewComponentImpl.java b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/PageViewComponentImpl.java index bbdc60a84..969a0a007 100644 --- a/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/PageViewComponentImpl.java +++ b/viewer/viewer-awt/src/main/java/org/icepdf/ri/common/views/PageViewComponentImpl.java @@ -33,8 +33,11 @@ import javax.swing.*; import java.awt.*; +import java.awt.event.ActionEvent; import java.awt.event.FocusEvent; import java.awt.event.FocusListener; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; import java.awt.geom.AffineTransform; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; @@ -200,6 +203,77 @@ public void setToolMode(final int viewToolMode) { addMouseListener(currentToolHandler); addMouseMotionListener(currentToolHandler); } + // keyboard caret navigation is only active with the text-selection tool. + setTextCaretBindings(viewToolMode == DocumentViewModel.DISPLAY_TOOL_TEXT_SELECTION); + } + + private boolean caretBindingsInstalled; + + private static final String[] CARET_ACTION_NAMES = { + "ts.left", "ts.leftExtend", "ts.right", "ts.rightExtend", + "ts.up", "ts.upExtend", "ts.down", "ts.downExtend", + "ts.home", "ts.homeExtend", "ts.end", "ts.endExtend", + "ts.wordLeft", "ts.wordLeftExtend", "ts.wordRight", "ts.wordRightExtend" + }; + + private static KeyStroke[] caretKeyStrokes() { + int shift = InputEvent.SHIFT_DOWN_MASK; + int ctrl = InputEvent.CTRL_DOWN_MASK; + return new KeyStroke[]{ + KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, shift), + KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, shift), + KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0), KeyStroke.getKeyStroke(KeyEvent.VK_UP, shift), + KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0), KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, shift), + KeyStroke.getKeyStroke(KeyEvent.VK_HOME, 0), KeyStroke.getKeyStroke(KeyEvent.VK_HOME, shift), + KeyStroke.getKeyStroke(KeyEvent.VK_END, 0), KeyStroke.getKeyStroke(KeyEvent.VK_END, shift), + KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, ctrl), KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, ctrl | shift), + KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, ctrl), KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, ctrl | shift), + }; + } + + private void runCaretAction(int index) { + TextSelectionPageHandler h = textSelectionPageHandler; + switch (index) { + case 0: h.caretLeft(false); break; + case 1: h.caretLeft(true); break; + case 2: h.caretRight(false); break; + case 3: h.caretRight(true); break; + case 4: h.caretUp(false); break; + case 5: h.caretUp(true); break; + case 6: h.caretDown(false); break; + case 7: h.caretDown(true); break; + case 8: h.caretLineStart(false); break; + case 9: h.caretLineStart(true); break; + case 10: h.caretLineEnd(false); break; + case 11: h.caretLineEnd(true); break; + case 12: h.caretWordLeft(false); break; + case 13: h.caretWordLeft(true); break; + case 14: h.caretWordRight(false); break; + case 15: h.caretWordRight(true); break; + default: break; + } + } + + private void setTextCaretBindings(boolean install) { + if (install == caretBindingsInstalled) return; + caretBindingsInstalled = install; + InputMap inputMap = getInputMap(WHEN_FOCUSED); + ActionMap actionMap = getActionMap(); + KeyStroke[] keys = caretKeyStrokes(); + for (int i = 0; i < keys.length; i++) { + if (install) { + final int index = i; + inputMap.put(keys[i], CARET_ACTION_NAMES[i]); + actionMap.put(CARET_ACTION_NAMES[i], new AbstractAction() { + public void actionPerformed(ActionEvent e) { + runCaretAction(index); + } + }); + } else { + inputMap.remove(keys[i]); + actionMap.remove(CARET_ACTION_NAMES[i]); + } + } } /** diff --git a/viewer/viewer-awt/src/test/java/org/icepdf/selection/DocumentSearchConvergenceTest.java b/viewer/viewer-awt/src/test/java/org/icepdf/selection/DocumentSearchConvergenceTest.java new file mode 100644 index 000000000..14858a71c --- /dev/null +++ b/viewer/viewer-awt/src/test/java/org/icepdf/selection/DocumentSearchConvergenceTest.java @@ -0,0 +1,229 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.icepdf.selection; + +import org.icepdf.core.pobjects.Document; +import org.icepdf.core.pobjects.graphics.text.LineText; +import org.icepdf.core.pobjects.graphics.text.OffsetRange; +import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.TextSequence; +import org.icepdf.core.pobjects.graphics.text.WordText; +import org.icepdf.core.search.SearchMode; +import org.icepdf.core.search.SearchTerm; +import org.icepdf.ri.common.search.DocumentSearchControllerImpl; +import org.icepdf.ri.common.tools.TextSelectionSupport; +import org.icepdf.ri.common.views.DocumentTextSelection; +import org.icepdf.ri.util.FontPropertiesManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Characterization tests for document search across two PDFs (the Parra poem and the PDF + * redaction addendum), covering WORD and PAGE modes, whole-word, phrases, across-line matches, + * accents, regex and result-fragment context. These pin current behavior as the safety net for + * streamlining WORD/PAGE into one corpus engine and adding Unicode-normalized matching. + *

+ * Counts are the current, observed behavior. The {@code accentGap} test intentionally documents a + * known limitation (accent-sensitive matching) that a later step is expected to change. + */ +public class DocumentSearchConvergenceTest { + + private static final String POEM = "/redact/test_print.pdf"; + private static final String ADDENDUM = "/redact/pdf_reference_addendum_redaction.pdf"; + + @BeforeAll + public static void init() { + FontPropertiesManager.getInstance().loadOrReadSystemFonts(); + } + + private Document doc(String resource) throws Exception { + Document d = new Document(); + d.setFile(DocumentSearchConvergenceTest.class.getResource(resource).getFile()); + return d; + } + + private int word(Document d, int page, String term, boolean caseSensitive, boolean wholeWord) { + DocumentSearchControllerImpl c = new DocumentSearchControllerImpl(d); + c.setSearchMode(SearchMode.WORD); + c.addSearchTerm(term, caseSensitive, wholeWord); + return c.searchHighlightPage(page); + } + + private int page(Document d, int page, String term, boolean caseSensitive, boolean regex) { + DocumentSearchControllerImpl c = new DocumentSearchControllerImpl(d); + c.setSearchMode(SearchMode.PAGE); + c.addSearchTerm(term, caseSensitive, false, regex); + return c.searchHighlightPage(page); + } + + /** WORD search with accent-insensitive (Unicode-normalized) matching opted in. */ + private int wordFold(Document d, int page, String term) { + DocumentSearchControllerImpl c = new DocumentSearchControllerImpl(d); + c.setSearchMode(SearchMode.WORD); + SearchTerm t = c.addSearchTerm(term, false, false); + t.setFoldDiacritics(true); + return c.searchHighlightPage(page); + } + + /** PAGE search with accent-insensitive (Unicode-normalized) matching opted in. */ + private int pageFold(Document d, int page, String term) { + DocumentSearchControllerImpl c = new DocumentSearchControllerImpl(d); + c.setSearchMode(SearchMode.PAGE); + SearchTerm t = c.addSearchTerm(term, false, false, false); + t.setFoldDiacritics(true); + return c.searchHighlightPage(page); + } + + private String pageHighlighted(Document d, int page, String term, boolean regex) { + DocumentSearchControllerImpl c = new DocumentSearchControllerImpl(d); + c.setSearchMode(SearchMode.PAGE); + c.addSearchTerm(term, false, false, regex); + List words = c.searchPage(page); + StringBuilder sb = new StringBuilder(); + if (words != null) for (WordText w : words) sb.append(w.getText()); + return sb.toString().replace(" ", ""); + } + + @DisplayName("WORD mode: substring vs whole-word counts") + @Test + public void wordMode() throws Exception { + Document d = doc(POEM); + assertEquals(10, word(d, 0, "Un", false, false)); // substring, case-insensitive + assertEquals(7, word(d, 0, "Un", true, true)); // whole word, case-sensitive + assertEquals(8, word(d, 0, "un", false, true)); // whole word, case-insensitive + assertEquals(4, word(d, 0, "que", false, false)); // exact (accent-folding is opt-in) + assertEquals(8, word(d, 0, "de", false, false)); // substring + assertEquals(5, word(d, 0, "de", false, true)); // whole word + assertEquals(2, word(d, 0, "de la", false, false)); // phrase across tokens + assertEquals(0, word(d, 0, "zzznotpresent", false, false)); + d.dispose(); + } + + @DisplayName("PAGE mode: substring, phrase, across-line, regex") + @Test + public void pageMode() throws Exception { + Document d = doc(POEM); + assertEquals(10, page(d, 0, "Un", false, false)); + assertEquals(1, page(d, 0, "todo el mundo", false, false)); + assertEquals(1, page(d, 0, "Un sacerdote", false, false)); // spans a line break + assertEquals(1, page(d, 0, "sí mismo", false, false)); + assertEquals(2, page(d, 0, "de\\s+la", false, true)); // regex + assertEquals(8, page(d, 0, "\\bUn\\b", false, true)); // whole-word via regex + d.dispose(); + } + + @DisplayName("accent-insensitive matching is opt-in: default exact, folded finds accented text") + @Test + public void accentInsensitive() throws Exception { + Document d = doc(POEM); + // default (exact): unaccented term does NOT match accented text. + assertEquals(0, word(d, 0, "caracter", false, false)); + assertEquals(0, page(d, 0, "si mismo", false, false)); + assertEquals(0, page(d, 0, "ataudes", false, false)); + // opted-in (folded): both the accented term and its unaccented form match. + assertEquals(1, wordFold(d, 0, "carácter")); + assertEquals(1, wordFold(d, 0, "caracter")); + assertEquals(1, pageFold(d, 0, "sí mismo")); + assertEquals(1, pageFold(d, 0, "si mismo")); + assertEquals(1, pageFold(d, 0, "ataúdes")); + assertEquals(1, pageFold(d, 0, "ataudes")); + // folding also makes "que" find the accented "Qué". + assertEquals(4, word(d, 0, "que", false, false)); // exact + assertEquals(5, wordFold(d, 0, "que")); // + "Qué" + d.dispose(); + } + + @DisplayName("second corpus: PDF redaction addendum (multi-page, dense)") + @Test + public void addendumCorpus() throws Exception { + Document d = doc(ADDENDUM); + assertEquals(10, word(d, 1, "Redaction", false, false)); + assertEquals(13, word(d, 2, "annotation", false, false)); + // whole word: \b boundaries now also find "PDF" glued to punctuation, e.g. "(PDF 1.7)", + // which the old token-equality matcher missed (6 -> 8, an intentional improvement). + assertEquals(8, word(d, 1, "PDF", false, true)); + assertEquals(8, page(d, 2, "redaction annotation", false, false)); + assertEquals(3, page(d, 1, "PDF Reference", false, false)); + d.dispose(); + } + + @DisplayName("find-and-select: a hit's word run maps to a selectable offset range") + @Test + public void findAndSelectRange() throws Exception { + Document d = doc(POEM); + DocumentSearchControllerImpl c = new DocumentSearchControllerImpl(d); + c.setSearchMode(SearchMode.PAGE); + c.addSearchTerm("todo el mundo", false, false, false); + assertTrue(c.searchHighlightPage(0) > 0); + + // gather the highlighted words (same instance the controller searched) in reading order. + PageText pageText = d.getPageViewText(0); + TextSequence seq = pageText.getTextSequence(); + WordText first = null, last = null; + for (LineText line : pageText.getPageLines()) { + for (WordText w : line.getWords()) { + if (w.isHighlighted()) { + if (first == null) first = w; + last = w; + } + } + } + assertNotNull(first); + assertNotNull(last); + + // the run maps to an offset range whose selected text is the found phrase (find-and-select). + OffsetRange range = OffsetRange.of(seq.rangeOf(first).getStart(), seq.rangeOf(last).getEnd()); + DocumentTextSelection selection = new DocumentTextSelection(); + selection.set(0, range.getStart(), 0, range.getEnd()); + assertTrue(TextSelectionSupport.selectedText(selection, d).replace(" ", "").contains("todoelmundo")); + d.dispose(); + } + + @DisplayName("PAGE mode highlights the matched words") + @Test + public void pageHighlightContent() throws Exception { + Document d = doc(POEM); + assertTrue(pageHighlighted(d, 0, "todo el mundo", false).contains("todoelmundo")); + d.dispose(); + } + + @DisplayName("result fragments + context padding") + @Test + public void resultFragmentsAndContext() throws Exception { + Document d = doc(POEM); + + // WORD mode: one fragment per hit, padded with surrounding context words. + DocumentSearchControllerImpl wordCtl = new DocumentSearchControllerImpl(d); + wordCtl.setSearchMode(SearchMode.WORD); + wordCtl.addSearchTerm("que", false, false); + List wordFragments = wordCtl.searchHighlightPage(0, 2); + assertEquals(4, wordFragments.size()); + for (LineText fragment : wordFragments) { + assertTrue(fragment.toString().toLowerCase().contains("que")); + assertTrue(fragment.getWords().size() > 1, "fragment should carry context words"); + } + + // PAGE mode: one fragment per phrase hit, with line context. + DocumentSearchControllerImpl pageCtl = new DocumentSearchControllerImpl(d); + pageCtl.setSearchMode(SearchMode.PAGE); + pageCtl.addSearchTerm("todo el mundo", false, false, false); + List pageFragments = pageCtl.searchHighlightPage(0, 0); + assertEquals(1, pageFragments.size()); + StringBuilder joined = new StringBuilder(); + pageFragments.get(0).getWords().forEach(w -> joined.append(w.getText())); + assertTrue(joined.toString().replace(" ", "").contains("todoelmundo")); + d.dispose(); + } +} diff --git a/viewer/viewer-awt/src/test/java/org/icepdf/selection/DocumentTextSelectionTest.java b/viewer/viewer-awt/src/test/java/org/icepdf/selection/DocumentTextSelectionTest.java new file mode 100644 index 000000000..a246ede9c --- /dev/null +++ b/viewer/viewer-awt/src/test/java/org/icepdf/selection/DocumentTextSelectionTest.java @@ -0,0 +1,174 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.icepdf.selection; + +import org.icepdf.core.pobjects.Document; +import org.icepdf.core.pobjects.graphics.text.GlyphText; +import org.icepdf.core.pobjects.graphics.text.OffsetRange; +import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.TextSequence; +import org.icepdf.ri.common.tools.TextSelectionSupport; +import org.icepdf.ri.common.views.DocumentTextSelection; +import org.icepdf.ri.util.FontPropertiesManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the viewer-side selection model and the core↔viewer bridge (Phase 2 Step 1): + * {@link DocumentTextSelection}, {@link TextSelectionSupport#rangeForPage}, + * {@link TextSelectionSupport#applySelectionToFlags}, and {@link TextSelectionSupport#selectedText}. + */ +public class DocumentTextSelectionTest { + + @BeforeAll + public static void init() { + FontPropertiesManager.getInstance().loadOrReadSystemFonts(); + } + + @DisplayName("DocumentTextSelection anchor/focus normalization") + @Test + public void selectionModel() { + DocumentTextSelection sel = new DocumentTextSelection(); + assertTrue(sel.isEmpty()); + + sel.collapseTo(2, 40); + assertFalse(sel.isEmpty()); + assertTrue(sel.isCollapsed()); + assertEquals(2, sel.startPage()); + assertEquals(40, sel.startOffset()); + + // forward extend + sel.extendTo(3, 10); + assertFalse(sel.isCollapsed()); + assertTrue(sel.isForward()); + assertEquals(2, sel.startPage()); + assertEquals(40, sel.startOffset()); + assertEquals(3, sel.endPage()); + assertEquals(10, sel.endOffset()); + + // backward extend on the same page -> start/end swap + sel.collapseTo(1, 50); + sel.extendTo(1, 20); + assertFalse(sel.isForward()); + assertEquals(20, sel.startOffset()); + assertEquals(50, sel.endOffset()); + + sel.clear(); + assertTrue(sel.isEmpty()); + } + + @DisplayName("rangeForPage: first page anchor->end, middle full, last start->focus") + @Test + public void rangeForPage() throws Exception { + TextSequence seq = pageText("/redact/test_print.pdf", 0).getTextSequence(); + int len = seq.length(); + + // selection spanning pages 1..3 + DocumentTextSelection sel = new DocumentTextSelection(); + sel.set(1, 30, 3, 12); + + assertNull(TextSelectionSupport.rangeForPage(sel, 0, seq), "page before selection"); + assertEquals(OffsetRange.of(30, len), TextSelectionSupport.rangeForPage(sel, 1, seq)); // first: anchor->end + assertEquals(OffsetRange.of(0, len), TextSelectionSupport.rangeForPage(sel, 2, seq)); // middle: full + assertEquals(OffsetRange.of(0, 12), TextSelectionSupport.rangeForPage(sel, 3, seq)); // last: start->focus + assertNull(TextSelectionSupport.rangeForPage(sel, 4, seq), "page after selection"); + + // single-page selection + DocumentTextSelection single = new DocumentTextSelection(); + single.set(0, 5, 0, 11); + assertEquals(OffsetRange.of(5, 11), TextSelectionSupport.rangeForPage(single, 0, seq)); + + // empty selection -> null everywhere + assertNull(TextSelectionSupport.rangeForPage(new DocumentTextSelection(), 0, seq)); + } + + @DisplayName("applySelectionToFlags marks exactly the covered glyphs") + @Test + public void applySelectionToFlags() throws Exception { + PageText pageText = pageText("/redact/test_print.pdf", 0); + TextSequence seq = pageText.getTextSequence(); + OffsetRange range = OffsetRange.of(10, 40); + + TextSelectionSupport.applySelectionToFlags(pageText, range); + + for (GlyphText g : seq.glyphsIn(seq.fullRange())) { + int start = seq.offsetOf(g); + boolean inRange = start >= range.getStart() && start < range.getEnd(); + assertEquals(inRange, g.isSelected(), + "glyph at offset " + start + " selected=" + g.isSelected() + " expected " + inRange); + } + + // clearing resets everything + TextSelectionSupport.applySelectionToFlags(pageText, null); + for (GlyphText g : seq.glyphsIn(seq.fullRange())) { + assertFalse(g.isSelected()); + } + } + + @DisplayName("selectedText walks the sequence, independent of node flags") + @Test + public void selectedText() throws Exception { + Document document = new Document(); + document.setFile(DocumentTextSelectionTest.class.getResource("/redact/test_print.pdf").getFile()); + TextSequence seq = document.getPageText(0).getTextSequence(); + + DocumentTextSelection sel = new DocumentTextSelection(); + sel.set(0, 0, 0, 20); + assertEquals(seq.text(0, 20), TextSelectionSupport.selectedText(sel, document)); + + // reversed anchor/focus yields the same normalized text + DocumentTextSelection rev = new DocumentTextSelection(); + rev.set(0, 20, 0, 0); + assertEquals(seq.text(0, 20), TextSelectionSupport.selectedText(rev, document)); + } + + @DisplayName("drag simulation: point -> caret -> range -> flags matches the model text") + @Test + public void dragSimulation() throws Exception { + PageText pageText = pageText("/redact/test_print.pdf", 0); + TextSequence seq = pageText.getTextSequence(); + + // two glyph centres on the first line (single-line selection, no newline) + GlyphText g1 = seq.glyphsIn(seq.fullRange()).get(5); + GlyphText g2 = seq.glyphsIn(seq.fullRange()).get(40); + int o1 = seq.caretAt(new java.awt.geom.Point2D.Double(g1.getBounds().getCenterX(), g1.getBounds().getCenterY())).getOffset(); + int o2 = seq.caretAt(new java.awt.geom.Point2D.Double(g2.getBounds().getCenterX(), g2.getBounds().getCenterY())).getOffset(); + + // mimic selectionStart/selection: collapseTo then extendTo, then write-through + DocumentTextSelection sel = new DocumentTextSelection(); + sel.collapseTo(0, o1); + sel.extendTo(0, o2); + OffsetRange range = TextSelectionSupport.rangeForPage(sel, 0, seq); + TextSelectionSupport.applySelectionToFlags(pageText, range); + + // the flag-based extraction (used by redaction / highlight / edit) matches the model text + // (getSelected() appends a trailing newline per selected line). + String flagText = pageText.getSelected().toString(); + if (flagText.endsWith("\n")) flagText = flagText.substring(0, flagText.length() - 1); + assertEquals(seq.text(range), flagText); + // and the model-based extraction agrees + assertEquals(seq.text(range), TextSelectionSupport.selectedText(sel, singlePageDoc())); + } + + private static Document singlePageDoc() throws Exception { + Document document = new Document(); + document.setFile(DocumentTextSelectionTest.class.getResource("/redact/test_print.pdf").getFile()); + return document; + } + + private static PageText pageText(String resource, int page) throws Exception { + Document document = new Document(); + document.setFile(DocumentTextSelectionTest.class.getResource(resource).getFile()); + return document.getPageText(page); + } +} diff --git a/viewer/viewer-awt/src/test/java/org/icepdf/selection/ReadingOrderCharacterizationTest.java b/viewer/viewer-awt/src/test/java/org/icepdf/selection/ReadingOrderCharacterizationTest.java new file mode 100644 index 000000000..5a78d6cdc --- /dev/null +++ b/viewer/viewer-awt/src/test/java/org/icepdf/selection/ReadingOrderCharacterizationTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.icepdf.selection; + +import org.icepdf.core.pobjects.Document; +import org.icepdf.core.pobjects.graphics.text.LineText; +import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.WordText; +import org.icepdf.ri.util.FontPropertiesManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.geom.Rectangle2D; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Characterization (golden-snapshot) harness that pins the current page reading order produced by + * {@link PageText#getPageLines()} across a small corpus that spans the hard cases: a single-column + * page drawn out of plot order (xr_650 p6), vertical-plus-horizontal mixed text (2005CAT p1), a + * plain single column (test_print), a dense reference layout, and a couple of general documents. + *

+ * This is a safety net, not a correctness assertion: the golden file records what the extractor does + * today. It exists so that a future reading-order change (e.g. an XY-cut / projection-profile + * column detector replacing the {@code preserveColumns} plot-order heuristic) produces a reviewable, + * line-by-line diff over real pages before anything ships — the same discipline the selection and + * search work has used throughout, and doubly important because {@code getPageLines()} feeds selection, + * search, and redaction. + *

+ * To regenerate after an intentional change: + *

./gradlew :viewer:viewer-awt:test --tests '*ReadingOrderCharacterizationTest' -Dupdate.reading.order.golden=true
+ * then review the diff to {@code src/test/resources/selection/reading-order-golden.txt} and commit it. + */ +public class ReadingOrderCharacterizationTest { + + /** {resource, zero-based page index, short label}. */ + private static final String[][] FIXTURES = { + {"/redact/xr_650.pdf", "5", "xr_650 p6 - single column drawn out of order (Environmental/Programs/legal)"}, + {"/redact/windrivercasestudy1n3d2m8km0r.pdf", "1", "windriver p2 - clean two-column body + full-width footer"}, + {"/redact/2005CAT.pdf", "0", "2005CAT p1 - vertical + horizontal mixed"}, + {"/redact/test_print.pdf", "0", "test_print p1 - plain single column"}, + {"/redact/pdf_reference_addendum_redaction.pdf", "0", "reference addendum p1 - dense reference layout"}, + {"/redact/libre-test.pdf", "0", "libre-test p1"}, + {"/redact/potato_out.pdf", "0", "potato_out p1"}, + }; + + private static final String GOLDEN_RESOURCE = "/selection/reading-order-golden.txt"; + private static final String GOLDEN_SOURCE_PATH = "src/test/resources/selection/reading-order-golden.txt"; + + @BeforeAll + public static void init() { + FontPropertiesManager.getInstance().loadOrReadSystemFonts(); + } + + @DisplayName("reading order snapshot is stable across the corpus") + @Test + public void readingOrderSnapshot() throws Exception { + String actual = buildSnapshot(); + + boolean update = Boolean.getBoolean("update.reading.order.golden"); + String golden = readGolden(); + + if (update || golden == null) { + writeGolden(actual); + if (golden == null && !update) { + fail("No golden snapshot found; generated " + GOLDEN_SOURCE_PATH + + ". Review it and re-run to lock the baseline."); + } + return; // regenerated on request + } + assertEquals(golden, actual, + "Reading order drifted from the golden snapshot. If the change is intentional, " + + "regenerate with -Dupdate.reading.order.golden=true and review the diff."); + } + + /** Normalized, human-readable dump of the reading order for every fixture. */ + private static String buildSnapshot() throws Exception { + StringBuilder out = new StringBuilder(); + for (String[] fx : FIXTURES) { + String resource = fx[0]; + int page = Integer.parseInt(fx[1]); + out.append("=== ").append(fx[2]).append(" [").append(resource) + .append(" p").append(page).append("] ===\n"); + dumpPage(resource, page, out); + out.append('\n'); + } + return out.toString(); + } + + private static void dumpPage(String resource, int page, StringBuilder out) throws Exception { + Document document = new Document(); + try { + document.setFile(ReadingOrderCharacterizationTest.class.getResource(resource).getFile()); + PageText pt = document.getPageText(page); + if (pt == null) { + out.append(" \n"); + return; + } + List lines = pt.getPageLines(); + int i = 0; + for (LineText line : lines) { + String text = collapse(lineText(line)); + if (text.isEmpty()) continue; + Rectangle2D.Double b = line.getBounds(); + out.append(String.format(" L%03d y=%7.1f x=%7.1f..%-7.1f %s%n", + i++, b.y, b.x, b.x + b.width, truncate(text, 60))); + } + } finally { + document.dispose(); + } + } + + private static String lineText(LineText line) { + StringBuilder sb = new StringBuilder(); + for (WordText w : line.getWords()) { + sb.append(w.getText()); + } + return sb.toString(); + } + + private static String collapse(String s) { + return s.replaceAll("\\s+", " ").trim(); + } + + private static String truncate(String s, int max) { + return s.length() > max ? s.substring(0, max) : s; + } + + private static String readGolden() throws IOException { + try (InputStream in = ReadingOrderCharacterizationTest.class.getResourceAsStream(GOLDEN_RESOURCE)) { + if (in == null) return null; + byte[] bytes = in.readAllBytes(); + return new String(bytes, StandardCharsets.UTF_8); + } + } + + private static void writeGolden(String content) throws IOException { + Path path = Path.of(GOLDEN_SOURCE_PATH); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + System.out.println("Wrote reading-order golden snapshot to " + path.toAbsolutePath()); + } +} diff --git a/viewer/viewer-awt/src/test/java/org/icepdf/selection/ReadingOrderXYCutTest.java b/viewer/viewer-awt/src/test/java/org/icepdf/selection/ReadingOrderXYCutTest.java new file mode 100644 index 000000000..cff1a94d4 --- /dev/null +++ b/viewer/viewer-awt/src/test/java/org/icepdf/selection/ReadingOrderXYCutTest.java @@ -0,0 +1,197 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.icepdf.selection; + +import org.icepdf.core.pobjects.Document; +import org.icepdf.core.pobjects.graphics.text.LineText; +import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.WordText; +import org.icepdf.core.pobjects.graphics.text.XYCutReadingOrder; +import org.icepdf.ri.util.FontPropertiesManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.geom.Rectangle2D; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Characterization + correctness harness for the {@link XYCutReadingOrder} geometry-driven reading + * order (see {@code READING-ORDER-XYCUT-PLAN.md}). + *

+ * The algorithm is exercised directly on the default (plot-order) extracted lines rather than via the + * {@code org.icepdf.core.views.page.text.readingOrder=xycut} system property: that property is read + * once per JVM into a static, so a property-driven test cannot coexist with the default-mode + * {@link ReadingOrderCharacterizationTest} in the same JVM. Calling {@code order()} directly is + * exactly what {@code PageText} does internally in XYCUT mode. + *

+ * Golden regeneration: + *

./gradlew :viewer:viewer-awt:test --tests '*ReadingOrderXYCutTest' -Dupdate.reading.order.xycut.golden=true
+ */ +public class ReadingOrderXYCutTest { + + private static final String[][] FIXTURES = { + {"/redact/xr_650.pdf", "5", "xr_650 p6 - single column drawn out of order (Environmental/Programs/legal)"}, + {"/redact/windrivercasestudy1n3d2m8km0r.pdf", "1", "windriver p2 - clean two-column body + full-width footer"}, + {"/redact/2005CAT.pdf", "0", "2005CAT p1 - vertical + horizontal mixed"}, + {"/redact/test_print.pdf", "0", "test_print p1 - plain single column"}, + {"/redact/pdf_reference_addendum_redaction.pdf", "0", "reference addendum p1 - dense reference layout"}, + {"/redact/libre-test.pdf", "0", "libre-test p1"}, + {"/redact/potato_out.pdf", "0", "potato_out p1"}, + }; + + private static final String GOLDEN_RESOURCE = "/selection/reading-order-xycut-golden.txt"; + private static final String GOLDEN_SOURCE_PATH = "src/test/resources/selection/reading-order-xycut-golden.txt"; + + @BeforeAll + public static void init() { + FontPropertiesManager.getInstance().loadOrReadSystemFonts(); + } + + @DisplayName("XY-cut reading order snapshot is stable across the corpus") + @Test + public void xyCutSnapshot() throws Exception { + StringBuilder out = new StringBuilder(); + for (String[] fx : FIXTURES) { + out.append("=== ").append(fx[2]).append(" [").append(fx[0]).append(" p").append(fx[1]).append("] ===\n"); + dump(orderedLines(fx[0], Integer.parseInt(fx[1])), out); + out.append('\n'); + } + String actual = out.toString(); + + boolean update = Boolean.getBoolean("update.reading.order.xycut.golden"); + String golden = readGolden(); + if (update || golden == null) { + writeGolden(actual); + if (golden == null && !update) { + fail("No XY-cut golden found; generated " + GOLDEN_SOURCE_PATH + ". Review and re-run."); + } + return; + } + assertEquals(golden, actual, + "XY-cut reading order drifted. If intentional, regenerate with " + + "-Dupdate.reading.order.xycut.golden=true and review the diff."); + } + + @DisplayName("xr_650 p6: out-of-order single column reads top-to-bottom (Environmental before Programs)") + @Test + public void xr650SingleColumnOrdered() throws Exception { + List order = texts(orderedLines("/redact/xr_650.pdf", 5)); + assertTrue(indexOfContaining(order, "ENVIRONMENTAL") < indexOfContaining(order, "PROGRAMS"), + "Environmental Commitment (higher on page) must read before Programs that Perform"); + assertTrue(indexOfContaining(order, "PROGRAMS") < indexOfContaining(order, "Specifications"), + "Programs block must read before the legal print below it"); + } + + @DisplayName("windriver p2: left column fully precedes right column (no interleaving)") + @Test + public void windriverColumnsNotInterleaved() throws Exception { + List lines = orderedLines("/redact/windrivercasestudy1n3d2m8km0r.pdf", 1); + // last index of a left-column body line must come before the first right-column body line. + // columns split around x~310; ignore the full-width footer band (y < 60). + int lastLeft = -1, firstRight = Integer.MAX_VALUE; + for (int i = 0; i < lines.size(); i++) { + Rectangle2D.Double b = lines.get(i).getBounds(); + if (b.getMaxY() < 60) continue; // footer band, not part of the column body + if (b.getCenterX() < 310) lastLeft = Math.max(lastLeft, i); + else firstRight = Math.min(firstRight, i); + } + assertTrue(lastLeft >= 0 && firstRight < Integer.MAX_VALUE, "expected two columns"); + assertTrue(lastLeft < firstRight, + "every left-column line must read before every right-column line (got lastLeft=" + + lastLeft + " firstRight=" + firstRight + ")"); + } + + @DisplayName("2005CAT p1: vertical INTRODUCTION label is not reversed (reads bottom-to-top)") + @Test + public void verticalLabelPreserved() throws Exception { + List lines = orderedLines("/redact/2005CAT.pdf", 0); + // the vertical run is the skinny left-margin stack (x < 40, single glyphs) + List vy = new ArrayList<>(); + for (LineText l : lines) { + Rectangle2D.Double b = l.getBounds(); + if (b.getMaxX() < 40) vy.add((int) b.getY()); + } + assertTrue(vy.size() >= 5, "expected the vertical INTRODUCTION stack"); + // vertical text here reads bottom-to-top, so emitted y should be ascending (not reversed) + for (int i = 1; i < vy.size(); i++) { + assertTrue(vy.get(i) >= vy.get(i - 1), + "vertical label must stay in bottom-to-top order, not be reversed by a y-sort"); + } + } + + // ------------------------------------------------------------------ + + private static List orderedLines(String resource, int page) throws Exception { + Document document = new Document(); + try { + document.setFile(ReadingOrderXYCutTest.class.getResource(resource).getFile()); + PageText pt = document.getPageText(page); + return XYCutReadingOrder.order(pt.getPageLines()); + } finally { + document.dispose(); + } + } + + private static List texts(List lines) { + List out = new ArrayList<>(); + for (LineText line : lines) { + String t = lineText(line); + if (!t.isEmpty()) out.add(t); + } + return out; + } + + private static int indexOfContaining(List texts, String needle) { + for (int i = 0; i < texts.size(); i++) { + if (texts.get(i).replace(" ", "").contains(needle.replace(" ", ""))) return i; + } + return Integer.MAX_VALUE; + } + + private static void dump(List lines, StringBuilder out) { + int i = 0; + for (LineText line : lines) { + String text = lineText(line); + if (text.isEmpty()) continue; + Rectangle2D.Double b = line.getBounds(); + out.append(String.format(" L%03d y=%7.1f x=%7.1f..%-7.1f %s%n", + i++, b.y, b.x, b.x + b.width, text.length() > 60 ? text.substring(0, 60) : text)); + } + } + + private static String lineText(LineText line) { + StringBuilder sb = new StringBuilder(); + for (WordText w : line.getWords()) sb.append(w.getText()); + return sb.toString().replaceAll("\\s+", " ").trim(); + } + + private static String readGolden() throws IOException { + try (InputStream in = ReadingOrderXYCutTest.class.getResourceAsStream(GOLDEN_RESOURCE)) { + if (in == null) return null; + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void writeGolden(String content) throws IOException { + Path path = Path.of(GOLDEN_SOURCE_PATH); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + System.out.println("Wrote XY-cut golden snapshot to " + path.toAbsolutePath()); + } +} diff --git a/viewer/viewer-awt/src/test/java/org/icepdf/selection/TextSequenceTest.java b/viewer/viewer-awt/src/test/java/org/icepdf/selection/TextSequenceTest.java new file mode 100644 index 000000000..096e83545 --- /dev/null +++ b/viewer/viewer-awt/src/test/java/org/icepdf/selection/TextSequenceTest.java @@ -0,0 +1,287 @@ +/* + * Copyright 2026 Patrick Corless + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.icepdf.selection; + +import org.icepdf.core.pobjects.Document; +import org.icepdf.core.pobjects.graphics.text.Caret; +import org.icepdf.core.pobjects.graphics.text.GlyphText; +import org.icepdf.core.pobjects.graphics.text.OffsetRange; +import org.icepdf.core.pobjects.graphics.text.PageText; +import org.icepdf.core.pobjects.graphics.text.TextSequence; +import org.icepdf.ri.util.FontPropertiesManager; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the core {@link TextSequence} reading-order primitive (Phase 2 Step 1). + * Lives in the viewer test module because building the sequence needs page text extraction, + * which needs the font manager (viewer-side) and the viewer test PDFs. + */ +public class TextSequenceTest { + + @BeforeAll + public static void init() { + FontPropertiesManager.getInstance().loadOrReadSystemFonts(); + } + + private static PageText pageText(String resource, int page) throws Exception { + Document document = new Document(); + document.setFile(TextSequenceTest.class.getResource(resource).getFile()); + return document.getPageText(page); + } + + @DisplayName("reading order + offset/geometry invariants on test_print.pdf") + @Test + public void invariants_testPrint() throws Exception { + TextSequence seq = pageText("/redact/test_print.pdf", 0).getTextSequence(); + + // canonical reading order + assertEquals(6, seq.lineCount()); + assertTrue(seq.text().toString().startsWith("Qué es un antipoeta:"), + "unexpected reading order: " + seq.text()); + assertEquals(seq.text().toString(), seq.text(0, seq.length())); // select-all identity + assertEquals(seq.text().length(), seq.length()); + + // glyphAt / offsetOf round-trip for every glyph + for (int off = 0; off < seq.length(); off++) { + GlyphText g = seq.glyphAt(off); + if (g != null) { + int start = seq.offsetOf(g); + assertTrue(off >= start, "glyphAt(" + off + ") maps to glyph starting after the offset"); + assertSame(g, seq.glyphAt(start), "offsetOf/glyphAt not consistent"); + } + } + + // glyphsIn(full) covers every glyph exactly once + assertEquals(seq.glyphCount(), seq.glyphsIn(seq.fullRange()).size()); + + // caretAt totality across a grid incl. off-page margins + Rectangle2D pb = bounds(seq); + for (int gx = -1; gx <= 11; gx++) { + for (int gy = -1; gy <= 11; gy++) { + double x = pb.getMinX() + pb.getWidth() * gx / 10.0; + double y = pb.getMinY() + pb.getHeight() * gy / 10.0; + Caret c = seq.caretAt(new Point2D.Double(x, y)); + assertNotNull(c); + assertTrue(c.getOffset() >= 0 && c.getOffset() <= seq.length()); + } + } + + // caretAt round-trip: a glyph's centre resolves to a caret on that glyph's span + for (GlyphText g : seq.glyphsIn(seq.fullRange())) { + Rectangle2D.Double b = g.getBounds(); + if (b.width <= 0 || b.height <= 0) continue; + Caret c = seq.caretAt(new Point2D.Double(b.getCenterX(), b.getCenterY())); + int start = seq.offsetOf(g); + assertTrue(c.getOffset() >= start && c.getOffset() <= start + g.getUnicode().length(), + "caret " + c.getOffset() + " off glyph span for '" + g.getUnicode() + "'"); + } + + // a single word -> exactly one merged line rect + OffsetRange word = seq.wordRange(0); + assertEquals(1, seq.rectsFor(word).size()); + + // cross-line span -> one rect per touched line (first/last clipped by construction) + int midA = seq.lineRange(0).getEnd() - 6; // near end of line 0 + int line2Start = seq.lineRange(seq.length() / 2).getStart(); // start of some interior line + List frag = seq.rectsFor(midA, line2Start + 4); + assertTrue(frag.size() >= 2, "cross-line selection should span multiple line rects"); + } + + @DisplayName("caretAt hits the clicked glyph on a multi-column / vertical-text page (2005CAT.pdf)") + @Test + public void caretAtMultiColumn() throws Exception { + // Layout with vertical text and columns: a y-only line lookup selects the wrong word. + // Every glyph's own centre must resolve to a caret on that glyph. + TextSequence seq = pageText("/redact/2005CAT.pdf", 0).getTextSequence(); + int total = 0, wrong = 0; + for (GlyphText g : seq.glyphsIn(seq.fullRange())) { + Rectangle2D.Double b = g.getBounds(); + if (b.width <= 0 || b.height <= 0) continue; + total++; + int off = seq.caretAt(new Point2D.Double(b.getCenterX(), b.getCenterY())).getOffset(); + int start = seq.offsetOf(g); + if (!(off >= start && off <= start + g.getUnicode().length())) wrong++; + } + assertTrue(total > 0); + assertEquals(0, wrong, wrong + "/" + total + " glyph centres resolved to the wrong caret"); + } + + @DisplayName("caret navigation primitives (nextBoundary / caretAbove-Below / caretAtLine / caretRect)") + @Test + public void navigation() throws Exception { + TextSequence seq = pageText("/redact/test_print.pdf", 0).getTextSequence(); + + // glyph boundary: +/- 1 + assertEquals(6, seq.nextBoundary(5, org.icepdf.core.pobjects.graphics.text.BreakType.GLYPH, true)); + assertEquals(4, seq.nextBoundary(5, org.icepdf.core.pobjects.graphics.text.BreakType.GLYPH, false)); + + // word boundary at start of the first word "Qué" + OffsetRange firstWord = seq.wordRange(0); + assertEquals(firstWord.getEnd(), seq.nextBoundary(0, org.icepdf.core.pobjects.graphics.text.BreakType.WORD, true)); + + // line boundary + OffsetRange line0 = seq.lineRange(3); + assertEquals(line0.getEnd(), seq.nextBoundary(3, org.icepdf.core.pobjects.graphics.text.BreakType.LINE, true)); + assertEquals(line0.getStart(), seq.nextBoundary(3, org.icepdf.core.pobjects.graphics.text.BreakType.LINE, false)); + + // vertical navigation moves one sorted line at the goal column + int mid = seq.lineRange(seq.length() / 2).getStart() + 3; + int midLine = seq.lineIndexOf(mid); + double goalX = seq.caretRect(new Caret(mid, org.icepdf.core.pobjects.graphics.text.Bias.FORWARD)).getX(); + Caret below = seq.caretBelow(new Caret(mid, org.icepdf.core.pobjects.graphics.text.Bias.FORWARD), goalX); + Caret above = seq.caretAbove(new Caret(mid, org.icepdf.core.pobjects.graphics.text.Bias.FORWARD), goalX); + assertNotNull(below); + assertNotNull(above); + assertEquals(midLine + 1, seq.lineIndexOf(below.getOffset())); + assertEquals(midLine - 1, seq.lineIndexOf(above.getOffset())); + + // top line has nothing above, last line nothing below + assertNull(seq.caretAbove(new Caret(seq.lineRange(0).getStart(), org.icepdf.core.pobjects.graphics.text.Bias.FORWARD), goalX)); + assertNull(seq.caretBelow(new Caret(seq.length(), org.icepdf.core.pobjects.graphics.text.Bias.FORWARD), goalX)); + + // caretAtLine lands on the requested line; caretRect is a zero-width bar with height + assertEquals(2, seq.lineIndexOf(seq.caretAtLine(2, goalX).getOffset())); + Rectangle2D.Double bar = seq.caretRect(new Caret(mid, org.icepdf.core.pobjects.graphics.text.Bias.FORWARD)); + assertEquals(0.0, bar.width); + assertTrue(bar.height > 0); + } + + @DisplayName("shift-down onto a shorter next line still advances (goalX overshoots the short line)") + @Test + public void caretBelowOntoShorterLine() throws Exception { + TextSequence seq = pageText("/redact/test_print.pdf", 0).getTextSequence(); + org.icepdf.core.pobjects.graphics.text.Bias back = org.icepdf.core.pobjects.graphics.text.Bias.BACKWARD; + // find a line immediately followed by a shorter one (the classic end-of-paragraph case) + int longLine = -1; + for (int i = 0; i < seq.lineCount() - 1; i++) { + double rightThis = lineRightX(seq, i); + double rightNext = lineRightX(seq, i + 1); + if (rightThis > rightNext + 2) { + longLine = i; + break; + } + } + assertTrue(longLine >= 0, "test PDF should have a line followed by a shorter one"); + // caret at end of the long line, goalX at its right edge (past the shorter line's end) + int endOfLong = seq.caretAtLine(longLine, Double.MAX_VALUE).getOffset(); + int endOfNext = seq.caretAtLine(longLine + 1, Double.MAX_VALUE).getOffset(); + double goalX = seq.caretRect(new Caret(endOfLong, back)).getX(); + Caret below = seq.caretBelow(new Caret(endOfLong, back), goalX); + assertNotNull(below); + // must land on the shorter next line, not snap back to the long line, clamped to its end + assertEquals(longLine + 1, seq.lineIndexOf(below.getOffset())); + assertEquals(endOfNext, below.getOffset()); + } + + private static double lineRightX(TextSequence seq, int lineIndex) { + int end = seq.caretAtLine(lineIndex, Double.MAX_VALUE).getOffset(); + return seq.caretRect(new Caret(end, org.icepdf.core.pobjects.graphics.text.Bias.BACKWARD)).getX(); + } + + @DisplayName("search corpus: whitespace collapsed, maps back to canonical range/words") + @Test + public void searchCorpus() throws Exception { + TextSequence seq = pageText("/redact/test_print.pdf", 0).getTextSequence(); + String corpus = seq.searchText(); + + // collapsed: no double spaces, no newlines, not longer than canonical + assertFalse(corpus.contains(" "), "runs of whitespace should collapse"); + assertFalse(corpus.contains("\n"), "newlines should collapse to spaces"); + assertTrue(corpus.length() <= seq.length()); + assertTrue(corpus.contains("todo el mundo"), "collapsed corpus should contain the phrase"); + + // a match in the corpus maps back to a canonical range covering the phrase words + int start = corpus.indexOf("todo el mundo"); + OffsetRange canonical = seq.searchToCanonicalRange(start, start + "todo el mundo".length()); + String mapped = seq.text(canonical); + assertTrue(mapped.contains("todo") && mapped.contains("mundo"), "mapped canonical text: " + mapped); + StringBuilder words = new StringBuilder(); + seq.wordsIn(canonical).forEach(w -> words.append(w.getText())); + assertTrue(words.toString().replace(" ", "").contains("todoelmundo"), + "wordsIn should cover the phrase: " + words); + } + + @DisplayName("folded search corpus: accents removed, maps back to accented canonical text") + @Test + public void foldedCorpus() throws Exception { + TextSequence seq = pageText("/redact/test_print.pdf", 0).getTextSequence(); + + assertEquals("ataudes", TextSequence.foldDiacritics("ataúdes")); + assertEquals("PDF", TextSequence.foldDiacritics("PDF")); // ASCII unchanged + + String folded = seq.foldedSearchText(); + assertTrue(folded.contains("ataudes"), "folded corpus should drop accents: " + folded.substring(0, 40)); + assertFalse(folded.contains("ataúdes"), "folded corpus should not retain accented form"); + + // an unaccented match maps back to the accented canonical text + int start = folded.indexOf("ataudes"); + OffsetRange canonical = seq.foldedToCanonicalRange(start, start + "ataudes".length()); + assertEquals("ataúdes", seq.text(canonical)); + } + + @DisplayName("robustness sweep on dense/table doc pdf_reference_addendum_redaction.pdf") + @Test + public void sweep_addendum() throws Exception { + Document document = new Document(); + document.setFile(TextSequenceTest.class.getResource("/redact/pdf_reference_addendum_redaction.pdf").getFile()); + Random rnd = new Random(42); + int pagesWithText = 0; + for (int pi = 0; pi < document.getNumberOfPages(); pi++) { + PageText pt = document.getPageText(pi); + if (pt == null) continue; + TextSequence seq = pt.getTextSequence(); + if (seq.isEmpty()) continue; + pagesWithText++; + + Rectangle2D pb = bounds(seq); + for (int gx = -1; gx <= 11; gx++) { + for (int gy = -1; gy <= 11; gy++) { + Caret c = seq.caretAt(new Point2D.Double( + pb.getMinX() + pb.getWidth() * gx / 10.0, + pb.getMinY() + pb.getHeight() * gy / 10.0)); + assertTrue(c.getOffset() >= 0 && c.getOffset() <= seq.length()); + } + } + for (int t = 0; t < 25; t++) { + GlyphText g1 = seq.glyphsIn(seq.fullRange()).get(rnd.nextInt(seq.glyphCount())); + GlyphText g2 = seq.glyphsIn(seq.fullRange()).get(rnd.nextInt(seq.glyphCount())); + int o1 = seq.caretAt(center(g1)).getOffset(); + int o2 = seq.caretAt(center(g2)).getOffset(); + int lo = Math.min(o1, o2), hi = Math.max(o1, o2); + assertEquals(seq.text().subSequence(lo, hi).toString(), seq.text(lo, hi), + "page " + pi + " selection not a contiguous substring"); + assertTrue(seq.rectsFor(lo, hi).size() <= seq.lineCount()); + } + } + assertTrue(pagesWithText > 0); + } + + private static Rectangle2D bounds(TextSequence seq) { + Rectangle2D b = null; + for (GlyphText g : seq.glyphsIn(seq.fullRange())) { + b = b == null ? (Rectangle2D) g.getBounds().clone() : b.createUnion(g.getBounds()); + } + return b == null ? new Rectangle2D.Double() : b; + } + + private static Point2D center(GlyphText g) { + Rectangle2D.Double b = g.getBounds(); + return new Point2D.Double(b.getCenterX(), b.getCenterY()); + } +} diff --git a/viewer/viewer-awt/src/test/resources/redact/windrivercasestudy1n3d2m8km0r.pdf b/viewer/viewer-awt/src/test/resources/redact/windrivercasestudy1n3d2m8km0r.pdf new file mode 100644 index 000000000..2e77b23ad Binary files /dev/null and b/viewer/viewer-awt/src/test/resources/redact/windrivercasestudy1n3d2m8km0r.pdf differ diff --git a/viewer/viewer-awt/src/test/resources/selection/reading-order-golden.txt b/viewer/viewer-awt/src/test/resources/selection/reading-order-golden.txt new file mode 100644 index 000000000..44dfe36c2 --- /dev/null +++ b/viewer/viewer-awt/src/test/resources/selection/reading-order-golden.txt @@ -0,0 +1,174 @@ +=== xr_650 p6 - single column drawn out of order (Environmental/Programs/legal) [/redact/xr_650.pdf p5] === + L000 y= 471.1 x= 143.9..432.0 P R O G R A M S T H A T P E R F O R M + L001 y= 453.9 x= 143.9..480.0 You chose your new Honda XR or XRL because it has so many gr + L002 y= 441.9 x= 143.9..480.0 all of the programs available to you as a Honda owner. Take + L003 y= 429.9 x= 143.9..480.0 let you extend virtually all of your Honda’s great warranty + L004 y= 417.9 x= 143.9..480.0 America.† Open to all Honda owners,†† the real-world benefit + L005 y= 405.9 x= 143.9..480.0 HRCA. Interested in some accessories to make your XR or XRL + L006 y= 393.9 x= 143.9..445.4 selection of Honda Genuine Accessories. Time for service? Be + L007 y= 393.9 x= 444.6..480.0 ™ Oils and + L008 y= 381.9 x= 143.9..480.0 Chemicals. And if you’re looking for a way to pay for your n + L009 y= 369.9 x= 143.9..480.0 gestions. First, ask about the American Honda Finance Corpor + L010 y= 357.9 x= 143.9..454.6 can set everything up for you right in the showroom. Another + L011 y= 357.9 x= 454.6..480.0 ™ revolv- + L012 y= 345.9 x= 143.9..480.0 ing charge card.‡‡ You can use the Honda Card to purchase Ho + L013 y= 333.9 x= 143.9..480.0 And make sure to ask your Honda Dealer about MSF rider train + L014 y= 321.9 x= 143.9..480.0 we think our family of Honda off-road motorcycles is the bes + L015 y= 309.9 x= 143.9..254.6 grams and support to go with them. + L016 y= 277.6 x= 143.9..480.0 Specifications, programs and availability subject to change + L017 y= 268.6 x= 143.9..480.0 specifications in this brochure—including colors, warranty t + L018 y= 259.6 x= 143.9..480.0 States. ◊ Mounting brackets required. * Maximum reimbursemen + L019 y= 250.6 x= 143.9..480.0 American Honda Service Contract Corporation in the state of + L020 y= 241.6 x= 143.9..480.0 motorcycles purchased from participating dealers in the U.S. + L021 y= 232.6 x= 143.9..480.0 approved credit by AHFC. ‡‡ Financing available to qualified + L022 y= 223.6 x= 143.9..480.0 Honda Card program at participating dealers. California vers + L023 y= 214.6 x= 143.9..480.0 are standard equipment on all Honda streetbikes. Performance + L024 y= 205.6 x= 143.9..481.3 Honda Genuine Accessories,™ HRCA,null Honda Rider’s Club of + L025 y= 196.6 x= 143.9..403.7 of Honda Motor Co., Ltd. ©2003 American Honda Motor Co., Inc + L026 y= 569.4 x= 144.0..476.5 E N V I R O N M E N T A L C O M M I T M E N T + L027 y= 552.1 x= 144.0..480.0 At Honda, we believe in performance and leadership, and that + L028 y= 540.1 x= 144.0..480.0 it comes to the environment. We continue to develop low-emis + L029 y= 528.1 x= 144.0..480.0 motorcycles, ATVs, scooters and personal watercraft. We alre + L030 y= 516.1 x= 144.0..480.0 2008 CARB emissions requirements years ahead of schedule. An + L031 y= 504.1 x= 144.0..192.9 can appreciate. + L032 y= 97.9 x= 331.5..396.8 honda.com + +=== windriver p2 - clean two-column body + full-width footer [/redact/windrivercasestudy1n3d2m8km0r.pdf p1] === + L000 y= 46.0 x= 76.7..153.5 Macrovision Corporation + L001 y= 37.0 x= 76.7..232.2 2830 De La Cruz Boulevard, Santa Clara, CA 95050 + L002 y= 28.0 x= 76.7..223.1 US +1 888-755-0861 Int’l +44 (0) 870-873-6300 + L003 y= 19.0 x= 76.7..145.9 www.macrovision .com + L004 y= 9.6 x= 76.8..525.8 ©2004 Macrovision Corporation. Macrovision and FLEXnet are r + L005 y= 4.1 x= 76.8..471.6 and VxWorks is a registered trademark of Wind River Systems, + L006 y= 670.6 x= 77.1..283.6 information helps both parties improve their support + L007 y= 653.6 x= 77.1..281.3 plans, for example training seminars, services en- + L008 y= 636.6 x= 77.1..251.9 gagements, and project resource allocation. + L009 y= 612.6 x= 77.1..291.6 “One of the key benefits of the Macrovision solution is + L010 y= 595.6 x= 77.1..295.0 that it provides easy access to technology. Second, it is + L011 y= 578.6 x= 77.1..296.9 a very objective approach to licensing. It’s not about how + L012 y= 561.6 x= 77.1..290.2 many concurrent users you might have, or about how + L013 y= 544.6 x= 77.1..298.1 many license servers you need, or how many machines + L014 y= 527.6 x= 77.1..286.6 the product is installed on—it really comes down to + L015 y= 510.6 x= 77.1..293.8 making sure that software developers have access to + L016 y= 493.6 x= 77.1..199.5 our software,” explains Hogan. + L017 y= 469.6 x= 77.1..292.8 Hogan says Wind River has been using Macrovision’s + L018 y= 452.6 x= 77.1..296.1 licensing solutions for more than 3 years, and is planning + L019 y= 435.6 x= 77.1..296.8 to migrate to the next Macrovision release in 2004. Hogan + L020 y= 418.6 x= 77.1..299.5 says that Macrovision has done a good job of making its + L021 y= 401.6 x= 77.1..292.1 software easy to implement and easy to embed in its + L022 y= 384.6 x= 77.1..300.1 products. “At the end of the day, utility pricing, although + L023 y= 367.6 x= 77.1..298.6 gaining in popularity, is still relatively new for customers + L024 y= 350.6 x= 77.1..179.4 and software publishers .” + L025 y= 326.6 x= 77.1..298.7 Hogan points out one caveat that other software vendors + L026 y= 309.6 x= 77.1..293.7 should consider when evaluating this approach: “Not + L027 y= 292.6 x= 77.1..300.0 all buyers will be attracted to utility pricing so publisher + L028 y= 275.6 x= 77.1..298.1 should view it as an extension to their current offerings + L029 y= 258.6 x= 77.1..188.1 rather than a replacement .” + L030 y= 234.6 x= 77.1..298.6 Hogan also cites three key questions for software vendors + L031 y= 217.6 x= 77.1..285.4 considering utility pricing: First, what market forces + L032 y= 200.6 x= 77.1..295.8 are driving you and your customers toward utility-based + L033 y= 183.6 x= 77.1..296.6 pricing? Second, what’s the utility you’re trying to price + L034 y= 166.6 x= 77.1..288.9 and why have you chosen it over others? Third, who’s + L035 y= 149.6 x= 77.1..298.0 asking for it and why? His recommendation is to evaluate + L036 y= 132.6 x= 77.1..287.6 the strengths and weaknesses of traditional models, + L037 y= 115.6 x= 77.1..296.5 and then determine what is most important to a specific + L038 y= 98.6 x= 77.1..130.1 market need. + L039 y= 670.3 x= 319.4..527.4 Says Hogan, “A major benefit of Macrovision’s utility + L040 y= 653.3 x= 319.4..515.4 pricing is being able to create a new offering for + L041 y= 636.3 x= 319.4..531.2 existing and new buyers to consider. And that usually + L042 y= 619.3 x= 319.4..530.0 translates into new revenue streams and an increasing + L043 y= 602.3 x= 319.4..514.1 ability to be more responsive to your customers .” + L044 y= 578.3 x= 319.4..396.0 About Macrovision + L045 y= 561.3 x= 319.4..531.9 Macrovision Corporation (Nasdaq:MVSN) is the market + L046 y= 544.3 x= 319.4..520.7 leader in electronic licensing, copy protection, and + L047 y= 527.3 x= 319.4..519.0 digital rights management ("DRM") technologies. + L048 y= 510.3 x= 319.4..520.0 Analysts recognize Macrovision as the clear market + L049 y= 493.3 x= 319.4..530.7 leader, and it's estimated that the company sells more + L050 y= 476.3 x= 319.4..531.1 software-based licensing solutions than all its compet- + L051 y= 459.3 x= 319.4..529.0 itors combined. Macrovision FLEXnet, the world's first + L052 y= 442.3 x= 319.4..523.2 universal licensing platform, enables customers to + L053 y= 425.3 x= 319.4..512.3 easily price, package and protect their software. + L054 y= 408.3 x= 319.4..518.7 More than 3000 software publishers have shipped + L055 y= 391.3 x= 319.4..371.7 FLEXenabled + L056 y= 391.3 x= 371.7..520.7 tm software, and hundreds of Fortune + L057 y= 374.3 x= 319.4..519.1 1000 companies use Macrovision technologies to + L058 y= 357.3 x= 319.4..530.4 better manage their software licenses. The company + L059 y= 340.3 x= 319.4..524.6 holds over 700 software licensing and DRM patents + L060 y= 323.3 x= 319.4..525.7 worldwide, and has been ranked by Business 2.0 as + L061 y= 306.3 x= 319.4..533.6 one of the top 100 Tech Companies for two consecutive + L062 y= 289.3 x= 319.4..514.6 years. Headquartered in Santa Clara, California, + L063 y= 272.3 x= 319.4..513.9 Macrovision has international offices in London, + L064 y= 255.3 x= 319.4..516.8 Frankfurt, Tel Aviv, Tokyo, Taipei, Hong Kong and + L065 y= 238.3 x= 319.4..523.6 Seoul. More information about the Macrovision can + L066 y= 221.3 x= 319.4..457.4 be found at www.macrovision.com. + +=== 2005CAT p1 - vertical + horizontal mixed [/redact/2005CAT.pdf p0] === + L000 y= 253.7 x= 2.1..24.6 IN + L001 y= 270.5 x= 2.1..24.6 T + L002 y= 280.3 x= 2.1..24.6 R + L003 y= 291.7 x= -5.2..24.6 O + L004 y= 303.9 x= 2.6..24.4 D + L005 y= 315.6 x= 2.6..24.4 U + L006 y= 327.1 x= 2.6..24.4 C + L007 y= 338.2 x= 2.6..24.4 T + L008 y= 347.7 x= 2.6..24.4 IO + L009 y= 362.9 x= 9.8..31.6 N + L010 y= 523.1 x= 60.8..267.2 It all started in 1949. In Bologna, Italy two + L011 y= 503.2 x= 60.8..260.6 brothers Stefano and Gugliemo Marzocchi + L012 y= 483.2 x= 60.8..268.4 founded Marzocchi SPA Italia. They built the + L013 y= 463.2 x= 60.8..257.6 company on solid, race-proven technology + L014 y= 443.2 x= 60.8..257.5 providing durability and performance from + L015 y= 423.2 x= 60.8..253.6 all of their premium suspension products. + L016 y= 383.1 x= 60.8..210.8 In the early 1990’s, Marzocchi + L017 y= 363.2 x= 60.8..260.3 introduced the Bomber line of suspension. + L018 y= 343.2 x= 60.8..256.3 Due to our extensive knowledge base and + L019 y= 323.2 x= 60.8..273.7 more than fi fty years experience, we met the + L020 y= 303.2 x= 60.8..266.5 demand mountain bikers had for a stronger + L021 y= 283.2 x= 60.8..150.9 more durable fork. + L022 y= 263.2 x= 60.8..257.9 Marzocchi became the benchmark in hard + L023 y= 243.2 x= 60.8..206.4 core mountain bike suspension. + L024 y= 203.1 x= 60.8..278.9 This year we are proud to introduce our most + L025 y= 183.2 x= 60.8..270.3 extensive line to date. We feel that for every + L026 y= 163.2 x= 60.8..258.4 rider there is a fork. If you can’t fi nd what + L027 y= 143.2 x= 60.8..214.6 you’re looking for in this catalog, + L028 y= 123.2 x= 60.8..159.4 then it doesn’t exist. + L029 y= 83.1 x= 60.8..236.1 Enjoy the mountain, ride Marzocchi. + +=== test_print p1 - plain single column [/redact/test_print.pdf p0] === + L000 y= 779.3 x= 70.9..488.5 Qué es un antipoeta: Un comerciante en urnas y ataúdes? Un + L001 y= 767.3 x= 70.9..524.5 sacerdote que no cree en nada? Un general que duda de sí mis + L002 y= 755.3 x= 70.9..481.2 Un vagabundo que se ríe de todo Hasta de la vejez y de la + L003 y= 743.3 x= 70.9..510.0 muerte? Un interlocutor de mal carácter? Un bailarín al bord + L004 y= 731.3 x= 70.9..409.3 del abismo? Un narciso que ama a todo el mundo? + L005 y= 807.6 x= 286.7..322.7 - 1 - + +=== reference addendum p1 - dense reference layout [/redact/pdf_reference_addendum_redaction.pdf p0] === + L000 y= 662.2 x= 70.5..114.7 bbc + L001 y= 420.4 x= 113.8..463.7 PDF Redaction: Addendum for the + L002 y= 391.4 x= 113.8..514.1 PDF Reference, sixth edition, version 1.7 + L003 y= 66.2 x= 70.0..150.2 November 2006 + +=== libre-test p1 [/redact/libre-test.pdf p0] === + L000 y= 706.7 x= 56.8..533.6 Calculate a foundation price with your current plan + L001 y= 687.1 x= 56.8..506.7 Now, let’s lay out the foundation we’ll work from when consi + L002 y= 671.2 x= 56.8..549.9 experience, the most important thing to keep in mind is the + L003 y= 655.4 x= 56.8..553.4 in most cases, the amount you pay for the smartphone won’t c + L004 y= 639.5 x= 56.8..548.9 a phone from a carrier, most require you also to get specifi + L005 y= 623.7 x= 56.8..161.9 what you already pay. + L006 y= 589.1 x= 56.8..509.6 The monthly cost of your phone plan will fluctuate depending + L007 y= 573.2 x= 56.8..530.5 Therefore, I recommend starting your search for a cheap Pixe + L008 y= 557.4 x= 56.8..551.3 considering whether it covers your needs. If it doesn’t, you + L009 y= 541.5 x= 56.8..534.6 (which you can probably do alongside getting a new Pixel). I + L010 y= 525.7 x= 56.8..539.0 currently need, it might be worth investigating cheaper opti + L011 y= 509.8 x= 56.8..540.3 own-phone (BYOP) plans offered by various wireless providers + L012 y= 494.0 x= 56.8..149.4 works best for you. + +=== potato_out p1 [/redact/potato_out.pdf p0] === + L000 y= 692.1 x= 311.0..443.5 SOUPS,STARTERS & SNACKS + L001 y= 647.0 x= 147.4..463.5 Spanish potato omelette + L002 y= 676.8 x= 23.0..472.4 PAN    5 + L003 y= 674.1 x= 36.1..67.5 PREPARATION + L004 y= 664.6 x= 38.3..56.6 25 mins + L005 y= 653.7 x= 36.0..62.2 COOKING + L006 y= 644.7 x= 38.3..56.6 40mins + diff --git a/viewer/viewer-awt/src/test/resources/selection/reading-order-xycut-golden.txt b/viewer/viewer-awt/src/test/resources/selection/reading-order-xycut-golden.txt new file mode 100644 index 000000000..98fe2e3f3 --- /dev/null +++ b/viewer/viewer-awt/src/test/resources/selection/reading-order-xycut-golden.txt @@ -0,0 +1,174 @@ +=== xr_650 p6 - single column drawn out of order (Environmental/Programs/legal) [/redact/xr_650.pdf p5] === + L000 y= 569.4 x= 144.0..476.5 E N V I R O N M E N T A L C O M M I T M E N T + L001 y= 552.1 x= 144.0..480.0 At Honda, we believe in performance and leadership, and that + L002 y= 540.1 x= 144.0..480.0 it comes to the environment. We continue to develop low-emis + L003 y= 528.1 x= 144.0..480.0 motorcycles, ATVs, scooters and personal watercraft. We alre + L004 y= 516.1 x= 144.0..480.0 2008 CARB emissions requirements years ahead of schedule. An + L005 y= 504.1 x= 144.0..192.9 can appreciate. + L006 y= 471.1 x= 143.9..432.0 P R O G R A M S T H A T P E R F O R M + L007 y= 453.9 x= 143.9..480.0 You chose your new Honda XR or XRL because it has so many gr + L008 y= 441.9 x= 143.9..480.0 all of the programs available to you as a Honda owner. Take + L009 y= 429.9 x= 143.9..480.0 let you extend virtually all of your Honda’s great warranty + L010 y= 417.9 x= 143.9..480.0 America.† Open to all Honda owners,†† the real-world benefit + L011 y= 405.9 x= 143.9..480.0 HRCA. Interested in some accessories to make your XR or XRL + L012 y= 393.9 x= 444.6..480.0 ™ Oils and + L013 y= 393.9 x= 143.9..445.4 selection of Honda Genuine Accessories. Time for service? Be + L014 y= 381.9 x= 143.9..480.0 Chemicals. And if you’re looking for a way to pay for your n + L015 y= 369.9 x= 143.9..480.0 gestions. First, ask about the American Honda Finance Corpor + L016 y= 357.9 x= 454.6..480.0 ™ revolv- + L017 y= 357.9 x= 143.9..454.6 can set everything up for you right in the showroom. Another + L018 y= 345.9 x= 143.9..480.0 ing charge card.‡‡ You can use the Honda Card to purchase Ho + L019 y= 333.9 x= 143.9..480.0 And make sure to ask your Honda Dealer about MSF rider train + L020 y= 321.9 x= 143.9..480.0 we think our family of Honda off-road motorcycles is the bes + L021 y= 309.9 x= 143.9..254.6 grams and support to go with them. + L022 y= 277.6 x= 143.9..480.0 Specifications, programs and availability subject to change + L023 y= 268.6 x= 143.9..480.0 specifications in this brochure—including colors, warranty t + L024 y= 259.6 x= 143.9..480.0 States. ◊ Mounting brackets required. * Maximum reimbursemen + L025 y= 250.6 x= 143.9..480.0 American Honda Service Contract Corporation in the state of + L026 y= 241.6 x= 143.9..480.0 motorcycles purchased from participating dealers in the U.S. + L027 y= 232.6 x= 143.9..480.0 approved credit by AHFC. ‡‡ Financing available to qualified + L028 y= 223.6 x= 143.9..480.0 Honda Card program at participating dealers. California vers + L029 y= 214.6 x= 143.9..480.0 are standard equipment on all Honda streetbikes. Performance + L030 y= 205.6 x= 143.9..481.3 Honda Genuine Accessories,™ HRCA,null Honda Rider’s Club of + L031 y= 196.6 x= 143.9..403.7 of Honda Motor Co., Ltd. ©2003 American Honda Motor Co., Inc + L032 y= 97.9 x= 331.5..396.8 honda.com + +=== windriver p2 - clean two-column body + full-width footer [/redact/windrivercasestudy1n3d2m8km0r.pdf p1] === + L000 y= 670.6 x= 77.1..283.6 information helps both parties improve their support + L001 y= 653.6 x= 77.1..281.3 plans, for example training seminars, services en- + L002 y= 636.6 x= 77.1..251.9 gagements, and project resource allocation. + L003 y= 612.6 x= 77.1..291.6 “One of the key benefits of the Macrovision solution is + L004 y= 595.6 x= 77.1..295.0 that it provides easy access to technology. Second, it is + L005 y= 578.6 x= 77.1..296.9 a very objective approach to licensing. It’s not about how + L006 y= 561.6 x= 77.1..290.2 many concurrent users you might have, or about how + L007 y= 544.6 x= 77.1..298.1 many license servers you need, or how many machines + L008 y= 527.6 x= 77.1..286.6 the product is installed on—it really comes down to + L009 y= 510.6 x= 77.1..293.8 making sure that software developers have access to + L010 y= 493.6 x= 77.1..199.5 our software,” explains Hogan. + L011 y= 469.6 x= 77.1..292.8 Hogan says Wind River has been using Macrovision’s + L012 y= 452.6 x= 77.1..296.1 licensing solutions for more than 3 years, and is planning + L013 y= 435.6 x= 77.1..296.8 to migrate to the next Macrovision release in 2004. Hogan + L014 y= 418.6 x= 77.1..299.5 says that Macrovision has done a good job of making its + L015 y= 401.6 x= 77.1..292.1 software easy to implement and easy to embed in its + L016 y= 384.6 x= 77.1..300.1 products. “At the end of the day, utility pricing, although + L017 y= 367.6 x= 77.1..298.6 gaining in popularity, is still relatively new for customers + L018 y= 350.6 x= 77.1..179.4 and software publishers .” + L019 y= 326.6 x= 77.1..298.7 Hogan points out one caveat that other software vendors + L020 y= 309.6 x= 77.1..293.7 should consider when evaluating this approach: “Not + L021 y= 292.6 x= 77.1..300.0 all buyers will be attracted to utility pricing so publisher + L022 y= 275.6 x= 77.1..298.1 should view it as an extension to their current offerings + L023 y= 258.6 x= 77.1..188.1 rather than a replacement .” + L024 y= 234.6 x= 77.1..298.6 Hogan also cites three key questions for software vendors + L025 y= 217.6 x= 77.1..285.4 considering utility pricing: First, what market forces + L026 y= 200.6 x= 77.1..295.8 are driving you and your customers toward utility-based + L027 y= 183.6 x= 77.1..296.6 pricing? Second, what’s the utility you’re trying to price + L028 y= 166.6 x= 77.1..288.9 and why have you chosen it over others? Third, who’s + L029 y= 149.6 x= 77.1..298.0 asking for it and why? His recommendation is to evaluate + L030 y= 132.6 x= 77.1..287.6 the strengths and weaknesses of traditional models, + L031 y= 115.6 x= 77.1..296.5 and then determine what is most important to a specific + L032 y= 98.6 x= 77.1..130.1 market need. + L033 y= 670.3 x= 319.4..527.4 Says Hogan, “A major benefit of Macrovision’s utility + L034 y= 653.3 x= 319.4..515.4 pricing is being able to create a new offering for + L035 y= 636.3 x= 319.4..531.2 existing and new buyers to consider. And that usually + L036 y= 619.3 x= 319.4..530.0 translates into new revenue streams and an increasing + L037 y= 602.3 x= 319.4..514.1 ability to be more responsive to your customers .” + L038 y= 578.3 x= 319.4..396.0 About Macrovision + L039 y= 561.3 x= 319.4..531.9 Macrovision Corporation (Nasdaq:MVSN) is the market + L040 y= 544.3 x= 319.4..520.7 leader in electronic licensing, copy protection, and + L041 y= 527.3 x= 319.4..519.0 digital rights management ("DRM") technologies. + L042 y= 510.3 x= 319.4..520.0 Analysts recognize Macrovision as the clear market + L043 y= 493.3 x= 319.4..530.7 leader, and it's estimated that the company sells more + L044 y= 476.3 x= 319.4..531.1 software-based licensing solutions than all its compet- + L045 y= 459.3 x= 319.4..529.0 itors combined. Macrovision FLEXnet, the world's first + L046 y= 442.3 x= 319.4..523.2 universal licensing platform, enables customers to + L047 y= 425.3 x= 319.4..512.3 easily price, package and protect their software. + L048 y= 408.3 x= 319.4..518.7 More than 3000 software publishers have shipped + L049 y= 391.3 x= 371.7..520.7 tm software, and hundreds of Fortune + L050 y= 391.3 x= 319.4..371.7 FLEXenabled + L051 y= 374.3 x= 319.4..519.1 1000 companies use Macrovision technologies to + L052 y= 357.3 x= 319.4..530.4 better manage their software licenses. The company + L053 y= 340.3 x= 319.4..524.6 holds over 700 software licensing and DRM patents + L054 y= 323.3 x= 319.4..525.7 worldwide, and has been ranked by Business 2.0 as + L055 y= 306.3 x= 319.4..533.6 one of the top 100 Tech Companies for two consecutive + L056 y= 289.3 x= 319.4..514.6 years. Headquartered in Santa Clara, California, + L057 y= 272.3 x= 319.4..513.9 Macrovision has international offices in London, + L058 y= 255.3 x= 319.4..516.8 Frankfurt, Tel Aviv, Tokyo, Taipei, Hong Kong and + L059 y= 238.3 x= 319.4..523.6 Seoul. More information about the Macrovision can + L060 y= 221.3 x= 319.4..457.4 be found at www.macrovision.com. + L061 y= 46.0 x= 76.7..153.5 Macrovision Corporation + L062 y= 37.0 x= 76.7..232.2 2830 De La Cruz Boulevard, Santa Clara, CA 95050 + L063 y= 28.0 x= 76.7..223.1 US +1 888-755-0861 Int’l +44 (0) 870-873-6300 + L064 y= 19.0 x= 76.7..145.9 www.macrovision .com + L065 y= 9.6 x= 76.8..525.8 ©2004 Macrovision Corporation. Macrovision and FLEXnet are r + L066 y= 4.1 x= 76.8..471.6 and VxWorks is a registered trademark of Wind River Systems, + +=== 2005CAT p1 - vertical + horizontal mixed [/redact/2005CAT.pdf p0] === + L000 y= 253.7 x= 2.1..24.6 IN + L001 y= 270.5 x= 2.1..24.6 T + L002 y= 280.3 x= 2.1..24.6 R + L003 y= 291.7 x= -5.2..24.6 O + L004 y= 303.9 x= 2.6..24.4 D + L005 y= 315.6 x= 2.6..24.4 U + L006 y= 327.1 x= 2.6..24.4 C + L007 y= 338.2 x= 2.6..24.4 T + L008 y= 347.7 x= 2.6..24.4 IO + L009 y= 362.9 x= 9.8..31.6 N + L010 y= 523.1 x= 60.8..267.2 It all started in 1949. In Bologna, Italy two + L011 y= 503.2 x= 60.8..260.6 brothers Stefano and Gugliemo Marzocchi + L012 y= 483.2 x= 60.8..268.4 founded Marzocchi SPA Italia. They built the + L013 y= 463.2 x= 60.8..257.6 company on solid, race-proven technology + L014 y= 443.2 x= 60.8..257.5 providing durability and performance from + L015 y= 423.2 x= 60.8..253.6 all of their premium suspension products. + L016 y= 383.1 x= 60.8..210.8 In the early 1990’s, Marzocchi + L017 y= 363.2 x= 60.8..260.3 introduced the Bomber line of suspension. + L018 y= 343.2 x= 60.8..256.3 Due to our extensive knowledge base and + L019 y= 323.2 x= 60.8..273.7 more than fi fty years experience, we met the + L020 y= 303.2 x= 60.8..266.5 demand mountain bikers had for a stronger + L021 y= 283.2 x= 60.8..150.9 more durable fork. + L022 y= 263.2 x= 60.8..257.9 Marzocchi became the benchmark in hard + L023 y= 243.2 x= 60.8..206.4 core mountain bike suspension. + L024 y= 203.1 x= 60.8..278.9 This year we are proud to introduce our most + L025 y= 183.2 x= 60.8..270.3 extensive line to date. We feel that for every + L026 y= 163.2 x= 60.8..258.4 rider there is a fork. If you can’t fi nd what + L027 y= 143.2 x= 60.8..214.6 you’re looking for in this catalog, + L028 y= 123.2 x= 60.8..159.4 then it doesn’t exist. + L029 y= 83.1 x= 60.8..236.1 Enjoy the mountain, ride Marzocchi. + +=== test_print p1 - plain single column [/redact/test_print.pdf p0] === + L000 y= 807.6 x= 286.7..322.7 - 1 - + L001 y= 779.3 x= 70.9..488.5 Qué es un antipoeta: Un comerciante en urnas y ataúdes? Un + L002 y= 767.3 x= 70.9..524.5 sacerdote que no cree en nada? Un general que duda de sí mis + L003 y= 755.3 x= 70.9..481.2 Un vagabundo que se ríe de todo Hasta de la vejez y de la + L004 y= 743.3 x= 70.9..510.0 muerte? Un interlocutor de mal carácter? Un bailarín al bord + L005 y= 731.3 x= 70.9..409.3 del abismo? Un narciso que ama a todo el mundo? + +=== reference addendum p1 - dense reference layout [/redact/pdf_reference_addendum_redaction.pdf p0] === + L000 y= 662.2 x= 70.5..114.7 bbc + L001 y= 420.4 x= 113.8..463.7 PDF Redaction: Addendum for the + L002 y= 391.4 x= 113.8..514.1 PDF Reference, sixth edition, version 1.7 + L003 y= 66.2 x= 70.0..150.2 November 2006 + +=== libre-test p1 [/redact/libre-test.pdf p0] === + L000 y= 706.7 x= 56.8..533.6 Calculate a foundation price with your current plan + L001 y= 687.1 x= 56.8..506.7 Now, let’s lay out the foundation we’ll work from when consi + L002 y= 671.2 x= 56.8..549.9 experience, the most important thing to keep in mind is the + L003 y= 655.4 x= 56.8..553.4 in most cases, the amount you pay for the smartphone won’t c + L004 y= 639.5 x= 56.8..548.9 a phone from a carrier, most require you also to get specifi + L005 y= 623.7 x= 56.8..161.9 what you already pay. + L006 y= 589.1 x= 56.8..509.6 The monthly cost of your phone plan will fluctuate depending + L007 y= 573.2 x= 56.8..530.5 Therefore, I recommend starting your search for a cheap Pixe + L008 y= 557.4 x= 56.8..551.3 considering whether it covers your needs. If it doesn’t, you + L009 y= 541.5 x= 56.8..534.6 (which you can probably do alongside getting a new Pixel). I + L010 y= 525.7 x= 56.8..539.0 currently need, it might be worth investigating cheaper opti + L011 y= 509.8 x= 56.8..540.3 own-phone (BYOP) plans offered by various wireless providers + L012 y= 494.0 x= 56.8..149.4 works best for you. + +=== potato_out p1 [/redact/potato_out.pdf p0] === + L000 y= 692.1 x= 311.0..443.5 SOUPS,STARTERS & SNACKS + L001 y= 676.8 x= 23.0..472.4 PAN    5 + L002 y= 674.1 x= 36.1..67.5 PREPARATION + L003 y= 664.6 x= 38.3..56.6 25 mins + L004 y= 653.7 x= 36.0..62.2 COOKING + L005 y= 647.0 x= 147.4..463.5 Spanish potato omelette + L006 y= 644.7 x= 38.3..56.6 40mins +