arguments, int operat
}
GraphicsState xFormGraphicsState = graphicsState.clone();
xFormGraphicsState.getCTM().concatenate(new Matrix(((PDXForm) xObject).getMatrix()));
+ // A form's /BBox clips its own content (ISO 32000-1,
+ // 8.10.2), and the clone already carries the clip
+ // inherited from here, so the two compose.
+ intersectFormBBoxClip(xFormGraphicsState, (PDXForm) xObject);
GFSAXForm xForm = new GFSAXForm((PDXForm) xObject, resourcesHandler, xFormGraphicsState, pageNumber,
key, markedContent, xObjectName.getName().getValue());
artifacts.addAll(xForm.getArtifacts());
@@ -602,6 +652,116 @@ private void processDoubleQuote(double op1, double op2) {
this.graphicsState.getTextState().setCharacterSpacing(op2);
}
+ /*
+ * ------------------------------------------------------------------
+ * Clipping: the coordinate contract
+ * ------------------------------------------------------------------
+ *
+ * Path points are transformed by the CTM current at each path
+ * construction operator (m, l, re, c, v, y), not by whatever CTM the
+ * painting operator happens to see, and are accumulated into
+ * clipPathBox in page space -- the same space createTextChunk's
+ * bounding box is built in, because the text rendering matrix that
+ * produces it ends with the same CTM.
+ *
+ * W and W* do not clip. They set pendingClip, and the pending clip is
+ * applied after the path painting operator that ends the path (n, f, F,
+ * f*, B, B*, b, b*, S, s), using the bounding box of the whole
+ * constructed path; the path is then reset.
+ *
+ * The effective clip is the intersection of the inherited clip and that
+ * box. Null means unclipped; an EMPTY intersection stays an empty box
+ * and must never become "unclipped" again (see GraphicsState).
+ *
+ * q and Q save and restore the clip with the rest of the graphics
+ * state, because GraphicsState carries it through both clone() and
+ * copyProperties.
+ *
+ * On Do of a Form XObject the nested parser inherits the clip through
+ * the cloned graphics state and additionally intersects the form's
+ * /BBox transformed by /Matrix x CTM, so nesting composes.
+ *
+ * Non-rectangular clipping paths are approximated by their bounding
+ * box. That approximation only ever makes the clip LARGER, so
+ * disjointness still proves invisibility -- but overlap proves nothing,
+ * and no visibility is claimed. Text inside the box but outside the
+ * real path is therefore retained.
+ */
+
+ /**
+ * Extends the path under construction with one point, transformed by the
+ * CTM in force right now.
+ */
+ private void addClipPathPoint(double x, double y) {
+ double pageX = graphicsState.getCTM().transformX(x, y);
+ double pageY = graphicsState.getCTM().transformY(x, y);
+ if (clipPathBox == null) {
+ clipPathBox = new BoundingBox(pageNumber, pageX, pageY, pageX, pageY);
+ return;
+ }
+ clipPathBox.setLeftX(Math.min(clipPathBox.getLeftX(), pageX));
+ clipPathBox.setRightX(Math.max(clipPathBox.getRightX(), pageX));
+ clipPathBox.setBottomY(Math.min(clipPathBox.getBottomY(), pageY));
+ clipPathBox.setTopY(Math.max(clipPathBox.getTopY(), pageY));
+ }
+
+ /**
+ * Applies the clip armed by W / W*, if any, and resets the path. Called by
+ * every path painting operator, so that a path built without W also ends
+ * here.
+ *
+ * A W with no path point at all is left as a no-op rather than read as
+ * an empty clip: the conservative reading of a degenerate stream is that
+ * nothing was clipped.
+ */
+ private void applyPendingClip() {
+ if (pendingClip && clipPathBox != null) {
+ graphicsState.intersectClip(clipPathBox);
+ }
+ pendingClip = false;
+ clipPathBox = null;
+ }
+
+ /**
+ * Intersects a form's own /BBox, transformed by the CTM the form will be
+ * parsed with, into that state's clip.
+ */
+ private void intersectFormBBoxClip(GraphicsState state, PDXForm form) {
+ double[] bbox = form.getBBox();
+ if (bbox == null || bbox.length != 4) {
+ return;
+ }
+ state.intersectClip(state.getCTM().transformBoundingBox(
+ new BoundingBox(pageNumber, bbox[0], bbox[1], bbox[2], bbox[3])));
+ }
+
+ /**
+ * Marks a chunk whose bounding box is wholly outside the effective clip, so
+ * a consumer can drop text that cannot appear on the page.
+ *
+ *
Conservative on purpose: only a box separated from the clip by more
+ * than CLIP_TOLERANCE counts. A chunk that touches the clip boundary or
+ * crosses it keeps its text, because part of it is readable and a chunk is
+ * not split at the boundary. A degenerate (zero area) text box has no
+ * geometry to place outside the clip and is never marked.
+ */
+ private void markIfClipped(TextChunk textChunk) {
+ BoundingBox clip = graphicsState.getClipBox();
+ if (clip == null || clip.getPageNumber() == null) {
+ return;
+ }
+ BoundingBox box = textChunk.getBoundingBox();
+ if (box == null || box.getPageNumber() == null) {
+ return;
+ }
+ if (box.getWidth() <= CLIP_TOLERANCE || box.getHeight() <= CLIP_TOLERANCE) {
+ return;
+ }
+ if (clip.notOverlaps(box, CLIP_TOLERANCE)) {
+ textChunk.setClippedText(true);
+ }
+ }
+
private void processh() {
if (!NodeUtils.areCloseNumbers(path.getStartX(), path.getCurrentX()) ||
!NodeUtils.areCloseNumbers(path.getStartY(), path.getCurrentY())) {
@@ -978,6 +1138,7 @@ private TextChunk createTextChunk(List arguments, String operatorType,
if (StaticContainers.isDataLoader()) {
textChunk.getStreamInfos().addAll(textPieces.getStreamInfos(operatorIndex, xObjectName));
}
+ markIfClipped(textChunk);
if (StaticContainers.isDataLoader() && !fontNameToFontFamilyMap.containsKey(fontNameWithoutSubset)) {
String fontFamily = descriptor.getFontFamily();
if (fontFamily == null || fontFamily.isEmpty()) {
diff --git a/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/GraphicsState.java b/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/GraphicsState.java
index 93b4b9791..42ef471b6 100644
--- a/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/GraphicsState.java
+++ b/wcag-validation/src/main/java/org/verapdf/gf/model/factory/chunks/GraphicsState.java
@@ -27,6 +27,7 @@
import org.verapdf.pd.colors.PDColorSpace;
import org.verapdf.pd.font.PDFont;
import org.verapdf.wcag.algorithms.entities.content.LineChunk;
+import org.verapdf.wcag.algorithms.entities.geometry.BoundingBox;
/**
* @author Maxim Plushchov
@@ -40,6 +41,16 @@ public class GraphicsState implements Cloneable {
private boolean processColorOperators = true;
private double lineWidth = 1.0;
private int lineCap = LineChunk.BUTT_CAP_STYLE;
+ /**
+ * The effective clipping path in force, approximated by its bounding box in
+ * page space, or null when nothing has clipped this state yet.
+ *
+ * null means unclipped. An empty box means the clip admits
+ * nothing, and it is kept as an empty box: an empty intersection must never
+ * collapse back to null, because "nothing is visible" and "everything is
+ * visible" are opposite answers.
+ */
+ private BoundingBox clipBox;
private GraphicsState() {
@@ -105,6 +116,45 @@ public void setLineCap(int lineCap) {
this.lineCap = lineCap;
}
+ /**
+ * @return the bounding box of the effective clipping path in page space, or
+ * null when this state is unclipped
+ */
+ public BoundingBox getClipBox() {
+ return clipBox;
+ }
+
+ /**
+ * Intersects the effective clipping path with another box, in page space.
+ * The first box to arrive becomes the clip; every later one narrows it.
+ *
+ * @param box the new clip box in page space; null and boxes without a page
+ * number are ignored, since neither can be compared with a chunk
+ */
+ public void intersectClip(BoundingBox box) {
+ if (box == null || box.getPageNumber() == null) {
+ return;
+ }
+ this.clipBox = this.clipBox == null ? new BoundingBox(box) : intersection(this.clipBox, box);
+ }
+
+ /**
+ * BoundingBox.cross returns null for disjoint boxes and the public
+ * constructors order their corners, so neither can express an empty clip.
+ * Detect the empty case here and answer the canonical empty box, which
+ * reports isEmpty() and does not overlap anything.
+ */
+ private static BoundingBox intersection(BoundingBox first, BoundingBox second) {
+ double leftX = Math.max(first.getLeftX(), second.getLeftX());
+ double rightX = Math.min(first.getRightX(), second.getRightX());
+ double bottomY = Math.max(first.getBottomY(), second.getBottomY());
+ double topY = Math.min(first.getTopY(), second.getTopY());
+ if (leftX > rightX || bottomY > topY) {
+ return new BoundingBox(first.getPageNumber());
+ }
+ return new BoundingBox(first.getPageNumber(), leftX, bottomY, rightX, topY);
+ }
+
public void copyProperties(GraphicsState graphicState) {
this.CTM = graphicState.getCTM();
this.textState = graphicState.getTextState();
@@ -113,6 +163,7 @@ public void copyProperties(GraphicsState graphicState) {
this.processColorOperators = graphicState.isProcessColorOperators();
this.lineWidth = graphicState.getLineWidth();
this.lineCap = graphicState.getLineCap();
+ this.clipBox = graphicState.getClipBox();
}
public void copyPropertiesFromExtGState(PDExtGState extGState) {
@@ -138,6 +189,7 @@ public GraphicsState clone() {
clone.processColorOperators = this.processColorOperators;
clone.lineWidth = this.lineWidth;
clone.lineCap = this.lineCap;
+ clone.clipBox = this.clipBox == null ? null : new BoundingBox(this.clipBox);
return clone;
}
}
diff --git a/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClipTestChunks.java b/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClipTestChunks.java
new file mode 100644
index 000000000..e850f2d0c
--- /dev/null
+++ b/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClipTestChunks.java
@@ -0,0 +1,99 @@
+/*
+ * This file is part of veraPDF WCAG Validation, a module of the veraPDF project.
+ * Copyright (c) 2015-2026, veraPDF Consortium
+ * All rights reserved.
+ *
+ * veraPDF WCAG Validation is free software: you can redistribute it and/or modify
+ * it under the terms of either:
+ *
+ * The GNU General public license GPLv3+.
+ * You should have received a copy of the GNU General Public License
+ * along with veraPDF WCAG Validation as the LICENSE.GPL file in the root of the source
+ * tree. If not, see http://www.gnu.org/licenses/ or
+ * https://www.gnu.org/licenses/gpl-3.0.en.html.
+ *
+ * The Mozilla Public License MPLv2+.
+ * You should have received a copy of the Mozilla Public License along with
+ * veraPDF WCAG Validation as the LICENSE.MPL file in the root of the source tree.
+ * If a copy of the MPL was not distributed with this file, you can obtain one at
+ * http://mozilla.org/MPL/2.0/.
+ */
+package org.verapdf.gf.model.factory.chunks;
+
+import org.verapdf.containers.StaticCoreContainers;
+import org.verapdf.gf.model.impl.containers.StaticStorages;
+import org.verapdf.gf.model.impl.sa.GFSAPDFDocument;
+import org.verapdf.parser.PDFFlavour;
+import org.verapdf.pd.PDDocument;
+import org.verapdf.tools.StaticResources;
+import org.verapdf.wcag.algorithms.entities.content.IChunk;
+import org.verapdf.wcag.algorithms.entities.content.TextChunk;
+import org.verapdf.wcag.algorithms.semanticalgorithms.containers.StaticContainers;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Runs the chunk parser over a fixture and hands back the page's text chunks,
+ * so a test can assert on which strings survived clipping.
+ */
+final class ClipTestChunks {
+
+ private ClipTestChunks() {
+ }
+
+ /** Parses page 0 of the file and returns its text chunks, in stream order. */
+ static List parse(Path file) throws IOException {
+ StaticResources.clear();
+ StaticContainers.updateContainers(null);
+ StaticCoreContainers.clearAllContainers();
+
+ PDDocument pdDocument = new PDDocument(file.toAbsolutePath().toString());
+ try {
+ StaticResources.setDocument(pdDocument);
+ GFSAPDFDocument document = new GFSAPDFDocument(pdDocument);
+ StaticResources.setFlavour(Collections.singletonList(PDFFlavour.WCAG_2_2_HUMAN));
+ StaticContainers.setDocument(document);
+ StaticContainers.setFileName(file.toAbsolutePath().toString());
+ StaticContainers.setIsDataLoader(true);
+ StaticContainers.setIsIgnoreCharactersWithoutUnicode(false);
+ StaticResources.setIsFontProgramsParsing(true);
+ StaticStorages.setIsIgnoreMCIDs(true);
+ StaticStorages.setIsAddSpacesBetweenTextPieces(true);
+ document.parseChunks();
+
+ List chunks = new ArrayList<>();
+ for (IChunk chunk : document.getArtifacts(0)) {
+ if (chunk instanceof TextChunk) {
+ chunks.add((TextChunk) chunk);
+ }
+ }
+ return chunks;
+ } finally {
+ pdDocument.close();
+ }
+ }
+
+ /** The values of the chunks a clipped-text consumer would keep. */
+ static List retained(List chunks) {
+ return values(chunks, false);
+ }
+
+ /** The values of the chunks the parser marked as clipped away. */
+ static List clipped(List chunks) {
+ return values(chunks, true);
+ }
+
+ private static List values(List chunks, boolean wanted) {
+ List values = new ArrayList<>();
+ for (TextChunk chunk : chunks) {
+ if (chunk.isClippedText() == wanted) {
+ values.add(chunk.getValue().trim());
+ }
+ }
+ return values;
+ }
+}
diff --git a/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClipTestPdfBuilder.java b/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClipTestPdfBuilder.java
new file mode 100644
index 000000000..cc3c68209
--- /dev/null
+++ b/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClipTestPdfBuilder.java
@@ -0,0 +1,190 @@
+/*
+ * This file is part of veraPDF WCAG Validation, a module of the veraPDF project.
+ * Copyright (c) 2015-2026, veraPDF Consortium
+ * All rights reserved.
+ *
+ * veraPDF WCAG Validation is free software: you can redistribute it and/or modify
+ * it under the terms of either:
+ *
+ * The GNU General public license GPLv3+.
+ * You should have received a copy of the GNU General Public License
+ * along with veraPDF WCAG Validation as the LICENSE.GPL file in the root of the source
+ * tree. If not, see http://www.gnu.org/licenses/ or
+ * https://www.gnu.org/licenses/gpl-3.0.en.html.
+ *
+ * The Mozilla Public License MPLv2+.
+ * You should have received a copy of the Mozilla Public License along with
+ * veraPDF WCAG Validation as the LICENSE.MPL file in the root of the source tree.
+ * If a copy of the MPL was not distributed with this file, you can obtain one at
+ * http://mozilla.org/MPL/2.0/.
+ */
+package org.verapdf.gf.model.factory.chunks;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Writes a one page PDF byte by byte, so a clipping fixture is the content
+ * stream the test author wrote and nothing else.
+ *
+ * A PDF writing library would put its own graphics state operators around
+ * the stream, which is exactly what these tests are measuring. The file is
+ * small enough to build by hand: a catalog, a page tree, one Helvetica font,
+ * the page's content stream and one stream per Form XObject.
+ */
+final class ClipTestPdfBuilder {
+
+ static final double PAGE_WIDTH = 612;
+ static final double PAGE_HEIGHT = 792;
+
+ private final Map forms = new LinkedHashMap<>();
+ private String content = "";
+
+ /** A Form XObject: its own /BBox, its /Matrix and its content stream. */
+ static final class Form {
+ private final double[] bbox;
+ private final double[] matrix;
+ private final String content;
+
+ Form(double[] bbox, double[] matrix, String content) {
+ this.bbox = bbox;
+ this.matrix = matrix;
+ this.content = content;
+ }
+ }
+
+ ClipTestPdfBuilder content(String pageContent) {
+ this.content = pageContent;
+ return this;
+ }
+
+ ClipTestPdfBuilder form(String name, double[] bbox, String formContent) {
+ return form(name, bbox, new double[]{1, 0, 0, 1, 0, 0}, formContent);
+ }
+
+ ClipTestPdfBuilder form(String name, double[] bbox, double[] matrix, String formContent) {
+ forms.put(name, new Form(bbox, matrix, formContent));
+ return this;
+ }
+
+ /** One {@code Tj} on its own line, at the given point, in 10pt Helvetica. */
+ static String text(double x, double y, String value) {
+ return text(x, y, 10, value);
+ }
+
+ static String text(double x, double y, double size, String value) {
+ return String.format("BT /F1 %s Tf %s %s Td (%s) Tj ET%n",
+ number(size), number(x), number(y), value.replace("\\", "\\\\")
+ .replace("(", "\\(").replace(")", "\\)"));
+ }
+
+ static String number(double value) {
+ if (value == Math.rint(value)) {
+ return Long.toString((long) value);
+ }
+ return String.format("%.4f", value);
+ }
+
+ Path write(Path file) throws IOException {
+ List objects = new ArrayList<>();
+ // 1 catalog, 2 page tree, 3 font, 4 page, 5 page contents, then one
+ // stream object per form. Numbers are fixed so the references below
+ // can be written straight out.
+ int firstForm = 6;
+ StringBuilder xObjects = new StringBuilder();
+ int number = firstForm;
+ for (String name : forms.keySet()) {
+ xObjects.append('/').append(name).append(' ').append(number++).append(" 0 R ");
+ }
+ String resources = "/Font << /F1 3 0 R >>"
+ + (xObjects.length() == 0 ? "" : " /XObject << " + xObjects + ">>");
+
+ objects.add(ascii("<< /Type /Catalog /Pages 2 0 R >>"));
+ objects.add(ascii("<< /Type /Pages /Kids [4 0 R] /Count 1 >>"));
+ objects.add(ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
+ + "/Encoding /WinAnsiEncoding >>"));
+ objects.add(ascii(String.format("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %s %s] "
+ + "/Resources << %s >> /Contents 5 0 R >>",
+ number(PAGE_WIDTH), number(PAGE_HEIGHT), resources)));
+ objects.add(stream("", content));
+ for (Form form : forms.values()) {
+ objects.add(stream(String.format(
+ "/Type /XObject /Subtype /Form /BBox [%s %s %s %s] /Matrix [%s %s %s %s %s %s] "
+ + "/Resources << /Font << /F1 3 0 R >> >> ",
+ number(form.bbox[0]), number(form.bbox[1]), number(form.bbox[2]), number(form.bbox[3]),
+ number(form.matrix[0]), number(form.matrix[1]), number(form.matrix[2]),
+ number(form.matrix[3]), number(form.matrix[4]), number(form.matrix[5])),
+ form.content));
+ }
+
+ ByteBuffer out = new ByteBuffer();
+ out.append(ascii("%PDF-1.7\n"));
+ out.append(new byte[]{'%', (byte) 0xe2, (byte) 0xe3, (byte) 0xcf, (byte) 0xd3, '\n'});
+ List offsets = new ArrayList<>();
+ for (int index = 0; index < objects.size(); index++) {
+ offsets.add(out.size());
+ out.append(ascii((index + 1) + " 0 obj\n"));
+ out.append(objects.get(index));
+ out.append(ascii("\nendobj\n"));
+ }
+ int startxref = out.size();
+ StringBuilder xref = new StringBuilder();
+ xref.append("xref\n0 ").append(objects.size() + 1).append('\n');
+ xref.append("0000000000 65535 f \n");
+ for (int offset : offsets) {
+ xref.append(String.format("%010d 00000 n %n", offset));
+ }
+ xref.append("trailer\n<< /Size ").append(objects.size() + 1).append(" /Root 1 0 R >>\n");
+ xref.append("startxref\n").append(startxref).append("\n%%EOF\n");
+ out.append(ascii(xref.toString()));
+
+ Files.createDirectories(file.getParent());
+ Files.write(file, out.toByteArray());
+ return file;
+ }
+
+ private static byte[] stream(String extraDictionaryEntries, String body) {
+ byte[] bytes = ascii(body);
+ ByteBuffer buffer = new ByteBuffer();
+ buffer.append(ascii("<< " + extraDictionaryEntries + "/Length " + bytes.length + " >>\nstream\n"));
+ buffer.append(bytes);
+ buffer.append(ascii("\nendstream"));
+ return buffer.toByteArray();
+ }
+
+ private static byte[] ascii(String value) {
+ return value.getBytes(StandardCharsets.ISO_8859_1);
+ }
+
+ /** A growable byte sink; the file is a few kilobytes at most. */
+ private static final class ByteBuffer {
+ private byte[] bytes = new byte[4096];
+ private int length = 0;
+
+ void append(byte[] more) {
+ while (length + more.length > bytes.length) {
+ byte[] grown = new byte[bytes.length * 2];
+ System.arraycopy(bytes, 0, grown, 0, length);
+ bytes = grown;
+ }
+ System.arraycopy(more, 0, bytes, length, more.length);
+ length += more.length;
+ }
+
+ int size() {
+ return length;
+ }
+
+ byte[] toByteArray() {
+ byte[] result = new byte[length];
+ System.arraycopy(bytes, 0, result, 0, length);
+ return result;
+ }
+ }
+}
diff --git a/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClippedTextChunkParserTest.java b/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClippedTextChunkParserTest.java
new file mode 100644
index 000000000..384c465bd
--- /dev/null
+++ b/wcag-validation/src/test/java/org/verapdf/gf/model/factory/chunks/ClippedTextChunkParserTest.java
@@ -0,0 +1,261 @@
+/*
+ * This file is part of veraPDF WCAG Validation, a module of the veraPDF project.
+ * Copyright (c) 2015-2026, veraPDF Consortium
+ * All rights reserved.
+ *
+ * veraPDF WCAG Validation is free software: you can redistribute it and/or modify
+ * it under the terms of either:
+ *
+ * The GNU General public license GPLv3+.
+ * You should have received a copy of the GNU General Public License
+ * along with veraPDF WCAG Validation as the LICENSE.GPL file in the root of the source
+ * tree. If not, see http://www.gnu.org/licenses/ or
+ * https://www.gnu.org/licenses/gpl-3.0.en.html.
+ *
+ * The Mozilla Public License MPLv2+.
+ * You should have received a copy of the Mozilla Public License along with
+ * veraPDF WCAG Validation as the LICENSE.MPL file in the root of the source tree.
+ * If a copy of the MPL was not distributed with this file, you can obtain one at
+ * http://mozilla.org/MPL/2.0/.
+ */
+package org.verapdf.gf.model.factory.chunks;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.verapdf.wcag.algorithms.entities.content.TextChunk;
+
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The chunk parser's clipping geometry, asserted on the exact strings that
+ * survive and the exact strings that do not.
+ *
+ * Line counts are not the acceptance criterion here: a filter can improve a
+ * count while deleting the wrong text, so every case names both sides.
+ *
+ *
Geometry note for whoever edits these fixtures: a Helvetica chunk's
+ * bounding box runs from the pen's x, and from {@code baseline - 0.225em} to
+ * {@code baseline + 0.931em}. At 10pt that is 2.25pt below and 9.31pt above
+ * the {@code Td} position.
+ */
+class ClippedTextChunkParserTest {
+
+ @TempDir
+ Path tempDir;
+
+ /** The five lines of the tiled figure, as the reproducer draws them. */
+ private static String figureLines() {
+ return ClipTestPdfBuilder.text(0, 100, "Figure line 1")
+ + ClipTestPdfBuilder.text(0, 80, "Figure line 2")
+ + ClipTestPdfBuilder.text(0, 60, "Figure line 3")
+ + ClipTestPdfBuilder.text(0, 40, "Figure line 4")
+ + ClipTestPdfBuilder.text(0, 20, "Figure line 5");
+ }
+
+ /**
+ * The shape that started this: a Form XObject carrying every line of a
+ * figure, placed under a window that shows only the first one. Four fifths
+ * of the text is on the page and none of it can be seen.
+ */
+ @Test
+ void formUnderAWindowKeepsOnlyTheLineTheWindowShows() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ .form("Fx", new double[]{0, 0, 200, 120}, figureLines())
+ .content(ClipTestPdfBuilder.text(72, 700, 12, "Page body.")
+ + "q 1 0 0 1 100 400 cm 0 95 200 30 re W n /Fx Do Q\n")
+ .write(tempDir.resolve("reproducer.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertEquals(Arrays.asList("Page body.", "Figure line 1"), ClipTestChunks.retained(chunks));
+ assertEquals(Arrays.asList("Figure line 2", "Figure line 3", "Figure line 4", "Figure line 5"),
+ ClipTestChunks.clipped(chunks));
+ }
+
+ /**
+ * The clipped-text signal must stay distinct from the hidden-text one, so
+ * that the hidden-text consumers do not react to clipped chunks and the
+ * clipped-text opt-out can be genuine.
+ */
+ @Test
+ void clippedTextIsNotMarkedAsHiddenText() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ .form("Fx", new double[]{0, 0, 200, 120}, figureLines())
+ .content("q 1 0 0 1 100 400 cm 0 95 200 30 re W n /Fx Do Q\n")
+ .write(tempDir.resolve("distinct.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertEquals(4, ClipTestChunks.clipped(chunks).size());
+ for (TextChunk chunk : chunks) {
+ assertFalse(chunk.isHiddenText(),
+ "clipping must not set the hidden-text flag: " + chunk.getValue());
+ }
+ }
+
+ /**
+ * The discard rule is conservative: only text conclusively outside the clip
+ * goes. A chunk the clip cuts in half is still partly readable, and a chunk
+ * is not split at the boundary, so it keeps its text.
+ */
+ @Test
+ void partiallyClippedChunkKeepsItsText() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ .content("q 72 690 40 30 re W n\n"
+ + ClipTestPdfBuilder.text(72, 700, "Partly visible line")
+ + "Q\n")
+ .write(tempDir.resolve("partial.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertEquals(Collections.singletonList("Partly visible line"), ClipTestChunks.retained(chunks));
+ assertEquals(Collections.emptyList(), ClipTestChunks.clipped(chunks));
+ }
+
+ /** Touching the clip boundary is not being outside it. */
+ @Test
+ void chunkTouchingTheClipBoundaryKeepsItsText() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ // The clip's right edge is exactly the pen's x, which is exactly
+ // the chunk's left edge.
+ .content("q 32 690 40 20 re W n\n"
+ + ClipTestPdfBuilder.text(72, 700, "Edge touching text")
+ + "Q\n")
+ .write(tempDir.resolve("touching.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertEquals(Collections.singletonList("Edge touching text"), ClipTestChunks.retained(chunks));
+ }
+
+ /** One point of clear separation is enough, so the rule is not inert. */
+ @Test
+ void chunkOnePointBeyondTheClipIsMarked() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ .content("q 31 690 40 20 re W n\n"
+ + ClipTestPdfBuilder.text(72, 700, "One point clear")
+ + "Q\n")
+ .write(tempDir.resolve("separated.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertEquals(Collections.emptyList(), ClipTestChunks.retained(chunks));
+ assertEquals(Collections.singletonList("One point clear"), ClipTestChunks.clipped(chunks));
+ }
+
+ /** Q restores the clip that was in force before q, so a later caption survives. */
+ @Test
+ void restoringGraphicsStateRestoresTheEarlierClip() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ .content("q 0 0 10 10 re W n\n"
+ + ClipTestPdfBuilder.text(72, 700, "Inside the window")
+ + "Q\n"
+ + ClipTestPdfBuilder.text(72, 660, "Caption after Q"))
+ .write(tempDir.resolve("restore.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertEquals(Collections.singletonList("Caption after Q"), ClipTestChunks.retained(chunks));
+ assertEquals(Collections.singletonList("Inside the window"), ClipTestChunks.clipped(chunks));
+ }
+
+ /**
+ * Two placements of one form are two graphics states: each gets its own
+ * transform and its own window, and neither leaks into the other.
+ */
+ @Test
+ void twoPlacementsOfTheSameFormEachGetTheirOwnResult() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ .form("Fx", new double[]{0, 0, 200, 120}, figureLines())
+ .content("q 1 0 0 1 100 400 cm 0 95 200 30 re W n /Fx Do Q\n"
+ + "q 1 0 0 1 100 200 cm 0 55 200 20 re W n /Fx Do Q\n")
+ .write(tempDir.resolve("placements.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ // The first window shows line 1, the second shows line 3.
+ assertEquals(Arrays.asList("Figure line 1", "Figure line 3"), ClipTestChunks.retained(chunks));
+ assertEquals(Arrays.asList(
+ "Figure line 2", "Figure line 3", "Figure line 4", "Figure line 5",
+ "Figure line 1", "Figure line 2", "Figure line 4", "Figure line 5"),
+ ClipTestChunks.clipped(chunks));
+ }
+
+ /**
+ * A form's own /BBox clips its content, and composes with the clip it
+ * inherits. When the two do not meet, the effective clip is EMPTY and every
+ * chunk inside is clipped away -- an empty intersection must never be read
+ * back as "unclipped".
+ */
+ @Test
+ void formBBoxAndInheritedClipCompose() throws Exception {
+ String formContent = ClipTestPdfBuilder.text(5, 10, "Low line")
+ + ClipTestPdfBuilder.text(5, 250, "High line");
+ Path file = new ClipTestPdfBuilder()
+ .form("Fb", new double[]{0, 0, 100, 40}, formContent)
+ .content(ClipTestPdfBuilder.text(72, 700, 12, "Page body.")
+ // No inherited clip: the /BBox alone decides. "Low line"
+ // lands inside it, "High line" far above it.
+ + "q 1 0 0 1 100 100 cm /Fb Do Q\n"
+ // Inherited clip y 500..600 against a /BBox at y 300..340:
+ // the intersection is empty, so both lines go, although
+ // "Low line" is inside the /BBox and "High line" is
+ // inside the inherited clip.
+ + "q 0 500 612 100 re W n 1 0 0 1 100 300 cm /Fb Do Q\n")
+ .write(tempDir.resolve("bbox.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertEquals(Arrays.asList("Page body.", "Low line"), ClipTestChunks.retained(chunks));
+ assertEquals(Arrays.asList("High line", "Low line", "High line"), ClipTestChunks.clipped(chunks));
+ }
+
+ /**
+ * A non-rectangular clipping path is approximated by its bounding box. The
+ * approximation only makes the clip larger, so text it lets through may
+ * still be invisible -- disjointness proves invisibility, overlap proves
+ * nothing. This pins the documented limitation rather than a wish.
+ */
+ @Test
+ void nonRectangularClipIsApproximatedByItsBoundingBox() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ // A triangle with vertices (100,100), (300,100), (200,300);
+ // its bounding box is x 100..300, y 100..300.
+ .content("q 100 100 m 300 100 l 200 300 l h W n\n"
+ + ClipTestPdfBuilder.text(170, 150, "Inside the triangle")
+ + ClipTestPdfBuilder.text(105, 280, "Corner outside the triangle")
+ + ClipTestPdfBuilder.text(400, 500, "Outside the bounding box")
+ + "Q\n")
+ .write(tempDir.resolve("triangle.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ // The corner text is invisible on the page but inside the box, so it is
+ // retained: that is the approximation, stated.
+ assertEquals(Arrays.asList("Inside the triangle", "Corner outside the triangle"),
+ ClipTestChunks.retained(chunks));
+ assertEquals(Collections.singletonList("Outside the bounding box"), ClipTestChunks.clipped(chunks));
+ }
+
+ /** Without W the path is only painted; it must not clip anything. */
+ @Test
+ void aPaintedPathWithoutWDoesNotClip() throws Exception {
+ Path file = new ClipTestPdfBuilder()
+ .content("q 0 0 10 10 re f\n"
+ + ClipTestPdfBuilder.text(72, 700, "Not clipped by a filled box")
+ + "Q\n")
+ .write(tempDir.resolve("nowclip.pdf"));
+
+ List chunks = ClipTestChunks.parse(file);
+
+ assertTrue(ClipTestChunks.clipped(chunks).isEmpty(), "a plain fill must not clip");
+ assertEquals(Collections.singletonList("Not clipped by a filled box"), ClipTestChunks.retained(chunks));
+ }
+}