Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
75ed12a
Text selection: caret/offset model (Steps 1-2)
pcorless Jul 16, 2026
1a45e66
Text selection: keyboard caret navigation + caret bar (Step 3)
pcorless Jul 16, 2026
4d03824
Text selection: search corpus primitive + search characterization tes…
pcorless Jul 16, 2026
d2c8907
Text selection: converge whole-page search onto TextSequence (Step 4b)
pcorless Jul 16, 2026
735c603
Text selection: widen search characterization net (Step 4c.1)
pcorless Jul 16, 2026
448e9f7
Text selection: unify WORD/PAGE search into one corpus matcher (Step …
pcorless Jul 16, 2026
e4ae5b7
Text selection: accent-insensitive (Unicode-normalized) search (Step …
pcorless Jul 16, 2026
7b29071
Text selection: find-and-select search hits (Step 4d)
pcorless Jul 16, 2026
5ca6185
Text selection: blinking caret
pcorless Jul 16, 2026
f6af7b0
Search: deprecate word-tokenization no longer used by the matcher
pcorless Jul 16, 2026
b3da669
Examples: demonstrate accent-insensitive search in search examples
pcorless Jul 16, 2026
e8c61a5
Text selection: fix caretAt on multi-column / vertical-text pages
pcorless Jul 17, 2026
b7f56ef
Text selection: keep the caret after a single click
pcorless Jul 17, 2026
1a3b33f
Text selection: give the page keyboard focus when a caret is placed
pcorless Jul 17, 2026
59f1ab5
Text selection: keep keyboard focus on the page after a single click
pcorless Jul 17, 2026
e9f8fb8
GH-513 clean up copyright statements for new files
pcorless Jul 17, 2026
038fb68
Text selection: fix shift-down onto a shorter next line
pcorless Jul 17, 2026
91b68df
Text selection: reading-order characterization harness
pcorless Jul 17, 2026
5771d45
Text selection: add clean two-column fixture to reading-order corpus
pcorless Jul 17, 2026
7d0eabd
Text selection: XY-cut reading-order prototype (opt-in)
pcorless Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 + ']';
}
}
Original file line number Diff line number Diff line change
@@ -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 + ")";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -63,6 +88,9 @@ public class PageText implements TextSelect {
private final ArrayList<LineText> pageLines;
private ArrayList<LineText> sortedPageLines;

// reading-order view over sortedPageLines; lazily built, invalidated on re-sort.
private TextSequence textSequence;

private AffineTransform previousTextTransform;
private AffineTransform previousXObjectTransform;

Expand Down Expand Up @@ -139,6 +167,21 @@ public ArrayList<LineText> 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.
Expand Down Expand Up @@ -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
Expand All @@ -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;
}


Expand Down
Loading